From e14909fff40360bec99acfb80ac7c1100c62ceb9 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Sun, 27 Nov 2022 03:48:57 +0700 Subject: [PATCH] Use Voyager on Library tab (#8620) --- .../components/CommonMangaItem.kt | 101 +-- .../presentation/library/LibraryScreen.kt | 130 ---- .../presentation/library/LibraryState.kt | 35 - .../library/components/LibraryBadges.kt | 30 +- .../components/LibraryComfortableGrid.kt | 29 +- .../library/components/LibraryCompactGrid.kt | 29 +- .../library/components/LibraryContent.kt | 53 +- .../library/components/LibraryList.kt | 27 +- .../library/components/LibraryPager.kt | 26 +- .../library/components/LibraryTabs.kt | 11 +- .../library/components/LibraryToolbar.kt | 26 +- .../tachiyomi/ui/library/LibraryController.kt | 260 +------- .../tachiyomi/ui/library/LibraryScreen.kt | 270 ++++++++ ...raryPresenter.kt => LibraryScreenModel.kt} | 614 +++++++++++------- .../ui/library/LibrarySettingsSheet.kt | 6 - 15 files changed, 802 insertions(+), 845 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt delete mode 100644 app/src/main/java/eu/kanade/presentation/library/LibraryState.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/library/{LibraryPresenter.kt => LibraryScreenModel.kt} (50%) diff --git a/app/src/main/java/eu/kanade/presentation/components/CommonMangaItem.kt b/app/src/main/java/eu/kanade/presentation/components/CommonMangaItem.kt index 65d2465bb..ff6645547 100644 --- a/app/src/main/java/eu/kanade/presentation/components/CommonMangaItem.kt +++ b/app/src/main/java/eu/kanade/presentation/components/CommonMangaItem.kt @@ -49,6 +49,9 @@ object CommonMangaItemDefaults { } private val ContinueReadingButtonSize = 32.dp +private val ContinueReadingButtonGridPadding = 6.dp +private val ContinueReadingButtonListSpacing = 8.dp + private const val GridSelectedCoverAlpha = 0.76f /** @@ -61,9 +64,8 @@ fun MangaCompactGridItem( title: String? = null, coverData: eu.kanade.domain.manga.model.MangaCover, coverAlpha: Float = 1f, - coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, - coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, - showContinueReadingButton: Boolean = false, + coverBadgeStart: @Composable (RowScope.() -> Unit)? = null, + coverBadgeEnd: @Composable (RowScope.() -> Unit)? = null, onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, @@ -86,12 +88,17 @@ fun MangaCompactGridItem( badgesEnd = coverBadgeEnd, content = { if (title != null) { - CoverTextOverlay(title = title, showContinueReadingButton) - } - }, - continueReadingButton = { - if (showContinueReadingButton && onClickContinueReading != null) { - ContinueReadingButton(onClickContinueReading) + CoverTextOverlay( + title = title, + onClickContinueReading = onClickContinueReading, + ) + } else if (onClickContinueReading != null) { + ContinueReadingButton( + modifier = Modifier + .padding(ContinueReadingButtonGridPadding) + .align(Alignment.BottomEnd), + onClickContinueReading = onClickContinueReading, + ) } }, ) @@ -104,7 +111,7 @@ fun MangaCompactGridItem( @Composable private fun BoxScope.CoverTextOverlay( title: String, - showContinueReadingButton: Boolean = false, + onClickContinueReading: (() -> Unit)? = null, ) { Box( modifier = Modifier @@ -119,20 +126,33 @@ private fun BoxScope.CoverTextOverlay( .fillMaxWidth() .align(Alignment.BottomCenter), ) - val endPadding = if (showContinueReadingButton) ContinueReadingButtonSize else 0.dp - GridItemTitle( - modifier = Modifier - .padding(start = 8.dp, top = 8.dp, end = endPadding + 8.dp, bottom = 8.dp) - .align(Alignment.BottomStart), - title = title, - style = MaterialTheme.typography.titleSmall.copy( - color = Color.White, - shadow = Shadow( - color = Color.Black, - blurRadius = 4f, + Row( + modifier = Modifier.align(Alignment.BottomStart), + verticalAlignment = Alignment.Bottom, + ) { + GridItemTitle( + modifier = Modifier + .weight(1f) + .padding(8.dp), + title = title, + style = MaterialTheme.typography.titleSmall.copy( + color = Color.White, + shadow = Shadow( + color = Color.Black, + blurRadius = 4f, + ), ), - ), - ) + ) + if (onClickContinueReading != null) { + ContinueReadingButton( + modifier = Modifier.padding( + end = ContinueReadingButtonGridPadding, + bottom = ContinueReadingButtonGridPadding, + ), + onClickContinueReading = onClickContinueReading, + ) + } + } } /** @@ -146,7 +166,6 @@ fun MangaComfortableGridItem( coverAlpha: Float = 1f, coverBadgeStart: (@Composable RowScope.() -> Unit)? = null, coverBadgeEnd: (@Composable RowScope.() -> Unit)? = null, - showContinueReadingButton: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, @@ -168,9 +187,14 @@ fun MangaComfortableGridItem( }, badgesStart = coverBadgeStart, badgesEnd = coverBadgeEnd, - continueReadingButton = { - if (showContinueReadingButton && onClickContinueReading != null) { - ContinueReadingButton(onClickContinueReading) + content = { + if (onClickContinueReading != null) { + ContinueReadingButton( + modifier = Modifier + .padding(ContinueReadingButtonGridPadding) + .align(Alignment.BottomEnd), + onClickContinueReading = onClickContinueReading, + ) } }, ) @@ -192,7 +216,6 @@ private fun MangaGridCover( cover: @Composable BoxScope.() -> Unit = {}, badgesStart: (@Composable RowScope.() -> Unit)? = null, badgesEnd: (@Composable RowScope.() -> Unit)? = null, - continueReadingButton: (@Composable BoxScope.() -> Unit)? = null, content: @Composable (BoxScope.() -> Unit)? = null, ) { Box( @@ -219,7 +242,6 @@ private fun MangaGridCover( content = badgesEnd, ) } - continueReadingButton?.invoke(this) } } @@ -310,8 +332,7 @@ fun MangaListItem( title: String, coverData: eu.kanade.domain.manga.model.MangaCover, coverAlpha: Float = 1f, - badge: @Composable RowScope.() -> Unit, - showContinueReadingButton: Boolean = false, + badge: @Composable (RowScope.() -> Unit), onLongClick: () -> Unit, onClick: () -> Unit, onClickContinueReading: (() -> Unit)? = null, @@ -343,23 +364,21 @@ fun MangaListItem( style = MaterialTheme.typography.bodyMedium, ) BadgeGroup(content = badge) - if (showContinueReadingButton && onClickContinueReading != null) { - Box { - ContinueReadingButton(onClickContinueReading) - } + if (onClickContinueReading != null) { + ContinueReadingButton( + modifier = Modifier.padding(start = ContinueReadingButtonListSpacing), + onClickContinueReading = onClickContinueReading, + ) } } } @Composable -private fun BoxScope.ContinueReadingButton( +private fun ContinueReadingButton( + modifier: Modifier = Modifier, onClickContinueReading: () -> Unit, ) { - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(horizontal = 4.dp, vertical = 8.dp), - ) { + Box(modifier = modifier) { FilledIconButton( onClick = onClickContinueReading, modifier = Modifier.size(ContinueReadingButtonSize), diff --git a/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt deleted file mode 100644 index 84bc2ca69..000000000 --- a/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt +++ /dev/null @@ -1,130 +0,0 @@ -package eu.kanade.presentation.library - -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.util.fastAll -import eu.kanade.domain.category.model.Category -import eu.kanade.domain.library.model.LibraryManga -import eu.kanade.domain.library.model.display -import eu.kanade.domain.manga.model.isLocal -import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.EmptyScreenAction -import eu.kanade.presentation.components.LibraryBottomActionMenu -import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.Scaffold -import eu.kanade.presentation.library.components.LibraryContent -import eu.kanade.presentation.library.components.LibraryToolbar -import eu.kanade.presentation.manga.DownloadAction -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.library.LibraryPresenter -import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView - -@Composable -fun LibraryScreen( - presenter: LibraryPresenter, - onMangaClicked: (Long) -> Unit, - onContinueReadingClicked: (LibraryManga) -> Unit, - onGlobalSearchClicked: () -> Unit, - onChangeCategoryClicked: () -> Unit, - onMarkAsReadClicked: () -> Unit, - onMarkAsUnreadClicked: () -> Unit, - onDownloadClicked: (DownloadAction) -> Unit, - onDeleteClicked: () -> Unit, - onClickUnselectAll: () -> Unit, - onClickSelectAll: () -> Unit, - onClickInvertSelection: () -> Unit, - onClickFilter: () -> Unit, - onClickRefresh: (Category?) -> Boolean, - onClickOpenRandomManga: () -> Unit, -) { - val haptic = LocalHapticFeedback.current - - Scaffold( - topBar = { scrollBehavior -> - val title by presenter.getToolbarTitle() - val tabVisible = presenter.tabVisibility && presenter.categories.size > 1 - LibraryToolbar( - state = presenter, - title = title, - incognitoMode = !tabVisible && presenter.isIncognitoMode, - downloadedOnlyMode = !tabVisible && presenter.isDownloadOnly, - onClickUnselectAll = onClickUnselectAll, - onClickSelectAll = onClickSelectAll, - onClickInvertSelection = onClickInvertSelection, - onClickFilter = onClickFilter, - onClickRefresh = { onClickRefresh(null) }, - onClickOpenRandomManga = onClickOpenRandomManga, - scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab - ) - }, - bottomBar = { - LibraryBottomActionMenu( - visible = presenter.selectionMode, - onChangeCategoryClicked = onChangeCategoryClicked, - onMarkAsReadClicked = onMarkAsReadClicked, - onMarkAsUnreadClicked = onMarkAsUnreadClicked, - onDownloadClicked = onDownloadClicked.takeIf { presenter.selection.fastAll { !it.manga.isLocal() } }, - onDeleteClicked = onDeleteClicked, - ) - }, - ) { paddingValues -> - if (presenter.isLoading) { - LoadingScreen() - return@Scaffold - } - - val contentPadding = TachiyomiBottomNavigationView.withBottomNavPadding(paddingValues) - if (presenter.searchQuery.isNullOrEmpty() && presenter.isLibraryEmpty) { - val handler = LocalUriHandler.current - EmptyScreen( - textResource = R.string.information_empty_library, - modifier = Modifier.padding(contentPadding), - actions = listOf( - EmptyScreenAction( - stringResId = R.string.getting_started_guide, - icon = Icons.Outlined.HelpOutline, - onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") }, - ), - ), - ) - return@Scaffold - } - - LibraryContent( - state = presenter, - contentPadding = contentPadding, - currentPage = { presenter.activeCategory }, - isLibraryEmpty = presenter.isLibraryEmpty, - showPageTabs = presenter.tabVisibility, - showMangaCount = presenter.mangaCountVisibility, - onChangeCurrentPage = { presenter.activeCategory = it }, - onMangaClicked = onMangaClicked, - onContinueReadingClicked = onContinueReadingClicked, - onToggleSelection = { presenter.toggleSelection(it) }, - onToggleRangeSelection = { - presenter.toggleRangeSelection(it) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onRefresh = onClickRefresh, - onGlobalSearchClicked = onGlobalSearchClicked, - getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) }, - getDisplayModeForPage = { presenter.categories[it].display }, - getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) }, - getLibraryForPage = { presenter.getMangaForCategory(page = it) }, - showDownloadBadges = presenter.showDownloadBadges, - showUnreadBadges = presenter.showUnreadBadges, - showLocalBadges = presenter.showLocalBadges, - showLanguageBadges = presenter.showLanguageBadges, - showContinueReadingButton = presenter.showContinueReadingButton, - isIncognitoMode = presenter.isIncognitoMode, - isDownloadOnly = presenter.isDownloadOnly, - ) - } -} diff --git a/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt b/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt deleted file mode 100644 index 681343bfe..000000000 --- a/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt +++ /dev/null @@ -1,35 +0,0 @@ -package eu.kanade.presentation.library - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import eu.kanade.domain.category.model.Category -import eu.kanade.domain.library.model.LibraryManga -import eu.kanade.tachiyomi.ui.library.LibraryPresenter - -@Stable -interface LibraryState { - val isLoading: Boolean - val categories: List - var searchQuery: String? - val selection: List - val selectionMode: Boolean - var hasActiveFilters: Boolean - var dialog: LibraryPresenter.Dialog? -} - -fun LibraryState(): LibraryState { - return LibraryStateImpl() -} - -class LibraryStateImpl : LibraryState { - override var isLoading: Boolean by mutableStateOf(true) - override var categories: List by mutableStateOf(emptyList()) - override var searchQuery: String? by mutableStateOf(null) - override var selection: List by mutableStateOf(emptyList()) - override val selectionMode: Boolean by derivedStateOf { selection.isNotEmpty() } - override var hasActiveFilters: Boolean by mutableStateOf(false) - override var dialog: LibraryPresenter.Dialog? by mutableStateOf(null) -} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt index 878f78d67..2446646e6 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryBadges.kt @@ -5,16 +5,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.Badge import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.library.LibraryItem @Composable -fun DownloadsBadge( - enabled: Boolean, - item: LibraryItem, -) { - if (enabled && item.downloadCount > 0) { +fun DownloadsBadge(count: Int) { + if (count > 0) { Badge( - text = "${item.downloadCount}", + text = "$count", color = MaterialTheme.colorScheme.tertiary, textColor = MaterialTheme.colorScheme.onTertiary, ) @@ -22,30 +18,26 @@ fun DownloadsBadge( } @Composable -fun UnreadBadge( - enabled: Boolean, - item: LibraryItem, -) { - if (enabled && item.unreadCount > 0) { - Badge(text = "${item.unreadCount}") +fun UnreadBadge(count: Int) { + if (count > 0) { + Badge(text = "$count") } } @Composable fun LanguageBadge( - showLanguage: Boolean, - showLocal: Boolean, - item: LibraryItem, + isLocal: Boolean, + sourceLanguage: String, ) { - if (showLocal && item.isLocal) { + if (isLocal) { Badge( text = stringResource(R.string.local_source_badge), color = MaterialTheme.colorScheme.tertiary, textColor = MaterialTheme.colorScheme.onTertiary, ) - } else if (showLanguage && item.sourceLanguage.isNotEmpty()) { + } else if (sourceLanguage.isNotEmpty()) { Badge( - text = item.sourceLanguage.uppercase(), + text = sourceLanguage.uppercase(), color = MaterialTheme.colorScheme.tertiary, textColor = MaterialTheme.colorScheme.onTertiary, ) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt index beb9ba51a..ee3950146 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt @@ -14,17 +14,12 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem @Composable fun LibraryComfortableGrid( items: List, - showDownloadBadges: Boolean, - showUnreadBadges: Boolean, - showLocalBadges: Boolean, - showLanguageBadges: Boolean, - showContinueReadingButton: Boolean, columns: Int, contentPadding: PaddingValues, selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, - onClickContinueReading: (LibraryManga) -> Unit, + onClickContinueReading: ((LibraryManga) -> Unit)?, searchQuery: String?, onGlobalSearchClicked: () -> Unit, ) { @@ -51,26 +46,22 @@ fun LibraryComfortableGrid( lastModified = manga.coverLastModified, ), coverBadgeStart = { - DownloadsBadge( - enabled = showDownloadBadges, - item = libraryItem, - ) - UnreadBadge( - enabled = showUnreadBadges, - item = libraryItem, - ) + DownloadsBadge(count = libraryItem.downloadCount.toInt()) + UnreadBadge(count = libraryItem.unreadCount.toInt()) }, coverBadgeEnd = { LanguageBadge( - showLanguage = showLanguageBadges, - showLocal = showLocalBadges, - item = libraryItem, + isLocal = libraryItem.isLocal, + sourceLanguage = libraryItem.sourceLanguage, ) }, - showContinueReadingButton = showContinueReadingButton, onLongClick = { onLongClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) }, - onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, + onClickContinueReading = if (onClickContinueReading != null) { + { onClickContinueReading(libraryItem.libraryManga) } + } else { + null + }, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt index 6dd62fda3..de3309325 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt @@ -15,17 +15,12 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem fun LibraryCompactGrid( items: List, showTitle: Boolean, - showDownloadBadges: Boolean, - showUnreadBadges: Boolean, - showLocalBadges: Boolean, - showLanguageBadges: Boolean, - showContinueReadingButton: Boolean, columns: Int, contentPadding: PaddingValues, selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, - onClickContinueReading: (LibraryManga) -> Unit, + onClickContinueReading: ((LibraryManga) -> Unit)?, searchQuery: String?, onGlobalSearchClicked: () -> Unit, ) { @@ -52,26 +47,22 @@ fun LibraryCompactGrid( lastModified = manga.coverLastModified, ), coverBadgeStart = { - DownloadsBadge( - enabled = showDownloadBadges, - item = libraryItem, - ) - UnreadBadge( - enabled = showUnreadBadges, - item = libraryItem, - ) + DownloadsBadge(count = libraryItem.downloadCount.toInt()) + UnreadBadge(count = libraryItem.unreadCount.toInt()) }, coverBadgeEnd = { LanguageBadge( - showLanguage = showLanguageBadges, - showLocal = showLocalBadges, - item = libraryItem, + isLocal = libraryItem.isLocal, + sourceLanguage = libraryItem.sourceLanguage, ) }, - showContinueReadingButton = showContinueReadingButton, onLongClick = { onLongClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) }, - onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, + onClickContinueReading = if (onClickContinueReading != null) { + { onClickContinueReading(libraryItem.libraryManga) } + } else { + null + }, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt index b12774ce9..051a2bb01 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,7 +20,6 @@ import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.library.model.LibraryManga import eu.kanade.presentation.components.SwipeRefresh import eu.kanade.presentation.components.rememberPagerState -import eu.kanade.presentation.library.LibraryState import eu.kanade.tachiyomi.ui.library.LibraryItem import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -29,28 +27,24 @@ import kotlin.time.Duration.Companion.seconds @Composable fun LibraryContent( - state: LibraryState, + categories: List, + searchQuery: String?, + selection: List, contentPadding: PaddingValues, currentPage: () -> Int, isLibraryEmpty: Boolean, showPageTabs: Boolean, - showMangaCount: Boolean, onChangeCurrentPage: (Int) -> Unit, onMangaClicked: (Long) -> Unit, - onContinueReadingClicked: (LibraryManga) -> Unit, + onContinueReadingClicked: ((LibraryManga) -> Unit)?, onToggleSelection: (LibraryManga) -> Unit, onToggleRangeSelection: (LibraryManga) -> Unit, onRefresh: (Category?) -> Boolean, onGlobalSearchClicked: () -> Unit, - getNumberOfMangaForCategory: @Composable (Long) -> State, + getNumberOfMangaForCategory: (Category) -> Int?, getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, - getLibraryForPage: @Composable (Int) -> List, - showDownloadBadges: Boolean, - showUnreadBadges: Boolean, - showLocalBadges: Boolean, - showLanguageBadges: Boolean, - showContinueReadingButton: Boolean, + getLibraryForPage: (Int) -> List, isDownloadOnly: Boolean, isIncognitoMode: Boolean, ) { @@ -61,38 +55,30 @@ fun LibraryContent( end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), ), ) { - val categories = state.categories val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } val pagerState = rememberPagerState(coercedCurrentPage) val scope = rememberCoroutineScope() var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } - if (isLibraryEmpty.not() && showPageTabs && categories.size > 1) { + if (!isLibraryEmpty && showPageTabs && categories.size > 1) { LibraryTabs( categories = categories, currentPageIndex = pagerState.currentPage, - showMangaCount = showMangaCount, - getNumberOfMangaForCategory = getNumberOfMangaForCategory, isDownloadOnly = isDownloadOnly, isIncognitoMode = isIncognitoMode, - onTabItemClick = { scope.launch { pagerState.animateScrollToPage(it) } }, - ) + getNumberOfMangaForCategory = getNumberOfMangaForCategory, + ) { scope.launch { pagerState.animateScrollToPage(it) } } } + val notSelectionMode = selection.isEmpty() val onClickManga = { manga: LibraryManga -> - if (state.selectionMode.not()) { + if (notSelectionMode) { onMangaClicked(manga.manga.id) } else { onToggleSelection(manga) } } - val onLongClickManga = { manga: LibraryManga -> - onToggleRangeSelection(manga) - } - val onClickContinueReading = { manga: LibraryManga -> - onContinueReadingClicked(manga) - } SwipeRefresh( refreshing = isRefreshing, @@ -106,26 +92,21 @@ fun LibraryContent( isRefreshing = false } }, - enabled = state.selectionMode.not(), + enabled = notSelectionMode, ) { LibraryPager( state = pagerState, contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), pageCount = categories.size, - selectedManga = state.selection, + selectedManga = selection, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, getDisplayModeForPage = getDisplayModeForPage, getColumnsForOrientation = getColumnsForOrientation, getLibraryForPage = getLibraryForPage, - showDownloadBadges = showDownloadBadges, - showUnreadBadges = showUnreadBadges, - showLocalBadges = showLocalBadges, - showLanguageBadges = showLanguageBadges, - showContinueReadingButton = showContinueReadingButton, onClickManga = onClickManga, - onLongClickManga = onLongClickManga, - onClickContinueReading = onClickContinueReading, - onGlobalSearchClicked = onGlobalSearchClicked, - searchQuery = state.searchQuery, + onLongClickManga = onToggleRangeSelection, + onClickContinueReading = onContinueReadingClicked, ) } diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt index d4f04d453..be8015020 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt @@ -23,16 +23,11 @@ import eu.kanade.tachiyomi.ui.library.LibraryItem @Composable fun LibraryList( items: List, - showDownloadBadges: Boolean, - showUnreadBadges: Boolean, - showLocalBadges: Boolean, - showLanguageBadges: Boolean, - showContinueReadingButton: Boolean, contentPadding: PaddingValues, selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, - onClickContinueReading: (LibraryManga) -> Unit, + onClickContinueReading: ((LibraryManga) -> Unit)?, searchQuery: String?, onGlobalSearchClicked: () -> Unit, ) { @@ -41,13 +36,13 @@ fun LibraryList( contentPadding = contentPadding + PaddingValues(vertical = 8.dp), ) { item { - if (searchQuery.isNullOrEmpty().not()) { + if (!searchQuery.isNullOrEmpty()) { TextButton( modifier = Modifier.fillMaxWidth(), onClick = onGlobalSearchClicked, ) { Text( - text = stringResource(R.string.action_global_search_query, searchQuery!!), + text = stringResource(R.string.action_global_search_query, searchQuery), modifier = Modifier.zIndex(99f), ) } @@ -70,14 +65,20 @@ fun LibraryList( lastModified = manga.coverLastModified, ), badge = { - DownloadsBadge(enabled = showDownloadBadges, item = libraryItem) - UnreadBadge(enabled = showUnreadBadges, item = libraryItem) - LanguageBadge(showLanguage = showLanguageBadges, showLocal = showLocalBadges, item = libraryItem) + DownloadsBadge(count = libraryItem.downloadCount.toInt()) + UnreadBadge(count = libraryItem.unreadCount.toInt()) + LanguageBadge( + isLocal = libraryItem.isLocal, + sourceLanguage = libraryItem.sourceLanguage, + ) }, - showContinueReadingButton = showContinueReadingButton, onLongClick = { onLongClick(libraryItem.libraryManga) }, onClick = { onClick(libraryItem.libraryManga) }, - onClickContinueReading = { onClickContinueReading(libraryItem.libraryManga) }, + onClickContinueReading = if (onClickContinueReading != null) { + { onClickContinueReading(libraryItem.libraryManga) } + } else { + null + }, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt index 40618a689..7c5be66ac 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -27,15 +27,10 @@ fun LibraryPager( onGlobalSearchClicked: () -> Unit, getDisplayModeForPage: @Composable (Int) -> LibraryDisplayMode, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, - getLibraryForPage: @Composable (Int) -> List, - showDownloadBadges: Boolean, - showUnreadBadges: Boolean, - showLocalBadges: Boolean, - showLanguageBadges: Boolean, - showContinueReadingButton: Boolean, + getLibraryForPage: (Int) -> List, onClickManga: (LibraryManga) -> Unit, onLongClickManga: (LibraryManga) -> Unit, - onClickContinueReading: (LibraryManga) -> Unit, + onClickContinueReading: ((LibraryManga) -> Unit)?, ) { HorizontalPager( count = pageCount, @@ -62,11 +57,6 @@ fun LibraryPager( LibraryDisplayMode.List -> { LibraryList( items = library, - showDownloadBadges = showDownloadBadges, - showUnreadBadges = showUnreadBadges, - showLocalBadges = showLocalBadges, - showLanguageBadges = showLanguageBadges, - showContinueReadingButton = showContinueReadingButton, contentPadding = contentPadding, selection = selectedManga, onClick = onClickManga, @@ -80,11 +70,6 @@ fun LibraryPager( LibraryCompactGrid( items = library, showTitle = displayMode is LibraryDisplayMode.CompactGrid, - showDownloadBadges = showDownloadBadges, - showUnreadBadges = showUnreadBadges, - showLocalBadges = showLocalBadges, - showLanguageBadges = showLanguageBadges, - showContinueReadingButton = showContinueReadingButton, columns = columns, contentPadding = contentPadding, selection = selectedManga, @@ -98,17 +83,12 @@ fun LibraryPager( LibraryDisplayMode.ComfortableGrid -> { LibraryComfortableGrid( items = library, - showDownloadBadges = showDownloadBadges, - showUnreadBadges = showUnreadBadges, - showLocalBadges = showLocalBadges, - showLanguageBadges = showLanguageBadges, - showContinueReadingButton = showContinueReadingButton, columns = columns, contentPadding = contentPadding, selection = selectedManga, onClick = onClickManga, - onClickContinueReading = onClickContinueReading, onLongClick = onLongClickManga, + onClickContinueReading = onClickContinueReading, searchQuery = searchQuery, onGlobalSearchClicked = onGlobalSearchClicked, ) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt index f23ca01f4..d2344d0d2 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt @@ -5,8 +5,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp import eu.kanade.domain.category.model.Category import eu.kanade.presentation.category.visualName @@ -19,10 +17,9 @@ import eu.kanade.presentation.components.TabText fun LibraryTabs( categories: List, currentPageIndex: Int, - showMangaCount: Boolean, isDownloadOnly: Boolean, isIncognitoMode: Boolean, - getNumberOfMangaForCategory: @Composable (Long) -> State, + getNumberOfMangaForCategory: (Category) -> Int?, onTabItemClick: (Int) -> Unit, ) { Column { @@ -41,11 +38,7 @@ fun LibraryTabs( text = { TabText( text = category.visualName, - badgeCount = if (showMangaCount) { - getNumberOfMangaForCategory(category.id) - } else { - null - }?.value, + badgeCount = getNumberOfMangaForCategory(category), ) }, unselectedContentColor = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt index b44f9cedb..c96fc62c6 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,13 +24,13 @@ import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.OverflowMenu import eu.kanade.presentation.components.Pill import eu.kanade.presentation.components.SearchToolbar -import eu.kanade.presentation.library.LibraryState import eu.kanade.presentation.theme.active import eu.kanade.tachiyomi.R @Composable fun LibraryToolbar( - state: LibraryState, + hasActiveFilters: Boolean, + selectedCount: Int, title: LibraryToolbarTitle, incognitoMode: Boolean, downloadedOnlyMode: Boolean, @@ -39,10 +40,12 @@ fun LibraryToolbar( onClickFilter: () -> Unit, onClickRefresh: () -> Unit, onClickOpenRandomManga: () -> Unit, + searchQuery: String?, + onSearchQueryChange: (String?) -> Unit, scrollBehavior: TopAppBarScrollBehavior?, ) = when { - state.selectionMode -> LibrarySelectionToolbar( - state = state, + selectedCount > 0 -> LibrarySelectionToolbar( + selectedCount = selectedCount, incognitoMode = incognitoMode, downloadedOnlyMode = downloadedOnlyMode, onClickUnselectAll = onClickUnselectAll, @@ -51,11 +54,11 @@ fun LibraryToolbar( ) else -> LibraryRegularToolbar( title = title, - hasFilters = state.hasActiveFilters, + hasFilters = hasActiveFilters, incognitoMode = incognitoMode, downloadedOnlyMode = downloadedOnlyMode, - searchQuery = state.searchQuery, - onChangeSearchQuery = { state.searchQuery = it }, + searchQuery = searchQuery, + onSearchQueryChange = onSearchQueryChange, onClickFilter = onClickFilter, onClickRefresh = onClickRefresh, onClickOpenRandomManga = onClickOpenRandomManga, @@ -70,7 +73,7 @@ fun LibraryRegularToolbar( incognitoMode: Boolean, downloadedOnlyMode: Boolean, searchQuery: String?, - onChangeSearchQuery: (String?) -> Unit, + onSearchQueryChange: (String?) -> Unit, onClickFilter: () -> Unit, onClickRefresh: () -> Unit, onClickOpenRandomManga: () -> Unit, @@ -96,7 +99,7 @@ fun LibraryRegularToolbar( } }, searchQuery = searchQuery, - onChangeSearchQuery = onChangeSearchQuery, + onChangeSearchQuery = onSearchQueryChange, actions = { val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current IconButton(onClick = onClickFilter) { @@ -128,7 +131,7 @@ fun LibraryRegularToolbar( @Composable fun LibrarySelectionToolbar( - state: LibraryState, + selectedCount: Int, incognitoMode: Boolean, downloadedOnlyMode: Boolean, onClickUnselectAll: () -> Unit, @@ -136,7 +139,7 @@ fun LibrarySelectionToolbar( onClickInvertSelection: () -> Unit, ) { AppBar( - titleContent = { Text(text = "${state.selection.size}") }, + titleContent = { Text(text = "$selectedCount") }, actions = { IconButton(onClick = onClickSelectAll) { Icon(Icons.Outlined.SelectAll, contentDescription = stringResource(R.string.action_select_all)) @@ -152,6 +155,7 @@ fun LibrarySelectionToolbar( ) } +@Immutable data class LibraryToolbarTitle( val text: String, val numberOfManga: Int? = null, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 1b7738da5..6209a1a0b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -1,167 +1,37 @@ package eu.kanade.tachiyomi.ui.library import android.os.Bundle -import android.view.Menu import android.view.View import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import com.bluelinelabs.conductor.ControllerChangeHandler -import com.bluelinelabs.conductor.ControllerChangeType -import eu.kanade.core.prefs.CheckboxState -import eu.kanade.domain.chapter.model.Chapter -import eu.kanade.domain.library.model.LibraryManga -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.isLocal -import eu.kanade.presentation.components.ChangeCategoryDialog -import eu.kanade.presentation.components.DeleteLibraryMangaDialog -import eu.kanade.presentation.library.LibraryScreen -import eu.kanade.presentation.manga.DownloadAction -import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.domain.category.model.Category +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController import eu.kanade.tachiyomi.ui.base.controller.RootController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController -import eu.kanade.tachiyomi.ui.category.CategoryController -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.launchUI -import eu.kanade.tachiyomi.util.lang.withUIContext -import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class LibraryController( bundle: Bundle? = null, -) : FullComposeController(bundle), RootController { +) : BasicFullComposeController(bundle), RootController { /** * Sheet containing filter/sort/display items. */ private var settingsSheet: LibrarySettingsSheet? = null - override fun createPresenter(): LibraryPresenter = LibraryPresenter() - @Composable override fun ComposeContent() { - val context = LocalContext.current - val getMangaForCategory = presenter.getMangaForCategory(page = presenter.activeCategory) - - LibraryScreen( - presenter = presenter, - onMangaClicked = ::openManga, - onContinueReadingClicked = ::continueReading, - onGlobalSearchClicked = { - router.pushController(GlobalSearchController(presenter.searchQuery)) - }, - onChangeCategoryClicked = ::showMangaCategoriesDialog, - onMarkAsReadClicked = { markReadStatus(true) }, - onMarkAsUnreadClicked = { markReadStatus(false) }, - onDownloadClicked = ::runDownloadChapterAction, - onDeleteClicked = ::showDeleteMangaDialog, - onClickFilter = ::showSettingsSheet, - onClickRefresh = { - val started = LibraryUpdateService.start(context, it) - context.toast(if (started) R.string.updating_category else R.string.update_already_running) - started - }, - onClickOpenRandomManga = { - val items = getMangaForCategory.map { it.libraryManga.manga.id } - if (getMangaForCategory.isNotEmpty()) { - openManga(items.random()) - } else { - context.toast(R.string.information_no_entries_found) - } - }, - onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) }, - onClickSelectAll = { presenter.selectAll(presenter.activeCategory) }, - onClickUnselectAll = ::clearSelection, - ) - - val onDismissRequest = { presenter.dialog = null } - when (val dialog = presenter.dialog) { - is LibraryPresenter.Dialog.ChangeCategory -> { - ChangeCategoryDialog( - initialSelection = dialog.initialSelection, - onDismissRequest = onDismissRequest, - onEditCategories = { - presenter.clearSelection() - router.pushController(CategoryController()) - }, - onConfirm = { include, exclude -> - presenter.clearSelection() - presenter.setMangaCategories(dialog.manga, include, exclude) - }, - ) - } - is LibraryPresenter.Dialog.DeleteManga -> { - DeleteLibraryMangaDialog( - containsLocalManga = dialog.manga.any(Manga::isLocal), - onDismissRequest = onDismissRequest, - onConfirm = { deleteManga, deleteChapter -> - presenter.removeMangas(dialog.manga, deleteManga, deleteChapter) - presenter.clearSelection() - }, - ) - } - is LibraryPresenter.Dialog.DownloadCustomAmount -> { - DownloadCustomAmountDialog( - maxAmount = dialog.max, - onDismissRequest = onDismissRequest, - onConfirm = { amount -> - presenter.downloadUnreadChapters(dialog.manga, amount) - presenter.clearSelection() - }, - ) - } - null -> {} - } - - LaunchedEffect(presenter.selectionMode) { - // Could perhaps be removed when navigation is in a Compose world - if (router.backstackSize == 1) { - (activity as? MainActivity)?.showBottomNav(presenter.selectionMode.not()) - } - } - LaunchedEffect(presenter.isLoading) { - if (!presenter.isLoading) { - (activity as? MainActivity)?.ready = true - } - } - } - - override fun handleBack(): Boolean { - return when { - presenter.selection.isNotEmpty() -> { - presenter.clearSelection() - true - } - presenter.searchQuery != null -> { - presenter.searchQuery = null - true - } - else -> false - } + Navigator(screen = LibraryScreen) } override fun onViewCreated(view: View) { super.onViewCreated(view) - settingsSheet = LibrarySettingsSheet(router) { group -> - when (group) { - is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() - else -> {} // Handled via different mechanisms - } - } - } - - override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeStarted(handler, type) - if (type.isEnter) { - presenter.subscribeLibrary() + settingsSheet = LibrarySettingsSheet(router) + viewScope.launch { + LibraryScreen.openSettingsSheetEvent + .collectLatest(::showSettingsSheet) } } @@ -171,111 +41,13 @@ class LibraryController( super.onDestroyView(view) } - fun showSettingsSheet() { - presenter.categories.getOrNull(presenter.activeCategory)?.let { category -> + fun showSettingsSheet(category: Category? = null) { + if (category != null) { settingsSheet?.show(category) + } else { + viewScope.launch { LibraryScreen.requestOpenSettingsSheet() } } } - private fun onFilterChanged() { - viewScope.launchUI { - presenter.requestFilterUpdate() - activity?.invalidateOptionsMenu() - } - } - - fun search(query: String) { - presenter.searchQuery = query - } - - override fun onPrepareOptionsMenu(menu: Menu) { - val settingsSheet = settingsSheet ?: return - presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters() - } - - private fun openManga(mangaId: Long) { - presenter.onOpenManga() - router.pushController(MangaController(mangaId)) - } - - private fun continueReading(libraryManga: LibraryManga) { - viewScope.launchIO { - val chapter = presenter.getNextUnreadChapter(libraryManga.manga) - if (chapter != null) { - openChapter(chapter) - } else { - withUIContext { activity?.toast(R.string.no_next_chapter) } - } - } - } - - private fun openChapter(chapter: Chapter) { - activity?.run { - startActivity(ReaderActivity.newIntent(this, chapter.mangaId, chapter.id)) - } - } - - /** - * Clear all of the manga currently selected, and - * invalidate the action mode to revert the top toolbar - */ - private fun clearSelection() { - presenter.clearSelection() - } - - /** - * Move the selected manga to a list of categories. - */ - private fun showMangaCategoriesDialog() { - viewScope.launchIO { - // Create a copy of selected manga - val mangaList = presenter.selection.map { it.manga } - - // Hide the default category because it has a different behavior than the ones from db. - val categories = presenter.categories.filter { it.id != 0L } - - // Get indexes of the common categories to preselect. - val common = presenter.getCommonCategories(mangaList) - // Get indexes of the mix categories to preselect. - val mix = presenter.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) - } - } - presenter.dialog = LibraryPresenter.Dialog.ChangeCategory(mangaList, preselected) - } - } - - private fun runDownloadChapterAction(action: DownloadAction) { - val mangas = presenter.selection.map { it.manga }.toList() - when (action) { - DownloadAction.NEXT_1_CHAPTER -> presenter.downloadUnreadChapters(mangas, 1) - DownloadAction.NEXT_5_CHAPTERS -> presenter.downloadUnreadChapters(mangas, 5) - DownloadAction.NEXT_10_CHAPTERS -> presenter.downloadUnreadChapters(mangas, 10) - DownloadAction.UNREAD_CHAPTERS -> presenter.downloadUnreadChapters(mangas, null) - DownloadAction.CUSTOM -> { - presenter.dialog = LibraryPresenter.Dialog.DownloadCustomAmount( - mangas, - presenter.selection.maxOf { it.unreadCount }.toInt(), - ) - return - } - else -> {} - } - presenter.clearSelection() - } - - private fun markReadStatus(read: Boolean) { - val mangaList = presenter.selection.toList() - presenter.markReadStatus(mangaList.map { it.manga }, read) - presenter.clearSelection() - } - - private fun showDeleteMangaDialog() { - val mangaList = presenter.selection.map { it.manga } - presenter.dialog = LibraryPresenter.Dialog.DeleteManga(mangaList) - } + fun search(query: String) = LibraryScreen.search(query) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt new file mode 100644 index 000000000..41149f130 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreen.kt @@ -0,0 +1,270 @@ +package eu.kanade.tachiyomi.ui.library + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.util.fastAll +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.currentOrThrow +import com.bluelinelabs.conductor.Router +import eu.kanade.domain.category.model.Category +import eu.kanade.domain.library.model.LibraryManga +import eu.kanade.domain.library.model.display +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.isLocal +import eu.kanade.presentation.components.ChangeCategoryDialog +import eu.kanade.presentation.components.DeleteLibraryMangaDialog +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.EmptyScreenAction +import eu.kanade.presentation.components.LibraryBottomActionMenu +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.library.components.LibraryContent +import eu.kanade.presentation.library.components.LibraryToolbar +import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object LibraryScreen : Screen { + + @Composable + override fun Content() { + val router = LocalRouter.currentOrThrow + val context = LocalContext.current + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + val screenModel = rememberScreenModel { LibraryScreenModel() } + val state by screenModel.state.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val onClickRefresh: (Category?) -> Boolean = { + val started = LibraryUpdateService.start(context, it) + scope.launch { + val msgRes = if (started) R.string.updating_category else R.string.update_already_running + snackbarHostState.showSnackbar(context.getString(msgRes)) + } + started + } + val onClickFilter: () -> Unit = { + scope.launch { sendSettingsSheetIntent(state.categories[screenModel.activeCategory]) } + } + + Scaffold( + topBar = { scrollBehavior -> + val title = state.getToolbarTitle( + defaultTitle = stringResource(R.string.label_library), + defaultCategoryTitle = stringResource(R.string.label_default), + page = screenModel.activeCategory, + ) + val tabVisible = state.showCategoryTabs && state.categories.size > 1 + LibraryToolbar( + hasActiveFilters = state.hasActiveFilters, + selectedCount = state.selection.size, + title = title, + incognitoMode = !tabVisible && screenModel.isIncognitoMode, + downloadedOnlyMode = !tabVisible && screenModel.isDownloadOnly, + onClickUnselectAll = screenModel::clearSelection, + onClickSelectAll = { screenModel.selectAll(screenModel.activeCategory) }, + onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategory) }, + onClickFilter = onClickFilter, + onClickRefresh = { onClickRefresh(null) }, + onClickOpenRandomManga = { + scope.launch { + val randomItem = screenModel.getRandomLibraryItemForCurrentCategory() + if (randomItem != null) { + router.openManga(randomItem.libraryManga.manga.id) + } else { + snackbarHostState.showSnackbar(context.getString(R.string.information_no_entries_found)) + } + } + }, + searchQuery = state.searchQuery, + onSearchQueryChange = screenModel::search, + scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab + ) + }, + bottomBar = { + LibraryBottomActionMenu( + visible = state.selectionMode, + onChangeCategoryClicked = screenModel::openChangeCategoryDialog, + onMarkAsReadClicked = { screenModel.markReadSelection(true) }, + onMarkAsUnreadClicked = { screenModel.markReadSelection(false) }, + onDownloadClicked = screenModel::runDownloadActionSelection + .takeIf { state.selection.fastAll { !it.manga.isLocal() } }, + onDeleteClicked = screenModel::openDeleteMangaDialog, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets), + ) { contentPadding -> + if (state.isLoading) { + LoadingScreen(modifier = Modifier.padding(contentPadding)) + return@Scaffold + } + + if (state.searchQuery.isNullOrEmpty() && state.library.isEmpty()) { + val handler = LocalUriHandler.current + EmptyScreen( + textResource = R.string.information_empty_library, + modifier = Modifier.padding(contentPadding), + actions = listOf( + EmptyScreenAction( + stringResId = R.string.getting_started_guide, + icon = Icons.Outlined.HelpOutline, + onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") }, + ), + ), + ) + return@Scaffold + } + + LibraryContent( + categories = state.categories, + searchQuery = state.searchQuery, + selection = state.selection, + contentPadding = contentPadding, + currentPage = { screenModel.activeCategory }, + isLibraryEmpty = state.library.isEmpty(), + showPageTabs = state.showCategoryTabs, + onChangeCurrentPage = { screenModel.activeCategory = it }, + onMangaClicked = { router.openManga(it) }, + onContinueReadingClicked = { it: LibraryManga -> + scope.launchIO { + val chapter = screenModel.getNextUnreadChapter(it.manga) + if (chapter != null) { + context.startActivity(ReaderActivity.newIntent(context, chapter.mangaId, chapter.id)) + } else { + snackbarHostState.showSnackbar(context.getString(R.string.no_next_chapter)) + } + } + Unit + }.takeIf { state.showMangaContinueButton }, + onToggleSelection = { screenModel.toggleSelection(it) }, + onToggleRangeSelection = { + screenModel.toggleRangeSelection(it) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onRefresh = onClickRefresh, + onGlobalSearchClicked = { + router.pushController(GlobalSearchController(screenModel.state.value.searchQuery)) + }, + getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, + getDisplayModeForPage = { state.categories[it].display }, + getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) }, + getLibraryForPage = { state.getLibraryItemsByPage(it) }, + isDownloadOnly = screenModel.isDownloadOnly, + isIncognitoMode = screenModel.isIncognitoMode, + ) + } + + val onDismissRequest = screenModel::closeDialog + when (val dialog = state.dialog) { + is LibraryScreenModel.Dialog.ChangeCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { + screenModel.clearSelection() + router.pushController(CategoryController()) + }, + onConfirm = { include, exclude -> + screenModel.clearSelection() + screenModel.setMangaCategories(dialog.manga, include, exclude) + }, + ) + } + is LibraryScreenModel.Dialog.DeleteManga -> { + DeleteLibraryMangaDialog( + containsLocalManga = dialog.manga.any(Manga::isLocal), + onDismissRequest = onDismissRequest, + onConfirm = { deleteManga, deleteChapter -> + screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter) + screenModel.clearSelection() + }, + ) + } + is LibraryScreenModel.Dialog.DownloadCustomAmount -> { + DownloadCustomAmountDialog( + maxAmount = dialog.max, + onDismissRequest = onDismissRequest, + onConfirm = { amount -> + screenModel.downloadUnreadChapters(dialog.manga, amount) + screenModel.clearSelection() + }, + ) + } + null -> {} + } + + BackHandler(enabled = state.selectionMode || state.searchQuery != null) { + when { + state.selectionMode -> screenModel.clearSelection() + state.searchQuery != null -> screenModel.search(null) + } + } + + LaunchedEffect(state.selectionMode) { + // Could perhaps be removed when navigation is in a Compose world + if (router.backstackSize == 1) { + (context as? MainActivity)?.showBottomNav(!state.selectionMode) + } + } + LaunchedEffect(state.isLoading) { + if (!state.isLoading) { + (context as? MainActivity)?.ready = true + } + } + + LaunchedEffect(Unit) { + launch { queryEvent.collectLatest(screenModel::search) } + launch { requestSettingsSheetEvent.collectLatest { onClickFilter() } } + } + } + + private fun Router.openManga(mangaId: Long) { + pushController(MangaController(mangaId)) + } + + // For invoking search from other screen + private val queryEvent = MutableSharedFlow(replay = 1) + fun search(query: String) = queryEvent.tryEmit(query) + + // For opening settings sheet in LibraryController + private val requestSettingsSheetEvent = MutableSharedFlow() + private val openSettingsSheetEvent_ = MutableSharedFlow() + val openSettingsSheetEvent = openSettingsSheetEvent_.asSharedFlow() + private suspend fun sendSettingsSheetIntent(category: Category) = openSettingsSheetEvent_.emit(category) + suspend fun requestOpenSettingsSheet() = requestSettingsSheetEvent.emit(Unit) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt similarity index 50% rename from app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index f0640be58..aa09dfd2c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -1,18 +1,15 @@ package eu.kanade.tachiyomi.ui.library -import android.os.Bundle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope import eu.kanade.core.prefs.CheckboxState import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.core.prefs.asState import eu.kanade.core.util.fastFilter import eu.kanade.core.util.fastFilterNot import eu.kanade.core.util.fastMapNotNull @@ -35,11 +32,8 @@ import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.MangaUpdate import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.track.interactor.GetTracksPerManga -import eu.kanade.presentation.category.visualName -import eu.kanade.presentation.library.LibraryState -import eu.kanade.presentation.library.LibraryStateImpl import eu.kanade.presentation.library.components.LibraryToolbarTitle -import eu.kanade.tachiyomi.R +import eu.kanade.presentation.manga.DownloadAction import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager @@ -47,38 +41,33 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.removeCovers -import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel +import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.text.Collator import java.util.Collections import java.util.Locale -/** - * Class containing library information. - */ -private data class Library(val categories: List, val mangaMap: LibraryMap) - /** * Typealias for the library manga, using the category as keys, and list of manga as values. */ -typealias LibraryMap = Map> +typealias LibraryMap = Map> -class LibraryPresenter( - private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl, +class LibraryScreenModel( private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), private val getTracksPerManga: GetTracksPerManga = Injekt.get(), @@ -94,90 +83,114 @@ class LibraryPresenter( private val downloadManager: DownloadManager = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(), private val trackManager: TrackManager = Injekt.get(), -) : BasePresenter(), LibraryState by state { +) : StateScreenModel(State()) { - private var loadedManga by mutableStateOf(emptyMap>()) + // This is active category INDEX NUMBER + var activeCategory: Int by libraryPreferences.lastUsedCategory().asState(coroutineScope) - val isLibraryEmpty by derivedStateOf { loadedManga.isEmpty() } + val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope) + val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope) - val tabVisibility by libraryPreferences.categoryTabs().asState() - val mangaCountVisibility by libraryPreferences.categoryNumberOfItems().asState() - - val showDownloadBadges by libraryPreferences.downloadBadge().asState() - val showUnreadBadges by libraryPreferences.unreadBadge().asState() - val showLocalBadges by libraryPreferences.localBadge().asState() - val showLanguageBadges by libraryPreferences.languageBadge().asState() - - var activeCategory: Int by libraryPreferences.lastUsedCategory().asState() - - val showContinueReadingButton by libraryPreferences.showContinueReadingButton().asState() - - val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() - val isIncognitoMode: Boolean by preferences.incognitoMode().asState() - - private val _filterChanges: Channel = Channel(Int.MAX_VALUE) - private val filterChanges = _filterChanges.receiveAsFlow().onStart { emit(Unit) } - - private var librarySubscription: Job? = null - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - subscribeLibrary() - } - - fun subscribeLibrary() { - /** - * TODO: - * - Move filter and sort to getMangaForCategory and only filter and sort the current display category instead of whole library as some has 5000+ items in the library - * - Create new db view and new query to just fetch the current category save as needed to instance variable - * - Fetch badges to maps and retrieve as needed instead of fetching all of them at once - */ - if (librarySubscription == null || librarySubscription!!.isCancelled) { - librarySubscription = presenterScope.launchIO { - combine(getLibraryFlow(), getTracksPerManga.subscribe(), filterChanges) { library, tracks, _ -> - library.mangaMap - .applyFilters(tracks) - .applySort(library.categories) - } - .collectLatest { - state.isLoading = false - loadedManga = it + init { + coroutineScope.launchIO { + combine( + state.map { it.searchQuery }.distinctUntilChanged(), + getLibraryFlow(), + getTracksPerManga.subscribe(), + getTrackingFilterFlow(), + ) { searchQuery, library, tracks, loggedInTrackServices -> + library + .applyFilters(tracks, loggedInTrackServices) + .applySort() + .mapValues { (_, value) -> + if (searchQuery != null) { + // Filter query + value.filter { it.matches(searchQuery) } + } else { + // Don't do anything + value + } } } + .collectLatest { + mutableState.update { state -> + state.copy( + isLoading = false, + library = it, + ) + } + } } + + combine( + libraryPreferences.categoryTabs().changes(), + libraryPreferences.categoryNumberOfItems().changes(), + libraryPreferences.showContinueReadingButton().changes(), + ) { a, b, c -> arrayOf(a, b, c) } + .onEach { (showCategoryTabs, showMangaCount, showMangaContinueButton) -> + mutableState.update { state -> + state.copy( + showCategoryTabs = showCategoryTabs, + showMangaCount = showMangaCount, + showMangaContinueButton = showMangaContinueButton, + ) + } + } + .launchIn(coroutineScope) + + combine( + getLibraryItemPreferencesFlow(), + getTrackingFilterFlow(), + ) { prefs, trackFilter -> + val a = ( + prefs.filterDownloaded or + prefs.filterUnread or + prefs.filterStarted or + prefs.filterBookmarked or + prefs.filterCompleted + ) != TriStateGroup.State.IGNORE.value + val b = trackFilter.values.any { it != TriStateGroup.State.IGNORE.value } + a || b + } + .distinctUntilChanged() + .onEach { + mutableState.update { state -> + state.copy(hasActiveFilters = it) + } + } + .launchIn(coroutineScope) } /** * Applies library filters to the given map of manga. */ - private fun LibraryMap.applyFilters(trackMap: Map>): LibraryMap { - val downloadedOnly = preferences.downloadedOnly().get() - val filterDownloaded = libraryPreferences.filterDownloaded().get() - val filterUnread = libraryPreferences.filterUnread().get() - val filterStarted = libraryPreferences.filterStarted().get() - val filterBookmarked = libraryPreferences.filterBookmarked().get() - val filterCompleted = libraryPreferences.filterCompleted().get() + private suspend fun LibraryMap.applyFilters( + trackMap: Map>, + loggedInTrackServices: Map, + ): LibraryMap { + val prefs = getLibraryItemPreferencesFlow().first() + val downloadedOnly = prefs.globalFilterDownloaded + val filterDownloaded = prefs.filterDownloaded + val filterUnread = prefs.filterUnread + val filterStarted = prefs.filterStarted + val filterBookmarked = prefs.filterBookmarked + val filterCompleted = prefs.filterCompleted - val loggedInTrackServices = trackManager.services.fastFilter { trackService -> trackService.isLogged } - .associate { trackService -> - trackService.id to libraryPreferences.filterTracking(trackService.id.toInt()).get() - } val isNotLoggedInAnyTrack = loggedInTrackServices.isEmpty() - val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.EXCLUDE.value) it.key else null } - val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.INCLUDE.value) it.key else null } + val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.EXCLUDE.value) it.key else null } + val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.INCLUDE.value) it.key else null } val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty() val filterFnDownloaded: (LibraryItem) -> Boolean = downloaded@{ item -> - if (!downloadedOnly && filterDownloaded == State.IGNORE.value) return@downloaded true + if (!downloadedOnly && filterDownloaded == TriStateGroup.State.IGNORE.value) return@downloaded true val isDownloaded = when { item.libraryManga.manga.isLocal() -> true item.downloadCount != -1L -> item.downloadCount > 0 else -> downloadManager.getDownloadCount(item.libraryManga.manga) > 0 } - return@downloaded if (downloadedOnly || filterDownloaded == State.INCLUDE.value) { + return@downloaded if (downloadedOnly || filterDownloaded == TriStateGroup.State.INCLUDE.value) { isDownloaded } else { !isDownloaded @@ -185,10 +198,10 @@ class LibraryPresenter( } val filterFnUnread: (LibraryItem) -> Boolean = unread@{ item -> - if (filterUnread == State.IGNORE.value) return@unread true + if (filterUnread == TriStateGroup.State.IGNORE.value) return@unread true val isUnread = item.libraryManga.unreadCount > 0 - return@unread if (filterUnread == State.INCLUDE.value) { + return@unread if (filterUnread == TriStateGroup.State.INCLUDE.value) { isUnread } else { !isUnread @@ -196,10 +209,10 @@ class LibraryPresenter( } val filterFnStarted: (LibraryItem) -> Boolean = started@{ item -> - if (filterStarted == State.IGNORE.value) return@started true + if (filterStarted == TriStateGroup.State.IGNORE.value) return@started true val hasStarted = item.libraryManga.hasStarted - return@started if (filterStarted == State.INCLUDE.value) { + return@started if (filterStarted == TriStateGroup.State.INCLUDE.value) { hasStarted } else { !hasStarted @@ -207,11 +220,11 @@ class LibraryPresenter( } val filterFnBookmarked: (LibraryItem) -> Boolean = bookmarked@{ item -> - if (filterBookmarked == State.IGNORE.value) return@bookmarked true + if (filterBookmarked == TriStateGroup.State.IGNORE.value) return@bookmarked true val hasBookmarks = item.libraryManga.hasBookmarks - return@bookmarked if (filterBookmarked == State.INCLUDE.value) { + return@bookmarked if (filterBookmarked == TriStateGroup.State.INCLUDE.value) { hasBookmarks } else { !hasBookmarks @@ -219,10 +232,10 @@ class LibraryPresenter( } val filterFnCompleted: (LibraryItem) -> Boolean = completed@{ item -> - if (filterCompleted == State.IGNORE.value) return@completed true + if (filterCompleted == TriStateGroup.State.IGNORE.value) return@completed true val isCompleted = item.libraryManga.manga.status.toInt() == SManga.COMPLETED - return@completed if (filterCompleted == State.INCLUDE.value) { + return@completed if (filterCompleted == TriStateGroup.State.INCLUDE.value) { isCompleted } else { !isCompleted @@ -266,9 +279,7 @@ class LibraryPresenter( /** * Applies library sorting to the given map of manga. */ - private fun LibraryMap.applySort(categories: List): LibraryMap { - val sortModes = categories.associate { it.id to it.sort } - + private fun LibraryMap.applySort(): LibraryMap { val locale = Locale.getDefault() val collator = Collator.getInstance(locale).apply { strength = Collator.PRIMARY @@ -278,7 +289,7 @@ class LibraryPresenter( } val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> - val sort = sortModes[i1.libraryManga.category]!! + val sort = keys.find { it.id == i1.libraryManga.category }!!.sort when (sort.type) { LibrarySort.Type.Alphabetical -> { sortAlphabetically(i1, i2) @@ -308,12 +319,11 @@ class LibraryPresenter( LibrarySort.Type.DateAdded -> { i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded) } - else -> throw IllegalStateException("Invalid SortModeSetting: ${sort.type}") } } return this.mapValues { entry -> - val comparator = if (sortModes[entry.key]!!.isAscending) { + val comparator = if (keys.find { it.id == entry.key.id }!!.sort.isAscending) { Comparator(sortFn) } else { Collections.reverseOrder(sortFn) @@ -323,24 +333,52 @@ class LibraryPresenter( } } + private fun getLibraryItemPreferencesFlow(): Flow { + return combine( + libraryPreferences.downloadBadge().changes(), + libraryPreferences.unreadBadge().changes(), + libraryPreferences.localBadge().changes(), + libraryPreferences.languageBadge().changes(), + + preferences.downloadedOnly().changes(), + libraryPreferences.filterDownloaded().changes(), + libraryPreferences.filterUnread().changes(), + libraryPreferences.filterStarted().changes(), + libraryPreferences.filterBookmarked().changes(), + libraryPreferences.filterCompleted().changes(), + transform = { + ItemPreferences( + downloadBadge = it[0] as Boolean, + unreadBadge = it[1] as Boolean, + localBadge = it[2] as Boolean, + languageBadge = it[3] as Boolean, + globalFilterDownloaded = it[4] as Boolean, + filterDownloaded = it[5] as Int, + filterUnread = it[6] as Int, + filterStarted = it[7] as Int, + filterBookmarked = it[8] as Int, + filterCompleted = it[9] as Int, + ) + }, + ) + } + /** * Get the categories and all its manga from the database. * * @return an observable of the categories and its manga. */ - private fun getLibraryFlow(): Flow { + private fun getLibraryFlow(): Flow { val libraryMangasFlow = combine( getLibraryManga.subscribe(), - libraryPreferences.downloadBadge().changes(), - libraryPreferences.filterDownloaded().changes(), - preferences.downloadedOnly().changes(), + getLibraryItemPreferencesFlow(), downloadCache.changes, - ) { libraryMangaList, downloadBadgePref, filterDownloadedPref, downloadedOnly, _ -> + ) { libraryMangaList, prefs, _ -> libraryMangaList .map { libraryManga -> - val needsDownloadCounts = downloadBadgePref || - filterDownloadedPref != State.IGNORE.value || - downloadedOnly + val needsDownloadCounts = prefs.downloadBadge || + prefs.filterDownloaded != TriStateGroup.State.IGNORE.value || + prefs.globalFilterDownloaded // Display mode based on user preference: take it from global library setting or category LibraryItem(libraryManga).apply { @@ -349,39 +387,44 @@ class LibraryPresenter( } else { 0 } - unreadCount = libraryManga.unreadCount - isLocal = libraryManga.manga.isLocal() - sourceLanguage = sourceManager.getOrStub(libraryManga.manga.source).lang + unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0 + isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false + sourceLanguage = if (prefs.languageBadge) { + sourceManager.getOrStub(libraryManga.manga.source).lang + } else { + "" + } } } .groupBy { it.libraryManga.category } } return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga -> - val displayCategories = if (libraryManga.isNotEmpty() && libraryManga.containsKey(0).not()) { + val displayCategories = if (libraryManga.isNotEmpty() && !libraryManga.containsKey(0)) { categories.fastFilterNot { it.isSystemCategory } } else { categories } - state.categories = displayCategories - Library(categories, libraryManga) + displayCategories.associateWith { libraryManga[it.id] ?: emptyList() } } } /** - * Requests the library to be filtered. + * Flow of tracking filter preferences + * + * @return map of track id with the filter value */ - suspend fun requestFilterUpdate() = withIOContext { - _filterChanges.send(Unit) - } - - /** - * Called when a manga is opened. - */ - fun onOpenManga() { - // Avoid further db updates for the library when it's not needed - librarySubscription?.cancel() + private fun getTrackingFilterFlow(): Flow> { + val loggedServices = trackManager.services.filter { it.isLogged } + val a = loggedServices + .map { libraryPreferences.filterTracking(it.id.toInt()).changes() } + .toTypedArray() + return combine(*a) { + loggedServices + .mapIndexed { index, trackService -> trackService.id to it[index] } + .toMap() + } } /** @@ -389,7 +432,7 @@ class LibraryPresenter( * * @param mangas the list of manga. */ - suspend fun getCommonCategories(mangas: List): Collection { + private suspend fun getCommonCategories(mangas: List): Collection { if (mangas.isEmpty()) return emptyList() return mangas .map { getCategories.await(it.id).toSet() } @@ -405,13 +448,37 @@ class LibraryPresenter( * * @param mangas the list of manga. */ - suspend fun getMixCategories(mangas: List): Collection { + 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) } + fun runDownloadActionSelection(action: DownloadAction) { + val selection = state.value.selection + val mangas = selection.map { it.manga }.toList() + when (action) { + DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1) + DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5) + DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10) + DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null) + DownloadAction.CUSTOM -> { + mutableState.update { state -> + state.copy( + dialog = Dialog.DownloadCustomAmount( + mangas, + selection.maxOf { it.unreadCount }.toInt(), + ), + ) + } + return + } + else -> {} + } + clearSelection() + } + /** * Queues the amount specified of unread chapters from the list of mangas given. * @@ -419,7 +486,7 @@ class LibraryPresenter( * @param amount the amount to queue or null to queue all */ fun downloadUnreadChapters(mangas: List, amount: Int?) { - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { mangas.forEach { manga -> val chapters = getNextChapters.await(manga.id) .fastFilterNot { chapter -> @@ -440,18 +507,18 @@ class LibraryPresenter( /** * Marks mangas' chapters read status. - * - * @param mangas the list of manga. */ - fun markReadStatus(mangas: List, read: Boolean) { - presenterScope.launchNonCancellable { + fun markReadSelection(read: Boolean) { + val mangas = state.value.selection.toList() + coroutineScope.launchNonCancellable { mangas.forEach { manga -> setReadStatus.await( - manga = manga, + manga = manga.manga, read = read, ) } } + clearSelection() } /** @@ -462,7 +529,7 @@ class LibraryPresenter( * @param deleteChapters whether to delete downloaded chapters. */ fun removeMangas(mangaList: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { val mangaToDelete = mangaList.distinctBy { it.id } if (deleteFromLibrary) { @@ -495,7 +562,7 @@ class LibraryPresenter( * @param removeCategories the categories to remove in all mangas. */ fun setMangaCategories(mangaList: List, addCategories: List, removeCategories: List) { - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { mangaList.forEach { manga -> val categoryIds = getCategories.await(manga.id) .map { it.id } @@ -508,148 +575,215 @@ class LibraryPresenter( } } - @Composable - fun getMangaCountForCategory(categoryId: Long): androidx.compose.runtime.State { - return produceState(initialValue = null, loadedManga) { - value = loadedManga[categoryId]?.size - } - } - fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState { - return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState() + return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState(coroutineScope) } - // TODO: This is good but should we separate title from count or get categories with count from db - @Composable - fun getToolbarTitle(): androidx.compose.runtime.State { - val category = categories.getOrNull(activeCategory) - - val defaultTitle = stringResource(R.string.label_library) - val categoryName = category?.visualName ?: defaultTitle - - val default = remember { LibraryToolbarTitle(defaultTitle) } - - return produceState(initialValue = default, category, loadedManga, mangaCountVisibility, tabVisibility) { - val title = if (tabVisibility.not()) categoryName else defaultTitle - val count = when { - category == null || mangaCountVisibility.not() -> null - tabVisibility.not() -> loadedManga[category.id]?.size - else -> loadedManga.values.flatten().distinctBy { it.libraryManga.manga.id }.size - } - - value = when (category) { - null -> default - else -> LibraryToolbarTitle(title, count) - } - } - } - - @Composable - fun getMangaForCategory(page: Int): List { - val categoryId = remember(categories, page) { - categories.getOrNull(page)?.id ?: -1 - } - val unfiltered = remember(loadedManga, categoryId) { - loadedManga[categoryId] ?: emptyList() - } - return remember(unfiltered, searchQuery) { - if (searchQuery.isNullOrBlank()) { - queriedMangaMap.clear() - unfiltered - } else { - unfiltered.fastFilter { it.matches(searchQuery!!) } - .also { queriedMangaMap[categoryId] = it } - } + suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? { + return withIOContext { + state.value + .getLibraryItemsByCategoryId(activeCategory.toLong()) + .randomOrNull() } } fun clearSelection() { - state.selection = emptyList() + mutableState.update { it.copy(selection = emptyList()) } } fun toggleSelection(manga: LibraryManga) { - state.selection = selection.toMutableList().apply { - if (fastAny { it.id == manga.id }) { - removeAll { it.id == manga.id } - } else { - add(manga) + mutableState.update { state -> + val newSelection = state.selection.toMutableList().apply { + if (fastAny { it.id == manga.id }) { + removeAll { it.id == manga.id } + } else { + add(manga) + } } + state.copy(selection = newSelection) } } - /** - * Map is cleared out via [getMangaForCategory] when [searchQuery] is null or blank - */ - private val queriedMangaMap: MutableMap> = mutableMapOf() - - /** - * Used by select all, inverse and range selection. - * - * If current query is empty then we get manga list from [loadedManga] otherwise from [queriedMangaMap] - */ - private fun getMangaForCategoryWithQuery(categoryId: Long, query: String?): List { - return if (query.isNullOrBlank()) loadedManga[categoryId].orEmpty() else queriedMangaMap[categoryId].orEmpty() - } - /** * Selects all mangas between and including the given manga and the last pressed manga from the * same category as the given manga */ fun toggleRangeSelection(manga: LibraryManga) { - state.selection = selection.toMutableList().apply { - val lastSelected = lastOrNull() - if (lastSelected?.category != manga.category) { - add(manga) - return@apply - } + mutableState.update { state -> + val newSelection = state.selection.toMutableList().apply { + val lastSelected = lastOrNull() + if (lastSelected?.category != manga.category) { + add(manga) + return@apply + } - val items = getMangaForCategoryWithQuery(manga.category, searchQuery) - .fastMap { it.libraryManga } - val lastMangaIndex = items.indexOf(lastSelected) - val curMangaIndex = items.indexOf(manga) + val items = state.getLibraryItemsByCategoryId(manga.category) + .fastMap { it.libraryManga } + val lastMangaIndex = items.indexOf(lastSelected) + val curMangaIndex = items.indexOf(manga) - val selectedIds = fastMap { it.id } - val selectionRange = when { - lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) - curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) - // We shouldn't reach this point - else -> return@apply + val selectedIds = fastMap { it.id } + val selectionRange = when { + lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) + curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) + // We shouldn't reach this point + else -> return@apply + } + val newSelections = selectionRange.mapNotNull { index -> + items[index].takeUnless { it.id in selectedIds } + } + addAll(newSelections) } - val newSelections = selectionRange.mapNotNull { index -> - items[index].takeUnless { it.id in selectedIds } - } - addAll(newSelections) + state.copy(selection = newSelection) } } fun selectAll(index: Int) { - state.selection = state.selection.toMutableList().apply { - val categoryId = categories.getOrNull(index)?.id ?: -1 - val selectedIds = fastMap { it.id } - val newSelections = getMangaForCategoryWithQuery(categoryId, searchQuery) - .fastMapNotNull { item -> - item.libraryManga.takeUnless { it.id in selectedIds } - } + mutableState.update { state -> + val newSelection = state.selection.toMutableList().apply { + val categoryId = state.categories.getOrNull(index)?.id ?: -1 + val selectedIds = fastMap { it.id } + val newSelections = state.getLibraryItemsByCategoryId(categoryId) + .fastMapNotNull { item -> + item.libraryManga.takeUnless { it.id in selectedIds } + } - addAll(newSelections) + addAll(newSelections) + } + state.copy(selection = newSelection) } } fun invertSelection(index: Int) { - state.selection = selection.toMutableList().apply { - val categoryId = categories[index].id - val items = getMangaForCategoryWithQuery(categoryId, searchQuery).fastMap { it.libraryManga } - val selectedIds = fastMap { it.id } - val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds } - val toRemoveIds = toRemove.fastMap { it.id } - removeAll { it.id in toRemoveIds } - addAll(toAdd) + mutableState.update { state -> + val newSelection = state.selection.toMutableList().apply { + val categoryId = state.categories[index].id + val items = state.getLibraryItemsByCategoryId(categoryId).fastMap { it.libraryManga } + val selectedIds = fastMap { it.id } + val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds } + val toRemoveIds = toRemove.fastMap { it.id } + removeAll { it.id in toRemoveIds } + addAll(toAdd) + } + state.copy(selection = newSelection) } } + fun search(query: String?) { + mutableState.update { it.copy(searchQuery = query) } + } + + fun openChangeCategoryDialog() { + coroutineScope.launchIO { + // Create a copy of selected manga + val mangaList = state.value.selection.map { it.manga } + + // Hide the default category because it has a different behavior than the ones from db. + val categories = state.value.categories.filter { it.id != 0L } + + // 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) + } + } + mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } + } + } + + fun openDeleteMangaDialog() { + val mangaList = state.value.selection.map { it.manga } + mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) } + } + + fun closeDialog() { + mutableState.update { it.copy(dialog = null) } + } + sealed class Dialog { data class ChangeCategory(val manga: List, val initialSelection: List>) : Dialog() data class DeleteManga(val manga: List) : Dialog() data class DownloadCustomAmount(val manga: List, val max: Int) : Dialog() } + + @Immutable + private data class ItemPreferences( + val downloadBadge: Boolean, + val unreadBadge: Boolean, + val localBadge: Boolean, + val languageBadge: Boolean, + + val globalFilterDownloaded: Boolean, + val filterDownloaded: Int, + val filterUnread: Int, + val filterStarted: Int, + val filterBookmarked: Int, + val filterCompleted: Int, + ) + + @Immutable + data class State( + val isLoading: Boolean = true, + val library: LibraryMap = emptyMap(), + val searchQuery: String? = null, + val selection: List = emptyList(), + val hasActiveFilters: Boolean = false, + val showCategoryTabs: Boolean = false, + val showMangaCount: Boolean = false, + val showMangaContinueButton: Boolean = false, + val dialog: Dialog? = null, + ) { + val selectionMode = selection.isNotEmpty() + + val categories = library.keys.toList() + + val libraryCount by lazy { + library + .flatMap { (_, v) -> v } + .distinctBy { it.libraryManga.manga.id } + .size + } + + fun getLibraryItemsByCategoryId(categoryId: Long): List { + return library.firstNotNullOf { (k, v) -> v.takeIf { k.id == categoryId } } + } + + fun getLibraryItemsByPage(page: Int): List { + return library.values.toTypedArray().getOrNull(page) ?: emptyList() + } + + fun getMangaCountForCategory(category: Category): Int? { + return library[category]?.size?.takeIf { showMangaCount } + } + + fun getToolbarTitle( + defaultTitle: String, + defaultCategoryTitle: String, + page: Int, + ): LibraryToolbarTitle { + val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle) + val categoryName = category.let { + if (it.isSystemCategory) { + defaultCategoryTitle + } else { + it.name + } + } + + val title = if (showCategoryTabs) defaultTitle else categoryName + val count = when { + !showMangaCount -> null + !showCategoryTabs -> getMangaCountForCategory(category) + // Whole library count + else -> libraryCount + } + + return LibraryToolbarTitle(title, count) + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt index 91da562a9..ee4e10105 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySettingsSheet.kt @@ -32,7 +32,6 @@ class LibrarySettingsSheet( private val trackManager: TrackManager = Injekt.get(), private val setDisplayModeForCategory: SetDisplayModeForCategory = Injekt.get(), private val setSortModeForCategory: SetSortModeForCategory = Injekt.get(), - onGroupClickListener: (ExtendedNavigationView.Group) -> Unit, ) : TabbedBottomSheetDialog(router.activity!!) { val filters: Filter @@ -43,13 +42,8 @@ class LibrarySettingsSheet( init { filters = Filter(router.activity!!) - filters.onGroupClicked = onGroupClickListener - sort = Sort(router.activity!!) - sort.onGroupClicked = onGroupClickListener - display = Display(router.activity!!) - display.onGroupClicked = onGroupClickListener } /**