Bulk selection for SourceFeedScreen

This commit is contained in:
Cuong M. Tran 2024-03-06 12:34:01 +07:00 committed by Cuong Tran
parent 46ad83a79b
commit 0da6b78d74
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
3 changed files with 275 additions and 12 deletions

View file

@ -3,6 +3,9 @@ package eu.kanade.presentation.browse
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
@ -14,8 +17,12 @@ import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.browse.components.SourceSettingsButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedScreenModel
import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.domain.manga.model.Manga
@ -100,20 +107,32 @@ fun SourceFeedScreen(
getMangaState: @Composable (Manga) -> State<Manga>,
// KMK -->
sourceId: Long,
onLongClickManga: (Manga) -> Unit,
screenModel: SourceFeedScreenModel,
screenState: SourceFeedState,
// KMK <--
) {
Scaffold(
topBar = { scrollBehavior ->
SourceFeedToolbar(
title = name,
searchQuery = searchQuery,
onSearchQueryChange = onSearchQueryChange,
scrollBehavior = scrollBehavior,
onClickSearch = onClickSearch,
// KMK -->
sourceId = sourceId,
// KMK <--
)
// KMK -->
if (screenState.selectionMode)
SelectionToolbar(
selectedCount = screenState.selection.size,
onClickClearSelection = screenModel::clearSelection,
onChangeCategoryClicked = screenModel::addFavorite,
)
else
// KMK <--
SourceFeedToolbar(
title = name,
searchQuery = searchQuery,
onSearchQueryChange = onSearchQueryChange,
scrollBehavior = scrollBehavior,
onClickSearch = onClickSearch,
// KMK -->
sourceId = sourceId,
// KMK <--
)
},
floatingActionButton = {
BrowseSourceFloatingActionButton(
@ -135,6 +154,10 @@ fun SourceFeedScreen(
onClickSavedSearch = onClickSavedSearch,
onClickDelete = onClickDelete,
onClickManga = onClickManga,
// KMK -->
onLongClickManga = onLongClickManga,
screenState = screenState,
// KMK <--
)
}
}
@ -152,6 +175,10 @@ fun SourceFeedList(
onClickSavedSearch: (SavedSearch) -> Unit,
onClickDelete: (FeedSavedSearch) -> Unit,
onClickManga: (Manga) -> Unit,
// KMK -->
onLongClickManga: (Manga) -> Unit,
screenState: SourceFeedState,
// KMK <--
) {
ScrollbarLazyColumn(
contentPadding = paddingValues + topSmallPaddingValues,
@ -183,6 +210,10 @@ fun SourceFeedList(
item = item,
getMangaState = { getMangaState(it) },
onClickManga = onClickManga,
// KMK -->
onLongClickManga = onLongClickManga,
selection = screenState.selection,
// KMK <--
)
}
}
@ -194,6 +225,10 @@ fun SourceFeedItem(
item: SourceFeedUI,
getMangaState: @Composable ((Manga) -> State<Manga>),
onClickManga: (Manga) -> Unit,
// KMK -->
onLongClickManga: (Manga) -> Unit,
selection: List<Manga>,
// KMK <--
) {
val results = item.results
when {
@ -208,7 +243,10 @@ fun SourceFeedItem(
titles = item.results.orEmpty(),
getManga = getMangaState,
onClick = onClickManga,
onLongClick = onClickManga,
// KMK -->
onLongClick = onLongClickManga,
selection = selection,
// KMK <--
)
}
}
@ -242,3 +280,29 @@ fun SourceFeedToolbar(
// KMK <--
)
}
// KMK -->
@Composable
private fun SelectionToolbar(
selectedCount: Int,
onClickClearSelection: () -> Unit = {},
onChangeCategoryClicked: () -> Unit = {},
) {
AppBar(
titleContent = { Text(text = "$selectedCount") },
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_bookmark),
icon = Icons.Outlined.BookmarkAdd,
onClick = onChangeCategoryClicked,
),
),
)
},
isActionMode = true,
onCancelActionMode = onClickClearSelection,
)
}
// KMK <--

View file

@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
@ -12,10 +14,12 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.SourceFeedScreen
import eu.kanade.presentation.browse.components.SourceFeedAddDialog
import eu.kanade.presentation.browse.components.SourceFeedDeleteDialog
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.system.toast
import exh.md.follows.MangaDexFollowsScreen
@ -32,6 +36,9 @@ class SourceFeedScreen(val sourceId: Long) : Screen() {
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
// KMK -->
val haptic = LocalHapticFeedback.current
// KMK <--
SourceFeedScreen(
name = screenModel.source.name,
@ -43,13 +50,30 @@ class SourceFeedScreen(val sourceId: Long) : Screen() {
onClickLatest = { onLatestClick(navigator, screenModel.source) },
onClickSavedSearch = { onSavedSearchClick(navigator, screenModel.source, it) },
onClickDelete = screenModel::openDeleteFeed,
onClickManga = { onMangaClick(navigator, it) },
onClickManga = {
// KMK -->
if (state.selectionMode)
screenModel.toggleSelection(it)
else
// KMK <--
onMangaClick(navigator, it)
},
onClickSearch = { onSearchClick(navigator, screenModel.source, it) },
searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search,
getMangaState = { screenModel.getManga(initialManga = it) },
// KMK -->
sourceId = screenModel.source.id,
onLongClickManga = { manga ->
if (state.selectionMode) {
navigator.push(MangaScreen(manga.id, true))
} else {
screenModel.toggleSelection(manga)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
screenModel = screenModel,
screenState = state,
// KMK <--
)
@ -74,6 +98,18 @@ class SourceFeedScreen(val sourceId: Long) : Screen() {
},
)
}
// KMK -->
is SourceFeedScreenModel.Dialog.ChangeCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, exclude ->
screenModel.setMangaCategories(dialog.mangas, include, exclude)
},
)
}
// KMK <--
SourceFeedScreenModel.Dialog.Filter -> {
SourceFilterDialog(
onDismissRequest = onDismissRequest,

View file

@ -5,6 +5,8 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
@ -22,12 +24,15 @@ import exh.source.getMainSource
import exh.source.mangaDexSourceIds
import exh.util.nullIfBlank
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -35,10 +40,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.source.interactor.CountFeedSavedSearchBySourceId
@ -70,6 +80,11 @@ open class SourceFeedScreenModel(
private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(),
private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(),
private val getExhSavedSearch: GetExhSavedSearch = Injekt.get(),
// KMK -->
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<SourceFeedState>(SourceFeedState()) {
val source = sourceManager.getOrStub(sourceId)
@ -308,10 +323,151 @@ open class SourceFeedScreenModel(
mutableState.update { it.copy(dialog = null) }
}
// KMK -->
fun clearSelection() {
mutableState.update { it.copy(selection = persistentListOf()) }
}
fun toggleSelection(manga: DomainManga) {
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
if (list.fastAny { it.id == manga.id }) {
list.removeAll { it.id == manga.id }
} else {
list.add(manga)
}
}
state.copy(selection = newSelection)
}
}
fun addFavorite() {
screenModelScope.launchIO {
val mangaList = state.value.selection
val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
when {
// Default category set
defaultCategory != null -> {
setMangaCategories(mangaList, listOf(defaultCategory.id), emptyList())
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
// Automatic 'Default' or no categories
setMangaCategories(mangaList, emptyList(), emptyList())
}
else -> {
// Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = getMixCategories(mangaList)
val preselected = categories
.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}
.toImmutableList()
setDialog(Dialog.ChangeCategory(mangaList, preselected))
}
}
}
}
/**
* Bulk update categories of manga using old and new common categories.
*
* @param mangaList the list of manga to move.
* @param addCategories the categories to add for all mangas.
* @param removeCategories the categories to remove in all mangas.
*/
fun setMangaCategories(mangaList: List<DomainManga>, addCategories: List<Long>, removeCategories: List<Long>) {
screenModelScope.launchNonCancellable {
mangaList.fastForEach { manga ->
val categoryIds = getCategories.await(manga.id)
.map { it.id }
.subtract(removeCategories.toSet())
.plus(addCategories)
.toList()
moveMangaToCategoriesAndAddToLibrary(manga, categoryIds)
}
}
clearSelection()
}
private fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, categories: List<Long>) {
moveMangaToCategory(manga.id, categories)
if (manga.favorite) return
screenModelScope.launchIO {
updateManga.awaitUpdateFavorite(manga.id, true)
}
}
private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) {
screenModelScope.launchIO {
setMangaCategories.await(mangaId, categoryIds)
}
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
suspend fun getCategories(): List<Category> {
return getCategories.subscribe()
.firstOrNull()
?.filterNot { it.isSystemCategory }
.orEmpty()
}
/**
* Returns the common categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getCommonCategories(mangas: List<DomainManga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList()
return mangas
.map { getCategories.await(it.id).toSet() }
.reduce { set1, set2 -> set1.intersect(set2) }
}
/**
* Returns the mix (non-common) categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getMixCategories(mangas: List<DomainManga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList()
val mangaCategories = mangas.map { getCategories.await(it.id).toSet() }
val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) }
return mangaCategories.flatten().distinct().subtract(common)
}
private fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) }
}
// KMK <--
sealed class Dialog {
data object Filter : Dialog()
data class DeleteFeed(val feed: FeedSavedSearch) : Dialog()
data class AddFeed(val feedId: Long, val name: String) : Dialog()
// KMK -->
data class ChangeCategory(
val mangas: List<DomainManga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog()
// KMK <--
}
override fun onDispose() {
@ -327,7 +483,14 @@ data class SourceFeedState(
val filters: FilterList = FilterList(),
val savedSearches: ImmutableList<EXHSavedSearch> = persistentListOf(),
val dialog: SourceFeedScreenModel.Dialog? = null,
// KMK -->
val selection: PersistentList<DomainManga> = persistentListOf(),
// KMK <--
) {
val isLoading
get() = items.isEmpty()
// KMK -->
val selectionMode = selection.isNotEmpty()
// KMK <--
}