From 24c2aa1bfb083ddc8ed163ae72a4db2adc1b3b0b Mon Sep 17 00:00:00 2001 From: Andreas Date: Sat, 23 Jul 2022 01:05:50 +0200 Subject: [PATCH] Use Compose for Library screen (#7557) - Move Pager to Compose - Move AppBar to Compose - Use Stable interface for state - Use pills for no. of manga in category instead of (x) (cherry picked from commit 2b8d1bcc02994e75e1479b037ef50eb84ae674d7) # Conflicts: # app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt # app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt # app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt --- app/build.gradle.kts | 3 + .../kanade/presentation/components/Badges.kt | 4 +- .../components/MangaBottomActionMenu.kt | 164 ++++++ .../eu/kanade/presentation/components/Pill.kt | 38 ++ .../presentation/library/LibraryScreen.kt | 90 +++ .../presentation/library/LibraryState.kt | 58 ++ .../components/LibraryComfortableGrid.kt | 17 + .../library/components/LibraryCompactGrid.kt | 18 + .../library/components/LibraryContent.kt | 132 +++++ .../components/LibraryCoverOnlyGrid.kt | 18 + .../library/components/LibraryList.kt | 15 + .../library/components/LibraryPager.kt | 108 ++++ .../library/components/LibraryTabs.kt | 77 +++ .../library/components/LibraryToolbar.kt | 231 ++++++++ .../eu/kanade/presentation/theme/Color.kt | 12 + .../tachiyomi/ui/library/LibraryAdapter.kt | 220 -------- .../tachiyomi/ui/library/LibraryController.kt | 517 +++--------------- .../tachiyomi/ui/library/LibraryPresenter.kt | 209 ++++--- .../kanade/tachiyomi/ui/main/MainActivity.kt | 6 +- .../main/res/layout/library_controller.xml | 36 -- gradle/compose.versions.toml | 4 +- 21 files changed, 1211 insertions(+), 766 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/components/Pill.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/LibraryState.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt create mode 100644 app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt create mode 100644 app/src/main/java/eu/kanade/presentation/theme/Color.kt delete mode 100755 app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt delete mode 100644 app/src/main/res/layout/library_controller.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efe7bebce..2322bb81c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -144,6 +144,8 @@ dependencies { implementation(compose.accompanist.webview) implementation(compose.accompanist.swiperefresh) implementation(compose.accompanist.flowlayout) + implementation(compose.accompanist.pager.core) + implementation(compose.accompanist.pager.indicators) implementation(androidx.paging.runtime) implementation(androidx.paging.compose) @@ -308,6 +310,7 @@ tasks { "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi", + "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi" ) } diff --git a/app/src/main/java/eu/kanade/presentation/components/Badges.kt b/app/src/main/java/eu/kanade/presentation/components/Badges.kt index 1ecbb6c8f..d15a06640 100644 --- a/app/src/main/java/eu/kanade/presentation/components/Badges.kt +++ b/app/src/main/java/eu/kanade/presentation/components/Badges.kt @@ -38,8 +38,8 @@ fun Badge( ) { Box( modifier = Modifier - .background(color) - .clip(shape), + .clip(shape) + .background(color), ) { Text( text = text, diff --git a/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt index b5166a689..b544f8590 100644 --- a/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt @@ -22,16 +22,22 @@ import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.SwapCalls import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -41,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.calculateWindowWidthSizeClass import eu.kanade.tachiyomi.R import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -195,3 +202,160 @@ private fun RowScope.Button( } } } + +@Composable +fun LibraryBottomActionMenu( + visible: Boolean, + modifier: Modifier = Modifier, + onChangeCategoryClicked: (() -> Unit)?, + onMarkAsReadClicked: (() -> Unit)?, + onMarkAsUnreadClicked: (() -> Unit)?, + onDownloadClicked: (() -> Unit)?, + onDeleteClicked: (() -> Unit)?, + // SY --> + onClickCleanTitles: (() -> Unit)?, + onClickMigrate: (() -> Unit)?, + onClickAddToMangaDex: (() -> Unit)?, + // SY <-- +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom), + ) { + val scope = rememberCoroutineScope() + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.large, + tonalElevation = 3.dp, + ) { + val haptic = LocalHapticFeedback.current + val confirm = remember { mutableStateListOf(false, false, false, false, false /* SY --> */, false /* SY <-- */) } + var resetJob: Job? = remember { null } + val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + (0 until 5).forEach { i -> confirm[i] = i == toConfirmIndex } + resetJob?.cancel() + resetJob = scope.launch { + delay(1000) + if (isActive) confirm[toConfirmIndex] = false + } + } + // SY --> + val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null + val moveMarkPrev = calculateWindowWidthSizeClass() == WindowWidthSizeClass.Compact + var overFlowOpen by remember { mutableStateOf(false) } + // SY <-- + Row( + modifier = Modifier + .navigationBarsPadding() + .padding(horizontal = 8.dp, vertical = 12.dp), + ) { + if (onChangeCategoryClicked != null) { + Button( + title = stringResource(R.string.action_move_category), + icon = Icons.Default.BookmarkAdd, + toConfirm = confirm[0], + onLongClick = { onLongClickItem(0) }, + onClick = onChangeCategoryClicked, + ) + } + if (onDownloadClicked != null) { + Button( + title = stringResource(R.string.action_download), + icon = Icons.Outlined.Download, + toConfirm = confirm[3], + onLongClick = { onLongClickItem(3) }, + onClick = onDownloadClicked, + ) + } + if (onDeleteClicked != null) { + Button( + title = stringResource(R.string.action_delete), + icon = Icons.Outlined.Delete, + toConfirm = confirm[4], + onLongClick = { onLongClickItem(4) }, + onClick = onDeleteClicked, + ) + } + // SY --> + if (onMarkAsReadClicked != null) { + Button( + title = stringResource(R.string.action_mark_as_read), + icon = Icons.Default.DoneAll, + toConfirm = confirm[1], + onLongClick = { onLongClickItem(1) }, + onClick = onMarkAsReadClicked, + ) + } + if (showOverflow) { + if (!moveMarkPrev && onMarkAsUnreadClicked != null) { + Button( + title = stringResource(R.string.action_mark_as_unread), + icon = Icons.Default.RemoveDone, + toConfirm = confirm[2], + onLongClick = { onLongClickItem(2) }, + onClick = onMarkAsUnreadClicked, + ) + } + Button( + title = stringResource(R.string.label_more), + icon = Icons.Outlined.MoreVert, + toConfirm = confirm[5], + onLongClick = { onLongClickItem(5) }, + onClick = { overFlowOpen = true }, + ) + DropdownMenu( + expanded = overFlowOpen, + onDismissRequest = { overFlowOpen = false }, + ) { + if (onMarkAsUnreadClicked != null && moveMarkPrev) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_mark_as_unread)) }, + onClick = onMarkAsUnreadClicked, + ) + } + if (onClickCleanTitles != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_clean_titles)) }, + onClick = onClickCleanTitles, + ) + } + if (onClickMigrate != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.migrate)) }, + onClick = onClickMigrate, + ) + } + if (onClickAddToMangaDex != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.mangadex_add_to_follows)) }, + onClick = onClickAddToMangaDex, + ) + } + } + } else { + if (onMarkAsUnreadClicked != null) { + Button( + title = stringResource(R.string.action_mark_as_unread), + icon = Icons.Default.RemoveDone, + toConfirm = confirm[2], + onLongClick = { onLongClickItem(2) }, + onClick = onMarkAsUnreadClicked, + ) + } + if (onClickMigrate != null) { + Button( + title = stringResource(R.string.migrate), + icon = Icons.Outlined.SwapCalls, + toConfirm = confirm[5], + onLongClick = { onLongClickItem(5) }, + onClick = onClickMigrate, + ) + } + } + // SY <-- + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/Pill.kt b/app/src/main/java/eu/kanade/presentation/components/Pill.kt new file mode 100644 index 000000000..2cbcdee29 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/Pill.kt @@ -0,0 +1,38 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +@Composable +fun Pill( + text: String, + modifier: Modifier = Modifier, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.background, + contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onBackground, + elevation: Dp = 1.dp, + fontSize: TextUnit = LocalTextStyle.current.fontSize, +) { + androidx.compose.material3.Surface( + modifier = modifier + .padding(start = 4.dp) + .clip(RoundedCornerShape(100)), + color = color, + contentColor = contentColor, + tonalElevation = elevation, + ) { + Text( + text = text, + modifier = Modifier.padding(6.dp, 1.dp), + fontSize = fontSize, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt new file mode 100644 index 000000000..6c0e06877 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/LibraryScreen.kt @@ -0,0 +1,90 @@ +package eu.kanade.presentation.library + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import eu.kanade.presentation.components.LibraryBottomActionMenu +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.library.components.LibraryContent +import eu.kanade.presentation.library.components.LibraryToolbar +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.ui.library.LibraryPresenter + +@Composable +fun LibraryScreen( + presenter: LibraryPresenter, + onMangaClicked: (Long) -> Unit, + onGlobalSearchClicked: () -> Unit, + onChangeCategoryClicked: () -> Unit, + onMarkAsReadClicked: () -> Unit, + onMarkAsUnreadClicked: () -> Unit, + onDownloadClicked: () -> Unit, + onDeleteClicked: () -> Unit, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + // SY --> + onClickCleanTitles: () -> Unit, + onClickMigrate: () -> Unit, + onClickAddToMangaDex: () -> Unit, + onClickSyncExh: () -> Unit, + onOpenReader: (LibraryManga) -> Unit, + // SY <-- +) { + Scaffold( + topBar = { + val title by presenter.getToolbarTitle() + LibraryToolbar( + state = presenter, + title = title, + onClickUnselectAll = onClickUnselectAll, + onClickSelectAll = onClickSelectAll, + onClickInvertSelection = onClickInvertSelection, + onClickFilter = onClickFilter, + onClickRefresh = onClickRefresh, + // SY --> + onClickSyncExh = onClickSyncExh, + // SY <-- + ) + }, + bottomBar = { + LibraryBottomActionMenu( + visible = presenter.selectionMode, + onChangeCategoryClicked = onChangeCategoryClicked, + onMarkAsReadClicked = onMarkAsReadClicked, + onMarkAsUnreadClicked = onMarkAsUnreadClicked, + onDownloadClicked = onDownloadClicked, + onDeleteClicked = onDeleteClicked, + // SY --> + onClickCleanTitles = onClickCleanTitles.takeIf { presenter.showCleanTitles }, + onClickMigrate = onClickMigrate, + onClickAddToMangaDex = onClickAddToMangaDex.takeIf { presenter.showAddToMangadex }, + // SY <-- + ) + }, + ) { paddingValues -> + LibraryContent( + state = presenter, + contentPadding = paddingValues, + currentPage = presenter.activeCategory, + isLibraryEmpty = presenter.loadedManga.isEmpty(), + showPageTabs = presenter.tabVisibility, + showMangaCount = presenter.mangaCountVisibility, + onChangeCurrentPage = { presenter.activeCategory = it }, + onMangaClicked = onMangaClicked, + onToggleSelection = { presenter.toggleSelection(it) }, + onRefresh = onClickRefresh, + onGlobalSearchClicked = onGlobalSearchClicked, + getNumberOfMangaForCategory = { presenter.getMangaCountForCategory(it) }, + getDisplayModeForPage = { presenter.getDisplayMode(index = it) }, + getColumnsForOrientation = { presenter.getColumnsPreferenceForCurrentOrientation(it) }, + getLibraryForPage = { presenter.getMangaForCategory(page = it) }, + isIncognitoMode = presenter.isIncognitoMode, + isDownloadOnly = presenter.isDownloadOnly, + // SY --> + onOpenReader = onOpenReader, + // SY <-- + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt b/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt new file mode 100644 index 000000000..94d06e393 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/LibraryState.kt @@ -0,0 +1,58 @@ +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.tachiyomi.data.database.models.LibraryManga +import exh.source.PERV_EDEN_EN_SOURCE_ID +import exh.source.PERV_EDEN_IT_SOURCE_ID +import exh.source.isEhBasedManga +import exh.source.mangaDexSourceIds +import exh.source.nHentaiSourceIds + +@Stable +interface LibraryState { + val isLoading: Boolean + val categories: List + var searchQuery: String? + val selection: List + val selectionMode: Boolean + var hasActiveFilters: Boolean + + // SY --> + val showSyncExh: Boolean + val showCleanTitles: Boolean + val showAddToMangadex: Boolean + // SY <-- +} + +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) + + // SY --> + override var showSyncExh: Boolean by mutableStateOf(true) + override val showCleanTitles: Boolean by derivedStateOf { + selection.any { + it.isEhBasedManga() || + it.source in nHentaiSourceIds || + it.source == PERV_EDEN_EN_SOURCE_ID || + it.source == PERV_EDEN_IT_SOURCE_ID + } + } + override val showAddToMangadex: Boolean by derivedStateOf { + selection.any { it.source in mangaDexSourceIds } + } + // SY <-- +} 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 b7659ee79..ec56fea1f 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 @@ -3,14 +3,19 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import eu.kanade.domain.manga.model.MangaCover +import eu.kanade.presentation.components.TextButton +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.ui.library.LibraryItem @@ -21,6 +26,8 @@ fun LibraryComfortableGrid( selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, // SY --> onOpenReader: (LibraryManga) -> Unit, // SY <-- @@ -28,6 +35,16 @@ fun LibraryComfortableGrid( LazyLibraryGrid( columns = columns, ) { + item(span = { GridItemSpan(maxLineSpan) }) { + if (searchQuery.isNullOrEmpty().not()) { + TextButton(onClick = onGlobalSearchClicked) { + Text( + text = stringResource(R.string.action_global_search_query, searchQuery!!), + modifier = Modifier.zIndex(99f), + ) + } + } + } items( items = items, key = { 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 b2d1e2fea..332aef4f0 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalTextStyle @@ -17,8 +18,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import eu.kanade.presentation.components.TextButton +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.ui.library.LibraryItem @@ -29,6 +34,8 @@ fun LibraryCompactGrid( selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, // SY --> onOpenReader: (LibraryManga) -> Unit, // SY <-- @@ -36,6 +43,17 @@ fun LibraryCompactGrid( LazyLibraryGrid( columns = columns, ) { + item(span = { GridItemSpan(maxLineSpan) }) { + if (searchQuery.isNullOrEmpty().not()) { + TextButton(onClick = onGlobalSearchClicked) { + Text( + text = stringResource(R.string.action_global_search_query, searchQuery!!), + modifier = Modifier.zIndex(99f), + ) + } + } + } + items( items = items, key = { 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 new file mode 100644 index 000000000..3118f5f9e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt @@ -0,0 +1,132 @@ +package eu.kanade.presentation.library.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import com.google.accompanist.pager.rememberPagerState +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.SwipeRefreshIndicator +import eu.kanade.presentation.library.LibraryState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.ui.library.LibraryItem +import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.widget.EmptyView + +@Composable +fun LibraryContent( + state: LibraryState, + contentPadding: PaddingValues, + currentPage: Int, + isLibraryEmpty: Boolean, + isDownloadOnly: Boolean, + isIncognitoMode: Boolean, + showPageTabs: Boolean, + showMangaCount: Boolean, + onChangeCurrentPage: (Int) -> Unit, + onMangaClicked: (Long) -> Unit, + onToggleSelection: (LibraryManga) -> Unit, + onRefresh: () -> Unit, + onGlobalSearchClicked: () -> Unit, + getNumberOfMangaForCategory: @Composable (Long) -> State, + getDisplayModeForPage: @Composable (Int) -> State, + getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getLibraryForPage: @Composable (Int) -> State>, + // SY --> + onOpenReader: (LibraryManga) -> Unit, + // SY <-- +) { + val nestedScrollInterop = rememberNestedScrollInteropConnection() + + val pagerState = rememberPagerState(currentPage) + + val categories = state.categories + + if (categories.isEmpty()) { + LoadingScreen() + return + } + + Column( + modifier = Modifier.padding(contentPadding), + ) { + if (showPageTabs && categories.size > 1) { + LibraryTabs( + state = pagerState, + categories = state.categories, + showMangaCount = showMangaCount, + getNumberOfMangaForCategory = getNumberOfMangaForCategory, + isDownloadOnly = isDownloadOnly, + isIncognitoMode = isIncognitoMode, + ) + } + + val onClickManga = { manga: LibraryManga -> + if (state.selectionMode.not()) { + onMangaClicked(manga.id!!) + } else { + onToggleSelection(manga) + } + } + val onLongClickManga = { manga: LibraryManga -> + onToggleSelection(manga) + } + + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing = false), + modifier = Modifier.nestedScroll(nestedScrollInterop), + onRefresh = onRefresh, + indicator = { s, trigger -> + SwipeRefreshIndicator( + state = s, + refreshTriggerDistance = trigger, + ) + }, + ) { + if (state.searchQuery.isNullOrEmpty() && isLibraryEmpty) { + val context = LocalContext.current + EmptyScreen( + R.string.information_empty_library, + listOf( + EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) { + context.openInBrowser("https://tachiyomi.org/help/guides/getting-started") + }, + ), + ) + return@SwipeRefresh + } + + LibraryPager( + state = pagerState, + pageCount = categories.size, + selectedManga = state.selection, + getDisplayModeForPage = getDisplayModeForPage, + getColumnsForOrientation = getColumnsForOrientation, + getLibraryForPage = getLibraryForPage, + onClickManga = onClickManga, + onLongClickManga = onLongClickManga, + onGlobalSearchClicked = onGlobalSearchClicked, + searchQuery = state.searchQuery, + // SY --> + onOpenReader = onOpenReader, + // SY <-- + ) + } + + LaunchedEffect(pagerState.currentPage) { + onChangeCurrentPage(pagerState.currentPage) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt index 8c899ef52..1382bb320 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCoverOnlyGrid.kt @@ -1,9 +1,15 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.zIndex +import eu.kanade.presentation.components.TextButton +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.ui.library.LibraryItem @@ -14,6 +20,8 @@ fun LibraryCoverOnlyGrid( selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, // SY --> onOpenReader: (LibraryManga) -> Unit, // SY <-- @@ -21,6 +29,16 @@ fun LibraryCoverOnlyGrid( LazyLibraryGrid( columns = columns, ) { + item(span = { GridItemSpan(maxLineSpan) }) { + if (searchQuery.isNullOrEmpty().not()) { + TextButton(onClick = onGlobalSearchClicked) { + Text( + text = stringResource(R.string.action_global_search_query, searchQuery!!), + modifier = Modifier.zIndex(99f), + ) + } + } + } items( items = items, key = { 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 9543335a1..9e7ebf425 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 @@ -17,9 +17,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import eu.kanade.domain.manga.model.MangaCover import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.BadgeGroup +import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.selectedBackground import eu.kanade.presentation.util.verticalPadding @@ -33,10 +35,23 @@ fun LibraryList( selection: List, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, ) { LazyColumn( contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { + item { + if (searchQuery.isNullOrEmpty().not()) { + TextButton(onClick = onGlobalSearchClicked) { + Text( + text = stringResource(R.string.action_global_search_query, searchQuery!!), + modifier = Modifier.zIndex(99f), + ) + } + } + } + items( items = items, key = { 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 new file mode 100644 index 000000000..ecbbb3feb --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -0,0 +1,108 @@ +package eu.kanade.presentation.library.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.PagerState +import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.ui.library.LibraryItem +import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting + +@Composable +fun LibraryPager( + state: PagerState, + pageCount: Int, + selectedManga: List, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, + getDisplayModeForPage: @Composable (Int) -> State, + getColumnsForOrientation: (Boolean) -> PreferenceMutableState, + getLibraryForPage: @Composable (Int) -> State>, + onClickManga: (LibraryManga) -> Unit, + onLongClickManga: (LibraryManga) -> Unit, + // SY --> + onOpenReader: (LibraryManga) -> Unit, + // SY <-- +) { + HorizontalPager( + count = pageCount, + modifier = Modifier.fillMaxSize(), + state = state, + verticalAlignment = Alignment.Top, + ) { page -> + val library by getLibraryForPage(page) + val displayMode by getDisplayModeForPage(page) + val columns by if (displayMode != DisplayModeSetting.LIST) { + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + remember(isLandscape) { getColumnsForOrientation(isLandscape) } + } else { + remember { mutableStateOf(0) } + } + + when (displayMode) { + DisplayModeSetting.LIST -> { + LibraryList( + items = library, + selection = selectedManga, + onClick = onClickManga, + onLongClick = onLongClickManga, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + DisplayModeSetting.COMPACT_GRID -> { + LibraryCompactGrid( + items = library, + columns = columns, + selection = selectedManga, + onClick = onClickManga, + onLongClick = onLongClickManga, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + // SY --> + onOpenReader = onOpenReader, + // SY <-- + ) + } + DisplayModeSetting.COMFORTABLE_GRID -> { + LibraryComfortableGrid( + items = library, + columns = columns, + selection = selectedManga, + onClick = onClickManga, + onLongClick = onLongClickManga, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + // SY --> + onOpenReader = onOpenReader, + // SY <-- + ) + } + DisplayModeSetting.COVER_ONLY_GRID -> { + LibraryCoverOnlyGrid( + items = library, + columns = columns, + selection = selectedManga, + onClick = onClickManga, + onLongClick = onLongClickManga, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + // SY --> + onOpenReader = onOpenReader, + // SY <-- + ) + } + } + } +} 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 new file mode 100644 index 000000000..b479cbdb2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt @@ -0,0 +1,77 @@ +package eu.kanade.presentation.library.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.pager.PagerState +import eu.kanade.domain.category.model.Category +import eu.kanade.presentation.components.DownloadedOnlyModeBanner +import eu.kanade.presentation.components.IncognitoModeBanner +import eu.kanade.presentation.components.Pill +import kotlinx.coroutines.launch + +@Composable +fun LibraryTabs( + state: PagerState, + categories: List, + showMangaCount: Boolean, + isDownloadOnly: Boolean, + isIncognitoMode: Boolean, + getNumberOfMangaForCategory: @Composable (Long) -> State, +) { + val scope = rememberCoroutineScope() + + val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f + + Column { + ScrollableTabRow( + selectedTabIndex = state.currentPage, + edgePadding = 0.dp, + ) { + categories.forEachIndexed { index, category -> + val count by if (showMangaCount) { + getNumberOfMangaForCategory(category.id) + } else { + remember { mutableStateOf(null) } + } + Tab( + selected = state.currentPage == index, + onClick = { scope.launch { state.animateScrollToPage(index) } }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = category.name) + if (count != null) { + Pill( + text = "$count", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), + fontSize = 10.sp, + ) + } + } + }, + ) + } + } + if (isDownloadOnly) { + DownloadedOnlyModeBanner() + } + if (isIncognitoMode) { + IncognitoModeBanner() + } + } +} 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 new file mode 100644 index 000000000..560610be6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryToolbar.kt @@ -0,0 +1,231 @@ +package eu.kanade.presentation.library.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.presentation.components.Pill +import eu.kanade.presentation.library.LibraryState +import eu.kanade.presentation.theme.active +import eu.kanade.presentation.util.calculateWindowWidthSizeClass +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.delay + +@Composable +fun LibraryToolbar( + state: LibraryState, + title: LibraryToolbarTitle, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + // SY --> + onClickSyncExh: () -> Unit, + // SY <-- +) = when { + state.searchQuery != null -> LibrarySearchToolbar( + searchQuery = state.searchQuery!!, + onChangeSearchQuery = { state.searchQuery = it }, + onClickCloseSearch = { state.searchQuery = null }, + ) + state.selectionMode -> LibrarySelectionToolbar( + state = state, + onClickUnselectAll = onClickUnselectAll, + onClickSelectAll = onClickSelectAll, + onClickInvertSelection = onClickInvertSelection, + ) + else -> LibraryRegularToolbar( + title = title, + hasFilters = state.hasActiveFilters, + onClickSearch = { state.searchQuery = "" }, + onClickFilter = onClickFilter, + onClickRefresh = onClickRefresh, + // SY --> + showSyncExh = state.showSyncExh, + onClickSyncExh = onClickSyncExh, + // SY <-- + ) +} + +@Composable +fun LibraryRegularToolbar( + title: LibraryToolbarTitle, + hasFilters: Boolean, + onClickSearch: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + // SY --> + showSyncExh: Boolean, + onClickSyncExh: () -> Unit, + // SY <-- +) { + val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f + val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current + SmallTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title.text, + maxLines = 1, + modifier = Modifier.weight(1f, false), + overflow = TextOverflow.Ellipsis, + ) + if (title.numberOfManga != null) { + Pill( + text = "${title.numberOfManga}", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), + fontSize = 14.sp, + ) + } + } + }, + actions = { + IconButton(onClick = onClickSearch) { + Icon(Icons.Outlined.Search, contentDescription = "search") + } + IconButton(onClick = onClickFilter) { + Icon(Icons.Outlined.FilterList, contentDescription = "search", tint = filterTint) + } + // SY --> + val moveGlobalUpdate = showSyncExh && calculateWindowWidthSizeClass() == WindowWidthSizeClass.Compact + if (!moveGlobalUpdate) { + IconButton(onClick = onClickRefresh) { + Icon(Icons.Outlined.Refresh, contentDescription = "search") + } + } + var showOverflow by remember { mutableStateOf(false) } + if (showSyncExh) { + IconButton(onClick = { showOverflow = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = "more") + } + } + DropdownMenu(showOverflow, onDismissRequest = { showOverflow = false }) { + if (moveGlobalUpdate) { + DropdownMenuItem( + text = { Text(stringResource(R.string.pref_category_library_update)) }, + onClick = onClickRefresh, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.sync_favorites)) }, + onClick = onClickSyncExh, + ) + } + // SY <-- + }, + ) +} + +@Composable +fun LibrarySelectionToolbar( + state: LibraryState, + onClickUnselectAll: () -> Unit, + onClickSelectAll: () -> Unit, + onClickInvertSelection: () -> Unit, +) { + val backgroundColor by TopAppBarDefaults.smallTopAppBarColors().containerColor(1f) + SmallTopAppBar( + modifier = Modifier + .drawBehind { + drawRect(backgroundColor.copy(alpha = 1f)) + } + .statusBarsPadding(), + navigationIcon = { + IconButton(onClick = onClickUnselectAll) { + Icon(Icons.Outlined.Close, contentDescription = "close") + } + }, + title = { + Text(text = "${state.selection.size}") + }, + actions = { + IconButton(onClick = onClickSelectAll) { + Icon(Icons.Outlined.SelectAll, contentDescription = "search") + } + IconButton(onClick = onClickInvertSelection) { + Icon(Icons.Outlined.FlipToBack, contentDescription = "invert") + } + }, + colors = TopAppBarDefaults.smallTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) +} + +@Composable +fun LibrarySearchToolbar( + searchQuery: String, + onChangeSearchQuery: (String) -> Unit, + onClickCloseSearch: () -> Unit, +) { + val focusRequester = remember { FocusRequester.Default } + SmallTopAppBar( + modifier = Modifier.statusBarsPadding(), + navigationIcon = { + IconButton(onClick = onClickCloseSearch) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "back") + } + }, + title = { + BasicTextField( + value = searchQuery, + onValueChange = onChangeSearchQuery, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), + ) + LaunchedEffect(focusRequester) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(100) + focusRequester.requestFocus() + } + }, + ) +} + +data class LibraryToolbarTitle( + val text: String, + val numberOfManga: Int? = null, +) diff --git a/app/src/main/java/eu/kanade/presentation/theme/Color.kt b/app/src/main/java/eu/kanade/presentation/theme/Color.kt new file mode 100644 index 000000000..ea12448df --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/theme/Color.kt @@ -0,0 +1,12 @@ +package eu.kanade.presentation.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val ColorScheme.active: Color + @Composable + get() { + return if (isSystemInDarkTheme()) Color(255, 235, 59) else Color(255, 193, 7) + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt deleted file mode 100755 index 5ee86ea3e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryAdapter.kt +++ /dev/null @@ -1,220 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import com.google.accompanist.swiperefresh.SwipeRefresh -import com.google.accompanist.swiperefresh.rememberSwipeRefreshState -import eu.kanade.domain.category.model.Category -import eu.kanade.presentation.components.SwipeRefreshIndicator -import eu.kanade.presentation.library.components.LibraryComfortableGrid -import eu.kanade.presentation.library.components.LibraryCompactGrid -import eu.kanade.presentation.library.components.LibraryCoverOnlyGrid -import eu.kanade.presentation.library.components.LibraryList -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.LibraryManga -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.databinding.ComposeControllerBinding -import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.setComposeContent -import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -/** - * This adapter stores the categories from the library, used with a ViewPager. - * - * @constructor creates an instance of the adapter. - */ -class LibraryAdapter( - private val presenter: LibraryPresenter, - private val onClickManga: (LibraryManga) -> Unit, - // SY --> - private val onOpenReader: (LibraryManga) -> Unit, - // SY <-- - private val preferences: PreferencesHelper = Injekt.get(), -) : RecyclerViewPagerAdapter() { - - /** - * The categories to bind in the adapter. - */ - var categories: List = mutableStateListOf() - private set - - /** - * The number of manga in each category. - * List order must be the same as [categories] - */ - private var itemsPerCategory: List = emptyList() - - private var boundViews = arrayListOf() - - /** - * Pair of category and size of category - */ - fun updateCategories(new: List>) { - var updated = false - - val newCategories = new.map { it.first } - if (categories != newCategories) { - categories = newCategories - updated = true - } - - val newItemsPerCategory = new.map { it.second } - if (itemsPerCategory !== newItemsPerCategory) { - itemsPerCategory = newItemsPerCategory - updated = true - } - - if (updated) { - notifyDataSetChanged() - } - } - - /** - * Creates a new view for this adapter. - * - * @return a new view. - */ - override fun inflateView(container: ViewGroup, viewType: Int): View { - val binding = ComposeControllerBinding.inflate(LayoutInflater.from(container.context), container, false) - return binding.root - } - - /** - * Binds a view with a position. - * - * @param view the view to bind. - * @param position the position in the adapter. - */ - override fun bindView(view: View, position: Int) { - (view as ComposeView).apply { - setComposeContent { - val nestedScrollInterop = rememberNestedScrollInteropConnection() - - val category = presenter.categories[position] - val displayMode = presenter.getDisplayMode(index = position) - val mangaList by presenter.getMangaForCategory(categoryId = category.id) - - val onClickManga = { manga: LibraryManga -> - if (presenter.hasSelection().not()) { - onClickManga(manga) - } else { - presenter.toggleSelection(manga) - } - } - val onLongClickManga = { manga: LibraryManga -> - presenter.toggleSelection(manga) - } - - SwipeRefresh( - modifier = Modifier.nestedScroll(nestedScrollInterop), - state = rememberSwipeRefreshState(isRefreshing = false), - onRefresh = { - if (LibraryUpdateService.start(context, category)) { - context.toast(R.string.updating_category) - } - }, - indicator = { s, trigger -> - SwipeRefreshIndicator( - state = s, - refreshTriggerDistance = trigger, - ) - }, - ) { - when (displayMode) { - DisplayModeSetting.LIST -> { - LibraryList( - items = mangaList, - selection = presenter.selection, - onClick = onClickManga, - onLongClick = onLongClickManga, - ) - } - DisplayModeSetting.COMPACT_GRID -> { - LibraryCompactGrid( - items = mangaList, - columns = presenter.columns, - selection = presenter.selection, - onClick = onClickManga, - onLongClick = onLongClickManga, - // SY --> - onOpenReader = onOpenReader, - // SY <-- - ) - } - DisplayModeSetting.COMFORTABLE_GRID -> { - LibraryComfortableGrid( - items = mangaList, - columns = presenter.columns, - selection = presenter.selection, - onClick = onClickManga, - onLongClick = onLongClickManga, - // SY --> - onOpenReader = onOpenReader, - // SY <-- - ) - } - DisplayModeSetting.COVER_ONLY_GRID -> { - LibraryCoverOnlyGrid( - items = mangaList, - columns = presenter.columns, - selection = presenter.selection, - onClick = onClickManga, - onLongClick = onLongClickManga, - // SY --> - onOpenReader = onOpenReader, - // SY <-- - ) - } - } - } - } - } - boundViews.add(view) - } - - /** - * Recycles a view. - * - * @param view the view to recycle. - * @param position the position in the adapter. - */ - override fun recycleView(view: View, position: Int) { - boundViews.remove(view) - } - - /** - * Returns the number of categories. - * - * @return the number of categories or 0 if the list is null. - */ - override fun getCount(): Int { - return categories.size - } - - /** - * Returns the title to display for a category. - * - * @param position the position of the element. - * @return the title to display. - */ - override fun getPageTitle(position: Int): CharSequence { - return if (!preferences.categoryNumberOfItems().get()) { - categories[position].name - } else { - categories[position].let { "${it.name} (${itemsPerCategory[position]})" } - } - } - - override fun getViewType(position: Int): Int = -1 -} 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 42bfe95d6..d7f580d70 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,35 +1,27 @@ package eu.kanade.tachiyomi.ui.library -import android.content.res.Configuration import android.os.Bundle -import android.view.LayoutInflater import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View import android.view.WindowManager import androidx.appcompat.app.AlertDialog -import androidx.appcompat.view.ActionMode -import androidx.core.view.isVisible +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 com.fredporciuncula.flow.preferences.Preference import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.tabs.TabLayout -import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.domain.category.model.Category import eu.kanade.domain.category.model.toDbCategory import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.toDbManga +import eu.kanade.presentation.library.LibraryScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.databinding.LibraryControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import eu.kanade.tachiyomi.ui.base.controller.RootController -import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController -import eu.kanade.tachiyomi.ui.base.controller.TabbedController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController @@ -38,16 +30,10 @@ 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.preference.asHotFlow -import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.widget.ActionModeWithToolbar -import eu.kanade.tachiyomi.widget.EmptyView import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView import exh.favorites.FavoritesIntroDialog import exh.favorites.FavoritesSyncStatus -import exh.source.EH_SOURCE_ID import exh.source.MERGED_SOURCE_ID import exh.source.PERV_EDEN_EN_SOURCE_ID import exh.source.PERV_EDEN_IT_SOURCE_ID @@ -57,60 +43,26 @@ import exh.source.nHentaiSourceIds import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample -import reactivecircus.flowbinding.android.view.clicks -import reactivecircus.flowbinding.viewpager.pageSelections -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class LibraryController( bundle: Bundle? = null, - private val preferences: PreferencesHelper = Injekt.get(), - private val trackManager: TrackManager = Injekt.get(), -) : SearchableNucleusController(bundle), +) : FullComposeController(bundle), RootController, - TabbedController, - ActionModeWithToolbar.Callback, ChangeMangaCategoriesDialog.Listener, DeleteLibraryMangasDialog.Listener { - /** - * Position of the active category. - */ - private var activeCategory: Int = preferences.lastUsedCategory().get() - - /** - * Action mode for selections. - */ - private var actionMode: ActionModeWithToolbar? = null - - private var mangaMap: LibraryMap = emptyMap() - - private var adapter: LibraryAdapter? = null - /** * Sheet containing filter/sort/display items. */ private var settingsSheet: LibrarySettingsSheet? = null - private var tabsVisibilityRelay: BehaviorRelay = BehaviorRelay.create(false) - - private var mangaCountVisibilityRelay: BehaviorRelay = BehaviorRelay.create(false) - - private var tabsVisibilitySubscription: Subscription? = null - - private var mangaCountVisibilitySubscription: Subscription? = null - // --> EH // Sync dialog private var favSyncDialog: AlertDialog? = null @@ -123,162 +75,115 @@ class LibraryController( // <-- EH init { - setHasOptionsMenu(true) retainViewMode = RetainViewMode.RETAIN_DETACH } - private var currentTitle: String? = null - set(value) { - if (field != value) { - field = value - setTitle() - } - } + override fun createPresenter(): LibraryPresenter = LibraryPresenter() - override fun getTitle(): String? { - return currentTitle ?: resources?.getString(R.string.label_library) - } - - private fun updateTitle() { - val showCategoryTabs = preferences.categoryTabs().get() - val currentCategory = adapter?.categories?.getOrNull(binding.libraryPager.currentItem) - - var title = if (showCategoryTabs) { - resources?.getString(R.string.label_library) - } else { - currentCategory?.name - } - - if (preferences.categoryNumberOfItems().get()) { - if (!showCategoryTabs || adapter?.categories?.size == 1) { - title += " (${mangaMap[currentCategory?.id]?.size ?: 0})" - } - } - - currentTitle = title - } - - override fun createPresenter(): LibraryPresenter { - return LibraryPresenter() - } - - override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - adapter = LibraryAdapter( + @Composable + override fun ComposeContent() { + val context = LocalContext.current + LibraryScreen( presenter = presenter, - onClickManga = { - openManga(it.id!!) + onMangaClicked = ::openManga, + onGlobalSearchClicked = { + router.pushController(GlobalSearchController(presenter.query)) }, + onChangeCategoryClicked = ::showMangaCategoriesDialog, + onMarkAsReadClicked = { markReadStatus(true) }, + onMarkAsUnreadClicked = { markReadStatus(false) }, + onDownloadClicked = ::downloadUnreadChapters, + onDeleteClicked = ::showDeleteMangaDialog, + onClickFilter = ::showSettingsSheet, + onClickRefresh = { + if (LibraryUpdateService.start(context)) { + context.toast(R.string.updating_library) + } + }, + onClickInvertSelection = { presenter.invertSelection(presenter.activeCategory) }, + onClickSelectAll = { presenter.selectAll(presenter.activeCategory) }, + onClickUnselectAll = ::clearSelection, // SY --> + onClickCleanTitles = ::cleanTitles, + onClickMigrate = { + val skipPre = Injekt.get().skipPreMigration().get() + val selectedMangaIds = presenter.selection.filterNot { it.source == MERGED_SOURCE_ID }.mapNotNull { it.id } + presenter.clearSelection() + if (selectedMangaIds.isNotEmpty()) { + PreMigrationController.navigateToMigration(skipPre, router, selectedMangaIds) + } else { + activity?.toast(R.string.no_valid_manga) + } + }, + onClickAddToMangaDex = ::pushToMdList, onOpenReader = { startReading(it.toDomainManga()!!) }, + onClickSyncExh = { + // TODO + if (Injekt.get().exhShowSyncIntro().get()) { + activity?.let { FavoritesIntroDialog().show(it) } + } else { + MaterialAlertDialogBuilder(activity!!) + .setTitle(R.string.favorites_sync) + .setMessage(R.string.favorites_sync_conformation_message) + .setPositiveButton(android.R.string.ok) { _, _ -> + presenter.runSync() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + }, // SY <-- ) - - getColumnsPreferenceForCurrentOrientation() - .asHotFlow { presenter.columns = it } - .launchIn(viewScope) - - binding.libraryPager.adapter = adapter - binding.libraryPager.pageSelections() - .drop(1) - .onEach { - preferences.lastUsedCategory().set(it) - activeCategory = it - updateTitle() - } - .launchIn(viewScope) - - if (adapter!!.categories.isNotEmpty()) { - createActionModeIfNeeded() + LaunchedEffect(presenter.selectionMode) { + val activity = (activity as? MainActivity) ?: return@LaunchedEffect + activity.showBottomNav(presenter.selectionMode.not()) } + } + + override fun handleBack(): Boolean { + if (presenter.selection.isNotEmpty()) { + presenter.clearSelection() + return true + } + return false + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) settingsSheet = LibrarySettingsSheet(router) { group -> when (group) { is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged() is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged() - is LibrarySettingsSheet.Display.DisplayGroup -> { - val delay = if (preferences.categorizedDisplaySettings().get()) 125L else 0L - - Observable.timer(delay, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribe { - reattachAdapter() - } - } + is LibrarySettingsSheet.Display.DisplayGroup -> {} is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged() - // SY --> - is LibrarySettingsSheet.Display.ButtonsGroup -> onButtonSettingChanged() - // SY <-- - is LibrarySettingsSheet.Display.TabsGroup -> onTabsSettingsChanged() + is LibrarySettingsSheet.Display.TabsGroup -> {} // onTabsSettingsChanged() // SY --> is LibrarySettingsSheet.Grouping.InternalGroup -> onGroupSettingChanged() - // SY <-- + is LibrarySettingsSheet.Display.ButtonsGroup -> onButtonSettingChanged() + // SY --> } } - - binding.btnGlobalSearch.clicks() - .onEach { - router.pushController(GlobalSearchController(presenter.query)) - } - .launchIn(viewScope) - } - - private fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { - preferences.portraitColumns() - } else { - preferences.landscapeColumns() - } } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) if (type.isEnter) { - (activity as? MainActivity)?.binding?.tabs?.setupWithViewPager(binding.libraryPager) presenter.subscribeLibrary() } } override fun onDestroyView(view: View) { - destroyActionModeIfNeeded() - adapter = null settingsSheet?.sheetScope?.cancel() settingsSheet = null - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = null super.onDestroyView(view) } - override fun configureTabs(tabs: TabLayout): Boolean { - with(tabs) { - isVisible = false - tabGravity = TabLayout.GRAVITY_START - tabMode = TabLayout.MODE_SCROLLABLE - } - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible -> - tabs.isVisible = visible - } - mangaCountVisibilitySubscription?.unsubscribe() - mangaCountVisibilitySubscription = mangaCountVisibilityRelay.subscribe { - adapter?.notifyDataSetChanged() - } - - return false - } - - override fun cleanupTabs(tabs: TabLayout) { - tabsVisibilitySubscription?.unsubscribe() - tabsVisibilitySubscription = null - } - fun showSettingsSheet() { - if (adapter?.categories?.isNotEmpty() == true) { - adapter?.categories?.getOrNull(binding.libraryPager.currentItem)?.let { category -> + if (presenter.categories.isNotEmpty()) { + presenter.categories[presenter.activeCategory].let { category -> settingsSheet?.show(category.toDbCategory()) } } else { @@ -286,61 +191,6 @@ class LibraryController( } } - fun onNextLibraryUpdate(categories: List, mangaMap: LibraryMap) { - val view = view ?: return - val adapter = adapter ?: return - - // Show empty view if needed - if (mangaMap.isNotEmpty()) { - binding.emptyView.hide() - } else { - binding.emptyView.show( - R.string.information_empty_library, - listOf( - EmptyView.Action(R.string.getting_started_guide, R.drawable.ic_help_24dp) { - activity?.openInBrowser("https://tachiyomi.org/help/guides/getting-started") - }, - ), - ) - (activity as? MainActivity)?.ready = true - } - - // Get the current active category. - val activeCat = if (adapter.categories.isNotEmpty()) { - binding.libraryPager.currentItem - } else { - activeCategory - } - - // Set the categories - adapter.updateCategories(categories.map { it to (mangaMap[it.id]?.size ?: 0) }) - - // Restore active category. - binding.libraryPager.setCurrentItem(activeCat, false) - - // Trigger display of tabs - onTabsSettingsChanged(firstLaunch = true) - - // Delay the scroll position to allow the view to be properly measured. - view.post { - if (isAttached) { - (activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) - } - } - - presenter.loadedManga.clear() - mangaMap.forEach { - presenter.loadedManga[it.key] = it.value - } - presenter.loadedMangaFlow.value = presenter.loadedManga - - // Send the manga map to child fragments after the adapter is updated. - this.mangaMap = mangaMap - - // Finally update the title - updateTitle() - } - private fun onFilterChanged() { presenter.requestFilterUpdate() activity?.invalidateOptionsMenu() @@ -360,197 +210,17 @@ class LibraryController( } // SY <-- - private fun onTabsSettingsChanged(firstLaunch: Boolean = false) { - if (!firstLaunch) { - mangaCountVisibilityRelay.call(preferences.categoryNumberOfItems().get()) - } - tabsVisibilityRelay.call(preferences.categoryTabs().get() && (adapter?.categories?.size ?: 0) > 1) - updateTitle() - } - private fun onSortChanged() { - // SY --> - activity?.invalidateOptionsMenu() - // SY <-- presenter.requestSortUpdate() } - /** - * Reattaches the adapter to the view pager to recreate fragments - */ - private fun reattachAdapter() { - val adapter = adapter ?: return - - val position = binding.libraryPager.currentItem - - adapter.recycle = false - binding.libraryPager.adapter = adapter - binding.libraryPager.currentItem = position - adapter.recycle = true - } - - fun createActionModeIfNeeded() { - val activity = activity - if (actionMode == null && activity is MainActivity) { - actionMode = activity.startActionModeAndToolbar(this) - activity.showBottomNav(false) - } - } - - private fun destroyActionModeIfNeeded() { - actionMode?.finish() - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search) - // Mutate the filter icon because it needs to be tinted and the resource is shared. - menu.findItem(R.id.action_filter).icon?.mutate() - - // SY --> - val hentaiEnabled = preferences.isHentaiEnabled().get() - val ehEnabled = EH_SOURCE_ID.toString() !in preferences.disabledSources().get() - val exhEnabled = preferences.enableExhentai().get() - menu.findItem(R.id.action_sync_favorites).isVisible = hentaiEnabled && (ehEnabled || exhEnabled) - // SY <-- - } - fun search(query: String) { - presenter.query = query - } - - private fun performSearch() { - if (presenter.query.isNotEmpty()) { - binding.btnGlobalSearch.isVisible = true - binding.btnGlobalSearch.text = - resources?.getString(R.string.action_global_search_query, presenter.query) - } else { - binding.btnGlobalSearch.isVisible = false - } + presenter.searchQuery = query } override fun onPrepareOptionsMenu(menu: Menu) { val settingsSheet = settingsSheet ?: return - - val filterItem = menu.findItem(R.id.action_filter) - - // Tint icon if there's a filter active - if (settingsSheet.filters.hasActiveFilters()) { - val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) - filterItem.icon?.setTint(filterColor) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> expandActionViewFromInteraction = true - R.id.action_filter -> showSettingsSheet() - R.id.action_update_library -> { - activity?.let { - if (LibraryUpdateService.start(it)) { - it.toast(R.string.updating_library) - } - } - } - // SY --> - R.id.action_sync_favorites -> { - if (preferences.exhShowSyncIntro().get()) { - activity?.let { FavoritesIntroDialog().show(it) } - } else { - MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.favorites_sync) - .setMessage(R.string.favorites_sync_conformation_message) - .setPositiveButton(android.R.string.ok) { _, _ -> - presenter.runSync() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - } - // SY <-- - } - - return super.onOptionsItemSelected(item) - } - - /** - * Invalidates the action mode, forcing it to refresh its content. - */ - fun invalidateActionMode() { - actionMode?.invalidate() - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.generic_selection, menu) - return true - } - - override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) { - menuInflater.inflate(R.menu.library_selection, menu) - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = presenter.selection.size - if (count == 0) { - // Destroy action mode if there are no items selected. - destroyActionModeIfNeeded() - } else { - mode.title = count.toString() - } - return true - } - - override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { - if (presenter.hasSelection().not()) return - toolbar.findToolbarItem(R.id.action_download_unread)?.isVisible = - presenter.selection.any { presenter.loadedManga.values.any { it.any { it.isLocal } } } - // SY --> - toolbar.findToolbarItem(R.id.action_clean)?.isVisible = presenter.selection.any { - it.isEhBasedManga() || - it.source in nHentaiSourceIds || - it.source == PERV_EDEN_EN_SOURCE_ID || - it.source == PERV_EDEN_IT_SOURCE_ID - } - toolbar.findToolbarItem(R.id.action_push_to_mdlist)?.isVisible = trackManager.mdList.isLogged && presenter.selection.any { - it.source in mangaDexSourceIds - } - // SY <-- - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_move_to_category -> showMangaCategoriesDialog() - R.id.action_download_unread -> downloadUnreadChapters() - R.id.action_mark_as_read -> markReadStatus(true) - R.id.action_mark_as_unread -> markReadStatus(false) - R.id.action_delete -> showDeleteMangaDialog() - R.id.action_select_all -> selectAllCategoryManga() - R.id.action_select_inverse -> selectInverseCategoryManga() - // SY --> - R.id.action_migrate -> { - val skipPre = preferences.skipPreMigration().get() - val selectedMangaIds = presenter.selection.filterNot { it.source == MERGED_SOURCE_ID }.mapNotNull { it.id } - clearSelection() - if (selectedMangaIds.isNotEmpty()) { - PreMigrationController.navigateToMigration(skipPre, router, selectedMangaIds) - } else { - activity?.toast(R.string.no_valid_manga) - } - } - R.id.action_clean -> cleanTitles() - R.id.action_push_to_mdlist -> pushToMdList() - // SY <-- - else -> return false - } - return true - } - - override fun onDestroyActionMode(mode: ActionMode) { - // Clear all the manga selections and notify child views. - presenter.clearSelection() - - (activity as? MainActivity)?.showBottomNav(true) - - actionMode = null + presenter.hasActiveFilters = settingsSheet.filters.hasActiveFilters() } private fun openManga(mangaId: Long) { @@ -566,7 +236,6 @@ class LibraryController( */ fun clearSelection() { presenter.clearSelection() - invalidateActionMode() } /** @@ -601,13 +270,13 @@ class LibraryController( private fun downloadUnreadChapters() { val mangas = presenter.selection.toList() presenter.downloadUnreadChapters(mangas.mapNotNull { it.toDomainManga() }) - destroyActionModeIfNeeded() + presenter.clearSelection() } private fun markReadStatus(read: Boolean) { val mangas = presenter.selection.toList() presenter.markReadStatus(mangas.mapNotNull { it.toDomainManga() }, read) - destroyActionModeIfNeeded() + presenter.clearSelection() } private fun showDeleteMangaDialog() { @@ -624,7 +293,7 @@ class LibraryController( it.source == PERV_EDEN_IT_SOURCE_ID } presenter.cleanTitles(mangas) - destroyActionModeIfNeeded() + presenter.clearSelection() } private fun pushToMdList() { @@ -632,17 +301,18 @@ class LibraryController( it.source in mangaDexSourceIds } presenter.syncMangaToDex(mangas) + presenter.clearSelection() } // SY <-- override fun updateCategoriesForMangas(mangas: List, addCategories: List, removeCategories: List) { presenter.setMangaCategories(mangas, addCategories, removeCategories) - destroyActionModeIfNeeded() + presenter.clearSelection() } override fun deleteMangas(mangas: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { presenter.removeMangas(mangas.map { it.toDbManga() }, deleteFromLibrary, deleteChapters) - destroyActionModeIfNeeded() + presenter.clearSelection() } // SY --> @@ -669,23 +339,6 @@ class LibraryController( } // SY <-- - private fun selectAllCategoryManga() { - presenter.selectAll(binding.libraryPager.currentItem) - } - - private fun selectInverseCategoryManga() { - presenter.invertSelection(binding.libraryPager.currentItem) - } - - override fun onSearchViewQueryTextChange(newText: String?) { - // Ignore events if this controller isn't at the top to avoid query being reset - if (router.backstack.lastOrNull()?.controller == this) { - presenter.query = newText ?: "" - presenter.searchQuery = newText ?: "" - performSearch() - } - } - // --> EXH private fun cleanupSyncState() { favoritesSyncJob?.cancel() @@ -802,7 +455,7 @@ class LibraryController( val activity = activity ?: return val chapter = presenter.getFirstUnread(manga) ?: return val intent = ReaderActivity.newIntent(activity, manga.id, chapter.id) - destroyActionModeIfNeeded() + presenter.clearSelection() startActivity(intent) } // <-- EXH diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 3b470593b..840f2fce4 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -2,15 +2,17 @@ package eu.kanade.tachiyomi.ui.library import android.os.Bundle import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf 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 com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.core.prefs.PreferenceMutableState +import eu.kanade.core.util.asFlow import eu.kanade.core.util.asObservable import eu.kanade.data.DatabaseHandler import eu.kanade.domain.category.interactor.GetCategories @@ -31,6 +33,9 @@ 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.GetTracks +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.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.models.LibraryManga @@ -49,7 +54,6 @@ import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting import eu.kanade.tachiyomi.util.lang.combineLatest -import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.removeCovers @@ -63,19 +67,26 @@ import exh.search.Namespace import exh.search.QueryComponent import exh.search.SearchEngine import exh.search.Text +import exh.source.EH_SOURCE_ID import exh.source.MERGED_SOURCE_ID import exh.source.isEhBasedManga import exh.source.isMetadataSource import exh.util.cancellable import exh.util.isLewd import exh.util.nullIfBlank -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import rx.Observable -import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt @@ -99,6 +110,7 @@ typealias LibraryMap = Map> * Presenter of [LibraryController]. */ class LibraryPresenter( + private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl, private val handler: DatabaseHandler = Injekt.get(), private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getTracks: GetTracks = Injekt.get(), @@ -121,31 +133,27 @@ class LibraryPresenter( private val getSearchTags: GetSearchTags = Injekt.get(), private val getSearchTitles: GetSearchTitles = Injekt.get(), // SY <-- -) : BasePresenter() { +) : BasePresenter(), LibraryState by state { private val context = preferences.context - /** - * Categories of the library. - */ - var categories: List = mutableStateListOf() + var loadedManga by mutableStateOf(emptyMap>()) private set - var loadedManga = mutableStateMapOf>() - private set - - val loadedMangaFlow = MutableStateFlow(loadedManga) - - var searchQuery by mutableStateOf(query) - - val selection: MutableList = mutableStateListOf() - val isPerCategory by preferences.categorizedDisplaySettings().asState() - var columns by mutableStateOf(0) - var currentDisplayMode by preferences.libraryDisplayMode().asState() + val tabVisibility by preferences.categoryTabs().asState() + + val mangaCountVisibility by preferences.categoryNumberOfItems().asState() + + var activeCategory: Int by preferences.lastUsedCategory().asState() + + val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() + + val isIncognitoMode: Boolean by preferences.incognitoMode().asState() + /** * Relay used to apply the UI filters to the last emission of the library. */ @@ -161,7 +169,7 @@ class LibraryPresenter( */ private val sortTriggerRelay = BehaviorRelay.create(Unit) - private var librarySubscription: Subscription? = null + private var librarySubscription: Job? = null // SY --> val favoritesSync = FavoritesSyncHelper(context) @@ -190,6 +198,16 @@ class LibraryPresenter( override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) + // SY --> + combine( + preferences.isHentaiEnabled().asFlow(), + preferences.disabledSources().asFlow(), + preferences.enableExhentai().asFlow(), + ) { isHentaiEnabled, disabledSources, enableExhentai -> + state.showSyncExh = isHentaiEnabled && (EH_SOURCE_ID.toString() !in disabledSources || enableExhentai) + }.flowOn(Dispatchers.IO).launchIn(presenterScope) + // SY <-- + subscribeLibrary() } @@ -197,31 +215,40 @@ class LibraryPresenter( * Subscribes to library if needed. */ fun subscribeLibrary() { - // TODO: Move this to a coroutine world - if (librarySubscription.isNullOrUnsubscribed()) { - librarySubscription = getLibraryObservable() - .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> - lib.apply { setBadges(mangaMap) } - } - // SY --> - .combineLatest(buttonTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> - lib.apply { setButtons(mangaMap) } - } - .combineLatest(groupingTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> - val (map, categories) = applyGrouping(lib.mangaMap, lib.categories) - lib.copy(mangaMap = map, categories = categories) - } - // SY <-- - .combineLatest(getFilterObservable()) { lib, tracks -> - lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks)) - } - .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> - lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap)) - } - .observeOn(AndroidSchedulers.mainThread()) - .subscribeLatestCache({ view, (categories, mangaMap) -> - view.onNextLibraryUpdate(categories, mangaMap) - },) + /** + * TODO: Move this to a coroutine world + * - 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 retrive as needed instead of fetching all of them at once + */ + if (librarySubscription == null || librarySubscription!!.isCancelled) { + librarySubscription = presenterScope.launchIO { + getLibraryObservable() + .combineLatest(badgeTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.apply { setBadges(mangaMap) } + } + // SY --> + .combineLatest(buttonTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.apply { setButtons(mangaMap) } + } + .combineLatest(groupingTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + val (map, categories) = applyGrouping(lib.mangaMap, lib.categories) + lib.copy(mangaMap = map, categories = categories) + } + // SY <-- + .combineLatest(getFilterObservable()) { lib, tracks -> + lib.copy(mangaMap = applyFilters(lib.mangaMap, tracks)) + } + .combineLatest(sortTriggerRelay.observeOn(Schedulers.io())) { lib, _ -> + lib.copy(mangaMap = applySort(lib.categories, lib.mangaMap)) + } + .observeOn(AndroidSchedulers.mainThread()) + .asFlow() + .collectLatest { + state.isLoading = false + loadedManga = it.mangaMap + } + } } } @@ -544,7 +571,7 @@ class LibraryPresenter( * @return an observable of the categories and its manga. */ private fun getLibraryObservable(): Observable { - return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> + return combine(getCategoriesObservable(), getLibraryMangasObservable()) { dbCategories, libraryManga -> val categories = if (libraryManga.containsKey(0)) { arrayListOf(Category.default(context)) + dbCategories } else { @@ -558,9 +585,9 @@ class LibraryPresenter( } } - this.categories = categories + state.categories = categories Library(categories, libraryManga) - } + }.asObservable() } // SY --> @@ -591,8 +618,8 @@ class LibraryPresenter( * * @return an observable of the categories. */ - private fun getCategoriesObservable(): Observable> { - return getCategories.subscribe().asObservable() + private fun getCategoriesObservable(): Flow> { + return getCategories.subscribe() } /** @@ -601,8 +628,8 @@ class LibraryPresenter( * @return an observable containing a map with the category id as key and a list of manga as the * value. */ - private fun getLibraryMangasObservable(): Observable { - return getLibraryManga.subscribe().asObservable() + private fun getLibraryMangasObservable(): Flow { + return getLibraryManga.subscribe() .map { list -> list.map { libraryManga -> // Display mode based on user preference: take it from global library setting or category @@ -617,7 +644,8 @@ class LibraryPresenter( * @return an observable of tracked manga. */ private fun getFilterObservable(): Observable>> { - return getTracksObservable().combineLatest(filterTriggerRelay.observeOn(Schedulers.io())) { tracks, _ -> tracks } + return filterTriggerRelay.observeOn(Schedulers.io()) + .combineLatest(getTracksObservable()) { _, tracks -> tracks } } /** @@ -628,7 +656,7 @@ class LibraryPresenter( private fun getTracksObservable(): Observable>> { // TODO: Move this to domain/data layer return getTracks.subscribe() - .asObservable().map { tracks -> + .map { tracks -> tracks .groupBy { it.mangaId } .mapValues { tracksForMangaId -> @@ -638,6 +666,7 @@ class LibraryPresenter( } } } + .asObservable() .observeOn(Schedulers.io()) } @@ -684,7 +713,7 @@ class LibraryPresenter( */ fun onOpenManga() { // Avoid further db updates for the library when it's not needed - librarySubscription?.let { remove(it) } + librarySubscription?.cancel() } /** @@ -856,15 +885,51 @@ 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) preferences.landscapeColumns() else preferences.portraitColumns()).asState() + } + + // 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(id = R.string.label_library) + val default = remember { LibraryToolbarTitle(defaultTitle) } + + return produceState(initialValue = default, category, mangaCountVisibility, tabVisibility) { + val title = if (tabVisibility.not()) category?.name ?: defaultTitle else defaultTitle + + value = when { + category == null -> default + (tabVisibility.not() && mangaCountVisibility.not()) -> LibraryToolbarTitle(title) + tabVisibility.not() && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size) + (tabVisibility && categories.size > 1) && mangaCountVisibility -> LibraryToolbarTitle(title) + tabVisibility && mangaCountVisibility -> LibraryToolbarTitle(title, loadedManga[category.id]?.size) + else -> default + } + } + } + // SY --> @Composable - fun getMangaForCategory(categoryId: Long): androidx.compose.runtime.State> { + fun getMangaForCategory(page: Int): androidx.compose.runtime.State> { + val categoryId = remember(categories) { + categories.getOrNull(page)?.id ?: -1 + } val unfiltered = loadedManga[categoryId] ?: emptyList() return produceState(initialValue = unfiltered, searchQuery) { val query = searchQuery value = withIOContext { - if (unfiltered.isNotEmpty() && query.isNotBlank()) { + if (unfiltered.isNotEmpty() && !query.isNullOrBlank()) { // Prepare filter object val parsedQuery = searchEngine.parseQuery(query) val mangaWithMetaIds = getIdsOfFavoriteMangaWithMetadata.await() @@ -976,9 +1041,9 @@ class LibraryPresenter( // SY <-- @Composable - fun getDisplayMode(index: Int): DisplayModeSetting { + fun getDisplayMode(index: Int): androidx.compose.runtime.State { val category = categories[index] - return remember { + return derivedStateOf { if (isPerCategory.not() || category.id == 0L) { currentDisplayMode } else { @@ -992,35 +1057,31 @@ class LibraryPresenter( } fun clearSelection() { - selection.clear() + state.selection = emptyList() } fun toggleSelection(manga: LibraryManga) { + val mutableList = state.selection.toMutableList() if (selection.fastAny { it.id == manga.id }) { - selection.remove(manga) + mutableList.remove(manga) } else { - selection.add(manga) + mutableList.add(manga) } - view?.invalidateActionMode() - view?.createActionModeIfNeeded() + state.selection = mutableList } fun selectAll(index: Int) { val category = categories[index] val items = loadedManga[category.id] ?: emptyList() - selection.addAll(items.filterNot { it.manga in selection }.map { it.manga }) - view?.createActionModeIfNeeded() - view?.invalidateActionMode() + state.selection = state.selection.toMutableList().apply { + addAll(items.filterNot { it.manga in selection }.map { it.manga }) + } } fun invertSelection(index: Int) { val category = categories[index] val items = (loadedManga[category.id] ?: emptyList()).map { it.manga } - val invert = items.filterNot { it in selection } - selection.removeAll(items) - selection.addAll(invert) - view?.createActionModeIfNeeded() - view?.invalidateActionMode() + state.selection = items.filterNot { it in selection } } // SY --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index b3e02be19..72a5f75f0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -542,9 +542,13 @@ class MainActivity : BaseActivity() { return } val backstackSize = router.backstackSize - if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { + val startScreen = router.getControllerWithTag("$startScreenId") + if (backstackSize == 1 && startScreen == null) { // Return to start screen moveToStartScreen() + setSelectedNavItem(startScreenId) + } else if (startScreen != null && router.handleBack()) { + // Clear selection for Library screen } else if (shouldHandleExitConfirmation()) { // Exit confirmation (resets after 2 seconds) lifecycleScope.launchUI { resetExitConfirmation() } diff --git a/app/src/main/res/layout/library_controller.xml b/app/src/main/res/layout/library_controller.xml deleted file mode 100644 index b1038fbb0..000000000 --- a/app/src/main/res/layout/library_controller.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - -