diff --git a/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt index da244db46..2faa80563 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt @@ -12,7 +12,7 @@ import eu.kanade.presentation.browse.components.GlobalSearchResultItem import eu.kanade.presentation.browse.components.GlobalSearchToolbar import eu.kanade.presentation.components.SelectionToolbar 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.SearchScreenModel import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter @@ -24,9 +24,6 @@ import tachiyomi.presentation.core.components.material.Scaffold @Composable fun GlobalSearchScreen( - // KMK --> - screenModel: GlobalSearchScreenModel, - // KMK <-- state: SearchScreenModel.State, navigateUp: () -> Unit, onChangeSearchQuery: (String?) -> Unit, @@ -37,15 +34,20 @@ fun GlobalSearchScreen( onClickSource: (CatalogueSource) -> Unit, onClickItem: (Manga) -> Unit, onLongClickItem: (Manga) -> Unit, + // KMK --> + bulkFavoriteState: BulkFavoriteScreenModel.State, + toggleSelectionMode: () -> Unit, + addFavorite: () -> Unit, + // KMK <-- ) { Scaffold( topBar = { scrollBehavior -> // KMK --> - if (state.selectionMode) + if (bulkFavoriteState.selectionMode) SelectionToolbar( - selectedCount = state.selection.size, - onClickClearSelection = screenModel::toggleSelectionMode, - onChangeCategoryClicked = screenModel::addFavorite, + selectedCount = bulkFavoriteState.selection.size, + onClickClearSelection = toggleSelectionMode, + onChangeCategoryClicked = addFavorite, ) else // KMK <-- @@ -62,7 +64,7 @@ fun GlobalSearchScreen( onToggleResults = onToggleResults, scrollBehavior = scrollBehavior, // KMK --> - toggleBulkSelectionMode = screenModel::toggleSelectionMode, + toggleBulkSelectionMode = toggleSelectionMode, // KMK <-- ) }, @@ -75,7 +77,7 @@ fun GlobalSearchScreen( onClickItem = onClickItem, onLongClickItem = onLongClickItem, // KMK --> - selection = state.selection, + selection = bulkFavoriteState.selection, // KMK <-- ) } diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt index c3a1e2c21..2823b5c9c 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.State import eu.kanade.presentation.browse.components.GlobalSearchToolbar import eu.kanade.presentation.components.SelectionToolbar 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.SourceFilter import tachiyomi.domain.manga.model.Manga @@ -13,9 +13,6 @@ import tachiyomi.presentation.core.components.material.Scaffold @Composable fun MigrateSearchScreen( - // KMK --> - screenModel: MigrateSearchScreenModel, - // KMK <-- state: SearchScreenModel.State, fromSourceId: Long?, navigateUp: () -> Unit, @@ -27,15 +24,20 @@ fun MigrateSearchScreen( onClickSource: (CatalogueSource) -> Unit, onClickItem: (Manga) -> Unit, onLongClickItem: (Manga) -> Unit, + // KMK --> + bulkFavoriteState: BulkFavoriteScreenModel.State, + toggleSelectionMode: () -> Unit, + addFavorite: () -> Unit, + // KMK <-- ) { Scaffold( topBar = { scrollBehavior -> // KMK --> - if (state.selectionMode) + if (bulkFavoriteState.selectionMode) SelectionToolbar( - selectedCount = state.selection.size, - onClickClearSelection = screenModel::toggleSelectionMode, - onChangeCategoryClicked = screenModel::addFavorite, + selectedCount = bulkFavoriteState.selection.size, + onClickClearSelection = toggleSelectionMode, + onChangeCategoryClicked = addFavorite, ) else // KMK <-- @@ -52,7 +54,7 @@ fun MigrateSearchScreen( onToggleResults = onToggleResults, scrollBehavior = scrollBehavior, // KMK --> - toggleBulkSelectionMode = screenModel::toggleSelectionMode + toggleBulkSelectionMode = toggleSelectionMode // KMK <-- ) }, @@ -66,7 +68,7 @@ fun MigrateSearchScreen( onClickItem = onClickItem, onLongClickItem = onLongClickItem, // KMK --> - selection = state.selection, + selection = bulkFavoriteState.selection, // KMK <-- ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt new file mode 100644 index 000000000..c9f195d13 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BulkFavoriteScreenModel.kt @@ -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(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 { + return state.value.selection.filterNot { manga -> + getDuplicateLibraryManga.await(manga).isNotEmpty() + } + } + + private suspend fun getDuplicateLibraryManga(startIdx: Int = 0): Pair? { + 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, addCategories: List, removeCategories: List) { + 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) { + moveMangaToCategory(manga.id, categories) + if (manga.favorite) return + + screenModelScope.launchIO { + updateManga.awaitUpdateFavorite(manga.id, true) + } + } + + private fun moveMangaToCategory(mangaId: Long, categoryIds: List) { + 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): Collection { + 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): Collection { + 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 { + 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) { + 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>, + ) : Dialog + data class ChangeMangasCategory( + val mangas: List, + val initialSelection: ImmutableList>, + ) : Dialog + data class AllowDuplicate(val duplicatedManga: Pair) : Dialog + } + + @Immutable + data class State ( + val dialog: Dialog? = null, + val selection: PersistentList = persistentListOf(), + val selectionMode: Boolean = false, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt index d2eb32924..2096ad42e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -11,8 +11,8 @@ import eu.kanade.presentation.browse.MigrateSearchScreen import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.manga.AllowDuplicateDialog 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.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.category.CategoryScreen 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() // KMK --> - BackHandler(enabled = state.selectionMode) { - when { - state.selectionMode -> screenModel.toggleSelectionMode() - } + val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() } + val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState() + + BackHandler(enabled = bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.toggleSelectionMode() } // KMK <-- MigrateSearchScreen( - // KMK --> - screenModel = screenModel, - // KMK <-- state = state, fromSourceId = state.fromSourceId, navigateUp = navigator::pop, @@ -55,8 +53,8 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L }, onClickItem = { // KMK --> - if (state.selectionMode) { - screenModel.toggleSelection(it) + if (bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.toggleSelection(it) } else // KMK <-- @@ -71,39 +69,44 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L } }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, + // KMK --> + bulkFavoriteState = bulkFavoriteState, + toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode, + addFavorite = bulkFavoriteScreenModel::addFavorite, + // KMK <-- ) // KMK --> - val onDismissRequest = { screenModel.setDialog(null) } - when (val dialog = state.dialog) { - is SearchScreenModel.Dialog.ChangeMangasCategory -> { + val onDismissRequest = { bulkFavoriteScreenModel.setDialog(null) } + when (val dialog = bulkFavoriteState.dialog) { + is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> { ChangeCategoryDialog( initialSelection = dialog.initialSelection, onDismissRequest = onDismissRequest, onEditCategories = { navigator.push(CategoryScreen()) }, onConfirm = { include, exclude -> - screenModel.setMangaCategories(dialog.mangas, include, exclude) + bulkFavoriteScreenModel.setMangasCategories(dialog.mangas, include, exclude) }, ) } - is SearchScreenModel.Dialog.AllowDuplicate -> { + is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> { AllowDuplicateDialog( onDismissRequest = onDismissRequest, onAllowAllDuplicate = { - screenModel.addFavoriteDuplicate() + bulkFavoriteScreenModel.addFavoriteDuplicate() }, onSkipAllDuplicate = { - screenModel.addFavoriteDuplicate(skipAllDuplicates = true) + bulkFavoriteScreenModel.addFavoriteDuplicate(skipAllDuplicates = true) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicatedManga.second.id)) }, onAllowDuplicate = { - screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) + bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) }, onSkipDuplicate = { - screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) - screenModel.addFavorite(startIdx = dialog.duplicatedManga.first) + bulkFavoriteScreenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) + bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first) }, duplicatedName = dialog.duplicatedManga.second.title, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt index a2cf8381b..96bf82ba6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt @@ -20,6 +20,7 @@ import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.manga.AllowDuplicateDialog import eu.kanade.presentation.manga.DuplicateMangaDialog 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.category.CategoryScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen @@ -50,10 +51,11 @@ class GlobalSearchScreen( val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current - BackHandler(enabled = state.selectionMode) { - when { - state.selectionMode -> screenModel.toggleSelectionMode() - } + val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() } + val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState() + + BackHandler(enabled = bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.backHandler() } // KMK <-- @@ -77,9 +79,6 @@ class GlobalSearchScreen( } } else { GlobalSearchScreen( - // KMK --> - screenModel = screenModel, - // KMK <-- state = state, navigateUp = navigator::pop, onChangeSearchQuery = screenModel::updateSearchQuery, @@ -92,96 +91,101 @@ class GlobalSearchScreen( }, onClickItem = { // KMK --> - if (state.selectionMode) - screenModel.toggleSelection(it) + if (bulkFavoriteState.selectionMode) + bulkFavoriteScreenModel.toggleSelection(it) else // KMK <-- navigator.push(MangaScreen(it.id, true)) }, onLongClickItem = { manga -> // KMK --> - if (state.selectionMode) + if (bulkFavoriteState.selectionMode) // KMK <-- navigator.push(MangaScreen(manga.id, true)) // KMK --> else scope.launchIO { - val duplicateManga = screenModel.getDuplicateLibraryManga(manga) + val duplicateManga = bulkFavoriteScreenModel.getDuplicateLibraryManga(manga) when { - manga.favorite -> screenModel.setDialog(SearchScreenModel.Dialog.RemoveManga(manga)) - duplicateManga != null -> screenModel.setDialog( - SearchScreenModel.Dialog.AddDuplicateManga( + manga.favorite -> bulkFavoriteScreenModel.setDialog(BulkFavoriteScreenModel.Dialog.RemoveManga(manga)) + duplicateManga != null -> bulkFavoriteScreenModel.setDialog( + BulkFavoriteScreenModel.Dialog.AddDuplicateManga( manga, duplicateManga, ), ) - else -> screenModel.addFavorite(manga) + else -> bulkFavoriteScreenModel.addFavorite(manga) } haptic.performHapticFeedback(HapticFeedbackType.LongPress) } // KMK <-- }, + // KMK --> + bulkFavoriteState = bulkFavoriteState, + toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode, + addFavorite = bulkFavoriteScreenModel::addFavorite, + // KMK <-- ) } // KMK --> - val onDismissRequest = { screenModel.setDialog(null) } - when (val dialog = state.dialog) { - is SearchScreenModel.Dialog.AddDuplicateManga -> { + val onDismissRequest = { bulkFavoriteScreenModel.setDialog(null) } + when (val dialog = bulkFavoriteState.dialog) { + is BulkFavoriteScreenModel.Dialog.AddDuplicateManga -> { DuplicateMangaDialog( onDismissRequest = onDismissRequest, - onConfirm = { screenModel.addFavorite(dialog.manga) }, + onConfirm = { bulkFavoriteScreenModel.addFavorite(dialog.manga) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, ) } - is SearchScreenModel.Dialog.RemoveManga -> { + is BulkFavoriteScreenModel.Dialog.RemoveManga -> { RemoveMangaDialog( onDismissRequest = onDismissRequest, onConfirm = { - screenModel.changeMangaFavorite(dialog.manga) + bulkFavoriteScreenModel.changeMangaFavorite(dialog.manga) }, mangaToRemove = dialog.manga, ) } - is SearchScreenModel.Dialog.ChangeMangaCategory -> { + is BulkFavoriteScreenModel.Dialog.ChangeMangaCategory -> { ChangeCategoryDialog( initialSelection = dialog.initialSelection, onDismissRequest = onDismissRequest, onEditCategories = { navigator.push(CategoryScreen()) }, onConfirm = { include, _ -> - screenModel.changeMangaFavorite(dialog.manga) - screenModel.moveMangaToCategories(dialog.manga, include) + bulkFavoriteScreenModel.changeMangaFavorite(dialog.manga) + bulkFavoriteScreenModel.moveMangaToCategories(dialog.manga, include) }, ) } - is SearchScreenModel.Dialog.ChangeMangasCategory -> { + is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> { ChangeCategoryDialog( initialSelection = dialog.initialSelection, onDismissRequest = onDismissRequest, onEditCategories = { navigator.push(CategoryScreen()) }, onConfirm = { include, exclude -> - screenModel.setMangaCategories(dialog.mangas, include, exclude) + bulkFavoriteScreenModel.setMangasCategories(dialog.mangas, include, exclude) }, ) } - is SearchScreenModel.Dialog.AllowDuplicate -> { + is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> { AllowDuplicateDialog( onDismissRequest = onDismissRequest, onAllowAllDuplicate = { - screenModel.addFavoriteDuplicate() + bulkFavoriteScreenModel.addFavoriteDuplicate() }, onSkipAllDuplicate = { - screenModel.addFavoriteDuplicate(skipAllDuplicates = true) + bulkFavoriteScreenModel.addFavoriteDuplicate(skipAllDuplicates = true) }, onOpenManga = { navigator.push(MangaScreen(dialog.duplicatedManga.second.id)) }, onAllowDuplicate = { - screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) + bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1) }, onSkipDuplicate = { - screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) - screenModel.addFavorite(startIdx = dialog.duplicatedManga.first) + bulkFavoriteScreenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first) + bulkFavoriteScreenModel.addFavorite(startIdx = dialog.duplicatedManga.first) }, duplicatedName = dialog.duplicatedManga.second.title, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt index 6eeae8bd7..fe1fe79d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt @@ -3,27 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.source.globalsearch import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable 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.screenModelScope -import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.model.toDomainManga import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.presentation.util.ioCoroutineScope -import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.extension.ExtensionManager 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.mutate -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.Job @@ -32,29 +20,16 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch 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.NetworkToLocalManga 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 import java.util.concurrent.Executors abstract class SearchScreenModel( @@ -64,16 +39,6 @@ abstract class SearchScreenModel( private val extensionManager: ExtensionManager = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = 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(initialState) { private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher() @@ -228,280 +193,6 @@ abstract class SearchScreenModel( 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 { - return state.value.selection.filterNot { manga -> - getDuplicateLibraryManga.await(manga).isNotEmpty() - } - } - - private suspend fun getDuplicateLibraryManga(startIdx: Int = 0): Pair? { - 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, addCategories: List, removeCategories: List) { - 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) { - moveMangaToCategory(manga.id, categories) - if (manga.favorite) return - - screenModelScope.launchIO { - updateManga.awaitUpdateFavorite(manga.id, true) - } - } - - private fun moveMangaToCategory(mangaId: Long, categoryIds: List) { - 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): Collection { - 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): Collection { - 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 { - 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) { - 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>, - ) : Dialog - data class ChangeMangasCategory( - val mangas: List, - val initialSelection: ImmutableList>, - ) : Dialog - data class AllowDuplicate(val duplicatedManga: Pair) : Dialog - } - // KMK <-- - @Immutable data class State( val fromSourceId: Long? = null, @@ -509,11 +200,6 @@ abstract class SearchScreenModel( val sourceFilter: SourceFilter = SourceFilter.PinnedOnly, val onlyShowHasResults: Boolean = false, val items: PersistentMap = persistentMapOf(), - // KMK --> - val dialog: Dialog? = null, - val selection: PersistentList = persistentListOf(), - val selectionMode: Boolean = false, - // KMK <-- ) { val progress: Int = items.count { it.value !is SearchItemResult.Loading } val total: Int = items.size