From d4b764fa317ddcb04b8fefb982cd6b9a6dfc1598 Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 31 Aug 2022 20:41:35 +0200 Subject: [PATCH] Use Compose on BrowseSourceScreens (#7901) --- .../kanade/data/manga/MangaRepositoryImpl.kt | 4 + .../domain/manga/interactor/GetManga.kt | 4 + .../manga/repository/MangaRepository.kt | 2 + .../presentation/browse/BrowseLatestScreen.kt | 67 ++ .../presentation/browse/BrowseSourceScreen.kt | 210 ++++++ .../presentation/browse/BrowseSourceState.kt | 37 + .../presentation/browse/SourceSearchScreen.kt | 22 + .../browse/components/BrowseLatestToolbar.kt | 105 +++ .../components/BrowseSourceComfortableGrid.kt | 106 +++ .../components/BrowseSourceCompactGrid.kt | 129 ++++ .../browse/components/BrowseSourceDialogs.kt | 41 ++ .../browse/components/BrowseSourceList.kt | 94 +++ .../components/BrowseSourceLoadingItem.kt | 25 + .../browse/components/BrowseSourceToolbar.kt | 206 ++++++ .../components/DuplicateMangaDialog.kt | 2 +- .../components/LibraryComfortableGrid.kt | 19 +- .../library/components/LibraryCompactGrid.kt | 38 +- .../library/components/LibraryGridCover.kt | 119 +-- .../library/components/LibraryList.kt | 149 ++-- .../kanade/tachiyomi/data/cache/CoverCache.kt | 15 + .../search/SourceSearchController.kt | 48 +- .../source/browse/BrowsePagingSource.kt | 37 + .../source/browse/BrowseSourceController.kt | 679 +++--------------- .../source/browse/BrowseSourcePresenter.kt | 498 ++++++------- .../ui/browse/source/browse/Pager.kt | 31 - .../ui/browse/source/browse/ProgressItem.kt | 54 -- .../source/browse/SourceBrowsePagingSource.kt | 20 + .../browse/SourceComfortableGridHolder.kt | 53 -- .../source/browse/SourceCompactGridHolder.kt | 53 -- .../ui/browse/source/browse/SourceHolder.kt | 35 - .../ui/browse/source/browse/SourceItem.kt | 63 -- .../browse/source/browse/SourceListHolder.kt | 60 -- .../ui/browse/source/browse/SourcePager.kt | 26 - .../latest/LatestUpdatesBrowsePagingSource.kt | 13 + .../source/latest/LatestUpdatesController.kt | 72 +- .../source/latest/LatestUpdatesPager.kt | 13 - .../source/latest/LatestUpdatesPresenter.kt | 7 +- .../ui/library/ChangeMangaCategoriesDialog.kt | 81 --- .../ui/manga/AddDuplicateMangaDialog.kt | 48 -- .../tachiyomi/ui/manga/MangaController.kt | 2 +- .../kanade/tachiyomi/util/MangaExtensions.kt | 6 + .../color/source_comfortable_item_title.xml | 6 - .../layout/source_comfortable_grid_item.xml | 50 -- .../res/layout/source_compact_grid_item.xml | 58 -- app/src/main/res/layout/source_controller.xml | 38 - .../res/layout/source_grid_item_badges.xml | 116 --- app/src/main/res/layout/source_list_item.xml | 138 ---- .../main/res/layout/source_progress_item.xml | 27 - .../res/layout/source_recycler_autofit.xml | 11 - app/src/main/res/menu/source_browse.xml | 46 -- app/src/main/res/values/strings.xml | 1 + 51 files changed, 1760 insertions(+), 2024 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt rename app/src/main/java/eu/kanade/presentation/{manga => }/components/DuplicateMangaDialog.kt (97%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt delete mode 100644 app/src/main/res/color/source_comfortable_item_title.xml delete mode 100644 app/src/main/res/layout/source_comfortable_grid_item.xml delete mode 100644 app/src/main/res/layout/source_compact_grid_item.xml delete mode 100644 app/src/main/res/layout/source_controller.xml delete mode 100644 app/src/main/res/layout/source_grid_item_badges.xml delete mode 100644 app/src/main/res/layout/source_list_item.xml delete mode 100644 app/src/main/res/layout/source_progress_item.xml delete mode 100644 app/src/main/res/layout/source_recycler_autofit.xml delete mode 100644 app/src/main/res/menu/source_browse.xml diff --git a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt index e5af0946aa..28f51402e4 100644 --- a/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt +++ b/app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt @@ -27,6 +27,10 @@ class MangaRepositoryImpl( return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) } } + override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow { + return handler.subscribeToOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) } + } + override suspend fun getFavorites(): List { return handler.awaitList { mangasQueries.getFavorites(mangaMapper) } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt index da787116b5..e2c54b9932 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt @@ -26,4 +26,8 @@ class GetManga( suspend fun await(url: String, sourceId: Long): Manga? { return mangaRepository.getMangaByUrlAndSourceId(url, sourceId) } + + fun subscribe(url: String, sourceId: Long): Flow { + return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId) + } } diff --git a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt index 72fcef7736..0da72ae435 100644 --- a/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt +++ b/app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt @@ -13,6 +13,8 @@ interface MangaRepository { suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga? + fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow + suspend fun getFavorites(): List suspend fun getLibraryManga(): List diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt new file mode 100644 index 0000000000..73d2f5813f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt @@ -0,0 +1,67 @@ +package eu.kanade.presentation.browse + +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.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.paging.compose.collectAsLazyPagingItems +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.browse.components.BrowseLatestToolbar +import eu.kanade.presentation.components.Scaffold +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity + +@Composable +fun BrowseLatestScreen( + presenter: BrowseSourcePresenter, + navigateUp: () -> Unit, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + val columns by presenter.getColumnsPreferenceForCurrentOrientation() + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + val onHelpClick = { + uriHandler.openUri(LocalSource.HELP_URL) + } + + val onWebViewClick = f@{ + val source = presenter.source as? HttpSource ?: return@f + val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) + context.startActivity(intent) + } + + Scaffold( + topBar = { + BrowseLatestToolbar( + navigateUp = navigateUp, + source = presenter.source!!, + displayMode = presenter.displayMode, + onDisplayModeChange = { presenter.displayMode = it }, + onHelpClick = onHelpClick, + onWebViewClick = onWebViewClick, + ) + }, + ) { paddingValues -> + BrowseSourceContent( + source = presenter.source, + mangaList = presenter.getMangaList().collectAsLazyPagingItems(), + getMangaState = { presenter.getManga(it) }, + columns = columns, + displayMode = presenter.displayMode, + snackbarHostState = remember { SnackbarHostState() }, + contentPadding = paddingValues, + onWebViewClick = onWebViewClick, + onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) }, + onLocalSourceHelpClick = onHelpClick, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt new file mode 100644 index 0000000000..cef02b13d7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt @@ -0,0 +1,210 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material3.Icon +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.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import eu.kanade.domain.manga.model.Manga +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.EmptyScreen +import eu.kanade.presentation.components.ExtendedFloatingActionButton +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.source.online.HttpSource +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException +import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode +import eu.kanade.tachiyomi.ui.more.MoreController +import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.widget.EmptyView + +@Composable +fun BrowseSourceScreen( + presenter: BrowseSourcePresenter, + navigateUp: () -> Unit, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + onFabClick: () -> Unit, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + val columns by presenter.getColumnsPreferenceForCurrentOrientation() + + val mangaList = presenter.getMangaList().collectAsLazyPagingItems() + + val snackbarHostState = remember { SnackbarHostState() } + + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + val onHelpClick = { + uriHandler.openUri(LocalSource.HELP_URL) + } + + val onWebViewClick = f@{ + val source = presenter.source as? HttpSource ?: return@f + val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name) + context.startActivity(intent) + } + + Scaffold( + topBar = { + BrowseSourceToolbar( + state = presenter, + source = presenter.source!!, + displayMode = presenter.displayMode, + onDisplayModeChange = onDisplayModeChange, + navigateUp = navigateUp, + onWebViewClick = onWebViewClick, + onHelpClick = onHelpClick, + onSearch = { presenter.search() }, + ) + }, + floatingActionButton = { + if (presenter.filters.isNotEmpty()) { + ExtendedFloatingActionButton( + modifier = Modifier.navigationBarsPadding(), + text = { Text(text = stringResource(id = R.string.action_filter)) }, + icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") }, + onClick = onFabClick, + ) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { paddingValues -> + BrowseSourceContent( + source = presenter.source, + 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 BrowseSourceContent( + source: CatalogueSource?, + mangaList: LazyPagingItems, + getMangaState: @Composable ((Manga) -> State), + columns: GridCells, + displayMode: LibraryDisplayMode, + snackbarHostState: SnackbarHostState, + contentPadding: PaddingValues, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, + onLocalSourceHelpClick: () -> Unit, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + val context = LocalContext.current + + val errorState = mangaList.loadState.refresh.takeIf { it is LoadState.Error } + ?: mangaList.loadState.append.takeIf { it is LoadState.Error } + + val getErrorMessage: (LoadState.Error) -> String = { state -> + when { + state.error is NoResultsException -> context.getString(R.string.no_results_found) + state.error.message == null -> "" + state.error.message!!.startsWith("HTTP error") -> "${state.error.message}: ${context.getString(R.string.http_error_hint)}" + else -> state.error.message!! + } + } + + LaunchedEffect(errorState) { + if (mangaList.itemCount > 0 && errorState != null && errorState is LoadState.Error) { + val result = snackbarHostState.showSnackbar( + message = getErrorMessage(errorState), + actionLabel = context.getString(R.string.action_webview_refresh), + duration = SnackbarDuration.Indefinite, + ) + when (result) { + SnackbarResult.Dismissed -> snackbarHostState.currentSnackbarData?.dismiss() + SnackbarResult.ActionPerformed -> mangaList.refresh() + } + } + } + + if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) { + EmptyScreen( + message = getErrorMessage(errorState), + actions = if (source is LocalSource) { + listOf( + EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { onLocalSourceHelpClick() }, + ) + } else { + listOf( + EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp) { mangaList.refresh() }, + EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { onWebViewClick() }, + EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { onHelpClick() }, + ) + }, + ) + + return + } + + when (displayMode) { + LibraryDisplayMode.ComfortableGrid -> { + BrowseSourceComfortableGrid( + mangaList = mangaList, + getMangaState = getMangaState, + columns = columns, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + ) + } + LibraryDisplayMode.List -> { + BrowseSourceList( + mangaList = mangaList, + getMangaState = getMangaState, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + ) + } + else -> { + BrowseSourceCompactGrid( + mangaList = mangaList, + getMangaState = getMangaState, + columns = columns, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt new file mode 100644 index 0000000000..45a9fa4ce2 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt @@ -0,0 +1,37 @@ +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.toItems + +@Stable +interface BrowseSourceState { + val source: CatalogueSource? + var searchQuery: String? + val currentQuery: String + val filters: FilterList + val filterItems: List> + val appliedFilters: FilterList + var dialog: BrowseSourcePresenter.Dialog? +} + +fun BrowseSourceState(initialQuery: String?): BrowseSourceState { + return BrowseSourceStateImpl(initialQuery) +} + +class BrowseSourceStateImpl(initialQuery: String?) : BrowseSourceState { + override var source: CatalogueSource? by mutableStateOf(null) + override var searchQuery: String? by mutableStateOf(initialQuery) + override var currentQuery: String by mutableStateOf(initialQuery ?: "") + override var filters: FilterList by mutableStateOf(FilterList()) + override val filterItems: List> by derivedStateOf { filters.toItems() } + override var appliedFilters by mutableStateOf(FilterList()) + 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 new file mode 100644 index 0000000000..fa3affb8c3 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt @@ -0,0 +1,22 @@ +package eu.kanade.presentation.browse + +import androidx.compose.runtime.Composable +import eu.kanade.domain.manga.model.Manga +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter + +@Composable +fun SourceSearchScreen( + presenter: BrowseSourcePresenter, + navigateUp: () -> Unit, + onFabClick: () -> Unit, + onClickManga: (Manga) -> Unit, +) { + BrowseSourceScreen( + presenter = presenter, + navigateUp = navigateUp, + onDisplayModeChange = { presenter.displayMode = (it) }, + onFabClick = onFabClick, + onMangaClick = onClickManga, + onMangaLongClick = onClickManga, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt new file mode 100644 index 0000000000..cbff31300e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt @@ -0,0 +1,105 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Help +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.ViewModule +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode + +@Composable +fun BrowseLatestToolbar( + navigateUp: () -> Unit, + source: CatalogueSource, + displayMode: LibraryDisplayMode, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + onHelpClick: () -> Unit, + onWebViewClick: () -> Unit, +) { + AppBar( + navigateUp = navigateUp, + title = source.name, + actions = { + var selectingDisplayMode by remember { mutableStateOf(false) } + AppBarActions( + actions = listOf( + AppBar.Action( + title = "display_mode", + icon = Icons.Filled.ViewModule, + onClick = { selectingDisplayMode = true }, + ), + if (source is LocalSource) { + AppBar.Action( + title = "help", + icon = Icons.Outlined.Help, + onClick = onHelpClick, + ) + } else { + AppBar.Action( + title = "webview", + icon = Icons.Outlined.Public, + onClick = onWebViewClick, + ) + }, + ), + ) + DropdownMenu( + expanded = selectingDisplayMode, + onDismissRequest = { selectingDisplayMode = false }, + ) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.ComfortableGrid) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_grid)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.CompactGrid) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_list)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.List) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.List) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + } + }, + ) +} 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 new file mode 100644 index 0000000000..75bb50c7cf --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -0,0 +1,106 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.library.components.MangaGridComfortableText +import eu.kanade.presentation.library.components.MangaGridCover +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseSourceComfortableGrid( + mangaList: LazyPagingItems, + getMangaState: @Composable ((Manga) -> State), + columns: GridCells, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = PaddingValues(8.dp) + contentPadding, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + if (mangaList.loadState.prepend is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + + items(mangaList.itemCount) { index -> + val initialManga = mangaList[index] ?: return@items + val manga by getMangaState(initialManga) + BrowseSourceComfortableGridItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + ) + } + + item(span = { GridItemSpan(maxLineSpan) }) { + if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +fun BrowseSourceComfortableGridItem( + manga: Manga, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f) + Column( + modifier = Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + ) { + MangaGridCover( + cover = { + MangaCover.Book( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + drawContent() + if (manga.favorite) { + drawRect(overlayColor) + } + }, + data = manga.thumbnailUrl, + ) + }, + badgesStart = { + if (manga.favorite) { + Badge(text = stringResource(id = R.string.in_library)) + } + }, + ) + MangaGridComfortableText( + text = manga.title, + ) + } +} 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 new file mode 100644 index 0000000000..b9cb1e271f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -0,0 +1,129 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.library.components.MangaGridCompactText +import eu.kanade.presentation.library.components.MangaGridCover +import eu.kanade.presentation.util.plus +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseSourceCompactGrid( + mangaList: LazyPagingItems, + getMangaState: @Composable ((Manga) -> State), + columns: GridCells, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = PaddingValues(8.dp) + contentPadding, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + if (mangaList.loadState.prepend is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + + items(mangaList.itemCount) { index -> + val initialManga = mangaList[index] ?: return@items + val manga by getMangaState(initialManga) + BrowseSourceCompactGridItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + ) + } + + item(span = { GridItemSpan(maxLineSpan) }) { + if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +fun BrowseSourceCompactGridItem( + manga: Manga, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f) + MangaGridCover( + modifier = Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + cover = { + MangaCover.Book( + modifier = Modifier + .fillMaxHeight() + .drawWithContent { + drawContent() + if (manga.favorite) { + drawRect(overlayColor) + } + }, + data = eu.kanade.domain.manga.model.MangaCover( + manga.id, + manga.source, + manga.favorite, + manga.thumbnailUrl, + manga.coverLastModified, + ), + ) + }, + badgesStart = { + if (manga.favorite) { + Badge(text = stringResource(id = R.string.in_library)) + } + }, + content = { + Box( + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp)) + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color(0xAA000000), + ), + ) + .fillMaxHeight(0.33f) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + MangaGridCompactText(manga.title) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt new file mode 100644 index 0000000000..bc6e05f316 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt @@ -0,0 +1,41 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.material.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import eu.kanade.tachiyomi.R + +@Composable +fun RemoveMangaDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(id = android.R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onDismissRequest() + onConfirm() + }, + ) { + Text(text = stringResource(id = R.string.action_remove)) + } + }, + title = { + Text(text = stringResource(id = R.string.are_you_sure)) + }, + text = { + Text(text = stringResource(R.string.remove_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 new file mode 100644 index 0000000000..7195188d67 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -0,0 +1,94 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.res.stringResource +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.items +import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.components.Badge +import eu.kanade.presentation.components.LazyColumn +import eu.kanade.presentation.components.MangaCover +import eu.kanade.presentation.library.components.MangaListItem +import eu.kanade.presentation.library.components.MangaListItemContent +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.verticalPadding +import eu.kanade.tachiyomi.R + +@Composable +fun BrowseSourceList( + mangaList: LazyPagingItems, + getMangaState: @Composable ((Manga) -> State), + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + LazyColumn( + contentPadding = contentPadding, + ) { + item { + if (mangaList.loadState.prepend is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + + items(mangaList) { initialManga -> + initialManga ?: return@items + val manga by getMangaState(initialManga) + BrowseSourceListItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + ) + } + + item { + if (mangaList.loadState.refresh is LoadState.Loading || mangaList.loadState.append is LoadState.Loading) { + BrowseSourceLoadingItem() + } + } + } +} + +@Composable +fun BrowseSourceListItem( + manga: Manga, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = onClick, +) { + val overlayColor = MaterialTheme.colorScheme.background.copy(alpha = 0.66f) + MangaListItem( + coverContent = { + MangaCover.Square( + modifier = Modifier + .padding(vertical = verticalPadding) + .fillMaxHeight() + .drawWithContent { + drawContent() + if (manga.favorite) { + drawRect(overlayColor) + } + }, + data = manga.thumbnailUrl, + ) + }, + onClick = onClick, + onLongClick = onLongClick, + badges = { + if (manga.favorite) { + Badge(text = stringResource(id = R.string.in_library)) + } + }, + content = { + MangaListItemContent(text = manga.title) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt new file mode 100644 index 0000000000..d27fadf4de --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt @@ -0,0 +1,25 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun BrowseSourceLoadingItem() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + ) + } +} 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 new file mode 100644 index 0000000000..3f896cc697 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt @@ -0,0 +1,206 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Help +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import eu.kanade.presentation.browse.BrowseSourceState +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode +import kotlinx.coroutines.delay + +@Composable +fun BrowseSourceToolbar( + state: BrowseSourceState, + source: CatalogueSource, + displayMode: LibraryDisplayMode, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + navigateUp: () -> Unit, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, + onSearch: () -> Unit, +) { + if (state.searchQuery == null) { + BrowseSourceRegularToolbar( + source = source, + displayMode = displayMode, + onDisplayModeChange = onDisplayModeChange, + navigateUp = navigateUp, + onSearchClick = { state.searchQuery = "" }, + onWebViewClick = onWebViewClick, + onHelpClick = onHelpClick, + ) + } else { + BrowseSourceSearchToolbar( + searchQuery = state.searchQuery!!, + onSearchQueryChanged = { state.searchQuery = it }, + navigateUp = { + state.searchQuery = null + onSearch() + }, + onResetClick = { state.searchQuery = "" }, + onSearchClick = onSearch, + ) + } +} + +@Composable +fun BrowseSourceRegularToolbar( + source: CatalogueSource, + displayMode: LibraryDisplayMode, + onDisplayModeChange: (LibraryDisplayMode) -> Unit, + navigateUp: () -> Unit, + onSearchClick: () -> Unit, + onWebViewClick: () -> Unit, + onHelpClick: () -> Unit, +) { + AppBar( + navigateUp = navigateUp, + title = source.name, + actions = { + var selectingDisplayMode by remember { mutableStateOf(false) } + AppBarActions( + actions = listOf( + AppBar.Action( + title = "search", + icon = Icons.Outlined.Search, + onClick = onSearchClick, + ), + AppBar.Action( + title = "display_mode", + icon = Icons.Filled.ViewModule, + onClick = { selectingDisplayMode = true }, + ), + if (source is LocalSource) { + AppBar.Action( + title = "help", + icon = Icons.Outlined.Help, + onClick = onHelpClick, + ) + } else { + AppBar.Action( + title = "webview", + icon = Icons.Outlined.Public, + onClick = onWebViewClick, + ) + }, + ), + ) + DropdownMenu( + expanded = selectingDisplayMode, + onDismissRequest = { selectingDisplayMode = false }, + ) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.ComfortableGrid) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_grid)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.CompactGrid) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_display_list)) }, + onClick = { onDisplayModeChange(LibraryDisplayMode.List) }, + trailingIcon = { + if (displayMode == LibraryDisplayMode.List) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "", + ) + } + }, + ) + } + }, + ) +} + +@Composable +fun BrowseSourceSearchToolbar( + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + navigateUp: () -> Unit, + onResetClick: () -> Unit, + onSearchClick: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + AppBar( + navigateUp = navigateUp, + titleContent = { + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChanged, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearchClick() + }, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), + ) + }, + actions = { + AppBarActions( + actions = listOf( + AppBar.Action( + title = "clear", + icon = Icons.Outlined.Clear, + onClick = onResetClick, + ), + ), + ) + }, + ) + LaunchedEffect(Unit) { + // TODO: https://issuetracker.google.com/issues/204502668 + delay(100) + focusRequester.requestFocus() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/DuplicateMangaDialog.kt b/app/src/main/java/eu/kanade/presentation/components/DuplicateMangaDialog.kt similarity index 97% rename from app/src/main/java/eu/kanade/presentation/manga/components/DuplicateMangaDialog.kt rename to app/src/main/java/eu/kanade/presentation/components/DuplicateMangaDialog.kt index 5b8e7f39cd..a39a378ec4 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/DuplicateMangaDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/components/DuplicateMangaDialog.kt @@ -1,4 +1,4 @@ -package eu.kanade.presentation.manga.components +package eu.kanade.presentation.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 39bb4be4b1..65d15fcdf7 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 @@ -78,13 +78,22 @@ fun LibraryComfortableGridItem( isLocal = item.isLocal, language = item.sourceLanguage, ) - Text( - modifier = Modifier.padding(4.dp), + MangaGridComfortableText( text = manga.title, - fontSize = 12.sp, - maxLines = 2, - style = MaterialTheme.typography.titleSmall, ) } } } + +@Composable +fun MangaGridComfortableText( + text: String, +) { + Text( + modifier = Modifier.padding(4.dp), + text = text, + fontSize = 12.sp, + maxLines = 2, + style = MaterialTheme.typography.titleSmall, + ) +} 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 e8a21b7c5e..14e0861135 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 @@ -3,6 +3,7 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -97,20 +98,27 @@ fun LibraryCompactGridItem( .fillMaxWidth() .align(Alignment.BottomCenter), ) - Text( - text = manga.title, - modifier = Modifier - .padding(8.dp) - .align(Alignment.BottomStart), - color = Color.White, - fontSize = 12.sp, - maxLines = 2, - style = MaterialTheme.typography.titleSmall.copy( - shadow = Shadow( - color = Color.Black, - blurRadius = 4f, - ), - ), - ) + MangaGridCompactText(manga.title) } } + +@Composable +fun BoxScope.MangaGridCompactText( + text: String, +) { + Text( + text = text, + modifier = Modifier + .padding(8.dp) + .align(Alignment.BottomStart), + color = Color.White, + fontSize = 12.sp, + maxLines = 2, + style = MaterialTheme.typography.titleSmall.copy( + shadow = Shadow( + color = Color.Black, + blurRadius = 4f, + ), + ), + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt index 79bdda245e..86de4c226b 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt @@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,6 +17,41 @@ import eu.kanade.presentation.components.BadgeGroup import eu.kanade.presentation.components.MangaCover import eu.kanade.tachiyomi.R +@Composable +fun MangaGridCover( + modifier: Modifier = Modifier, + cover: @Composable BoxScope.() -> Unit = {}, + badgesStart: (@Composable RowScope.() -> Unit)? = null, + badgesEnd: (@Composable RowScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(MangaCover.Book.ratio), + ) { + cover() + content() + if (badgesStart != null) { + BadgeGroup( + modifier = Modifier + .padding(4.dp) + .align(Alignment.TopStart), + content = badgesStart, + ) + } + + if (badgesEnd != null) { + BadgeGroup( + modifier = Modifier + .padding(4.dp) + .align(Alignment.TopEnd), + content = badgesEnd, + ) + } + } +} + @Composable fun LibraryGridCover( modifier: Modifier = Modifier, @@ -26,54 +62,41 @@ fun LibraryGridCover( language: String, content: @Composable BoxScope.() -> Unit = {}, ) { - Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(MangaCover.Book.ratio), - ) { - MangaCover.Book( - modifier = Modifier.fillMaxWidth(), - data = mangaCover, - ) - content() - if (downloadCount > 0 || unreadCount > 0) { - BadgeGroup( - modifier = Modifier - .padding(4.dp) - .align(Alignment.TopStart), - ) { - if (downloadCount > 0) { - Badge( - text = "$downloadCount", - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } - if (unreadCount > 0) { - Badge(text = "$unreadCount") - } + MangaGridCover( + modifier = modifier, + cover = { + MangaCover.Book( + modifier = Modifier.fillMaxWidth(), + data = mangaCover, + ) + }, + badgesStart = { + if (downloadCount > 0) { + Badge( + text = "$downloadCount", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) } - } - if (isLocal || language.isNotEmpty()) { - BadgeGroup( - modifier = Modifier - .padding(4.dp) - .align(Alignment.TopEnd), - ) { - if (isLocal) { - Badge( - text = stringResource(R.string.local_source_badge), - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } else if (language.isNotEmpty()) { - Badge( - text = language, - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } + if (unreadCount > 0) { + Badge(text = "$unreadCount") } - } - } + }, + badgesEnd = { + if (isLocal) { + Badge( + text = stringResource(R.string.local_source_badge), + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } else if (language.isNotEmpty()) { + Badge( + text = language, + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } + }, + content = content, + ) } 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 8f35b43445..4ddc9aaf4f 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 @@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -19,6 +20,7 @@ import eu.kanade.domain.manga.model.MangaCover import eu.kanade.presentation.components.Badge import eu.kanade.presentation.components.BadgeGroup import eu.kanade.presentation.components.FastScrollLazyColumn +import eu.kanade.presentation.components.MangaCover.Square import eu.kanade.presentation.components.TextButton import eu.kanade.presentation.util.bottomNavPaddingValues import eu.kanade.presentation.util.horizontalPadding @@ -74,62 +76,109 @@ fun LibraryListItem( onLongClick: (LibraryManga) -> Unit, ) { val manga = item.manga + MangaListItem( + modifier = Modifier.selectedBackground(isSelected), + title = manga.title, + cover = MangaCover( + manga.id!!, + manga.source, + manga.favorite, + manga.thumbnail_url, + manga.cover_last_modified, + ), + onClick = { onClick(manga) }, + onLongClick = { onLongClick(manga) }, + ) { + if (item.downloadCount > 0) { + Badge( + text = "${item.downloadCount}", + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } + if (item.unreadCount > 0) { + Badge(text = "${item.unreadCount}") + } + if (item.isLocal) { + Badge( + text = stringResource(R.string.local_source_badge), + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } + if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) { + Badge( + text = item.sourceLanguage, + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } + } +} + +@Composable +fun MangaListItem( + modifier: Modifier = Modifier, + title: String, + cover: MangaCover, + onClick: () -> Unit, + onLongClick: () -> Unit = onClick, + badges: @Composable RowScope.() -> Unit, +) { + MangaListItem( + modifier = modifier, + coverContent = { + Square( + modifier = Modifier + .padding(vertical = verticalPadding) + .fillMaxHeight(), + data = cover, + ) + }, + badges = badges, + onClick = onClick, + onLongClick = onLongClick, + content = { + MangaListItemContent(title) + }, + ) +} + +@Composable +fun MangaListItem( + modifier: Modifier = Modifier, + coverContent: @Composable RowScope.() -> Unit, + badges: @Composable RowScope.() -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { Row( - modifier = Modifier - .selectedBackground(isSelected) + modifier = modifier .height(56.dp) .combinedClickable( - onClick = { onClick(manga) }, - onLongClick = { onLongClick(manga) }, + onClick = onClick, + onLongClick = onLongClick, ) .padding(horizontal = horizontalPadding), verticalAlignment = Alignment.CenterVertically, ) { - eu.kanade.presentation.components.MangaCover.Square( - modifier = Modifier - .padding(vertical = verticalPadding) - .fillMaxHeight(), - data = MangaCover( - manga.id!!, - manga.source, - manga.favorite, - manga.thumbnail_url, - manga.cover_last_modified, - ), - ) - Text( - text = manga.title, - modifier = Modifier - .padding(horizontal = horizontalPadding) - .weight(1f), - maxLines = 2, - style = MaterialTheme.typography.bodyMedium, - ) - BadgeGroup { - if (item.downloadCount > 0) { - Badge( - text = "${item.downloadCount}", - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } - if (item.unreadCount > 0) { - Badge(text = "${item.unreadCount}") - } - if (item.isLocal) { - Badge( - text = stringResource(R.string.local_source_badge), - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } - if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) { - Badge( - text = item.sourceLanguage, - color = MaterialTheme.colorScheme.tertiary, - textColor = MaterialTheme.colorScheme.onTertiary, - ) - } - } + coverContent() + content() + BadgeGroup(content = badges) } } + +@Composable +fun RowScope.MangaListItemContent( + text: String, +) { + Text( + text = text, + modifier = Modifier + .padding(horizontal = horizontalPadding) + .weight(1f), + maxLines = 2, + style = MaterialTheme.typography.bodyMedium, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt index 6652c5898c..271b90f740 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil import java.io.File import java.io.IOException import java.io.InputStream +import eu.kanade.domain.manga.model.Manga as DomainManga /** * Class used to create cover cache. @@ -87,6 +88,20 @@ class CoverCache(private val context: Context) { return deleted } + fun deleteFromCache(manga: DomainManga, deleteCustomCover: Boolean = false): Int { + var amountDeleted = 0 + + getCoverFile(manga.thumbnailUrl)?.let { + if (it.exists() && it.delete()) amountDeleted++ + } + + if (deleteCustomCover && deleteCustomCover(manga.id)) { + amountDeleted++ + } + + return amountDeleted + } + /** * Delete custom cover of the manga from the cache * 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 8df3a45a4c..e32c169803 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 @@ -1,11 +1,13 @@ package eu.kanade.tachiyomi.ui.browse.migration.search import android.os.Bundle -import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.core.os.bundleOf import eu.kanade.domain.manga.model.Manga +import eu.kanade.presentation.browse.SourceSearchScreen import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController -import eu.kanade.tachiyomi.ui.browse.source.browse.SourceItem import eu.kanade.tachiyomi.util.system.getSerializableCompat class SourceSearchController( @@ -13,30 +15,34 @@ class SourceSearchController( ) : BrowseSourceController(bundle) { constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this( - Bundle().apply { - putLong(SOURCE_ID_KEY, source.id) - putSerializable(MANGA_KEY, manga) - if (searchQuery != null) { - putString(SEARCH_QUERY_KEY, searchQuery) - } - }, + bundleOf( + SOURCE_ID_KEY to source.id, + MANGA_KEY to manga, + SEARCH_QUERY_KEY to searchQuery, + ), ) + private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY) private var newManga: Manga? = null - override fun onItemClick(view: View, position: Int): Boolean { - val item = adapter?.getItem(position) as? SourceItem ?: return false - newManga = item.manga - val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController? - val dialog = - SearchController.MigrationDialog(oldManga, newManga, this) - dialog.targetController = searchController - dialog.showDialog(router) - return true - } + @Composable + override fun ComposeContent() { + SourceSearchScreen( + presenter = presenter, + navigateUp = { router.popCurrentController() }, + onFabClick = { filterSheet?.show() }, + onClickManga = { + newManga = it + val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController? + val dialog = SearchController.MigrationDialog(oldManga, newManga, this) + dialog.targetController = searchController + dialog.showDialog(router) + }, + ) - override fun onItemLongClick(position: Int) { - view?.let { super.onItemClick(it, position) } + LaunchedEffect(presenter.filters) { + initFilterSheet() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt new file mode 100644 index 0000000000..5fbc501bd3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.util.lang.withIOContext + +abstract class BrowsePagingSource : PagingSource() { + + abstract suspend fun requestNextPage(currentPage: Int): MangasPage + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 + + val mangasPage = try { + withIOContext { + requestNextPage(page.toInt()) + } + } catch (e: Exception) { + return LoadResult.Error(e) + } + + return LoadResult.Page( + data = mangasPage.mangas, + prevKey = null, + nextKey = if (mangasPage.hasNextPage) page + 1 else null, + ) + } + + override fun getRefreshKey(state: PagingState): Long? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } +} 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 cba2725a2e..eca86ad13d 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,328 +1,125 @@ package eu.kanade.tachiyomi.ui.browse.source.browse -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.ViewGroup -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.fredporciuncula.flow.preferences.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton -import com.google.android.material.snackbar.Snackbar -import dev.chrisbanes.insetter.applyInsetter -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.domain.category.model.Category -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.toDbManga +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.os.bundleOf import eu.kanade.domain.source.model.Source -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.databinding.SourceControllerBinding +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.LocalSource import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.FabController -import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController +import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController -import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog -import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog +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.more.MoreController -import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext -import eu.kanade.tachiyomi.util.preference.asHotFlow -import eu.kanade.tachiyomi.util.system.connectivityManager -import eu.kanade.tachiyomi.util.system.logcat -import eu.kanade.tachiyomi.util.system.openInBrowser -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.inflate -import eu.kanade.tachiyomi.util.view.shrinkOnScroll -import eu.kanade.tachiyomi.util.view.snack -import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import eu.kanade.tachiyomi.widget.EmptyView -import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import logcat.LogPriority -import uy.kohesive.injekt.injectLazy open class BrowseSourceController(bundle: Bundle) : - SearchableNucleusController(bundle), - FabController, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.EndlessScrollListener, - ChangeMangaCategoriesDialog.Listener { + FullComposeController(bundle) { constructor(sourceId: Long, query: String? = null) : this( - Bundle().apply { - putLong(SOURCE_ID_KEY, sourceId) - query?.let { query -> - putString(SEARCH_QUERY_KEY, query) - } - }, + bundleOf( + SOURCE_ID_KEY to sourceId, + SEARCH_QUERY_KEY to query, + ), ) constructor(source: CatalogueSource, query: String? = null) : this(source.id, query) constructor(source: Source, query: String? = null) : this(source.id, query) - private val preferences: PreferencesHelper by injectLazy() - - /** - * Adapter containing the list of manga from the catalogue. - */ - protected var adapter: FlexibleAdapter>? = null - - private var actionFab: ExtendedFloatingActionButton? = null - private var actionFabScrollListener: RecyclerView.OnScrollListener? = null - - /** - * Snackbar containing an error message when a request fails. - */ - private var snack: Snackbar? = null - /** * Sheet containing filter items. */ - private var filterSheet: SourceFilterSheet? = null + protected var filterSheet: SourceFilterSheet? = null - /** - * Recycler view with the list of results. - */ - private var recycler: RecyclerView? = null + @Composable + override fun ComposeContent() { + val scope = rememberCoroutineScope() - /** - * Subscription for the number of manga per row. - */ - private var numColumnsJob: Job? = null + BrowseSourceScreen( + presenter = presenter, + navigateUp = { router.popCurrentController() }, + onDisplayModeChange = { presenter.displayMode = (it) }, + onFabClick = { 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) + } + } + }, + ) - /** - * Endless loading item. - */ - private var progressItem: ProgressItem? = null + val onDismissRequest = { presenter.dialog = null } + when (val dialog = presenter.dialog) { + 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) + }, + ) + } + is Dialog.ChangeMangaCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { + router.pushController(CategoryController()) + }, + onConfirm = { include, _ -> + presenter.changeMangaFavorite(dialog.manga) + presenter.moveMangaToCategories(dialog.manga, include) + }, + ) + } + null -> {} + } - init { - setHasOptionsMenu(true) - } - - override fun getTitle(): String? { - return presenter.source.name + LaunchedEffect(presenter.filters) { + initFilterSheet() + } } override fun createPresenter(): BrowseSourcePresenter { return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY)) } - override fun createBinding(inflater: LayoutInflater) = SourceControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - // Initialize adapter, scroll listener and recycler views - adapter = FlexibleAdapter(null, this) - setupRecycler(view) - - binding.progress.isVisible = true - - presenter.restartPager() - } - open fun initFilterSheet() { - if (presenter.sourceFilters.isEmpty()) { + if (presenter.filters.isEmpty()) { return } filterSheet = SourceFilterSheet( activity!!, onFilterClicked = { - showProgressBar() - adapter?.clear() - presenter.setSourceFilter(presenter.sourceFilters) + presenter.setSourceFilter(presenter.filters) }, onResetClicked = { - presenter.appliedFilters = FilterList() - val newFilters = presenter.source.getFilterList() - presenter.sourceFilters = newFilters + presenter.resetFilter() filterSheet?.setFilters(presenter.filterItems) }, ) + filterSheet?.setFilters(presenter.filterItems) - - filterSheet?.setOnShowListener { actionFab?.hide() } - filterSheet?.setOnDismissListener { actionFab?.show() } - - actionFab?.setOnClickListener { filterSheet?.show() } - - actionFab?.show() - } - - override fun configureFab(fab: ExtendedFloatingActionButton) { - actionFab = fab - - fab.setText(R.string.action_filter) - fab.setIconResource(R.drawable.ic_filter_list_24dp) - - // Controlled by initFilterSheet() - fab.hide() - initFilterSheet() - } - - override fun cleanupFab(fab: ExtendedFloatingActionButton) { - fab.setOnClickListener(null) - actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) } - actionFab = null - } - - override fun onDestroyView(view: View) { - numColumnsJob?.cancel() - numColumnsJob = null - adapter = null - snack = null - recycler = null - super.onDestroyView(view) - } - - private fun setupRecycler(view: View) { - numColumnsJob?.cancel() - - var oldPosition = RecyclerView.NO_POSITION - val oldRecycler = binding.catalogueView.getChildAt(1) - if (oldRecycler is RecyclerView) { - oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() - oldRecycler.adapter = null - - binding.catalogueView.removeView(oldRecycler) - } - - val recycler = if (preferences.sourceDisplayMode().get() == LibraryDisplayMode.List) { - RecyclerView(view.context).apply { - id = R.id.recycler - layoutManager = LinearLayoutManager(context) - layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - } - } else { - (binding.catalogueView.inflate(R.layout.source_recycler_autofit) as AutofitRecyclerView).apply { - numColumnsJob = getColumnsPreferenceForCurrentOrientation().asHotFlow { spanCount = it } - .drop(1) - // Set again the adapter to recalculate the covers height - .onEach { adapter = this@BrowseSourceController.adapter } - .launchIn(viewScope) - - (layoutManager as GridLayoutManager).spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { - return when (adapter?.getItemViewType(position)) { - R.layout.source_compact_grid_item, R.layout.source_comfortable_grid_item -> 1 - else -> spanCount - } - } - } - } - } - - if (filterSheet != null) { - // Add bottom padding if filter FAB is visible - recycler.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.fab_list_padding)) - recycler.clipToPadding = false - - actionFab?.shrinkOnScroll(recycler) - } - - recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - recycler.setHasFixedSize(true) - recycler.adapter = adapter - - binding.catalogueView.addView(recycler, 1) - - if (oldPosition != RecyclerView.NO_POSITION) { - recycler.layoutManager?.scrollToPosition(oldPosition) - } - this.recycler = recycler - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - createOptionsMenu(menu, inflater, R.menu.source_browse, R.id.action_search) - val searchItem = menu.findItem(R.id.action_search) - - searchItem.fixExpand( - onExpand = { invalidateMenuOnExpand() }, - onCollapse = { - if (router.backstackSize >= 2 && router.backstack[router.backstackSize - 2].controller is GlobalSearchController) { - router.popController(this) - } else { - nonSubmittedQuery = "" - searchWithQuery("") - } - - true - }, - ) - - val displayItem = when (preferences.sourceDisplayMode().get()) { - LibraryDisplayMode.List -> R.id.action_list - LibraryDisplayMode.ComfortableGrid -> R.id.action_comfortable_grid - else -> R.id.action_compact_grid - } - menu.findItem(displayItem).isChecked = true - } - - override fun onSearchViewQueryTextSubmit(query: String?) { - searchWithQuery(query ?: "") - } - - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - - val isHttpSource = presenter.source is HttpSource - menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource - - val isLocalSource = presenter.source is LocalSource - menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_search -> expandActionViewFromInteraction = true - R.id.action_compact_grid -> setDisplayMode(LibraryDisplayMode.CompactGrid) - R.id.action_comfortable_grid -> setDisplayMode(LibraryDisplayMode.ComfortableGrid) - R.id.action_list -> setDisplayMode(LibraryDisplayMode.List) - R.id.action_open_in_web_view -> openInWebView() - R.id.action_local_source_help -> openLocalSourceHelpGuide() - } - return super.onOptionsItemSelected(item) - } - - private fun openInWebView() { - val source = presenter.source as? HttpSource ?: return - - val activity = activity ?: return - val intent = WebViewActivity.newIntent(activity, source.baseUrl, source.id, presenter.source.name) - startActivity(intent) - } - - private fun openLocalSourceHelpGuide() { - activity?.openInBrowser(LocalSource.HELP_URL) } /** @@ -331,15 +128,8 @@ open class BrowseSourceController(bundle: Bundle) : * @param newQuery the new query. */ fun searchWithQuery(newQuery: String) { - // If text didn't change, do nothing - if (presenter.query == newQuery) { - return - } - - showProgressBar() - adapter?.clear() - - presenter.restartPager(newQuery, presenter.sourceFilters) + presenter.searchQuery = newQuery + presenter.search() } /** @@ -350,7 +140,7 @@ open class BrowseSourceController(bundle: Bundle) : * @param genreName the name of the genre */ fun searchWithGenre(genreName: String) { - val defaultFilters = presenter.source.getFilterList() + val defaultFilters = presenter.source!!.getFilterList() var genreExists = false @@ -380,320 +170,15 @@ open class BrowseSourceController(bundle: Bundle) : } if (genreExists) { - presenter.sourceFilters = defaultFilters filterSheet?.setFilters(presenter.filterItems) - showProgressBar() - - adapter?.clear() - presenter.restartPager("", defaultFilters) + presenter.searchQuery = "" + presenter.setFilter(defaultFilters) } else { searchWithQuery(genreName) } } - /** - * Called from the presenter when the network request is received. - * - * @param page the current page. - * @param mangas the list of manga of the page. - */ - fun onAddPage(page: Int, mangas: List) { - val adapter = adapter ?: return - hideProgressBar() - if (page == 1) { - adapter.clear() - resetProgressItem() - } - adapter.onLoadMoreComplete(mangas) - } - - /** - * Called from the presenter when the network request fails. - * - * @param error the error received. - */ - fun onAddPageError(error: Throwable) { - logcat(LogPriority.ERROR, error) - val adapter = adapter ?: return - adapter.onLoadMoreComplete(null) - hideProgressBar() - - snack?.dismiss() - - val message = getErrorMessage(error) - val retryAction = View.OnClickListener { - // If not the first page, show bottom progress bar. - if (adapter.mainItemCount > 0 && progressItem != null) { - adapter.addScrollableFooterWithDelay(progressItem!!, 0, true) - } else { - showProgressBar() - } - presenter.requestNext() - } - - if (adapter.isEmpty) { - val actions = if (presenter.source is LocalSource) { - listOf( - EmptyView.Action(R.string.local_source_help_guide, R.drawable.ic_help_24dp) { openLocalSourceHelpGuide() }, - ) - } else { - listOf( - EmptyView.Action(R.string.action_retry, R.drawable.ic_refresh_24dp, retryAction), - EmptyView.Action(R.string.action_open_in_web_view, R.drawable.ic_public_24dp) { openInWebView() }, - EmptyView.Action(R.string.label_help, R.drawable.ic_help_24dp) { activity?.openInBrowser(MoreController.URL_HELP) }, - ) - } - - binding.emptyView.show(message, actions) - } else { - snack = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(message, Snackbar.LENGTH_INDEFINITE) { - setAction(R.string.action_retry, retryAction) - } - } - } - - private fun getErrorMessage(error: Throwable): String { - if (error is NoResultsException) { - return binding.catalogueView.context.getString(R.string.no_results_found) - } - - return when { - error.message == null -> "" - error.message!!.startsWith("HTTP error") -> "${error.message}: ${binding.catalogueView.context.getString(R.string.http_error_hint)}" - else -> error.message!! - } - } - - /** - * Sets a new progress item and reenables the scroll listener. - */ - private fun resetProgressItem() { - progressItem = ProgressItem() - adapter?.endlessTargetCount = 0 - adapter?.setEndlessScrollListener(this, progressItem!!) - } - - /** - * Called by the adapter when scrolled near the bottom. - */ - override fun onLoadMore(lastPosition: Int, currentPage: Int) { - if (presenter.hasNextPage()) { - presenter.requestNext() - } else { - adapter?.onLoadMoreComplete(null) - adapter?.endlessTargetCount = 1 - } - } - - override fun noMoreLoad(newItemsSize: Int) { - } - - /** - * Called from the presenter when a manga is initialized. - * - * @param manga the manga initialized - */ - fun onMangaInitialized(manga: Manga) { - getHolder(manga)?.setImage(manga) - } - - /** - * Sets the current display mode. - * - * @param mode the mode to change to - */ - private fun setDisplayMode(mode: LibraryDisplayMode) { - val view = view ?: return - val adapter = adapter ?: return - - preferences.sourceDisplayMode().set(mode) - activity?.invalidateOptionsMenu() - setupRecycler(view) - - // Initialize mangas if not on a metered connection - if (!view.context.connectivityManager.isActiveNetworkMetered) { - val mangas = (0 until adapter.itemCount).mapNotNull { - (adapter.getItem(it) as? SourceItem)?.manga - } - presenter.initializeMangas(mangas) - } - } - - /** - * Returns a preference for the number of manga per row based on the current orientation. - * - * @return the preference. - */ - private fun getColumnsPreferenceForCurrentOrientation(): Preference { - return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT) { - preferences.portraitColumns() - } else { - preferences.landscapeColumns() - } - } - - /** - * Returns the view holder for the given manga. - * - * @param manga the manga to find. - * @return the holder of the manga or null if it's not bound. - */ - private fun getHolder(manga: Manga): SourceHolder<*>? { - val adapter = adapter ?: return null - - adapter.allBoundViewHolders.forEach { holder -> - val item = adapter.getItem(holder.bindingAdapterPosition) as? SourceItem - if (item != null && item.manga.id == manga.id) { - return holder as SourceHolder<*> - } - } - - return null - } - - /** - * Shows the progress bar. - */ - private fun showProgressBar() { - binding.emptyView.hide() - binding.progress.isVisible = true - snack?.dismiss() - snack = null - } - - /** - * Hides active progress bars. - */ - private fun hideProgressBar() { - binding.emptyView.hide() - binding.progress.isVisible = false - } - - /** - * Called when a manga is clicked. - * - * @param position the position of the element clicked. - * @return true if the item should be selected, false otherwise. - */ - override fun onItemClick(view: View, position: Int): Boolean { - val item = adapter?.getItem(position) as? SourceItem ?: return false - router.pushController(MangaController(item.manga.id, true)) - - return false - } - - /** - * Called when a manga is long clicked. - * - * Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga - * in, the list consists of the default category plus the user's categories. The default category is preselected on - * new manga, and on already favorited manga the manga's categories are preselected. - * - * @param position the position of the element clicked. - */ - override fun onItemLongClick(position: Int) { - val activity = activity ?: return - val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return - viewScope.launchIO { - val duplicateManga = presenter.getDuplicateLibraryManga(manga) - - withUIContext { - if (manga.favorite) { - MaterialAlertDialogBuilder(activity) - .setTitle(manga.title) - .setItems(arrayOf(activity.getString(R.string.remove_from_library))) { _, which -> - when (which) { - 0 -> { - presenter.changeMangaFavorite(manga.toDbManga()) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_removed_library)) - } - } - } - .show() - } else { - if (duplicateManga != null) { - AddDuplicateMangaDialog(this@BrowseSourceController, duplicateManga) { - addToLibrary( - manga, - position, - ) - } - .showDialog(router) - } else { - addToLibrary(manga, position) - } - } - } - } - } - - private fun addToLibrary(newManga: Manga, position: Int) { - val activity = activity ?: return - viewScope.launchIO { - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } - - withUIContext { - when { - // Default category set - defaultCategory != null -> { - presenter.moveMangaToCategory(newManga.toDbManga(), defaultCategory) - - presenter.changeMangaFavorite(newManga.toDbManga()) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } - - // Automatic 'Default' or no categories - defaultCategoryId == 0 || categories.isEmpty() -> { - presenter.moveMangaToCategory(newManga.toDbManga(), null) - - presenter.changeMangaFavorite(newManga.toDbManga()) - adapter?.notifyItemChanged(position) - activity.toast(activity.getString(R.string.manga_added_library)) - } - - // Choose a category - else -> { - val ids = presenter.getMangaCategoryIds(newManga) - val preselected = categories.map { - if (it.id in ids) { - QuadStateTextView.State.CHECKED.ordinal - } else { - QuadStateTextView.State.UNCHECKED.ordinal - } - }.toTypedArray() - - ChangeMangaCategoriesDialog(this@BrowseSourceController, listOf(newManga), categories, preselected) - .showDialog(router) - } - } - } - } - } - - /** - * Update manga to use selected categories. - * - * @param mangas The list of manga to move to categories. - * @param categories The list of categories where manga will be placed. - */ - override fun updateCategoriesForMangas(mangas: List, addCategories: List, removeCategories: List) { - val manga = mangas.firstOrNull() ?: return - - presenter.changeMangaFavorite(manga.toDbManga()) - presenter.updateMangaCategories(manga.toDbManga(), addCategories) - - val position = adapter?.currentItems?.indexOfFirst { it -> (it as SourceItem).manga.id == manga.id } - if (position != null) { - adapter?.notifyItemChanged(position) - } - activity?.toast(activity?.getString(R.string.manga_added_library)) - } - protected companion object { const val SOURCE_ID_KEY = "sourceId" const val SEARCH_QUERY_KEY = "searchQuery" 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/BrowseSourcePresenter.kt index c6c187c382..e6124557a5 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/BrowseSourcePresenter.kt @@ -1,7 +1,25 @@ package eu.kanade.tachiyomi.ui.browse.source.browse +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.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.PagingSource +import androidx.paging.cachedIn +import androidx.paging.map import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.core.prefs.CheckboxState +import eu.kanade.core.prefs.mapAsCheckboxState import eu.kanade.domain.category.interactor.GetCategories import eu.kanade.domain.category.interactor.SetMangaCategories import eu.kanade.domain.chapter.interactor.GetChapterByMangaId @@ -14,6 +32,8 @@ import eu.kanade.domain.manga.model.toDbManga import eu.kanade.domain.manga.model.toMangaUpdate 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.database.models.Manga import eu.kanade.tachiyomi.data.database.models.toDomainManga @@ -22,6 +42,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager 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 @@ -42,19 +63,17 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import logcat.LogPriority import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -65,6 +84,7 @@ import eu.kanade.domain.manga.model.Manga as DomainManga open class BrowseSourcePresenter( private val sourceId: Long, searchQuery: String? = null, + private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl, private val sourceManager: SourceManager = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), @@ -77,55 +97,76 @@ open class BrowseSourcePresenter( private val updateManga: UpdateManga = Injekt.get(), private val insertTrack: InsertTrack = Injekt.get(), private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(), -) : BasePresenter() { +) : BasePresenter(), BrowseSourceState by state { - /** - * Selected source. - */ - lateinit var source: CatalogueSource + var displayMode by preferences.sourceDisplayMode().asState() - /** - * Modifiable list of filters. - */ - var sourceFilters = FilterList() - set(value) { - field = value - filterItems = value.toItems() + @Composable + fun getColumnsPreferenceForCurrentOrientation(): State { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + return produceState(initialValue = GridCells.Adaptive(128.dp), isLandscape) { + (if (isLandscape) preferences.landscapeColumns() else preferences.portraitColumns()) + .asFlow() + .collectLatest { columns -> + value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) + } } + } - var filterItems: List> = emptyList() + @Composable + fun getMangaList(): Flow> { + return remember(currentQuery, appliedFilters) { + Pager( + PagingConfig(pageSize = 25), + ) { + createPager(currentQuery, appliedFilters) + }.flow + .map { + it.map { + withIOContext { + networkToLocalManga(it, sourceId).toDomainManga()!! + } + } + } + .cachedIn(presenterScope) + } + } - /** - * List of filters used by the [Pager]. If empty alongside [query], the popular query is used. - */ - var appliedFilters = FilterList() + @Composable + fun getManga(initialManga: DomainManga): State { + return produceState(initialValue = initialManga, initialManga.url, initialManga.source) { + getManga.subscribe(initialManga.url, initialManga.source) + .collectLatest { manga -> + if (manga == null) return@collectLatest + launchIO { + initializeMangas(manga) + } + value = manga + } + } + } - /** - * Pager containing a list of manga results. - */ - private lateinit var pager: Pager + fun setFilter(filters: FilterList) { + state.filters = filters + } - /** - * Subscription for the pager. - */ - private var pagerJob: Job? = null + fun resetFilter() { + state.appliedFilters = FilterList() + val newFilters = source!!.getFilterList() + state.filters = newFilters + } - /** - * Subscription for one request from the pager. - */ - private var nextPageJob: Job? = null + fun search() { + state.currentQuery = searchQuery ?: "" + } private val loggedServices by lazy { Injekt.get().services.filter { it.isLogged } } - init { - query = searchQuery ?: "" - } - override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - source = sourceManager.get(sourceId) as? CatalogueSource ?: return - sourceFilters = source.getFilterList() + state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return + state.filters = source!!.getFilterList() if (savedState != null) { query = savedState.getString(::query.name, "") @@ -137,79 +178,6 @@ open class BrowseSourcePresenter( super.onSave(state) } - /** - * Restarts the pager for the active source with the provided query and filters. - * - * @param query the query. - * @param filters the current state of the filters (for search mode). - */ - fun restartPager(query: String = this.query, filters: FilterList = this.appliedFilters) { - this.query = query - this.appliedFilters = filters - - // Create a new pager. - pager = createPager(query, filters) - - val sourceId = source.id - val sourceDisplayMode = preferences.sourceDisplayMode() - - pagerJob?.cancel() - pagerJob = presenterScope.launchIO { - pager.asFlow() - .map { (first, second) -> - first to second.map { - networkToLocalManga( - it, - sourceId, - ).toDomainManga()!! - } - } - .onEach { initializeMangas(it.second) } - .map { (first, second) -> - first to second.map { - SourceItem( - it, - sourceDisplayMode, - ) - } - } - .catch { error -> - logcat(LogPriority.ERROR, error) - } - .collectLatest { (page, mangas) -> - withUIContext { - view?.onAddPage(page, mangas) - } - } - } - - // Request first page. - requestNext() - } - - /** - * Requests the next page for the active pager. - */ - fun requestNext() { - if (!hasNextPage()) return - - nextPageJob?.cancel() - nextPageJob = presenterScope.launchIO { - try { - pager.requestNextPage() - } catch (e: Throwable) { - withUIContext { view?.onAddPageError(e) } - } - } - } - - /** - * Returns true if the last fetched page has a next page. - */ - fun hasNextPage(): Boolean { - return pager.hasNextPage - } - /** * Returns a manga from the database for the given manga from network. It creates a new entry * if the manga is not yet in the database. @@ -217,16 +185,14 @@ open class BrowseSourcePresenter( * @param sManga the manga from the source. * @return a manga from the database. */ - private fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { - var localManga = runBlocking { getManga.await(sManga.url, sourceId) } + private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { + var localManga = getManga.await(sManga.url, sourceId) if (localManga == null) { val newManga = Manga.create(sManga.url, sManga.title, sourceId) newManga.copyFrom(sManga) newManga.id = -1 - val result = runBlocking { - val id = insertManga.await(newManga.toDomainManga()!!) - getManga.await(id!!) - } + val id = insertManga.await(newManga.toDomainManga()!!) + val result = getManga.await(id!!) localManga = result } else if (!localManga.favorite) { // if the manga isn't a favorite, set its display title from source @@ -237,146 +203,123 @@ open class BrowseSourcePresenter( } /** - * Initialize a list of manga. + * Initialize a manga. * * @param mangas the list of manga to initialize. */ - fun initializeMangas(mangas: List) { - presenterScope.launchIO { - mangas.asFlow() - .filter { it.thumbnailUrl == null && !it.initialized } - .map { getMangaDetails(it.toDbManga()) } - .onEach { - withUIContext { - @Suppress("DEPRECATION") - view?.onMangaInitialized(it.toDomainManga()!!) - } - } - .catch { e -> logcat(LogPriority.ERROR, e) } - .collect() + private suspend fun initializeMangas(manga: DomainManga) { + if (manga.thumbnailUrl != null && manga.initialized) return + withContext(NonCancellable) { + val db = manga.toDbManga() + try { + val networkManga = source!!.getMangaDetails(db.copy()) + db.copyFrom(networkManga) + db.initialized = true + updateManga.await( + db + .toDomainManga() + ?.toMangaUpdate()!!, + ) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } } } - /** - * Returns the initialized manga. - * - * @param manga the manga to initialize. - * @return the initialized manga - */ - private suspend fun getMangaDetails(manga: Manga): Manga { - try { - val networkManga = source.getMangaDetails(manga.copy()) - manga.copyFrom(networkManga) - manga.initialized = true - updateManga.await( - manga - .toDomainManga() - ?.toMangaUpdate()!!, - ) - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - } - return manga - } - /** * Adds or removes a manga from the library. * * @param manga the manga to update. */ - fun changeMangaFavorite(manga: Manga) { - manga.favorite = !manga.favorite - manga.date_added = when (manga.favorite) { - true -> Date().time - false -> 0 - } - - if (!manga.favorite) { - manga.removeCovers(coverCache) - } else { - ChapterSettingsHelper.applySettingDefaults(manga.toDomainManga()!!) - - autoAddTrack(manga) - } - - runBlocking { - updateManga.await( - manga - .toDomainManga() - ?.toMangaUpdate()!!, + fun changeMangaFavorite(manga: DomainManga) { + presenterScope.launch { + var new = manga.copy( + favorite = !manga.favorite, + dateAdded = when (manga.favorite) { + true -> Date().time + false -> 0 + }, ) + + if (!new.favorite) { + new = new.removeCovers(coverCache) + } else { + ChapterSettingsHelper.applySettingDefaults(manga) + + autoAddTrack(manga) + } + + updateManga.await(new.toMangaUpdate()) } } - private fun autoAddTrack(manga: Manga) { - launchIO { - loggedServices - .filterIsInstance() - .filter { it.accept(source) } - .forEach { service -> - try { - service.match(manga)?.let { track -> - track.manga_id = manga.id!! - (service as TrackService).bind(track) - insertTrack.await(track.toDomainTrack()!!) + fun getSourceOrStub(manga: DomainManga): Source { + return sourceManager.getOrStub(manga.source) + } - val chapters = getChapterByMangaId.await(manga.id!!) - syncChaptersWithTrackServiceTwoWay.await(chapters, track.toDomainTrack()!!, service) - } - } catch (e: Exception) { - logcat(LogPriority.WARN, e) { "Could not match manga: ${manga.title} with service $service" } - } + fun addFavorite(manga: DomainManga) { + presenterScope.launch { + val categories = getCategories() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() } + + when { + // Default category set + defaultCategory != null -> { + moveMangaToCategories(manga, defaultCategory) + + changeMangaFavorite(manga) + // activity.toast(activity.getString(R.string.manga_added_library)) } + + // Automatic 'Default' or no categories + defaultCategoryId == 0 || categories.isEmpty() -> { + moveMangaToCategories(manga) + + changeMangaFavorite(manga) + // activity.toast(activity.getString(R.string.manga_added_library)) + } + + // Choose a category + else -> { + val preselectedIds = getCategories.await(manga.id).map { it.id } + state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }) + } + } } } + private suspend fun autoAddTrack(manga: DomainManga) { + loggedServices + .filterIsInstance() + .filter { it.accept(source!!) } + .forEach { service -> + try { + service.match(manga.toDbManga())?.let { track -> + track.manga_id = manga.id + (service as TrackService).bind(track) + insertTrack.await(track.toDomainTrack()!!) + + val chapters = getChapterByMangaId.await(manga.id) + syncChaptersWithTrackServiceTwoWay.await(chapters, track.toDomainTrack()!!, service) + } + } catch (e: Exception) { + logcat(LogPriority.WARN, e) { "Could not match manga: ${manga.title} with service $service" } + } + } + } + /** * Set the filter states for the current source. * * @param filters a list of active filters. */ fun setSourceFilter(filters: FilterList) { - restartPager(filters = filters) + state.appliedFilters = filters } - open fun createPager(query: String, filters: FilterList): Pager { - return SourcePager(source, query, filters) - } - - 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<*> -> { - 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) - else -> null - } - } - subItems.forEach { it.header = group } - group.subItems = subItems - group - } - is Filter.Sort -> { - val group = SortGroup(filter) - val subItems = filter.values.map { - SortItem(it, group) - } - group.subItems = subItems - group - } - } - } + open fun createPager(query: String, filters: FilterList): PagingSource { + return SourceBrowsePagingSource(source!!, query, filters) } /** @@ -395,54 +338,67 @@ open class BrowseSourcePresenter( return getDuplicateLibraryManga.await(manga.title, manga.source) } - /** - * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. - * - * @param manga the manga to get categories from. - * @return Array of category ids the manga is in, if none returns default id - */ - fun getMangaCategoryIds(manga: DomainManga): Array { - return runBlocking { getCategories.await(manga.id) } - .map { it.id } - .toTypedArray() - } - /** * Move the given manga to categories. * * @param categories the selected categories. * @param manga the manga to move. */ - private fun moveMangaToCategories(manga: Manga, categories: List) { + fun moveMangaToCategories(manga: DomainManga, vararg categories: DomainCategory) { + moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id }) + } + + fun moveMangaToCategories(manga: DomainManga, categoryIds: List) { presenterScope.launchIO { setMangaCategories.await( - mangaId = manga.id!!, - categoryIds = categories.filter { it.id != 0L }.map { it.id }, + mangaId = manga.id, + categoryIds = categoryIds.toList(), ) } } - /** - * Move the given manga to the category. - * - * @param category the selected category. - * @param manga the manga to move. - */ - fun moveMangaToCategory(manga: Manga, category: DomainCategory?) { - moveMangaToCategories(manga, listOfNotNull(category)) - } - - /** - * Update manga to use selected categories. - * - * @param manga needed to change - * @param selectedCategories selected categories - */ - fun updateMangaCategories(manga: Manga, selectedCategories: List) { - if (!manga.favorite) { - changeMangaFavorite(manga) - } - - moveMangaToCategories(manga, selectedCategories) + sealed class Dialog { + data class RemoveManga(val manga: DomainManga) : Dialog() + data class AddDuplicateManga(val manga: DomainManga, val duplicate: DomainManga) : Dialog() + data class ChangeMangaCategory( + val manga: DomainManga, + val initialSelection: List>, + ) : Dialog() + } +} + +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<*> -> { + 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) + else -> null + } + } + subItems.forEach { it.header = group } + group.subItems = subItems + group + } + is Filter.Sort -> { + val group = SortGroup(filter) + val subItems = filter.values.map { + SortItem(it, group) + } + group.subItems = subItems + group + } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt deleted file mode 100644 index 0c8a895a98..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt +++ /dev/null @@ -1,31 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.core.util.asFlow -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.SManga -import kotlinx.coroutines.flow.Flow - -/** - * A general pager for source requests (latest updates, popular, search) - */ -abstract class Pager(var currentPage: Int = 1) { - - var hasNextPage = true - private set - - protected val results: PublishRelay>> = PublishRelay.create() - - fun asFlow(): Flow>> { - return results.asObservable().asFlow() - } - - abstract suspend fun requestNextPage() - - fun onPageReceived(mangasPage: MangasPage) { - val page = currentPage - currentPage++ - hasNextPage = mangasPage.hasNextPage && mangasPage.mangas.isNotEmpty() - results.call(Pair(page, mangasPage.mangas)) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt deleted file mode 100644 index df46f54a0a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt +++ /dev/null @@ -1,54 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import android.view.View -import android.widget.ProgressBar -import android.widget.TextView -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.tachiyomi.R - -class ProgressItem : AbstractFlexibleItem() { - - private var loadMore = true - - override fun getLayoutRes(): Int { - return R.layout.source_progress_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { - return Holder(view, adapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List) { - holder.progressBar.isVisible = false - holder.progressMessage.isVisible = false - - if (!adapter.isEndlessScrollEnabled) { - loadMore = false - } - - if (loadMore) { - holder.progressBar.isVisible = true - } else { - holder.progressMessage.isVisible = true - } - } - - override fun equals(other: Any?): Boolean { - return this === other - } - - override fun hashCode(): Int { - return loadMore.hashCode() - } - - class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { - - val progressBar: ProgressBar = view.findViewById(R.id.progress_bar) - val progressMessage: TextView = view.findViewById(R.id.progress_message) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt new file mode 100644 index 0000000000..5bb308724a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.browse.source.browse + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.util.lang.awaitSingle + +class SourceBrowsePagingSource(val source: CatalogueSource, val query: String, val filters: FilterList) : BrowsePagingSource() { + + override suspend fun requestNextPage(currentPage: Int): MangasPage { + val observable = if (query.isBlank() && filters.isEmpty()) { + source.fetchPopularManga(currentPage) + } else { + source.fetchSearchManga(currentPage, query, filters) + } + + return observable.awaitSingle() + .takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt deleted file mode 100644 index 6c16339b9a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt +++ /dev/null @@ -1,53 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import androidx.core.view.isVisible -import coil.dispose -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher -import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding -import eu.kanade.tachiyomi.util.view.loadAutoPause - -/** - * Class used to hold the displayed data of a manga in the catalogue, like the cover or the title. - * All the elements from the layout file "item_source_grid" are available in this class. - * - * @param binding the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @constructor creates a new catalogue holder. - */ -class SourceComfortableGridHolder( - override val binding: SourceComfortableGridItemBinding, - adapter: FlexibleAdapter<*>, -) : SourceHolder(binding.root, adapter) { - - /** - * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param manga the manga to bind. - */ - override fun onSetValues(manga: Manga) { - // Set manga title - binding.title.text = manga.title - - // Set alpha of thumbnail. - binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f - - // For rounded corners - binding.badges.leftBadges.clipToOutline = true - binding.badges.rightBadges.clipToOutline = true - - // Set favorite badge - binding.badges.favoriteText.isVisible = manga.favorite - - setImage(manga) - } - - override fun setImage(manga: Manga) { - binding.thumbnail.dispose() - binding.thumbnail.loadAutoPause(manga) { - setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt deleted file mode 100644 index f3f741d337..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt +++ /dev/null @@ -1,53 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import androidx.core.view.isVisible -import coil.dispose -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher -import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding -import eu.kanade.tachiyomi.util.view.loadAutoPause - -/** - * Class used to hold the displayed data of a manga in the catalogue, like the cover or the title. - * All the elements from the layout file "item_source_grid" are available in this class. - * - * @param binding the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @constructor creates a new catalogue holder. - */ -class SourceCompactGridHolder( - override val binding: SourceCompactGridItemBinding, - adapter: FlexibleAdapter<*>, -) : SourceHolder(binding.root, adapter) { - - /** - * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param manga the manga to bind. - */ - override fun onSetValues(manga: Manga) { - // Set manga title - binding.title.text = manga.title - - // Set alpha of thumbnail. - binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f - - // For rounded corners - binding.badges.leftBadges.clipToOutline = true - binding.badges.rightBadges.clipToOutline = true - - // Set favorite badge - binding.badges.favoriteText.isVisible = manga.favorite - - setImage(manga) - } - - override fun setImage(manga: Manga) { - binding.thumbnail.dispose() - binding.thumbnail.loadAutoPause(manga) { - setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt deleted file mode 100644 index 968b15ef3a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt +++ /dev/null @@ -1,35 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import android.view.View -import androidx.viewbinding.ViewBinding -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.viewholders.FlexibleViewHolder -import eu.kanade.domain.manga.model.Manga - -/** - * Generic class used to hold the displayed data of a manga in the catalogue. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - */ -abstract class SourceHolder(view: View, adapter: FlexibleAdapter<*>) : - FlexibleViewHolder(view, adapter) { - - abstract val binding: VB - - /** - * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param manga the manga to bind. - */ - abstract fun onSetValues(manga: Manga) - - /** - * Updates the image for this holder. Useful to update the image when the manga is initialized - * and the url is now known. - * - * @param manga the manga to bind. - */ - abstract fun setImage(manga: Manga) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt deleted file mode 100644 index 544c6dbe75..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt +++ /dev/null @@ -1,63 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.fredporciuncula.flow.preferences.Preference -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.SourceComfortableGridItemBinding -import eu.kanade.tachiyomi.databinding.SourceCompactGridItemBinding -import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode - -class SourceItem(val manga: Manga, private val displayMode: Preference) : - AbstractFlexibleItem>() { - - override fun getLayoutRes(): Int { - return when (displayMode.get()) { - LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> R.layout.source_compact_grid_item - LibraryDisplayMode.ComfortableGrid -> R.layout.source_comfortable_grid_item - LibraryDisplayMode.List -> R.layout.source_list_item - } - } - - override fun createViewHolder( - view: View, - adapter: FlexibleAdapter>, - ): SourceHolder<*> { - return when (displayMode.get()) { - LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { - SourceCompactGridHolder(SourceCompactGridItemBinding.bind(view), adapter) - } - LibraryDisplayMode.ComfortableGrid -> { - SourceComfortableGridHolder(SourceComfortableGridItemBinding.bind(view), adapter) - } - LibraryDisplayMode.List -> { - SourceListHolder(view, adapter) - } - } - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: SourceHolder<*>, - position: Int, - payloads: List?, - ) { - holder.onSetValues(manga) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other is SourceItem) { - return manga.id == other.manga.id - } - return false - } - - override fun hashCode(): Int { - return manga.id.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt deleted file mode 100644 index 043655712f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import android.view.View -import androidx.core.view.isVisible -import coil.dispose -import coil.load -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher -import eu.kanade.tachiyomi.databinding.SourceListItemBinding -import eu.kanade.tachiyomi.util.system.getResourceColor - -/** - * Class used to hold the displayed data of a manga in the catalogue, like the cover or the title. - * All the elements from the layout file "item_catalogue_list" are available in this class. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @constructor creates a new catalogue holder. - */ -class SourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) : - SourceHolder(view, adapter) { - - override val binding = SourceListItemBinding.bind(view) - - private val favoriteColor = view.context.getResourceColor(R.attr.colorOnSurface, 0.38f) - private val unfavoriteColor = view.context.getResourceColor(R.attr.colorOnSurface) - - /** - * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this - * holder with the given manga. - * - * @param manga the manga to bind. - */ - override fun onSetValues(manga: Manga) { - binding.title.text = manga.title - binding.title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor) - - // Set alpha of thumbnail. - binding.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f - - // For rounded corners - binding.badges.clipToOutline = true - - // Set favorite badge - binding.favoriteText.isVisible = manga.favorite - - setImage(manga) - } - - override fun setImage(manga: Manga) { - binding.thumbnail.dispose() - if (!manga.thumbnailUrl.isNullOrEmpty()) { - binding.thumbnail.load(manga) { - setParameter(MangaCoverFetcher.USE_CUSTOM_COVER, false) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt deleted file mode 100644 index 786ffca3a4..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt +++ /dev/null @@ -1,26 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.browse - -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.util.lang.awaitSingle - -class SourcePager(val source: CatalogueSource, val query: String, val filters: FilterList) : Pager() { - - override suspend fun requestNextPage() { - val page = currentPage - - val observable = if (query.isBlank() && filters.isEmpty()) { - source.fetchPopularManga(page) - } else { - source.fetchSearchManga(page, query, filters) - } - - val mangasPage = observable.awaitSingle() - - if (mangasPage.mangas.isNotEmpty()) { - onPageReceived(mangasPage) - } else { - throw NoResultsException() - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt new file mode 100644 index 0000000000..166a4a184c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.ui.browse.source.latest + +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.ui.browse.source.browse.BrowsePagingSource +import eu.kanade.tachiyomi.util.lang.awaitSingle + +class LatestUpdatesBrowsePagingSource(val source: CatalogueSource) : BrowsePagingSource() { + + override suspend fun requestNextPage(currentPage: Int): MangasPage { + return source.fetchLatestUpdates(currentPage).awaitSingle() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt index f065e5b16d..7f062b6135 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt @@ -1,12 +1,20 @@ package eu.kanade.tachiyomi.ui.browse.source.latest import android.os.Bundle -import android.view.Menu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.core.os.bundleOf import eu.kanade.domain.source.model.Source -import eu.kanade.tachiyomi.R +import eu.kanade.presentation.browse.BrowseLatestScreen +import eu.kanade.presentation.browse.components.RemoveMangaDialog +import eu.kanade.presentation.components.ChangeCategoryDialog +import eu.kanade.presentation.components.DuplicateMangaDialog +import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter +import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.manga.MangaController +import eu.kanade.tachiyomi.util.lang.launchIO /** * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController]. @@ -21,9 +29,63 @@ class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) { return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY)) } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.action_search).isVisible = false + @Composable + override fun ComposeContent() { + val scope = rememberCoroutineScope() + + BrowseLatestScreen( + presenter = presenter, + navigateUp = { router.popCurrentController() }, + onMangaClick = { router.pushController(MangaController(it.id, true)) }, + onMangaLongClick = { manga -> + scope.launchIO { + val duplicateManga = presenter.getDuplicateLibraryManga(manga) + when { + manga.favorite -> presenter.dialog = BrowseSourcePresenter.Dialog.RemoveManga(manga) + duplicateManga != null -> presenter.dialog = BrowseSourcePresenter.Dialog.AddDuplicateManga(manga, duplicateManga) + else -> presenter.addFavorite(manga) + } + } + }, + ) + + val onDismissRequest = { presenter.dialog = null } + when (val dialog = presenter.dialog) { + is BrowseSourcePresenter.Dialog.AddDuplicateManga -> { + DuplicateMangaDialog( + onDismissRequest = onDismissRequest, + onOpenManga = { + router.pushController(MangaController(dialog.duplicate.id, true)) + }, + onConfirm = { + presenter.addFavorite(dialog.manga) + }, + duplicateFrom = presenter.getSourceOrStub(dialog.manga), + ) + } + is BrowseSourcePresenter.Dialog.RemoveManga -> { + RemoveMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { + presenter.changeMangaFavorite(dialog.manga) + }, + ) + } + is BrowseSourcePresenter.Dialog.ChangeMangaCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { + router.pushController(CategoryController()) + }, + onConfirm = { include, _ -> + presenter.changeMangaFavorite(dialog.manga) + presenter.moveMangaToCategories(dialog.manga, include) + }, + ) + } + null -> {} + } } override fun initFilterSheet() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt deleted file mode 100644 index fa07a34626..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.source.latest - -import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.ui.browse.source.browse.Pager -import eu.kanade.tachiyomi.util.lang.awaitSingle - -class LatestUpdatesPager(val source: CatalogueSource) : Pager() { - - override suspend fun requestNextPage() { - val mangasPage = source.fetchLatestUpdates(currentPage).awaitSingle() - onPageReceived(mangasPage) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt index 968d3635d7..b5626a3aa5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt @@ -1,12 +1,13 @@ package eu.kanade.tachiyomi.ui.browse.source.latest +import androidx.paging.PagingSource import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.ui.browse.source.browse.Pager class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) { - override fun createPager(query: String, filters: FilterList): Pager { - return LatestUpdatesPager(source) + override fun createPager(query: String, filters: FilterList): PagingSource { + return LatestUpdatesBrowsePagingSource(source!!) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt deleted file mode 100644 index 682f6643bd..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt +++ /dev/null @@ -1,81 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.domain.category.model.Category -import eu.kanade.domain.manga.model.Manga -import eu.kanade.presentation.category.visualName -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import eu.kanade.tachiyomi.ui.category.CategoryController -import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView -import eu.kanade.tachiyomi.widget.materialdialogs.setQuadStateMultiChoiceItems - -class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { - - private var mangas = emptyList() - private var categories = emptyList() - private var preselected = emptyArray() - private var selected = emptyArray().toIntArray() - - constructor( - target: T, - mangas: List, - categories: List, - preselected: Array, - ) : this() { - this.mangas = mangas - this.categories = categories - this.preselected = preselected - this.selected = preselected.toIntArray() - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(activity!!) - .setTitle(R.string.action_move_category) - .setNegativeButton(android.R.string.cancel, null) - .apply { - if (categories.isNotEmpty()) { - setQuadStateMultiChoiceItems( - items = categories.map { it.visualName(context) }, - isActionList = false, - initialSelected = preselected.toIntArray(), - ) { selections -> - selected = selections - } - setPositiveButton(android.R.string.ok) { _, _ -> - val add = selected - .mapIndexed { index, value -> if (value == QuadStateTextView.State.CHECKED.ordinal) categories[index] else null } - .filterNotNull() - val remove = selected - .mapIndexed { index, value -> if (value == QuadStateTextView.State.UNCHECKED.ordinal) categories[index] else null } - .filterNotNull() - (targetController as? Listener)?.updateCategoriesForMangas(mangas, add, remove) - } - setNeutralButton(R.string.action_edit) { _, _ -> openCategoryController() } - } else { - setMessage(R.string.information_empty_category_dialog) - setPositiveButton(R.string.action_edit_categories) { _, _ -> openCategoryController() } - } - } - .create() - } - - private fun openCategoryController() { - if (targetController is LibraryController) { - val libController = targetController as LibraryController - libController.clearSelection() - } - router.popCurrentController() - router.pushController(CategoryController()) - } - - interface Listener { - fun updateCategoriesForMangas(mangas: List, addCategories: List, removeCategories: List = emptyList()) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt deleted file mode 100644 index e3d4ec0bd9..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt +++ /dev/null @@ -1,48 +0,0 @@ -package eu.kanade.tachiyomi.ui.manga - -import android.app.Dialog -import android.os.Bundle -import com.bluelinelabs.conductor.Controller -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import eu.kanade.domain.manga.model.Manga -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.base.controller.pushController -import uy.kohesive.injekt.injectLazy - -class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle) { - - private val sourceManager: SourceManager by injectLazy() - - private lateinit var libraryManga: Manga - private lateinit var onAddToLibrary: () -> Unit - - constructor( - target: Controller, - libraryManga: Manga, - onAddToLibrary: () -> Unit, - ) : this() { - targetController = target - - this.libraryManga = libraryManga - this.onAddToLibrary = onAddToLibrary - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val source = sourceManager.getOrStub(libraryManga.source) - - return MaterialAlertDialogBuilder(activity!!) - .setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name)) - .setPositiveButton(activity?.getString(R.string.action_add)) { _, _ -> - onAddToLibrary() - } - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ -> - dismissDialog() - router.pushController(MangaController(libraryManga.id)) - } - .setCancelable(true) - .create() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index cdfd2c54b1..f025be34bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -22,12 +22,12 @@ import eu.kanade.data.chapter.NoChaptersException import eu.kanade.domain.manga.model.toDbManga import eu.kanade.presentation.components.ChangeCategoryDialog import eu.kanade.presentation.components.ChapterDownloadAction +import eu.kanade.presentation.components.DuplicateMangaDialog import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog -import eu.kanade.presentation.manga.components.DuplicateMangaDialog import eu.kanade.presentation.util.calculateWindowWidthSizeClass import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.DownloadService diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt index 03a2b202ca..4fdb2a34d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt @@ -51,6 +51,12 @@ fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int { return coverCache.deleteFromCache(this, true) } +fun DomainManga.removeCovers(coverCache: CoverCache = Injekt.get()): DomainManga { + if (isLocal()) return this + coverCache.deleteFromCache(this, true) + return copy(coverLastModified = Date().time) +} + fun DomainManga.shouldDownloadNewChapters(dbCategories: List, preferences: PreferencesHelper): Boolean { if (!favorite) return false diff --git a/app/src/main/res/color/source_comfortable_item_title.xml b/app/src/main/res/color/source_comfortable_item_title.xml deleted file mode 100644 index c643506823..0000000000 --- a/app/src/main/res/color/source_comfortable_item_title.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/source_comfortable_grid_item.xml b/app/src/main/res/layout/source_comfortable_grid_item.xml deleted file mode 100644 index b9c3646191..0000000000 --- a/app/src/main/res/layout/source_comfortable_grid_item.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/source_compact_grid_item.xml b/app/src/main/res/layout/source_compact_grid_item.xml deleted file mode 100644 index 9c2851dc50..0000000000 --- a/app/src/main/res/layout/source_compact_grid_item.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/source_controller.xml b/app/src/main/res/layout/source_controller.xml deleted file mode 100644 index 1338a925e5..0000000000 --- a/app/src/main/res/layout/source_controller.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/source_grid_item_badges.xml b/app/src/main/res/layout/source_grid_item_badges.xml deleted file mode 100644 index 622abb77d2..0000000000 --- a/app/src/main/res/layout/source_grid_item_badges.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/source_list_item.xml b/app/src/main/res/layout/source_list_item.xml deleted file mode 100644 index 03221dc017..0000000000 --- a/app/src/main/res/layout/source_list_item.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/source_progress_item.xml b/app/src/main/res/layout/source_progress_item.xml deleted file mode 100644 index 5a2988e280..0000000000 --- a/app/src/main/res/layout/source_progress_item.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/source_recycler_autofit.xml b/app/src/main/res/layout/source_recycler_autofit.xml deleted file mode 100644 index a3198c0b52..0000000000 --- a/app/src/main/res/layout/source_recycler_autofit.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/main/res/menu/source_browse.xml b/app/src/main/res/menu/source_browse.xml deleted file mode 100644 index ec8e115b62..0000000000 --- a/app/src/main/res/menu/source_browse.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1112d9448..bdc6556080 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -870,4 +870,5 @@ See your recently updated manga Widget not available when app lock is enabled + You are about to remove this manga from your library