mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Use Compose on BrowseSourceScreens (#7901)
This commit is contained in:
		| @@ -27,6 +27,10 @@ class MangaRepositoryImpl( | ||||
|         return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) } | ||||
|     } | ||||
|  | ||||
|     override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> { | ||||
|         return handler.subscribeToOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) } | ||||
|     } | ||||
|  | ||||
|     override suspend fun getFavorites(): List<Manga> { | ||||
|         return handler.awaitList { mangasQueries.getFavorites(mangaMapper) } | ||||
|     } | ||||
|   | ||||
| @@ -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<Manga?> { | ||||
|         return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,8 @@ interface MangaRepository { | ||||
|  | ||||
|     suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga? | ||||
|  | ||||
|     fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> | ||||
|  | ||||
|     suspend fun getFavorites(): List<Manga> | ||||
|  | ||||
|     suspend fun getLibraryManga(): List<LibraryManga> | ||||
|   | ||||
| @@ -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, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     getMangaState: @Composable ((Manga) -> State<Manga>), | ||||
|     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, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<IFlexible<*>> | ||||
|     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<IFlexible<*>> by derivedStateOf { filters.toItems() } | ||||
|     override var appliedFilters by mutableStateOf(FilterList()) | ||||
|     override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null) | ||||
| } | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
| @@ -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 = "", | ||||
|                             ) | ||||
|                         } | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     getMangaState: @Composable ((Manga) -> State<Manga>), | ||||
|     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, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     getMangaState: @Composable ((Manga) -> State<Manga>), | ||||
|     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) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -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)) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -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<Manga>, | ||||
|     getMangaState: @Composable ((Manga) -> State<Manga>), | ||||
|     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) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
| @@ -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), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|             ), | ||||
|         ), | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|      * | ||||
|   | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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<Long, SManga>() { | ||||
|  | ||||
|     abstract suspend fun requestNextPage(currentPage: Int): MangasPage | ||||
|  | ||||
|     override suspend fun load(params: LoadParams<Long>): LoadResult<Long, SManga> { | ||||
|         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, SManga>): Long? { | ||||
|         return state.anchorPosition?.let { anchorPosition -> | ||||
|             val anchorPage = state.closestPageToPosition(anchorPosition) | ||||
|             anchorPage?.prevKey ?: anchorPage?.nextKey | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<SourceControllerBinding, BrowseSourcePresenter>(bundle), | ||||
|     FabController, | ||||
|     FlexibleAdapter.OnItemClickListener, | ||||
|     FlexibleAdapter.OnItemLongClickListener, | ||||
|     FlexibleAdapter.EndlessScrollListener, | ||||
|     ChangeMangaCategoriesDialog.Listener { | ||||
|     FullComposeController<BrowseSourcePresenter>(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<IFlexible<*>>? = 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<SourceItem>) { | ||||
|         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<Int> { | ||||
|         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<Manga>, addCategories: List<Category>, removeCategories: List<Category>) { | ||||
|         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" | ||||
|   | ||||
| @@ -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<BrowseSourceController>() { | ||||
| ) : BasePresenter<BrowseSourceController>(), 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<GridCells> { | ||||
|         val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE | ||||
|         return produceState<GridCells>(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<IFlexible<*>> = emptyList() | ||||
|     @Composable | ||||
|     fun getMangaList(): Flow<PagingData<DomainManga>> { | ||||
|         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<DomainManga> { | ||||
|         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<TrackManager>().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<DomainManga>) { | ||||
|         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<EnhancedTrackService>() | ||||
|                 .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<EnhancedTrackService>() | ||||
|             .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<IFlexible<*>> { | ||||
|         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<Long, SManga> { | ||||
|         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<Long?> { | ||||
|         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<DomainCategory>) { | ||||
|     fun moveMangaToCategories(manga: DomainManga, vararg categories: DomainCategory) { | ||||
|         moveMangaToCategories(manga, categories.filter { it.id != 0L }.map { it.id }) | ||||
|     } | ||||
|  | ||||
|     fun moveMangaToCategories(manga: DomainManga, categoryIds: List<Long>) { | ||||
|         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<DomainCategory>) { | ||||
|         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<CheckboxState.State<DomainCategory>>, | ||||
|         ) : Dialog() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun FilterList.toItems(): List<IFlexible<*>> { | ||||
|     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 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<Pair<Int, List<SManga>>> = PublishRelay.create() | ||||
|  | ||||
|     fun asFlow(): Flow<Pair<Int, List<SManga>>> { | ||||
|         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)) | ||||
|     } | ||||
| } | ||||
| @@ -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<ProgressItem.Holder>() { | ||||
|  | ||||
|     private var loadMore = true | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.source_progress_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder { | ||||
|         return Holder(view, adapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: Holder, position: Int, payloads: List<Any?>) { | ||||
|         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) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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<SourceComfortableGridItemBinding>(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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<SourceCompactGridItemBinding>(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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<VB : ViewBinding>(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) | ||||
| } | ||||
| @@ -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<LibraryDisplayMode>) : | ||||
|     AbstractFlexibleItem<SourceHolder<*>>() { | ||||
|  | ||||
|     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<IFlexible<RecyclerView.ViewHolder>>, | ||||
|     ): 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<IFlexible<RecyclerView.ViewHolder>>, | ||||
|         holder: SourceHolder<*>, | ||||
|         position: Int, | ||||
|         payloads: List<Any?>?, | ||||
|     ) { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
| @@ -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<SourceListItemBinding>(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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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<Long, SManga> { | ||||
|         return LatestUpdatesBrowsePagingSource(source!!) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<T>(bundle: Bundle? = null) : | ||||
|     DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { | ||||
|  | ||||
|     private var mangas = emptyList<Manga>() | ||||
|     private var categories = emptyList<Category>() | ||||
|     private var preselected = emptyArray<Int>() | ||||
|     private var selected = emptyArray<Int>().toIntArray() | ||||
|  | ||||
|     constructor( | ||||
|         target: T, | ||||
|         mangas: List<Manga>, | ||||
|         categories: List<Category>, | ||||
|         preselected: Array<Int>, | ||||
|     ) : 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<Manga>, addCategories: List<Category>, removeCategories: List<Category> = emptyList<Category>()) | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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<Long>, preferences: PreferencesHelper): Boolean { | ||||
|     if (!favorite) return false | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user