Bulk selection for SourceFeedScreen
This commit is contained in:
parent
46ad83a79b
commit
0da6b78d74
3 changed files with 275 additions and 12 deletions
|
|
@ -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 <--
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <--
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue