From 94d1b68598692cc0ef981e2dfbf12303fa962f63 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Thu, 1 Dec 2022 11:05:11 +0700 Subject: [PATCH] Use Voyager on BrowseSource and SourceSearch screen (#8650) Some navigation janks will be dealt with when the migration is complete --- .../presentation/browse/BrowseSourceScreen.kt | 191 +----------- .../presentation/browse/BrowseSourceState.kt | 41 --- .../presentation/browse/SourceSearchScreen.kt | 72 ----- .../components/BrowseSourceComfortableGrid.kt | 9 +- .../components/BrowseSourceCompactGrid.kt | 9 +- .../browse/components/BrowseSourceList.kt | 12 +- .../browse/components/BrowseSourceToolbar.kt | 8 +- .../search/SourceSearchController.kt | 63 +--- .../migration/search/SourceSearchScreen.kt | 134 +++++++++ .../tachiyomi/ui/browse/source/SourcesTab.kt | 2 +- .../source/browse/BrowseSourceController.kt | 193 ++---------- .../source/browse/BrowseSourceScreen.kt | 283 ++++++++++++++++++ ...resenter.kt => BrowseSourceScreenModel.kt} | 264 ++++++++++------ .../browse/source/browse/SourceFilterSheet.kt | 7 +- .../source/globalsearch/GlobalSearchScreen.kt | 2 +- 15 files changed, 653 insertions(+), 637 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/{BrowseSourcePresenter.kt => BrowseSourceScreenModel.kt} (60%) diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt index b094a12892..a718479464 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt @@ -1,213 +1,37 @@ package eu.kanade.presentation.browse -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Favorite -import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems import eu.kanade.data.source.NoResultsException import eu.kanade.domain.library.model.LibraryDisplayMode import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.source.interactor.GetRemoteManga import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid import eu.kanade.presentation.browse.components.BrowseSourceList -import eu.kanade.presentation.browse.components.BrowseSourceToolbar -import eu.kanade.presentation.components.AppStateBanners -import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreenAction -import eu.kanade.presentation.components.ExtendedFloatingActionButton import eu.kanade.presentation.components.LoadingScreen -import eu.kanade.presentation.components.Scaffold import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.ui.more.MoreController - -@Composable -fun BrowseSourceScreen( - presenter: BrowseSourcePresenter, - navigateUp: () -> Unit, - openFilterSheet: () -> Unit, - onMangaClick: (Manga) -> Unit, - onMangaLongClick: (Manga) -> Unit, - onWebViewClick: () -> Unit, - incognitoMode: Boolean, - downloadedOnlyMode: Boolean, -) { - val columns by presenter.getColumnsPreferenceForCurrentOrientation() - - val mangaList = presenter.getMangaList().collectAsLazyPagingItems() - - val snackbarHostState = remember { SnackbarHostState() } - - val uriHandler = LocalUriHandler.current - - val onHelpClick = { - uriHandler.openUri(LocalSource.HELP_URL) - } - - Scaffold( - topBar = { - Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - BrowseSourceToolbar( - state = presenter, - source = presenter.source, - displayMode = presenter.displayMode, - onDisplayModeChange = { presenter.displayMode = it }, - navigateUp = navigateUp, - onWebViewClick = onWebViewClick, - onHelpClick = onHelpClick, - onSearch = { presenter.search(it) }, - ) - - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FilterChip( - selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Popular, - onClick = { - presenter.reset() - presenter.search(GetRemoteManga.QUERY_POPULAR) - }, - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Favorite, - contentDescription = "", - modifier = Modifier - .size(FilterChipDefaults.IconSize), - ) - }, - label = { - Text(text = stringResource(R.string.popular)) - }, - ) - if (presenter.source?.supportsLatest == true) { - FilterChip( - selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Latest, - onClick = { - presenter.reset() - presenter.search(GetRemoteManga.QUERY_LATEST) - }, - leadingIcon = { - Icon( - imageVector = Icons.Outlined.NewReleases, - contentDescription = "", - modifier = Modifier - .size(FilterChipDefaults.IconSize), - ) - }, - label = { - Text(text = stringResource(R.string.latest)) - }, - ) - } - if (presenter.filters.isNotEmpty()) { - FilterChip( - selected = presenter.currentFilter is BrowseSourcePresenter.Filter.UserInput, - onClick = openFilterSheet, - leadingIcon = { - Icon( - imageVector = Icons.Outlined.FilterList, - contentDescription = "", - modifier = Modifier - .size(FilterChipDefaults.IconSize), - ) - }, - label = { - Text(text = stringResource(R.string.action_filter)) - }, - ) - } - } - - Divider() - - AppStateBanners(downloadedOnlyMode, incognitoMode) - } - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, - ) { paddingValues -> - BrowseSourceContent( - state = presenter, - mangaList = mangaList, - getMangaState = { presenter.getManga(it) }, - columns = columns, - displayMode = presenter.displayMode, - snackbarHostState = snackbarHostState, - contentPadding = paddingValues, - onWebViewClick = onWebViewClick, - onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, - onLocalSourceHelpClick = onHelpClick, - onMangaClick = onMangaClick, - onMangaLongClick = onMangaLongClick, - ) - } -} - -@Composable -fun BrowseSourceFloatingActionButton( - modifier: Modifier = Modifier.navigationBarsPadding(), - isVisible: Boolean, - onFabClick: () -> Unit, -) { - AnimatedVisibility(visible = isVisible) { - ExtendedFloatingActionButton( - modifier = modifier, - text = { Text(text = stringResource(R.string.action_filter)) }, - icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") }, - onClick = onFabClick, - ) - } -} +import kotlinx.coroutines.flow.StateFlow @Composable fun BrowseSourceContent( - state: BrowseSourceState, - mangaList: LazyPagingItems, - getMangaState: @Composable ((Manga) -> State), + source: CatalogueSource?, + mangaList: LazyPagingItems>, columns: GridCells, displayMode: LibraryDisplayMode, snackbarHostState: SnackbarHostState, @@ -249,7 +73,7 @@ fun BrowseSourceContent( if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) { EmptyScreen( message = getErrorMessage(errorState), - actions = if (state.source is LocalSource) { + actions = if (source is LocalSource) { listOf( EmptyScreenAction( stringResId = R.string.local_source_help_guide, @@ -290,7 +114,6 @@ fun BrowseSourceContent( LibraryDisplayMode.ComfortableGrid -> { BrowseSourceComfortableGrid( mangaList = mangaList, - getMangaState = getMangaState, columns = columns, contentPadding = contentPadding, onMangaClick = onMangaClick, @@ -300,16 +123,14 @@ fun BrowseSourceContent( LibraryDisplayMode.List -> { BrowseSourceList( mangaList = mangaList, - getMangaState = getMangaState, contentPadding = contentPadding, onMangaClick = onMangaClick, onMangaLongClick = onMangaLongClick, ) } - else -> { + LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { BrowseSourceCompactGrid( mangaList = mangaList, - getMangaState = getMangaState, columns = columns, contentPadding = contentPadding, onMangaClick = onMangaClick, diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt deleted file mode 100644 index afff9f0dda..0000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.kanade.presentation.browse - -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.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Filter -import eu.kanade.tachiyomi.ui.browse.source.browse.toItems - -@Stable -interface BrowseSourceState { - val source: CatalogueSource? - var searchQuery: String? - val currentFilter: Filter - val isUserQuery: Boolean - val filters: FilterList - val filterItems: List> - var dialog: BrowseSourcePresenter.Dialog? -} - -fun BrowseSourceState(initialQuery: String?): BrowseSourceState { - return when (val filter = Filter.valueOf(initialQuery ?: "")) { - Filter.Latest, Filter.Popular -> BrowseSourceStateImpl(initialCurrentFilter = filter) - is Filter.UserInput -> BrowseSourceStateImpl(initialQuery = initialQuery, initialCurrentFilter = filter) - } -} - -class BrowseSourceStateImpl(initialQuery: String? = null, initialCurrentFilter: Filter) : BrowseSourceState { - override var source: CatalogueSource? by mutableStateOf(null) - override var searchQuery: String? by mutableStateOf(initialQuery) - override var currentFilter: Filter by mutableStateOf(initialCurrentFilter) - override val isUserQuery: Boolean by derivedStateOf { currentFilter is Filter.UserInput && currentFilter.query.isNotEmpty() } - override var filters: FilterList by mutableStateOf(FilterList()) - override val filterItems: List> by derivedStateOf { filters.toItems() } - override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null) -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt deleted file mode 100644 index 60543e8aff..0000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt +++ /dev/null @@ -1,72 +0,0 @@ -package eu.kanade.presentation.browse - -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalUriHandler -import androidx.paging.compose.collectAsLazyPagingItems -import eu.kanade.domain.manga.model.Manga -import eu.kanade.presentation.components.Scaffold -import eu.kanade.presentation.components.SearchToolbar -import eu.kanade.tachiyomi.source.LocalSource -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.ui.more.MoreController - -@Composable -fun SourceSearchScreen( - presenter: BrowseSourcePresenter, - navigateUp: () -> Unit, - onFabClick: () -> Unit, - onMangaClick: (Manga) -> Unit, - onWebViewClick: () -> Unit, -) { - val columns by presenter.getColumnsPreferenceForCurrentOrientation() - - val mangaList = presenter.getMangaList().collectAsLazyPagingItems() - - val snackbarHostState = remember { SnackbarHostState() } - - val uriHandler = LocalUriHandler.current - - val onHelpClick = { - uriHandler.openUri(LocalSource.HELP_URL) - } - - Scaffold( - topBar = { scrollBehavior -> - SearchToolbar( - searchQuery = presenter.searchQuery ?: "", - onChangeSearchQuery = { presenter.searchQuery = it }, - onClickCloseSearch = navigateUp, - onSearch = { presenter.search(it) }, - scrollBehavior = scrollBehavior, - ) - }, - floatingActionButton = { - BrowseSourceFloatingActionButton( - isVisible = presenter.filters.isNotEmpty(), - onFabClick = onFabClick, - ) - }, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, - ) { paddingValues -> - BrowseSourceContent( - state = presenter, - mangaList = mangaList, - getMangaState = { presenter.getManga(it) }, - columns = columns, - displayMode = presenter.displayMode, - snackbarHostState = snackbarHostState, - contentPadding = paddingValues, - onWebViewClick = onWebViewClick, - onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, - onLocalSourceHelpClick = onHelpClick, - onMangaClick = onMangaClick, - onMangaLongClick = onMangaClick, - ) - } -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt index e84afa94f3..3239e434be 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp import androidx.paging.LoadState @@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.MangaComfortableGridItem import eu.kanade.presentation.util.plus +import kotlinx.coroutines.flow.StateFlow @Composable fun BrowseSourceComfortableGrid( - mangaList: LazyPagingItems, - getMangaState: @Composable ((Manga) -> State), + mangaList: LazyPagingItems>, columns: GridCells, contentPadding: PaddingValues, onMangaClick: (Manga) -> Unit, @@ -40,8 +40,7 @@ fun BrowseSourceComfortableGrid( } items(mangaList.itemCount) { index -> - val initialManga = mangaList[index] ?: return@items - val manga by getMangaState(initialManga) + val manga by mangaList[index]?.collectAsState() ?: return@items BrowseSourceComfortableGridItem( manga = manga, onClick = { onMangaClick(manga) }, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt index b062b956f3..2ac2e7222d 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp import androidx.paging.LoadState @@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.MangaCompactGridItem import eu.kanade.presentation.util.plus +import kotlinx.coroutines.flow.StateFlow @Composable fun BrowseSourceCompactGrid( - mangaList: LazyPagingItems, - getMangaState: @Composable ((Manga) -> State), + mangaList: LazyPagingItems>, columns: GridCells, contentPadding: PaddingValues, onMangaClick: (Manga) -> Unit, @@ -40,8 +40,7 @@ fun BrowseSourceCompactGrid( } items(mangaList.itemCount) { index -> - val initialManga = mangaList[index] ?: return@items - val manga by getMangaState(initialManga) + val manga by mangaList[index]?.collectAsState() ?: return@items BrowseSourceCompactGridItem( manga = manga, onClick = { onMangaClick(manga) }, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index 4fd4134d77..ae695ba165 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -2,7 +2,7 @@ package eu.kanade.presentation.browse.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp import androidx.paging.LoadState @@ -15,11 +15,11 @@ import eu.kanade.presentation.components.CommonMangaItemDefaults import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.MangaListItem import eu.kanade.presentation.util.plus +import kotlinx.coroutines.flow.StateFlow @Composable fun BrowseSourceList( - mangaList: LazyPagingItems, - getMangaState: @Composable ((Manga) -> State), + mangaList: LazyPagingItems>, contentPadding: PaddingValues, onMangaClick: (Manga) -> Unit, onMangaLongClick: (Manga) -> Unit, @@ -33,9 +33,9 @@ fun BrowseSourceList( } } - items(mangaList) { initialManga -> - initialManga ?: return@items - val manga by getMangaState(initialManga) + items(mangaList) { mangaflow -> + mangaflow ?: return@items + val manga by mangaflow.collectAsState() BrowseSourceListItem( manga = manga, onClick = { onMangaClick(manga) }, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt index 5cfb34b500..2092558a78 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import eu.kanade.domain.library.model.LibraryDisplayMode -import eu.kanade.presentation.browse.BrowseSourceState import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarTitle @@ -27,7 +26,8 @@ import eu.kanade.tachiyomi.source.LocalSource @Composable fun BrowseSourceToolbar( - state: BrowseSourceState, + searchQuery: String?, + onSearchQueryChange: (String?) -> Unit, source: CatalogueSource?, displayMode: LibraryDisplayMode, onDisplayModeChange: (LibraryDisplayMode) -> Unit, @@ -44,8 +44,8 @@ fun BrowseSourceToolbar( SearchToolbar( navigateUp = navigateUp, titleContent = { AppBarTitle(title) }, - searchQuery = state.searchQuery, - onChangeSearchQuery = { state.searchQuery = it }, + searchQuery = searchQuery, + onChangeSearchQuery = onSearchQueryChange, onSearch = onSearch, onClickCloseSearch = navigateUp, actions = { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt index 5c2d15a048..bb1d3c3597 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt @@ -2,26 +2,14 @@ package eu.kanade.tachiyomi.ui.browse.migration.search import android.os.Bundle import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.core.os.bundleOf +import cafe.adriel.voyager.navigator.Navigator import eu.kanade.domain.manga.model.Manga -import eu.kanade.presentation.browse.SourceSearchScreen -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.base.controller.setRoot -import eu.kanade.tachiyomi.ui.browse.BrowseController -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController import eu.kanade.tachiyomi.util.system.getSerializableCompat -class SourceSearchController( - bundle: Bundle, -) : BrowseSourceController(bundle) { +class SourceSearchController(bundle: Bundle) : BasicFullComposeController(bundle) { constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this( bundleOf( @@ -31,49 +19,16 @@ class SourceSearchController( ), ) - private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY) + private var oldManga: Manga = args.getSerializableCompat(MANGA_KEY)!! + private val sourceId = args.getLong(SOURCE_ID_KEY) + private val query = args.getString(SEARCH_QUERY_KEY) @Composable override fun ComposeContent() { - SourceSearchScreen( - presenter = presenter, - navigateUp = { router.popCurrentController() }, - onFabClick = { filterSheet?.show() }, - onMangaClick = { - presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it) - }, - onWebViewClick = f@{ - val source = presenter.source as? HttpSource ?: return@f - activity?.let { context -> - val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) - context.startActivity(intent) - } - }, - ) - - when (val dialog = presenter.dialog) { - is BrowseSourcePresenter.Dialog.Migrate -> { - MigrateDialog( - oldManga = oldManga!!, - newManga = dialog.newManga, - // TODO: Move screen model down into Dialog when this screen is using Voyager - screenModel = remember { MigrateDialogScreenModel() }, - onDismissRequest = { presenter.dialog = null }, - onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) }, - onPopScreen = { - // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager - router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse) - router.pushController(MangaController(dialog.newManga.id)) - }, - ) - } - else -> {} - } - - LaunchedEffect(presenter.filters) { - initFilterSheet() - } + Navigator(screen = SourceSearchScreen(oldManga, sourceId, query)) } } private const val MANGA_KEY = "oldManga" +private const val SOURCE_ID_KEY = "sourceId" +private const val SEARCH_QUERY_KEY = "searchQuery" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt new file mode 100644 index 0000000000..fcf9317104 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt @@ -0,0 +1,134 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +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.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.browse.BrowseSourceContent +import eu.kanade.presentation.components.ExtendedFloatingActionButton +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.components.SearchToolbar +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.base.controller.setRoot +import eu.kanade.tachiyomi.ui.browse.BrowseController +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity + +data class SourceSearchScreen( + private val oldManga: Manga, + private val sourceId: Long, + private val query: String? = null, +) : Screen { + + @Composable + override fun Content() { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + val router = LocalRouter.currentOrThrow + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) } + val state by screenModel.state.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val navigateUp: () -> Unit = { + when { + navigator.canPop -> navigator.pop() + router.backstackSize > 1 -> router.popCurrentController() + } + } + + Scaffold( + topBar = { scrollBehavior -> + SearchToolbar( + searchQuery = state.toolbarQuery ?: "", + onChangeSearchQuery = screenModel::setToolbarQuery, + onClickCloseSearch = navigateUp, + onSearch = { screenModel.search(it) }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility(visible = state.filters.isNotEmpty()) { + ExtendedFloatingActionButton( + text = { Text(text = stringResource(R.string.action_filter)) }, + icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") }, + onClick = screenModel::openFilterSheet, + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { paddingValues -> + val mangaList = remember(state.currentFilter) { + screenModel.getMangaListFlow(state.currentFilter) + }.collectAsLazyPagingItems() + val openMigrateDialog: (Manga) -> Unit = { + screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(it)) + } + BrowseSourceContent( + source = screenModel.source, + mangaList = mangaList, + columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation), + displayMode = screenModel.displayMode, + snackbarHostState = snackbarHostState, + contentPadding = paddingValues, + onWebViewClick = { + val source = screenModel.source as? HttpSource ?: return@BrowseSourceContent + val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) + context.startActivity(intent) + }, + onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, + onLocalSourceHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) }, + onMangaClick = openMigrateDialog, + onMangaLongClick = openMigrateDialog, + ) + } + + when (val dialog = state.dialog) { + is BrowseSourceScreenModel.Dialog.Migrate -> { + MigrateDialog( + oldManga = oldManga, + newManga = dialog.newManga, + screenModel = rememberScreenModel { MigrateDialogScreenModel() }, + onDismissRequest = { screenModel.setDialog(null) }, + onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) }, + onPopScreen = { + // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager + router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse) + router.pushController(MangaController(dialog.newManga.id)) + }, + ) + } + else -> {} + } + + LaunchedEffect(state.filters) { + screenModel.initFilterSheet(context) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt index 84166ae135..50c604631d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt @@ -49,7 +49,7 @@ fun Screen.sourcesTab(): TabContent { contentPadding = contentPadding, onClickItem = { source, query -> screenModel.onOpenSource(source) - router.pushController(BrowseSourceController(source, query)) + router.pushController(BrowseSourceController(source.id, query)) }, onClickPin = screenModel::togglePin, onLongClickItem = screenModel::showSourceDialog, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 0447a8f8d0..bbd9f736ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -1,32 +1,18 @@ package eu.kanade.tachiyomi.ui.browse.source.browse import android.os.Bundle -import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.core.os.bundleOf -import eu.kanade.domain.source.model.Source -import eu.kanade.presentation.browse.BrowseSourceScreen -import eu.kanade.presentation.browse.components.RemoveMangaDialog -import eu.kanade.presentation.components.ChangeCategoryDialog -import eu.kanade.presentation.components.DuplicateMangaDialog -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog -import eu.kanade.tachiyomi.ui.category.CategoryController -import eu.kanade.tachiyomi.ui.manga.MangaController -import eu.kanade.tachiyomi.ui.webview.WebViewActivity -import eu.kanade.tachiyomi.util.lang.launchIO +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch -open class BrowseSourceController(bundle: Bundle) : - FullComposeController(bundle) { +class BrowseSourceController(bundle: Bundle) : BasicFullComposeController(bundle) { constructor(sourceId: Long, query: String? = null) : this( bundleOf( @@ -35,117 +21,27 @@ open class BrowseSourceController(bundle: Bundle) : ), ) - constructor(source: CatalogueSource, query: String? = null) : this(source.id, query) + private val sourceId = args.getLong(SOURCE_ID_KEY) + private val initialQuery = args.getString(SEARCH_QUERY_KEY) - constructor(source: Source, query: String? = null) : this(source.id, query) - - /** - * Sheet containing filter items. - */ - protected var filterSheet: SourceFilterSheet? = null - - override fun createPresenter(): BrowseSourcePresenter { - return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY)) - } + private val queryEvent = Channel() @Composable override fun ComposeContent() { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val haptic = LocalHapticFeedback.current + Navigator(screen = BrowseSourceScreen(sourceId = sourceId, query = initialQuery)) { navigator -> + CurrentScreen() - BrowseSourceScreen( - presenter = presenter, - navigateUp = ::navigateUp, - openFilterSheet = { filterSheet?.show() }, - onMangaClick = { router.pushController(MangaController(it.id, true)) }, - onMangaLongClick = { manga -> - scope.launchIO { - val duplicateManga = presenter.getDuplicateLibraryManga(manga) - when { - manga.favorite -> presenter.dialog = Dialog.RemoveManga(manga) - duplicateManga != null -> presenter.dialog = Dialog.AddDuplicateManga(manga, duplicateManga) - else -> presenter.addFavorite(manga) + LaunchedEffect(Unit) { + queryEvent.consumeAsFlow() + .collectLatest { + val screen = (navigator.lastItem as? BrowseSourceScreen) + when (it) { + is BrowseSourceScreen.SearchType.Genre -> screen?.searchGenre(it.txt) + is BrowseSourceScreen.SearchType.Text -> screen?.search(it.txt) + } } - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - }, - onWebViewClick = f@{ - val source = presenter.source as? HttpSource ?: return@f - val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) - context.startActivity(intent) - }, - incognitoMode = presenter.isIncognitoMode, - downloadedOnlyMode = presenter.isDownloadOnly, - ) - - val onDismissRequest = { presenter.dialog = null } - when (val dialog = presenter.dialog) { - null -> {} - is Dialog.Migrate -> {} - is Dialog.AddDuplicateManga -> { - DuplicateMangaDialog( - onDismissRequest = onDismissRequest, - onConfirm = { presenter.addFavorite(dialog.manga) }, - onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) }, - duplicateFrom = presenter.getSourceOrStub(dialog.duplicate), - ) - } - is Dialog.RemoveManga -> { - RemoveMangaDialog( - onDismissRequest = onDismissRequest, - onConfirm = { - presenter.changeMangaFavorite(dialog.manga) - }, - mangaToRemove = dialog.manga, - ) - } - is Dialog.ChangeMangaCategory -> { - ChangeCategoryDialog( - initialSelection = dialog.initialSelection, - onDismissRequest = onDismissRequest, - onEditCategories = { - router.pushController(CategoryController()) - }, - onConfirm = { include, _ -> - presenter.changeMangaFavorite(dialog.manga) - presenter.moveMangaToCategories(dialog.manga, include) - }, - ) } } - - BackHandler(onBack = ::navigateUp) - - LaunchedEffect(presenter.filters) { - initFilterSheet() - } - } - - private fun navigateUp() { - when { - !presenter.isUserQuery && presenter.searchQuery != null -> presenter.searchQuery = null - else -> router.popCurrentController() - } - } - - open fun initFilterSheet() { - if (presenter.filters.isEmpty()) { - return - } - - filterSheet = SourceFilterSheet( - activity!!, - onFilterClicked = { - presenter.search(filters = presenter.filters) - }, - onResetClicked = { - presenter.reset() - filterSheet?.setFilters(presenter.filterItems) - }, - ) - - filterSheet?.setFilters(presenter.filterItems) } /** @@ -154,7 +50,7 @@ open class BrowseSourceController(bundle: Bundle) : * @param newQuery the new query. */ fun searchWithQuery(newQuery: String) { - presenter.search(newQuery) + viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Text(newQuery)) } } /** @@ -165,46 +61,9 @@ open class BrowseSourceController(bundle: Bundle) : * @param genreName the name of the genre */ fun searchWithGenre(genreName: String) { - val defaultFilters = presenter.source!!.getFilterList() - - var genreExists = false - - filter@ for (sourceFilter in defaultFilters) { - if (sourceFilter is Filter.Group<*>) { - for (filter in sourceFilter.state) { - if (filter is Filter<*> && filter.name.equals(genreName, true)) { - when (filter) { - is Filter.TriState -> filter.state = 1 - is Filter.CheckBox -> filter.state = true - else -> {} - } - genreExists = true - break@filter - } - } - } else if (sourceFilter is Filter.Select<*>) { - val index = sourceFilter.values.filterIsInstance() - .indexOfFirst { it.equals(genreName, true) } - - if (index != -1) { - sourceFilter.state = index - genreExists = true - break - } - } - } - - if (genreExists) { - filterSheet?.setFilters(defaultFilters.toItems()) - - presenter.search(filters = defaultFilters) - } else { - searchWithQuery(genreName) - } - } - - protected companion object { - const val SOURCE_ID_KEY = "sourceId" - const val SEARCH_QUERY_KEY = "searchQuery" + viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Genre(genreName)) } } } + +private const val SOURCE_ID_KEY = "sourceId" +private const val SEARCH_QUERY_KEY = "searchQuery" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt new file mode 100644 index 0000000000..4ce7fc1473 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -0,0 +1,283 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +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.LocalConfiguration +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.unit.dp +import androidx.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.source.interactor.GetRemoteManga +import eu.kanade.presentation.browse.BrowseSourceContent +import eu.kanade.presentation.browse.components.BrowseSourceToolbar +import eu.kanade.presentation.browse.components.RemoveMangaDialog +import eu.kanade.presentation.components.AppStateBanners +import eu.kanade.presentation.components.ChangeCategoryDialog +import eu.kanade.presentation.components.Divider +import eu.kanade.presentation.components.DuplicateMangaDialog +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.base.controller.pushController +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow + +data class BrowseSourceScreen( + private val sourceId: Long, + private val query: String? = null, +) : Screen { + + override val key = uniqueScreenKey + + @Composable + override fun Content() { + val router = LocalRouter.currentOrThrow + val navigator = LocalNavigator.currentOrThrow + val scope = rememberCoroutineScope() + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val uriHandler = LocalUriHandler.current + + val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) } + val state by screenModel.state.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val onHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) } + + val onWebViewClick = f@{ + val source = screenModel.source as? HttpSource ?: return@f + val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) + context.startActivity(intent) + } + + val navigateUp: () -> Unit = { + when { + navigator.canPop -> navigator.pop() + router.backstackSize > 1 -> router.popCurrentController() + } + } + + Scaffold( + topBar = { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { + BrowseSourceToolbar( + searchQuery = state.toolbarQuery, + onSearchQueryChange = screenModel::setToolbarQuery, + source = screenModel.source, + displayMode = screenModel.displayMode, + onDisplayModeChange = { screenModel.displayMode = it }, + navigateUp = navigateUp, + onWebViewClick = onWebViewClick, + onHelpClick = onHelpClick, + onSearch = { screenModel.search(it) }, + ) + + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.currentFilter == BrowseSourceScreenModel.Filter.Popular, + onClick = { + screenModel.reset() + screenModel.search(GetRemoteManga.QUERY_POPULAR) + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Favorite, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.popular)) + }, + ) + if (screenModel.source.supportsLatest) { + FilterChip( + selected = state.currentFilter == BrowseSourceScreenModel.Filter.Latest, + onClick = { + screenModel.reset() + screenModel.search(GetRemoteManga.QUERY_LATEST) + }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.latest)) + }, + ) + } + if (state.filters.isNotEmpty()) { + FilterChip( + selected = state.currentFilter is BrowseSourceScreenModel.Filter.UserInput, + onClick = screenModel::openFilterSheet, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = "", + modifier = Modifier + .size(FilterChipDefaults.IconSize), + ) + }, + label = { + Text(text = stringResource(R.string.action_filter)) + }, + ) + } + } + + Divider() + + AppStateBanners(screenModel.isDownloadOnly, screenModel.isIncognitoMode) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { paddingValues -> + val mangaList = remember(state.currentFilter) { + screenModel.getMangaListFlow(state.currentFilter) + }.collectAsLazyPagingItems() + + BrowseSourceContent( + source = screenModel.source, + mangaList = mangaList, + columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation), + displayMode = screenModel.displayMode, + snackbarHostState = snackbarHostState, + contentPadding = paddingValues, + onWebViewClick = onWebViewClick, + onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, + onLocalSourceHelpClick = onHelpClick, + onMangaClick = { router.pushController(MangaController(it.id, true)) }, + onMangaLongClick = { manga -> + scope.launchIO { + val duplicateManga = screenModel.getDuplicateLibraryManga(manga) + when { + manga.favorite -> screenModel.setDialog(BrowseSourceScreenModel.Dialog.RemoveManga(manga)) + duplicateManga != null -> screenModel.setDialog( + BrowseSourceScreenModel.Dialog.AddDuplicateManga( + manga, + duplicateManga, + ), + ) + else -> screenModel.addFavorite(manga) + } + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + ) + } + + val onDismissRequest = { screenModel.setDialog(null) } + when (val dialog = state.dialog) { + is BrowseSourceScreenModel.Dialog.Migrate -> {} + is BrowseSourceScreenModel.Dialog.AddDuplicateManga -> { + DuplicateMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { screenModel.addFavorite(dialog.manga) }, + onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) }, + duplicateFrom = screenModel.getSourceOrStub(dialog.duplicate), + ) + } + is BrowseSourceScreenModel.Dialog.RemoveManga -> { + RemoveMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { + screenModel.changeMangaFavorite(dialog.manga) + }, + mangaToRemove = dialog.manga, + ) + } + is BrowseSourceScreenModel.Dialog.ChangeMangaCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { + router.pushController(CategoryController()) + }, + onConfirm = { include, _ -> + screenModel.changeMangaFavorite(dialog.manga) + screenModel.moveMangaToCategories(dialog.manga, include) + }, + ) + } + else -> {} + } + + BackHandler(onBack = navigateUp) + + LaunchedEffect(state.filters) { + screenModel.initFilterSheet(context) + } + + LaunchedEffect(Unit) { + queryEvent.receiveAsFlow() + .collectLatest { + when (it) { + is SearchType.Genre -> screenModel.searchGenre(it.txt) + is SearchType.Text -> screenModel.search(it.txt) + } + } + } + } + + private val queryEvent = Channel() + suspend fun search(query: String) = queryEvent.send(SearchType.Text(query)) + suspend fun searchGenre(name: String) = queryEvent.send(SearchType.Genre(name)) + + sealed class SearchType(val txt: String) { + class Text(txt: String) : SearchType(txt) + class Genre(txt: String) : SearchType(txt) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt similarity index 60% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 179eb375dc..45c6f817a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -1,23 +1,22 @@ package eu.kanade.tachiyomi.ui.browse.source.browse +import android.content.Context import android.content.res.Configuration -import android.os.Bundle import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.core.prefs.CheckboxState +import eu.kanade.core.prefs.asState import eu.kanade.core.prefs.mapAsCheckboxState import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.category.interactor.GetCategories @@ -39,8 +38,6 @@ import eu.kanade.domain.source.interactor.GetRemoteManga import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.model.toDomainTrack -import eu.kanade.presentation.browse.BrowseSourceState -import eu.kanade.presentation.browse.BrowseSourceStateImpl import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager @@ -48,9 +45,7 @@ import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxItem import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxSectionItem import eu.kanade.tachiyomi.ui.browse.source.filter.GroupItem @@ -70,19 +65,23 @@ import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.system.logcat import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import logcat.LogPriority import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Date +import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter -open class BrowseSourcePresenter( +class BrowseSourceScreenModel( private val sourceId: Long, - searchQuery: String? = null, - private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl, + searchQuery: String?, private val sourceManager: SourceManager = Injekt.get(), preferences: BasePreferences = Injekt.get(), sourcePreferences: SourcePreferences = Injekt.get(), @@ -99,86 +98,122 @@ open class BrowseSourcePresenter( private val updateManga: UpdateManga = Injekt.get(), private val insertTrack: InsertTrack = Injekt.get(), private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(), -) : BasePresenter(), BrowseSourceState by state { +) : StateScreenModel(State(Filter.valueOf(searchQuery))) { private val loggedServices by lazy { Injekt.get().services.filter { it.isLogged } } - var displayMode by sourcePreferences.sourceDisplayMode().asState() + var displayMode by sourcePreferences.sourceDisplayMode().asState(coroutineScope) - val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() - val isIncognitoMode: Boolean by preferences.incognitoMode().asState() + val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope) + val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope) - @Composable - fun getColumnsPreferenceForCurrentOrientation(): State { - val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - return produceState(initialValue = GridCells.Adaptive(128.dp), isLandscape) { - (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()) - .changes() - .collectLatest { columns -> - value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) - } - } + val source = sourceManager.get(sourceId) as CatalogueSource + + /** + * Sheet containing filter items. + */ + private var filterSheet: SourceFilterSheet? = null + + init { + mutableState.update { it.copy(filters = source.getFilterList()) } } - @Composable - fun getMangaList(): Flow> { - return remember(currentFilter) { - Pager( - PagingConfig(pageSize = 25), - ) { - getRemoteManga.subscribe(sourceId, currentFilter.query, currentFilter.filters) - }.flow - .map { - it.map { sManga -> - withIOContext { - networkToLocalManga.await(sManga.toDomainManga(sourceId)) - } - } - } - .cachedIn(presenterScope) - } + fun getColumnsPreference(orientation: Int): GridCells { + val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE + val columns = if (isLandscape) { + libraryPreferences.landscapeColumns() + } else { + libraryPreferences.portraitColumns() + }.get() + return if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) } - @Composable - fun getManga(initialManga: Manga): State { - return produceState(initialValue = initialManga) { - getManga.subscribe(initialManga.url, initialManga.source) - .collectLatest { manga -> - if (manga == null) return@collectLatest - withIOContext { - initializeManga(manga) - } - value = manga + fun getMangaListFlow(currentFilter: Filter): Flow>> { + return Pager( + PagingConfig(pageSize = 25), + ) { + getRemoteManga.subscribe(sourceId, currentFilter.query ?: "", currentFilter.filters) + }.flow + .map { pagingData -> + pagingData.map { sManga -> + val dbManga = withIOContext { networkToLocalManga.await(sManga.toDomainManga(sourceId)) } + getManga.subscribe(dbManga.url, dbManga.source) + .filterNotNull() + .onEach { initializeManga(it) } + .stateIn(coroutineScope) } - } + } + .cachedIn(coroutineScope) } fun reset() { - val source = source ?: return - state.filters = source.getFilterList() + mutableState.update { it.copy(filters = source.getFilterList()) } } fun search(query: String? = null, filters: FilterList? = null) { - Filter.valueOf(query ?: "").let { + Filter.valueOf(query).let { if (it !is Filter.UserInput) { - state.currentFilter = it - state.searchQuery = null + mutableState.update { state -> state.copy(currentFilter = it) } return } } - val input: Filter.UserInput = if (currentFilter is Filter.UserInput) currentFilter as Filter.UserInput else Filter.UserInput() - state.currentFilter = input.copy( - query = query ?: input.query, - filters = filters ?: input.filters, - ) + val input = if (state.value.currentFilter is Filter.UserInput) { + state.value.currentFilter as Filter.UserInput + } else { + Filter.UserInput() + } + mutableState.update { + it.copy( + currentFilter = input.copy( + query = query ?: input.query, + filters = filters ?: input.filters, + ), + toolbarQuery = query ?: input.query, + ) + } } - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) + fun searchGenre(genreName: String) { + val defaultFilters = source.getFilterList() + var genreExists = false - state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return - state.filters = source!!.getFilterList() + filter@ for (sourceFilter in defaultFilters) { + if (sourceFilter is SourceModelFilter.Group<*>) { + for (filter in sourceFilter.state) { + if (filter is SourceModelFilter<*> && filter.name.equals(genreName, true)) { + when (filter) { + is SourceModelFilter.TriState -> filter.state = 1 + is SourceModelFilter.CheckBox -> filter.state = true + else -> {} + } + genreExists = true + break@filter + } + } + } else if (sourceFilter is SourceModelFilter.Select<*>) { + val index = sourceFilter.values.filterIsInstance() + .indexOfFirst { it.equals(genreName, true) } + + if (index != -1) { + sourceFilter.state = index + genreExists = true + break + } + } + } + + mutableState.update { + val filter = if (genreExists) { + Filter.UserInput(filters = defaultFilters) + } else { + Filter.UserInput(query = genreName) + } + it.copy( + filters = defaultFilters, + currentFilter = filter, + ) + } } /** @@ -190,7 +225,7 @@ open class BrowseSourcePresenter( if (manga.thumbnailUrl != null || manga.initialized) return withNonCancellableContext { try { - val networkManga = source!!.getMangaDetails(manga.toSManga()) + val networkManga = source.getMangaDetails(manga.toSManga()) val updatedManga = manga.copyFrom(networkManga) .copy(initialized = true) @@ -207,7 +242,7 @@ open class BrowseSourcePresenter( * @param manga the manga to update. */ fun changeMangaFavorite(manga: Manga) { - presenterScope.launch { + coroutineScope.launch { var new = manga.copy( favorite = !manga.favorite, dateAdded = when (manga.favorite) { @@ -233,7 +268,7 @@ open class BrowseSourcePresenter( } fun addFavorite(manga: Manga) { - presenterScope.launch { + coroutineScope.launch { val categories = getCategories() val defaultCategoryId = libraryPreferences.defaultCategory().get() val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } @@ -256,7 +291,7 @@ open class BrowseSourcePresenter( // Choose a category else -> { val preselectedIds = getCategories.await(manga.id).map { it.id } - state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }) + setDialog(Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds })) } } } @@ -265,7 +300,7 @@ open class BrowseSourcePresenter( private suspend fun autoAddTrack(manga: Manga) { loggedServices .filterIsInstance() - .filter { it.accept(source!!) } + .filter { it.accept(source) } .forEach { service -> try { service.match(manga.toDbManga())?.let { track -> @@ -303,7 +338,7 @@ open class BrowseSourcePresenter( } fun moveMangaToCategories(manga: Manga, categoryIds: List) { - presenterScope.launchIO { + coroutineScope.launchIO { setMangaCategories.await( mangaId = manga.id, categoryIds = categoryIds.toList(), @@ -311,13 +346,43 @@ open class BrowseSourcePresenter( } } - sealed class Filter(open val query: String, open val filters: FilterList) { + fun openFilterSheet() { + filterSheet?.show() + } + + fun setDialog(dialog: Dialog?) { + mutableState.update { it.copy(dialog = dialog) } + } + + fun setToolbarQuery(query: String?) { + mutableState.update { it.copy(toolbarQuery = query) } + } + + fun initFilterSheet(context: Context) { + val state = state.value + if (state.filters.isEmpty()) { + return + } + + filterSheet = SourceFilterSheet( + context = context, + onFilterClicked = { search(filters = state.filters) }, + onResetClicked = { + reset() + filterSheet?.setFilters(state.filterItems) + }, + ) + + filterSheet?.setFilters(state.filterItems) + } + + sealed class Filter(open val query: String?, open val filters: FilterList) { object Popular : Filter(query = GetRemoteManga.QUERY_POPULAR, filters = FilterList()) object Latest : Filter(query = GetRemoteManga.QUERY_LATEST, filters = FilterList()) - data class UserInput(override val query: String = "", override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters) + data class UserInput(override val query: String? = null, override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters) companion object { - fun valueOf(query: String): Filter { + fun valueOf(query: String?): Filter { return when (query) { GetRemoteManga.QUERY_POPULAR -> Popular GetRemoteManga.QUERY_LATEST -> Latest @@ -336,25 +401,40 @@ open class BrowseSourcePresenter( ) : Dialog() data class Migrate(val newManga: Manga) : Dialog() } + + @Immutable + data class State( + val currentFilter: Filter, + val filters: FilterList = FilterList(), + val toolbarQuery: String? = null, + val dialog: Dialog? = null, + ) { + val filterItems = filters.toItems() + val isUserQuery = currentFilter is Filter.UserInput && !currentFilter.query.isNullOrEmpty() + val searchQuery = when (currentFilter) { + is Filter.UserInput -> currentFilter.query + Filter.Latest, Filter.Popular -> null + } + } } -fun FilterList.toItems(): List> { +private fun FilterList.toItems(): List> { return mapNotNull { filter -> when (filter) { - is Filter.Header -> HeaderItem(filter) - is Filter.Separator -> SeparatorItem(filter) - is Filter.CheckBox -> CheckboxItem(filter) - is Filter.TriState -> TriStateItem(filter) - is Filter.Text -> TextItem(filter) - is Filter.Select<*> -> SelectItem(filter) - is Filter.Group<*> -> { + is SourceModelFilter.Header -> HeaderItem(filter) + is SourceModelFilter.Separator -> SeparatorItem(filter) + is SourceModelFilter.CheckBox -> CheckboxItem(filter) + is SourceModelFilter.TriState -> TriStateItem(filter) + is SourceModelFilter.Text -> TextItem(filter) + is SourceModelFilter.Select<*> -> SelectItem(filter) + is SourceModelFilter.Group<*> -> { val group = GroupItem(filter) val subItems = filter.state.mapNotNull { when (it) { - is Filter.CheckBox -> CheckboxSectionItem(it) - is Filter.TriState -> TriStateSectionItem(it) - is Filter.Text -> TextSectionItem(it) - is Filter.Select<*> -> SelectSectionItem(it) + is SourceModelFilter.CheckBox -> CheckboxSectionItem(it) + is SourceModelFilter.TriState -> TriStateSectionItem(it) + is SourceModelFilter.Text -> TextSectionItem(it) + is SourceModelFilter.Select<*> -> SelectSectionItem(it) else -> null } } @@ -362,7 +442,7 @@ fun FilterList.toItems(): List> { group.subItems = subItems group } - is Filter.Sort -> { + is SourceModelFilter.Sort -> { val group = SortGroup(filter) val subItems = filter.values.map { SortItem(it, group) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt index bcda39e8d3..109178d224 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceFilterSheet.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.browse.source.browse -import android.app.Activity import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater @@ -13,12 +12,12 @@ import eu.kanade.tachiyomi.widget.SimpleNavigationView import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog class SourceFilterSheet( - activity: Activity, + context: Context, private val onFilterClicked: () -> Unit, private val onResetClicked: () -> Unit, -) : BaseBottomSheetDialog(activity) { +) : BaseBottomSheetDialog(context) { - private var filterNavView: FilterNavigationView = FilterNavigationView(activity) + private var filterNavView: FilterNavigationView = FilterNavigationView(context) override fun createView(inflater: LayoutInflater): View { filterNavView.onFilterClicked = { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt index 48eed0a692..f8a02fcc46 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt @@ -44,7 +44,7 @@ class GlobalSearchScreen( if (!screenModel.incognitoMode.get()) { screenModel.lastUsedSourceId.set(it.id) } - router.pushController(BrowseSourceController(it, state.searchQuery)) + router.pushController(BrowseSourceController(it.id, state.searchQuery)) }, onClickItem = { router.pushController(MangaController(it.id, true)) }, onLongClickItem = { router.pushController(MangaController(it.id, true)) },