feat: change feed's order (#220)

allow change feed's order manually by moving up/down

partially implement #130

* add feed_order

- add/migrate feed_order column
- auto increament feed_order when adding new feed
- showing feed in order using feed_order

* refactor code to support feedOrder

* swap feed order

* Fix generating feed's order when no feed existed
This commit is contained in:
Tran M. Cuong 2024-08-04 03:02:19 +07:00 committed by GitHub
parent 64a2c98ad7
commit c6165c3c51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 215 additions and 14 deletions

View file

@ -68,6 +68,7 @@ import tachiyomi.domain.source.interactor.GetSavedSearchBySourceIdFeed
import tachiyomi.domain.source.interactor.GetSavedSearchGlobalFeed
import tachiyomi.domain.source.interactor.InsertFeedSavedSearch
import tachiyomi.domain.source.interactor.InsertSavedSearch
import tachiyomi.domain.source.interactor.SwapFeedOrder
import tachiyomi.domain.source.repository.FeedSavedSearchRepository
import tachiyomi.domain.source.repository.SavedSearchRepository
import tachiyomi.domain.track.interactor.IsTrackUnfollowed
@ -151,6 +152,9 @@ class SYDomainModule : InjektModule {
addFactory { CountFeedSavedSearchBySourceId(get()) }
addFactory { GetSavedSearchGlobalFeed(get()) }
addFactory { GetSavedSearchBySourceIdFeed(get()) }
// KMK -->
addFactory { SwapFeedOrder(get()) }
// KMK <--
addSingletonFactory<CustomMangaRepository> { CustomMangaRepositoryImpl(get<Application>()) }
addFactory { GetCustomMangaInfo(get()) }

View file

@ -1,7 +1,9 @@
package eu.kanade.presentation.browse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -42,6 +44,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.model.SavedSearch
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh
@ -67,7 +70,9 @@ fun FeedScreen(
contentPadding: PaddingValues,
onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit,
onClickSource: (CatalogueSource) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
// KMK -->
onLongClickFeed: (FeedItemUI, FeedSavedSearch?, FeedSavedSearch?) -> Unit,
// KMK <--
onClickManga: (Manga) -> Unit,
// KMK -->
onLongClickManga: (Manga) -> Unit,
@ -102,16 +107,26 @@ fun FeedScreen(
contentPadding = contentPadding + topSmallPaddingValues,
modifier = Modifier.fillMaxSize(),
) {
// KMK -->
val feeds = state.items.orEmpty()
// KMK <--
items(
state.items.orEmpty(),
feeds,
key = { "feed-${it.feed.id}" },
) { item ->
// KMK -->
val index = feeds.indexOf(item)
val prevFeed = feeds.getOrNull(index - 1)
val nextFeed = feeds.getOrNull(index + 1)
// KMK <--
GlobalSearchResultItem(
modifier = Modifier.animateItem(),
title = item.title,
subtitle = item.subtitle,
onLongClick = {
onClickDelete(item.feed)
// KMK -->
onLongClickFeed(item, prevFeed?.feed, nextFeed?.feed)
// KMK <--
},
onClick = {
if (item.savedSearch != null && item.source != null) {
@ -289,3 +304,43 @@ fun FeedDeleteConfirmDialog(
},
)
}
// KMK -->
@Composable
fun FeedActionsDialog(
feedItem: FeedItemUI,
hasPrevFeed: Boolean,
hasNextFeed: Boolean,
onDismiss: () -> Unit,
onClickDelete: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onMoveBottom: () -> Unit,
) {
AlertDialog(
title = {
Text(text = stringResource(SYMR.strings.feed))
},
text = {
Text(text = if (feedItem.savedSearch != null) feedItem.savedSearch.name else feedItem.source?.name ?: "")
},
onDismissRequest = onDismiss,
confirmButton = {
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
TextButton(onClick = onMoveUp, enabled = hasPrevFeed) {
Text(text = stringResource(KMR.strings.action_move_up))
}
TextButton(onClick = onMoveDown, enabled = hasNextFeed) {
Text(text = stringResource(KMR.strings.action_move_down))
}
TextButton(onClick = onMoveBottom, enabled = hasNextFeed) {
Text(text = stringResource(KMR.strings.action_move_bottom))
}
TextButton(onClick = onClickDelete) {
Text(text = stringResource(MR.strings.action_delete))
}
}
},
)
}
// KMK <--

View file

@ -11,11 +11,11 @@ data class BackupFeed(
)
val backupFeedMapper =
{ _: Long, source: Long, _: Long?, global: Boolean ->
{ _: Long, source: Long, _: Long?, global: Boolean, _: Long ->
BackupFeed(
source = source,
global = global,
)
}
fun SelectAllFeedHasSavedSearch.backupFeedMapper() = backupFeedMapper(_id, source, saved_search, global)
fun SelectAllFeedHasSavedSearch.backupFeedMapper() = backupFeedMapper(_id, source, saved_search, global, feed_order)

View file

@ -41,6 +41,7 @@ import tachiyomi.domain.source.interactor.GetFeedSavedSearchGlobal
import tachiyomi.domain.source.interactor.GetSavedSearchBySourceId
import tachiyomi.domain.source.interactor.GetSavedSearchGlobalFeed
import tachiyomi.domain.source.interactor.InsertFeedSavedSearch
import tachiyomi.domain.source.interactor.SwapFeedOrder
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.model.SavedSearch
import tachiyomi.domain.source.service.SourceManager
@ -65,6 +66,9 @@ open class FeedScreenModel(
private val getSavedSearchBySourceId: GetSavedSearchBySourceId = Injekt.get(),
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
// KMK -->
private val swapFeedOrder: SwapFeedOrder = Injekt.get(),
// KMK <--
) : StateScreenModel<FeedScreenState>(FeedScreenState()) {
private val _events = Channel<Event>(Int.MAX_VALUE)
@ -152,6 +156,22 @@ open class FeedScreenModel(
}
}
// KMK -->
fun openActionsDialog(
feed: FeedItemUI,
prevFeed: FeedSavedSearch? = null,
nextFeed: FeedSavedSearch? = null,
) {
screenModelScope.launchIO {
mutableState.update { state ->
state.copy(
dialog = Dialog.FeedActions(feed, prevFeed, nextFeed),
)
}
}
}
// KMK <--
private suspend fun hasTooManyFeeds(): Boolean {
return countFeedSavedSearchGlobal.await() > MaxFeedItems
}
@ -182,6 +202,7 @@ open class FeedScreenModel(
source = source.id,
savedSearch = savedSearch?.id,
global = true,
feedOrder = 0,
),
)
}
@ -193,6 +214,20 @@ open class FeedScreenModel(
}
}
// KMK -->
fun swapFeedOrder(feed1: FeedSavedSearch, feed2: FeedSavedSearch) {
screenModelScope.launchNonCancellable {
swapFeedOrder.swapOrder(feed1, feed2)
}
}
fun moveToBottom(feed: FeedSavedSearch) {
screenModelScope.launchNonCancellable {
swapFeedOrder.moveToBottom(feed)
}
}
// KMK <--
private suspend fun getSourcesToGetFeed(feedSavedSearch: List<FeedSavedSearch>): List<Pair<FeedSavedSearch, SavedSearch?>> {
val savedSearches = getSavedSearchGlobalFeed.await()
.associateBy { it.id }
@ -313,6 +348,14 @@ open class FeedScreenModel(
data class AddFeed(val options: ImmutableList<CatalogueSource>) : Dialog()
data class AddFeedSearch(val source: CatalogueSource, val options: ImmutableList<SavedSearch?>) : Dialog()
data class DeleteFeed(val feed: FeedSavedSearch) : Dialog()
// KMK -->
data class FeedActions(
val feedItem: FeedItemUI,
val prevFeed: FeedSavedSearch?,
val nextFeed: FeedSavedSearch?,
) : Dialog()
// KMK <--
}
sealed class Event {

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.FeedActionsDialog
import eu.kanade.presentation.browse.FeedAddDialog
import eu.kanade.presentation.browse.FeedAddSearchDialog
import eu.kanade.presentation.browse.FeedDeleteConfirmDialog
@ -118,7 +119,9 @@ fun feedTab(
),
)
},
onClickDelete = screenModel::openDeleteDialog,
// KMK -->
onLongClickFeed = screenModel::openActionsDialog,
// KMK <--
onClickManga = { manga ->
// KMK -->
if (bulkFavoriteState.selectionMode) {
@ -177,6 +180,32 @@ fun feedTab(
},
)
}
// KMK -->
is FeedScreenModel.Dialog.FeedActions -> {
FeedActionsDialog(
feedItem = dialog.feedItem,
hasPrevFeed = dialog.prevFeed != null,
hasNextFeed = dialog.nextFeed != null,
onDismiss = screenModel::dismissDialog,
onClickDelete = {
screenModel.openDeleteDialog(dialog.feedItem.feed)
screenModel.dismissDialog()
},
onMoveUp = {
dialog.prevFeed?.let { screenModel.swapFeedOrder(dialog.feedItem.feed, it) }
screenModel.dismissDialog()
},
onMoveDown = {
dialog.nextFeed?.let { screenModel.swapFeedOrder(dialog.feedItem.feed, it) }
screenModel.dismissDialog()
},
onMoveBottom = {
dialog.nextFeed?.let { screenModel.moveToBottom(dialog.feedItem.feed) }
screenModel.dismissDialog()
}
)
}
// KMK <--
}
}

