separate BulkSelection into standalone BulkFavoriteScreenModel

This commit is contained in:
Cuong M. Tran 2024-03-09 11:00:40 +07:00 committed by Cuong Tran
parent 42022bf661
commit cc5b6a1d5f
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
6 changed files with 419 additions and 386 deletions

View file

@ -12,7 +12,7 @@ import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.browse.components.GlobalSearchToolbar import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.presentation.components.SelectionToolbar import eu.kanade.presentation.components.SelectionToolbar
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreenModel import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
@ -24,9 +24,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable @Composable
fun GlobalSearchScreen( fun GlobalSearchScreen(
// KMK -->
screenModel: GlobalSearchScreenModel,
// KMK <--
state: SearchScreenModel.State, state: SearchScreenModel.State,
navigateUp: () -> Unit, navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit, onChangeSearchQuery: (String?) -> Unit,
@ -37,15 +34,20 @@ fun GlobalSearchScreen(
onClickSource: (CatalogueSource) -> Unit, onClickSource: (CatalogueSource) -> Unit,
onClickItem: (Manga) -> Unit, onClickItem: (Manga) -> Unit,
onLongClickItem: (Manga) -> Unit, onLongClickItem: (Manga) -> Unit,
// KMK -->
bulkFavoriteState: BulkFavoriteScreenModel.State,
toggleSelectionMode: () -> Unit,
addFavorite: () -> Unit,
// KMK <--
) { ) {
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
// KMK --> // KMK -->
if (state.selectionMode) if (bulkFavoriteState.selectionMode)
SelectionToolbar( SelectionToolbar(
selectedCount = state.selection.size, selectedCount = bulkFavoriteState.selection.size,
onClickClearSelection = screenModel::toggleSelectionMode, onClickClearSelection = toggleSelectionMode,
onChangeCategoryClicked = screenModel::addFavorite, onChangeCategoryClicked = addFavorite,
) )
else else
// KMK <-- // KMK <--
@ -62,7 +64,7 @@ fun GlobalSearchScreen(
onToggleResults = onToggleResults, onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
// KMK --> // KMK -->
toggleBulkSelectionMode = screenModel::toggleSelectionMode, toggleBulkSelectionMode = toggleSelectionMode,
// KMK <-- // KMK <--
) )
}, },
@ -75,7 +77,7 @@ fun GlobalSearchScreen(
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
// KMK --> // KMK -->
selection = state.selection, selection = bulkFavoriteState.selection,
// KMK <-- // KMK <--
) )
} }

View file

@ -5,7 +5,7 @@ import androidx.compose.runtime.State
import eu.kanade.presentation.browse.components.GlobalSearchToolbar import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.presentation.components.SelectionToolbar import eu.kanade.presentation.components.SelectionToolbar
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreenModel import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -13,9 +13,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable @Composable
fun MigrateSearchScreen( fun MigrateSearchScreen(
// KMK -->
screenModel: MigrateSearchScreenModel,
// KMK <--
state: SearchScreenModel.State, state: SearchScreenModel.State,
fromSourceId: Long?, fromSourceId: Long?,
navigateUp: () -> Unit, navigateUp: () -> Unit,
@ -27,15 +24,20 @@ fun MigrateSearchScreen(
onClickSource: (CatalogueSource) -> Unit, onClickSource: (CatalogueSource) -> Unit,
onClickItem: (Manga) -> Unit, onClickItem: (Manga) -> Unit,
onLongClickItem: (Manga) -> Unit, onLongClickItem: (Manga) -> Unit,
// KMK -->
bulkFavoriteState: BulkFavoriteScreenModel.State,
toggleSelectionMode: () -> Unit,
addFavorite: () -> Unit,
// KMK <--
) { ) {
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
// KMK --> // KMK -->
if (state.selectionMode) if (bulkFavoriteState.selectionMode)
SelectionToolbar( SelectionToolbar(
selectedCount = state.selection.size, selectedCount = bulkFavoriteState.selection.size,
onClickClearSelection = screenModel::toggleSelectionMode, onClickClearSelection = toggleSelectionMode,
onChangeCategoryClicked = screenModel::addFavorite, onChangeCategoryClicked = addFavorite,
) )
else else
// KMK <-- // KMK <--
@ -52,7 +54,7 @@ fun MigrateSearchScreen(
onToggleResults = onToggleResults, onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
// KMK --> // KMK -->
toggleBulkSelectionMode = screenModel::toggleSelectionMode toggleBulkSelectionMode = toggleSelectionMode
// KMK <-- // KMK <--
) )
}, },
@ -66,7 +68,7 @@ fun MigrateSearchScreen(
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
// KMK --> // KMK -->
selection = state.selection, selection = bulkFavoriteState.selection,
// KMK <-- // KMK <--
) )
} }

