diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt index f0f11746d..cbf6f6471 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt @@ -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, // 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), onClickManga: (Manga) -> Unit, + // KMK --> + onLongClickManga: (Manga) -> Unit, + selection: List, + // 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 <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt index fc1c7e197..5e487bc35 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreen.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt index 32eeded6d..c07bf1e2e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/feed/SourceFeedScreenModel.kt @@ -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()) { 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, 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) + } + } + clearSelection() + } + + private fun moveMangaToCategoriesAndAddToLibrary(manga: DomainManga, 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) + } + } + + /** + * Get user categories. + * + * @return List of categories, not including the default category + */ + suspend fun getCategories(): List { + 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): 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) + } + + 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, + val initialSelection: ImmutableList>, + ) : Dialog() + // KMK <-- } override fun onDispose() { @@ -327,7 +483,14 @@ data class SourceFeedState( val filters: FilterList = FilterList(), val savedSearches: ImmutableList = persistentListOf(), val dialog: SourceFeedScreenModel.Dialog? = null, + // KMK --> + val selection: PersistentList = persistentListOf(), + // KMK <-- ) { val isLoading get() = items.isEmpty() + + // KMK --> + val selectionMode = selection.isNotEmpty() + // KMK <-- }