View file

@ -130,6 +130,7 @@ open class SourceFeedScreenModel(
source = source.id,
savedSearch = savedSearchId,
global = false,
feedOrder = 0,
),
)
}

View file

@ -48,6 +48,7 @@ class MoveLatestToFeedMigration : Migration {
source = it.toLong(),
savedSearch = null,
global = true,
feedOrder = 0,
)
}
if (!feedSavedSearch.isNullOrEmpty()) {

View file

@ -8,12 +8,14 @@ object FeedSavedSearchMapper {
source: Long,
savedSearch: Long?,
global: Boolean,
feedOrder: Long,
): FeedSavedSearch {
return FeedSavedSearch(
id = id,
source = source,
savedSearch = savedSearch,
global = global,
feedOrder = feedOrder,
)
}
}

View file

@ -70,4 +70,27 @@ class FeedSavedSearchRepositoryImpl(
}
}
}
// KMK -->
override suspend fun swapOrder(feed1: FeedSavedSearch, feed2: FeedSavedSearch) {
return handler.await(true) {
feed_saved_searchQueries.setOrder(
id = feed2.id,
order = feed1.feedOrder,
)
feed_saved_searchQueries.setOrder(
id = feed1.id,
order = feed2.feedOrder,
)
}
}
override suspend fun moveToBottom(feed: FeedSavedSearch) {
return handler.await(true) {
feed_saved_searchQueries.moveToBottom(
id = feed.id,
)
}
}
// KMK <--
}

View file

@ -5,6 +5,7 @@ CREATE TABLE feed_saved_search (
source INTEGER NOT NULL,
saved_search INTEGER,
global INTEGER AS Boolean NOT NULL,
feed_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(saved_search) REFERENCES saved_search (_id)
ON DELETE CASCADE
);
@ -12,16 +13,17 @@ CREATE TABLE feed_saved_search (
CREATE INDEX feed_saved_search_saved_search_index ON feed_saved_search(saved_search);
selectAllGlobal:
SELECT * FROM feed_saved_search WHERE global = 1;
SELECT * FROM feed_saved_search WHERE global = 1 ORDER BY feed_order;
countGlobal:
SELECT count(*) FROM feed_saved_search WHERE global = 1;
selectBySource:
SELECT * FROM feed_saved_search WHERE source = :sourceId AND global = 0;
SELECT * FROM feed_saved_search WHERE source = :sourceId AND global = 0 ORDER BY feed_order;
insert:
INSERT INTO feed_saved_search (source, saved_search, global) VALUES (:sourceId, :savedSearch, :global);
INSERT INTO feed_saved_search (source, saved_search, global, feed_order)
VALUES (:sourceId, :savedSearch, :global, (SELECT COALESCE(MAX(feed_order), 0) + 1 FROM feed_saved_search));
deleteById:
DELETE FROM feed_saved_search WHERE _id = :id;
@ -32,7 +34,7 @@ DELETE FROM feed_saved_search;
selectGlobalFeedSavedSearch:
SELECT saved_search.*
FROM (
SELECT saved_search FROM feed_saved_search WHERE global = 1
SELECT saved_search FROM feed_saved_search WHERE global = 1 ORDER BY feed_order
) AS M
JOIN saved_search
ON saved_search._id = M.saved_search;
@ -40,7 +42,7 @@ ON saved_search._id = M.saved_search;
selectSourceFeedSavedSearch:
SELECT saved_search.*
FROM (
SELECT saved_search FROM feed_saved_search WHERE global = 0 AND source = :sourceId
SELECT saved_search FROM feed_saved_search WHERE global = 0 AND source = :sourceId ORDER BY feed_order
) AS M
JOIN saved_search
ON saved_search._id = M.saved_search;
@ -48,7 +50,7 @@ ON saved_search._id = M.saved_search;
countSourceFeedSavedSearch:
SELECT count(*)
FROM (
SELECT saved_search FROM feed_saved_search WHERE global = 0 AND source = :sourceId
SELECT saved_search FROM feed_saved_search WHERE global = 0 AND source = :sourceId ORDER BY feed_order
) AS M
JOIN saved_search
ON saved_search._id = M.saved_search;
@ -57,7 +59,16 @@ selectLastInsertedRowId:
SELECT last_insert_rowid();
selectAllGlobalNonSavedSearch:
SELECT * FROM feed_saved_search WHERE global = 1 AND saved_search IS NULL;
SELECT * FROM feed_saved_search WHERE global = 1 AND saved_search IS NULL ORDER BY feed_order;
selectAllFeedHasSavedSearch:
SELECT * FROM feed_saved_search WHERE saved_search IS NOT NULL;
SELECT * FROM feed_saved_search WHERE saved_search IS NOT NULL ORDER BY feed_order;
-- KMK -->
setOrder:
UPDATE feed_saved_search SET feed_order = :order WHERE _id = :id;
moveToBottom:
UPDATE feed_saved_search SET feed_order = (SELECT MAX(feed_order) + 1 FROM feed_saved_search)
WHERE _id = :id;
-- KMK <--

View file

@ -0,0 +1,5 @@
-- Add feed_order column to feed_saved_search --
ALTER TABLE feed_saved_search ADD COLUMN feed_order INTEGER NOT NULL DEFAULT 0;
-- Using ID as initial feed_order to existed order
UPDATE feed_saved_search SET feed_order = _id;

View file

@ -0,0 +1,17 @@
package tachiyomi.domain.source.interactor
import tachiyomi.domain.source.model.FeedSavedSearch
import tachiyomi.domain.source.repository.FeedSavedSearchRepository
class SwapFeedOrder(
private val feedSavedSearchRepository: FeedSavedSearchRepository,
) {
suspend fun swapOrder(feed1: FeedSavedSearch, feed2: FeedSavedSearch) {
return feedSavedSearchRepository.swapOrder(feed1, feed2)
}
suspend fun moveToBottom(feed: FeedSavedSearch) {
return feedSavedSearchRepository.moveToBottom(feed)
}
}

View file

@ -12,4 +12,5 @@ data class FeedSavedSearch(
// If the feed is a global (FeedScreen) or source specific feed (SourceFeedScreen)
val global: Boolean,
val feedOrder: Long,
)

View file

@ -27,4 +27,10 @@ interface FeedSavedSearchRepository {
suspend fun insert(feedSavedSearch: FeedSavedSearch): Long?
suspend fun insertAll(feedSavedSearch: List<FeedSavedSearch>)
// KMK -->
suspend fun swapOrder(feed1: FeedSavedSearch, feed2: FeedSavedSearch)
suspend fun moveToBottom(feed: FeedSavedSearch)
// KMK <--
}

View file

@ -7,6 +7,9 @@
<string name="action_toggle_nsfw_only">Toggle NSFW only</string>
<string name="action_bulk_select">Bulk selection mode</string>
<string name="action_faq_and_guides">FAQ and Guides</string>
<string name="action_move_up">Move up</string>
<string name="action_move_down">Move down</string>
<string name="action_move_bottom">Move bottom</string>
<!-- Preferences -->
<!-- Appearance section -->