View file

@ -0,0 +1,336 @@
package eu.kanade.tachiyomi.ui.browse
import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.util.removeCovers
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.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.mapAsCheckboxState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.toMangaUpdate
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
class BulkFavoriteScreenModel(
initialState: State = State(),
private val sourceManager: SourceManager = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
private val addTracks: AddTracks = Injekt.get(),
) : StateScreenModel<BulkFavoriteScreenModel.State>(initialState) {
fun backHandler() {
toggleSelectionMode()
}
fun toggleSelectionMode() {
if (state.value.selectionMode)
clearSelection()
mutableState.update { it.copy(selectionMode = !it.selectionMode) }
}
private fun clearSelection() {
mutableState.update { it.copy(selection = persistentListOf()) }
}
fun toggleSelection(manga: Manga) {
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)
}.also {
if (state.value.selection.isEmpty())
toggleSelectionMode()
}
}
fun addFavorite(startIdx: Int = 0) {
screenModelScope.launch {
val mangaWithDup = getDuplicateLibraryManga(startIdx)
if (mangaWithDup != null)
setDialog(Dialog.AllowDuplicate(mangaWithDup))
else
addFavoriteDuplicate()
}
}
fun addFavoriteDuplicate(skipAllDuplicates: Boolean = false) {
screenModelScope.launch {
val mangaList = if (skipAllDuplicates) getNotDuplicateLibraryMangas() else 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 -> {
setMangasCategories(mangaList, listOf(defaultCategory.id), emptyList())
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
// Automatic 'Default' or no categories
setMangasCategories(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.ChangeMangasCategory(mangaList, preselected))
}
}
}
}
private suspend fun getNotDuplicateLibraryMangas(): List<Manga> {
return state.value.selection.filterNot { manga ->
getDuplicateLibraryManga.await(manga).isNotEmpty()
}
}
private suspend fun getDuplicateLibraryManga(startIdx: Int = 0): Pair<Int, Manga>? {
val mangas = state.value.selection
mangas.fastForEachIndexed { index, manga ->
if (index < startIdx) return@fastForEachIndexed
val dup = getDuplicateLibraryManga.await(manga)
if (dup.isEmpty()) return@fastForEachIndexed
return Pair(index, dup.first())
}
return null
}
fun removeDuplicateSelectedManga(index: Int) {
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
list.removeAt(index)
}
state.copy(selection = newSelection)
}
}
/**
* 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 setMangasCategories(mangaList: List<Manga>, 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)
}
}
toggleSelectionMode()
}
private fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, 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)
}
}
/**
* Returns the common categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getCommonCategories(mangas: List<Manga>): 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<Manga>): 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)
}
/**
* 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()
}
suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
return getDuplicateLibraryManga.await(manga).getOrNull(0)
}
private fun moveMangaToCategories(manga: Manga, vararg categories: Category) {
moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id })
}
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
screenModelScope.launchIO {
setMangaCategories.await(
mangaId = manga.id,
categoryIds = categoryIds.toList(),
)
}
}
/**
* Adds or removes a manga from the library.
*
* @param manga the manga to update.
*/
fun changeMangaFavorite(manga: Manga) {
val source = sourceManager.getOrStub(manga.source)
screenModelScope.launch {
var new = manga.copy(
favorite = !manga.favorite,
dateAdded = when (manga.favorite) {
true -> 0
false -> Instant.now().toEpochMilli()
},
)
// TODO: also allow deleting chapters when remove favorite (just like in [MangaScreenModel])
if (!new.favorite) {
new = new.removeCovers(coverCache)
} else {
setMangaDefaultChapterFlags.await(manga)
addTracks.bindEnhancedTrackers(manga, source)
}
updateManga.await(new.toMangaUpdate())
}
}
fun addFavorite(manga: Manga) {
screenModelScope.launch {
val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
when {
// Default category set
defaultCategory != null -> {
moveMangaToCategories(manga, defaultCategory)
changeMangaFavorite(manga)
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
moveMangaToCategories(manga)
changeMangaFavorite(manga)
}
// Choose a category
else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id }
setDialog(
Dialog.ChangeMangaCategory(
manga,
categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(),
),
)
}
}
}
}
fun setDialog(dialog: Dialog?) {
mutableState.update {
it.copy(dialog = dialog)
}
}
interface Dialog {
data class RemoveManga(val manga: Manga) : Dialog
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class ChangeMangaCategory(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class ChangeMangasCategory(
val mangas: List<Manga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class AllowDuplicate(val duplicatedManga: Pair<Int, Manga>) : Dialog
}
@Immutable
data class State (
val dialog: Dialog? = null,
val selection: PersistentList<Manga> = persistentListOf(),
val selectionMode: Boolean = false,
)
}

View file

@ -11,8 +11,8 @@ import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.manga.AllowDuplicateDialog import eu.kanade.presentation.manga.AllowDuplicateDialog
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
@ -29,17 +29,15 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L
val dialogState by dialogScreenModel.state.collectAsState() val dialogState by dialogScreenModel.state.collectAsState()
// KMK --> // KMK -->
BackHandler(enabled = state.selectionMode) { val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() }
when { val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState()
state.selectionMode -> screenModel.toggleSelectionMode()
} BackHandler(enabled = bulkFavoriteState.selectionMode) {
bulkFavoriteScreenModel.toggleSelectionMode()
} }
// KMK <-- // KMK <--
MigrateSearchScreen( MigrateSearchScreen(
// KMK -->
screenModel = screenModel,
// KMK <--
state = state, state = state,
fromSourceId = state.fromSourceId, fromSourceId = state.fromSourceId,
navigateUp = navigator::pop, navigateUp = navigator::pop,
@ -55,8 +53,8 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L
}, },
onClickItem = { onClickItem = {
// KMK --> // KMK -->
if (state.selectionMode) { if (bulkFavoriteState.selectionMode) {
screenModel.toggleSelection(it) bulkFavoriteScreenModel.toggleSelection(it)
} }
else else
// KMK <-- // KMK <--
@ -71,39 +69,44 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L
} }
}, },
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
// KMK -->
bulkFavoriteState = bulkFavoriteState,
toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode,
addFavorite = bulkFavoriteScreenModel::addFavorite,
// KMK <--
) )
// KMK --> // KMK -->
val onDismissRequest = { screenModel.setDialog(null) } val onDismissRequest = { bulkFavoriteScreenModel.setDialog(null) }
when (val dialog = state.dialog) { when (val dialog = bulkFavoriteState.dialog) {
is SearchScreenModel.Dialog.ChangeMangasCategory -> { is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> {
ChangeCategoryDialog( ChangeCategoryDialog(
initialSelection = dialog.initialSelection, initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) }, onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, exclude -> onConfirm = { include, exclude ->
screenModel.setMangaCategories(dialog.mangas, include, exclude) bulkFavoriteScreenModel.setMangasCategories(dialog.mangas, include, exclude)
}, },
) )
} }
is SearchScreenModel.Dialog.AllowDuplicate -> { is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> {
AllowDuplicateDialog( AllowDuplicateDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onAllowAllDuplicate = { onAllowAllDuplicate = {
screenModel.addFavoriteDuplicate() bulkFavoriteScreenModel.addFavoriteDuplicate()
}, },
onSkipAllDuplicate = { onSkipAllDuplicate = {
screenModel.addFavoriteDuplicate(skipAllDuplicates = true) bulkFavoriteScreenModel.addFavoriteDuplicate(skipAllDuplicates = true)
}, },
onOpenManga = { onOpenManga = {
navigator.push(MangaScreen(dialog.duplicatedManga.second.id)) navigator.push(MangaScreen(dialog.duplicatedManga.second.id))
}, },
onAllowDuplicate = { onAllowDuplicate = {
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1)
}, },
onSkipDuplicate = { onSkipDuplicate = {
screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) bulkFavoriteScreenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first)
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first) bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first)
}, },
duplicatedName = dialog.duplicatedManga.second.title, duplicatedName = dialog.duplicatedManga.second.title,
) )

View file

@ -20,6 +20,7 @@ import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.manga.AllowDuplicateDialog import eu.kanade.presentation.manga.AllowDuplicateDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
@ -50,10 +51,11 @@ class GlobalSearchScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
BackHandler(enabled = state.selectionMode) { val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() }
when { val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState()
state.selectionMode -> screenModel.toggleSelectionMode()
} BackHandler(enabled = bulkFavoriteState.selectionMode) {
bulkFavoriteScreenModel.backHandler()
} }
// KMK <-- // KMK <--
@ -77,9 +79,6 @@ class GlobalSearchScreen(
} }
} else { } else {
GlobalSearchScreen( GlobalSearchScreen(
// KMK -->
screenModel = screenModel,
// KMK <--
state = state, state = state,
navigateUp = navigator::pop, navigateUp = navigator::pop,
onChangeSearchQuery = screenModel::updateSearchQuery, onChangeSearchQuery = screenModel::updateSearchQuery,
@ -92,96 +91,101 @@ class GlobalSearchScreen(
}, },
onClickItem = { onClickItem = {
// KMK --> // KMK -->
if (state.selectionMode) if (bulkFavoriteState.selectionMode)
screenModel.toggleSelection(it) bulkFavoriteScreenModel.toggleSelection(it)
else else
// KMK <-- // KMK <--
navigator.push(MangaScreen(it.id, true)) navigator.push(MangaScreen(it.id, true))
}, },
onLongClickItem = { manga -> onLongClickItem = { manga ->
// KMK --> // KMK -->
if (state.selectionMode) if (bulkFavoriteState.selectionMode)
// KMK <-- // KMK <--
navigator.push(MangaScreen(manga.id, true)) navigator.push(MangaScreen(manga.id, true))
// KMK --> // KMK -->
else else
scope.launchIO { scope.launchIO {
val duplicateManga = screenModel.getDuplicateLibraryManga(manga) val duplicateManga = bulkFavoriteScreenModel.getDuplicateLibraryManga(manga)
when { when {
manga.favorite -> screenModel.setDialog(SearchScreenModel.Dialog.RemoveManga(manga)) manga.favorite -> bulkFavoriteScreenModel.setDialog(BulkFavoriteScreenModel.Dialog.RemoveManga(manga))
duplicateManga != null -> screenModel.setDialog( duplicateManga != null -> bulkFavoriteScreenModel.setDialog(
SearchScreenModel.Dialog.AddDuplicateManga( BulkFavoriteScreenModel.Dialog.AddDuplicateManga(
manga, manga,
duplicateManga, duplicateManga,
), ),
) )
else -> screenModel.addFavorite(manga) else -> bulkFavoriteScreenModel.addFavorite(manga)
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} }
// KMK <-- // KMK <--
}, },
// KMK -->
bulkFavoriteState = bulkFavoriteState,
toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode,
addFavorite = bulkFavoriteScreenModel::addFavorite,
// KMK <--
) )
} }
// KMK --> // KMK -->
val onDismissRequest = { screenModel.setDialog(null) } val onDismissRequest = { bulkFavoriteScreenModel.setDialog(null) }
when (val dialog = state.dialog) { when (val dialog = bulkFavoriteState.dialog) {
is SearchScreenModel.Dialog.AddDuplicateManga -> { is BulkFavoriteScreenModel.Dialog.AddDuplicateManga -> {
DuplicateMangaDialog( DuplicateMangaDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onConfirm = { screenModel.addFavorite(dialog.manga) }, onConfirm = { bulkFavoriteScreenModel.addFavorite(dialog.manga) },
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
) )
} }
is SearchScreenModel.Dialog.RemoveManga -> { is BulkFavoriteScreenModel.Dialog.RemoveManga -> {
RemoveMangaDialog( RemoveMangaDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onConfirm = { onConfirm = {
screenModel.changeMangaFavorite(dialog.manga) bulkFavoriteScreenModel.changeMangaFavorite(dialog.manga)
}, },
mangaToRemove = dialog.manga, mangaToRemove = dialog.manga,
) )
} }
is SearchScreenModel.Dialog.ChangeMangaCategory -> { is BulkFavoriteScreenModel.Dialog.ChangeMangaCategory -> {
ChangeCategoryDialog( ChangeCategoryDialog(
initialSelection = dialog.initialSelection, initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) }, onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, _ -> onConfirm = { include, _ ->
screenModel.changeMangaFavorite(dialog.manga) bulkFavoriteScreenModel.changeMangaFavorite(dialog.manga)
screenModel.moveMangaToCategories(dialog.manga, include) bulkFavoriteScreenModel.moveMangaToCategories(dialog.manga, include)
}, },
) )
} }
is SearchScreenModel.Dialog.ChangeMangasCategory -> { is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> {
ChangeCategoryDialog( ChangeCategoryDialog(
initialSelection = dialog.initialSelection, initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) }, onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, exclude -> onConfirm = { include, exclude ->
screenModel.setMangaCategories(dialog.mangas, include, exclude) bulkFavoriteScreenModel.setMangasCategories(dialog.mangas, include, exclude)
}, },
) )
} }
is SearchScreenModel.Dialog.AllowDuplicate -> { is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> {
AllowDuplicateDialog( AllowDuplicateDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onAllowAllDuplicate = { onAllowAllDuplicate = {
screenModel.addFavoriteDuplicate() bulkFavoriteScreenModel.addFavoriteDuplicate()
}, },
onSkipAllDuplicate = { onSkipAllDuplicate = {
screenModel.addFavoriteDuplicate(skipAllDuplicates = true) bulkFavoriteScreenModel.addFavoriteDuplicate(skipAllDuplicates = true)
}, },
onOpenManga = { onOpenManga = {
navigator.push(MangaScreen(dialog.duplicatedManga.second.id)) navigator.push(MangaScreen(dialog.duplicatedManga.second.id))
}, },
onAllowDuplicate = { onAllowDuplicate = {
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1)
}, },
onSkipDuplicate = { onSkipDuplicate = {
screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) bulkFavoriteScreenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first)
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first) bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first)
}, },
duplicatedName = dialog.duplicatedManga.second.title, duplicatedName = dialog.duplicatedManga.second.title,
) )

View file

@ -3,27 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.toDomainManga import eu.kanade.domain.manga.model.toDomainManga
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.presentation.util.ioCoroutineScope
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toPersistentMap import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -32,29 +20,16 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.mapAsCheckboxState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.toMangaUpdate
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.time.Instant
import java.util.concurrent.Executors import java.util.concurrent.Executors
abstract class SearchScreenModel( abstract class SearchScreenModel(
@ -64,16 +39,6 @@ abstract class SearchScreenModel(
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
private val getManga: GetManga = Injekt.get(), private val getManga: GetManga = Injekt.get(),
// KMK -->
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
private val addTracks: AddTracks = Injekt.get(),
// KMK <--
) : StateScreenModel<SearchScreenModel.State>(initialState) { ) : StateScreenModel<SearchScreenModel.State>(initialState) {
private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher() private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher()
@ -228,280 +193,6 @@ abstract class SearchScreenModel(
updateItems(newItems) updateItems(newItems)
} }
// KMK -->
fun toggleSelectionMode() {
if (state.value.selectionMode)
clearSelection()
mutableState.update { it.copy(selectionMode = !it.selectionMode) }
}
private fun clearSelection() {
mutableState.update { it.copy(selection = persistentListOf()) }
}
fun toggleSelection(manga: Manga) {
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)
}.also {
if (state.value.selection.isEmpty())
toggleSelectionMode()
}
}
fun addFavorite(startIdx: Int = 0) {
screenModelScope.launch {
val mangaWithDup = getDuplicateLibraryManga(startIdx)
if (mangaWithDup != null)
setDialog(Dialog.AllowDuplicate(mangaWithDup))
else
addFavoriteDuplicate()
}
}
fun addFavoriteDuplicate(skipAllDuplicates: Boolean = false) {
screenModelScope.launch {
val mangaList = if (skipAllDuplicates) getNotDuplicateLibraryMangas() else 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.ChangeMangasCategory(mangaList, preselected))
}
}
}
}
private suspend fun getNotDuplicateLibraryMangas(): List<Manga> {
return state.value.selection.filterNot { manga ->
getDuplicateLibraryManga.await(manga).isNotEmpty()
}
}
private suspend fun getDuplicateLibraryManga(startIdx: Int = 0): Pair<Int, Manga>? {
val mangas = state.value.selection
mangas.fastForEachIndexed { index, manga ->
if (index < startIdx) return@fastForEachIndexed
val dup = getDuplicateLibraryManga.await(manga)
if (dup.isEmpty()) return@fastForEachIndexed
return Pair(index, dup.first())
}
return null
}
fun removeDuplicateSelectedManga(index: Int) {
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
list.removeAt(index)
}
state.copy(selection = newSelection)
}
}
/**
* 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<Manga>, 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)
}
}
toggleSelectionMode()
}
private fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, 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)
}
}
/**
* Returns the common categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getCommonCategories(mangas: List<Manga>): 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<Manga>): 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)
}
/**
* 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()
}
suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
return getDuplicateLibraryManga.await(manga).getOrNull(0)
}
private fun moveMangaToCategories(manga: Manga, vararg categories: Category) {
moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id })
}
fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
screenModelScope.launchIO {
setMangaCategories.await(
mangaId = manga.id,
categoryIds = categoryIds.toList(),
)
}
}
/**
* Adds or removes a manga from the library.
*
* @param manga the manga to update.
*/
fun changeMangaFavorite(manga: Manga) {
val source = sourceManager.getOrStub(manga.source)
screenModelScope.launch {
var new = manga.copy(
favorite = !manga.favorite,
dateAdded = when (manga.favorite) {
true -> 0
false -> Instant.now().toEpochMilli()
},
)
// TODO: also allow deleting chapters when remove favorite (just like in [MangaScreenModel])
if (!new.favorite) {
new = new.removeCovers(coverCache)
} else {
setMangaDefaultChapterFlags.await(manga)
addTracks.bindEnhancedTrackers(manga, source)
}
updateManga.await(new.toMangaUpdate())
}
}
fun addFavorite(manga: Manga) {
screenModelScope.launch {
val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
when {
// Default category set
defaultCategory != null -> {
moveMangaToCategories(manga, defaultCategory)
changeMangaFavorite(manga)
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
moveMangaToCategories(manga)
changeMangaFavorite(manga)
}
// Choose a category
else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id }
setDialog(
Dialog.ChangeMangaCategory(
manga,
categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(),
),
)
}
}
}
}
fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) }
}
sealed interface Dialog {
data class RemoveManga(val manga: Manga) : Dialog
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class ChangeMangaCategory(
val manga: Manga,
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog
data class ChangeMangasCategory(
val mangas: List<Manga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class AllowDuplicate(val duplicatedManga: Pair<Int, Manga>) : Dialog
}
// KMK <--
@Immutable @Immutable
data class State( data class State(
val fromSourceId: Long? = null, val fromSourceId: Long? = null,
@ -509,11 +200,6 @@ abstract class SearchScreenModel(
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly, val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
val onlyShowHasResults: Boolean = false, val onlyShowHasResults: Boolean = false,
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(), val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
// KMK -->
val dialog: Dialog? = null,
val selection: PersistentList<Manga> = persistentListOf(),
val selectionMode: Boolean = false,
// KMK <--
) { ) {
val progress: Int = items.count { it.value !is SearchItemResult.Loading } val progress: Int = items.count { it.value !is SearchItemResult.Loading }
val total: Int = items.size val total: Int = items.size