Browse Source

Use Compose on BrowseSourceScreens (#7901)

Andreas 2 years ago
parent
commit
d4b764fa31
51 changed files with 1749 additions and 2013 deletions
  1. 4 0
      app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt
  2. 4 0
      app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt
  3. 2 0
      app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt
  4. 67 0
      app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt
  5. 210 0
      app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt
  6. 37 0
      app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt
  7. 22 0
      app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt
  8. 105 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt
  9. 106 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt
  10. 129 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt
  11. 41 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt
  12. 94 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt
  13. 25 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt
  14. 206 0
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt
  15. 1 1
      app/src/main/java/eu/kanade/presentation/components/DuplicateMangaDialog.kt
  16. 14 5
      app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt
  17. 23 15
      app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt
  18. 62 39
      app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt
  19. 99 50
      app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt
  20. 15 0
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt
  21. 27 21
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt
  22. 37 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt
  23. 84 599
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt
  24. 223 267
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt
  25. 0 31
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt
  26. 0 54
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt
  27. 20 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt
  28. 0 53
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt
  29. 0 53
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt
  30. 0 35
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt
  31. 0 63
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt
  32. 0 60
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt
  33. 0 26
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt
  34. 13 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt
  35. 67 5
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt
  36. 0 13
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt
  37. 4 3
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt
  38. 0 81
      app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt
  39. 0 48
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt
  40. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  41. 6 0
      app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt
  42. 0 6
      app/src/main/res/color/source_comfortable_item_title.xml
  43. 0 50
      app/src/main/res/layout/source_comfortable_grid_item.xml
  44. 0 58
      app/src/main/res/layout/source_compact_grid_item.xml
  45. 0 38
      app/src/main/res/layout/source_controller.xml
  46. 0 116
      app/src/main/res/layout/source_grid_item_badges.xml
  47. 0 138
      app/src/main/res/layout/source_list_item.xml
  48. 0 27
      app/src/main/res/layout/source_progress_item.xml
  49. 0 11
      app/src/main/res/layout/source_recycler_autofit.xml
  50. 0 46
      app/src/main/res/menu/source_browse.xml
  51. 1 0
      app/src/main/res/values/strings.xml

+ 4 - 0
app/src/main/java/eu/kanade/data/manga/MangaRepositoryImpl.kt

@@ -27,6 +27,10 @@ class MangaRepositoryImpl(
         return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
     }
 
+    override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?> {
+        return handler.subscribeToOneOrNull { mangasQueries.getMangaByUrlAndSource(url, sourceId, mangaMapper) }
+    }
+
     override suspend fun getFavorites(): List<Manga> {
         return handler.awaitList { mangasQueries.getFavorites(mangaMapper) }
     }

+ 4 - 0
app/src/main/java/eu/kanade/domain/manga/interactor/GetManga.kt

@@ -26,4 +26,8 @@ class GetManga(
     suspend fun await(url: String, sourceId: Long): Manga? {
         return mangaRepository.getMangaByUrlAndSourceId(url, sourceId)
     }
+
+    fun subscribe(url: String, sourceId: Long): Flow<Manga?> {
+        return mangaRepository.getMangaByUrlAndSourceIdAsFlow(url, sourceId)
+    }
 }

+ 2 - 0
app/src/main/java/eu/kanade/domain/manga/repository/MangaRepository.kt

@@ -13,6 +13,8 @@ interface MangaRepository {
 
     suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga?
 
+    fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Manga?>
+
     suspend fun getFavorites(): List<Manga>
 
     suspend fun getLibraryManga(): List<LibraryManga>

+ 67 - 0
app/src/main/java/eu/kanade/presentation/browse/BrowseLatestScreen.kt

@@ -0,0 +1,67 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.paging.compose.collectAsLazyPagingItems
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.components.BrowseLatestToolbar
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+import eu.kanade.tachiyomi.ui.more.MoreController
+import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+
+@Composable
+fun BrowseLatestScreen(
+    presenter: BrowseSourcePresenter,
+    navigateUp: () -> Unit,
+    onMangaClick: (Manga) -> Unit,
+    onMangaLongClick: (Manga) -> Unit,
+) {
+    val columns by presenter.getColumnsPreferenceForCurrentOrientation()
+    val context = LocalContext.current
+    val uriHandler = LocalUriHandler.current
+
+    val onHelpClick = {
+        uriHandler.openUri(LocalSource.HELP_URL)
+    }
+
+    val onWebViewClick = f@{
+        val source = presenter.source as? HttpSource ?: return@f
+        val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
+        context.startActivity(intent)
+    }
+
+    Scaffold(
+        topBar = {
+            BrowseLatestToolbar(
+                navigateUp = navigateUp,
+                source = presenter.source!!,
+                displayMode = presenter.displayMode,
+                onDisplayModeChange = { presenter.displayMode = it },
+                onHelpClick = onHelpClick,
+                onWebViewClick = onWebViewClick,
+            )
+        },
+    ) { paddingValues ->
+        BrowseSourceContent(
+            source = presenter.source,
+            mangaList = presenter.getMangaList().collectAsLazyPagingItems(),
+            getMangaState = { presenter.getManga(it) },
+            columns = columns,
+            displayMode = presenter.displayMode,
+            snackbarHostState = remember { SnackbarHostState() },
+            contentPadding = paddingValues,
+            onWebViewClick = onWebViewClick,
+            onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
+            onLocalSourceHelpClick = onHelpClick,
+            onMangaClick = onMangaClick,
+            onMangaLongClick = onMangaLongClick,
+        )
+    }
+}

+ 210 - 0
app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt

@@ -0,0 +1,210 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material3.Icon
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
+import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
+import eu.kanade.presentation.browse.components.BrowseSourceList
+import eu.kanade.presentation.browse.components.BrowseSourceToolbar
+import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.ExtendedFloatingActionButton
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException
+import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
+import eu.kanade.tachiyomi.ui.more.MoreController
+import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+import eu.kanade.tachiyomi.widget.EmptyView
+
+@Composable
+fun BrowseSourceScreen(
+    presenter: BrowseSourcePresenter,
+    navigateUp: () -> Unit,
+    onDisplayModeChange: (LibraryDisplayMode) -> Unit,
+    onFabClick: () -> Unit,
+    onMangaClick: (Manga) -> Unit,
+    onMangaLongClick: (Manga) -> Unit,
+) {
+    val columns by presenter.getColumnsPreferenceForCurrentOrientation()
+
+    val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
+
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    val context = LocalContext.current
+    val uriHandler = LocalUriHandler.current
+
+    val onHelpClick = {
+        uriHandler.openUri(LocalSource.HELP_URL)
+    }
+
+    val onWebViewClick = f@{
+        val source = presenter.source as? HttpSource ?: return@f
+        val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
+        context.startActivity(intent)
+    }
+
+    Scaffold(
+        topBar = {
+            BrowseSourceToolbar(
+                state = presenter,
+                source = presenter.source!!,
+                displayMode = presenter.displayMode,
+                onDisplayModeChange = onDisplayModeChange,
+                navigateUp = navigateUp,
+                onWebViewClick = onWebViewClick,
+                onHelpClick = onHelpClick,
+                onSearch = { presenter.search() },
+            )
+        },
+        floatingActionButton = {
+            if (presenter.filters.isNotEmpty()) {
+                ExtendedFloatingActionButton(
+                    modifier = Modifier.navigationBarsPadding(),
+                    text = { Text(text = stringResource(id = R.string.action_filter)) },
+                    icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
+                    onClick = onFabClick,
+                )
+            }
+        },
+        snackbarHost = {
+            SnackbarHost(hostState = snackbarHostState)
+        },
+    ) { paddingValues ->
+        BrowseSourceContent(
+            source = presenter.source,
+            mangaList = mangaList,
+            getMangaState = { presenter.getManga(it) },
+            columns = columns,
+            displayMode = presenter.displayMode,
+            snackbarHostState = snackbarHostState,
+            contentPadding = paddingValues,
+            onWebViewClick = onWebViewClick,
+            onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
+            onLocalSourceHelpClick = onHelpClick,
+            onMangaClick = onMangaClick,
+            onMangaLongClick = onMangaLongClick,
+        )
+    }
+}
+
+@Composable
+fun BrowseSourceContent(
+    source: CatalogueSource?,
+    mangaList: LazyPagingItems<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,
+            )
+        }
+    }
+}

+ 37 - 0
app/src/main/java/eu/kanade/presentation/browse/BrowseSourceState.kt

@@ -0,0 +1,37 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
+
+@Stable
+interface BrowseSourceState {
+    val source: CatalogueSource?
+    var searchQuery: String?
+    val currentQuery: String
+    val filters: FilterList
+    val filterItems: List<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)
+}

+ 22 - 0
app/src/main/java/eu/kanade/presentation/browse/SourceSearchScreen.kt

@@ -0,0 +1,22 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.runtime.Composable
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+
+@Composable
+fun SourceSearchScreen(
+    presenter: BrowseSourcePresenter,
+    navigateUp: () -> Unit,
+    onFabClick: () -> Unit,
+    onClickManga: (Manga) -> Unit,
+) {
+    BrowseSourceScreen(
+        presenter = presenter,
+        navigateUp = navigateUp,
+        onDisplayModeChange = { presenter.displayMode = (it) },
+        onFabClick = onFabClick,
+        onMangaClick = onClickManga,
+        onMangaLongClick = onClickManga,
+    )
+}

+ 105 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseLatestToolbar.kt

@@ -0,0 +1,105 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ViewModule
+import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.Help
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.material.icons.outlined.ViewModule
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import eu.kanade.presentation.components.AppBar
+import eu.kanade.presentation.components.AppBarActions
+import eu.kanade.presentation.components.DropdownMenu
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
+
+@Composable
+fun BrowseLatestToolbar(
+    navigateUp: () -> Unit,
+    source: CatalogueSource,
+    displayMode: LibraryDisplayMode,
+    onDisplayModeChange: (LibraryDisplayMode) -> Unit,
+    onHelpClick: () -> Unit,
+    onWebViewClick: () -> Unit,
+) {
+    AppBar(
+        navigateUp = navigateUp,
+        title = source.name,
+        actions = {
+            var selectingDisplayMode by remember { mutableStateOf(false) }
+            AppBarActions(
+                actions = listOf(
+                    AppBar.Action(
+                        title = "display_mode",
+                        icon = Icons.Filled.ViewModule,
+                        onClick = { selectingDisplayMode = true },
+                    ),
+                    if (source is LocalSource) {
+                        AppBar.Action(
+                            title = "help",
+                            icon = Icons.Outlined.Help,
+                            onClick = onHelpClick,
+                        )
+                    } else {
+                        AppBar.Action(
+                            title = "webview",
+                            icon = Icons.Outlined.Public,
+                            onClick = onWebViewClick,
+                        )
+                    },
+                ),
+            )
+            DropdownMenu(
+                expanded = selectingDisplayMode,
+                onDismissRequest = { selectingDisplayMode = false },
+            ) {
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.ComfortableGrid) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_grid)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.CompactGrid) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_list)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.List) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+            }
+        },
+    )
+}

+ 106 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt

@@ -0,0 +1,106 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.library.components.MangaGridComfortableText
+import eu.kanade.presentation.library.components.MangaGridCover
+import eu.kanade.presentation.util.plus
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun BrowseSourceComfortableGrid(
+    mangaList: LazyPagingItems<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,
+        )
+    }
+}

+ 129 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt

@@ -0,0 +1,129 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.library.components.MangaGridCompactText
+import eu.kanade.presentation.library.components.MangaGridCover
+import eu.kanade.presentation.util.plus
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun BrowseSourceCompactGrid(
+    mangaList: LazyPagingItems<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)
+        },
+    )
+}

+ 41 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceDialogs.kt

@@ -0,0 +1,41 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.material.TextButton
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun RemoveMangaDialog(
+    onDismissRequest: () -> Unit,
+    onConfirm: () -> Unit,
+) {
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        dismissButton = {
+            TextButton(onClick = onDismissRequest) {
+                Text(text = stringResource(id = android.R.string.cancel))
+            }
+        },
+        confirmButton = {
+            TextButton(
+                onClick = {
+                    onDismissRequest()
+                    onConfirm()
+                },
+            ) {
+                Text(text = stringResource(id = R.string.action_remove))
+            }
+        },
+        title = {
+            Text(text = stringResource(id = R.string.are_you_sure))
+        },
+        text = {
+            Text(text = stringResource(R.string.remove_manga))
+        },
+    )
+}

+ 94 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt

@@ -0,0 +1,94 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.res.stringResource
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.items
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.components.Badge
+import eu.kanade.presentation.components.LazyColumn
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.library.components.MangaListItem
+import eu.kanade.presentation.library.components.MangaListItemContent
+import eu.kanade.presentation.util.plus
+import eu.kanade.presentation.util.verticalPadding
+import eu.kanade.tachiyomi.R
+
+@Composable
+fun BrowseSourceList(
+    mangaList: LazyPagingItems<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)
+        },
+    )
+}

+ 25 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceLoadingItem.kt

@@ -0,0 +1,25 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun BrowseSourceLoadingItem() {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 16.dp),
+        horizontalArrangement = Arrangement.Center,
+    ) {
+        CircularProgressIndicator(
+            modifier = Modifier.size(64.dp),
+        )
+    }
+}

+ 206 - 0
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt

@@ -0,0 +1,206 @@
+package eu.kanade.presentation.browse.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ViewModule
+import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material.icons.outlined.Help
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import eu.kanade.presentation.browse.BrowseSourceState
+import eu.kanade.presentation.components.AppBar
+import eu.kanade.presentation.components.AppBarActions
+import eu.kanade.presentation.components.DropdownMenu
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
+import kotlinx.coroutines.delay
+
+@Composable
+fun BrowseSourceToolbar(
+    state: BrowseSourceState,
+    source: CatalogueSource,
+    displayMode: LibraryDisplayMode,
+    onDisplayModeChange: (LibraryDisplayMode) -> Unit,
+    navigateUp: () -> Unit,
+    onWebViewClick: () -> Unit,
+    onHelpClick: () -> Unit,
+    onSearch: () -> Unit,
+) {
+    if (state.searchQuery == null) {
+        BrowseSourceRegularToolbar(
+            source = source,
+            displayMode = displayMode,
+            onDisplayModeChange = onDisplayModeChange,
+            navigateUp = navigateUp,
+            onSearchClick = { state.searchQuery = "" },
+            onWebViewClick = onWebViewClick,
+            onHelpClick = onHelpClick,
+        )
+    } else {
+        BrowseSourceSearchToolbar(
+            searchQuery = state.searchQuery!!,
+            onSearchQueryChanged = { state.searchQuery = it },
+            navigateUp = {
+                state.searchQuery = null
+                onSearch()
+            },
+            onResetClick = { state.searchQuery = "" },
+            onSearchClick = onSearch,
+        )
+    }
+}
+
+@Composable
+fun BrowseSourceRegularToolbar(
+    source: CatalogueSource,
+    displayMode: LibraryDisplayMode,
+    onDisplayModeChange: (LibraryDisplayMode) -> Unit,
+    navigateUp: () -> Unit,
+    onSearchClick: () -> Unit,
+    onWebViewClick: () -> Unit,
+    onHelpClick: () -> Unit,
+) {
+    AppBar(
+        navigateUp = navigateUp,
+        title = source.name,
+        actions = {
+            var selectingDisplayMode by remember { mutableStateOf(false) }
+            AppBarActions(
+                actions = listOf(
+                    AppBar.Action(
+                        title = "search",
+                        icon = Icons.Outlined.Search,
+                        onClick = onSearchClick,
+                    ),
+                    AppBar.Action(
+                        title = "display_mode",
+                        icon = Icons.Filled.ViewModule,
+                        onClick = { selectingDisplayMode = true },
+                    ),
+                    if (source is LocalSource) {
+                        AppBar.Action(
+                            title = "help",
+                            icon = Icons.Outlined.Help,
+                            onClick = onHelpClick,
+                        )
+                    } else {
+                        AppBar.Action(
+                            title = "webview",
+                            icon = Icons.Outlined.Public,
+                            onClick = onWebViewClick,
+                        )
+                    },
+                ),
+            )
+            DropdownMenu(
+                expanded = selectingDisplayMode,
+                onDismissRequest = { selectingDisplayMode = false },
+            ) {
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_comfortable_grid)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.ComfortableGrid) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.ComfortableGrid) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_grid)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.CompactGrid) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.CompactGrid) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+                DropdownMenuItem(
+                    text = { Text(text = stringResource(id = R.string.action_display_list)) },
+                    onClick = { onDisplayModeChange(LibraryDisplayMode.List) },
+                    trailingIcon = {
+                        if (displayMode == LibraryDisplayMode.List) {
+                            Icon(
+                                imageVector = Icons.Outlined.Check,
+                                contentDescription = "",
+                            )
+                        }
+                    },
+                )
+            }
+        },
+    )
+}
+
+@Composable
+fun BrowseSourceSearchToolbar(
+    searchQuery: String,
+    onSearchQueryChanged: (String) -> Unit,
+    navigateUp: () -> Unit,
+    onResetClick: () -> Unit,
+    onSearchClick: () -> Unit,
+) {
+    val focusRequester = remember { FocusRequester() }
+    AppBar(
+        navigateUp = navigateUp,
+        titleContent = {
+            BasicTextField(
+                value = searchQuery,
+                onValueChange = onSearchQueryChanged,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .focusRequester(focusRequester),
+                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+                keyboardActions = KeyboardActions(
+                    onSearch = {
+                        onSearchClick()
+                    },
+                ),
+                cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
+            )
+        },
+        actions = {
+            AppBarActions(
+                actions = listOf(
+                    AppBar.Action(
+                        title = "clear",
+                        icon = Icons.Outlined.Clear,
+                        onClick = onResetClick,
+                    ),
+                ),
+            )
+        },
+    )
+    LaunchedEffect(Unit) {
+        // TODO: https://issuetracker.google.com/issues/204502668
+        delay(100)
+        focusRequester.requestFocus()
+    }
+}

+ 1 - 1
app/src/main/java/eu/kanade/presentation/manga/components/DuplicateMangaDialog.kt → app/src/main/java/eu/kanade/presentation/components/DuplicateMangaDialog.kt

@@ -1,4 +1,4 @@
-package eu.kanade.presentation.manga.components
+package eu.kanade.presentation.components
 
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer

+ 14 - 5
app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt

@@ -78,13 +78,22 @@ fun LibraryComfortableGridItem(
                 isLocal = item.isLocal,
                 language = item.sourceLanguage,
             )
-            Text(
-                modifier = Modifier.padding(4.dp),
+            MangaGridComfortableText(
                 text = manga.title,
-                fontSize = 12.sp,
-                maxLines = 2,
-                style = MaterialTheme.typography.titleSmall,
             )
         }
     }
 }
+
+@Composable
+fun MangaGridComfortableText(
+    text: String,
+) {
+    Text(
+        modifier = Modifier.padding(4.dp),
+        text = text,
+        fontSize = 12.sp,
+        maxLines = 2,
+        style = MaterialTheme.typography.titleSmall,
+    )
+}

+ 23 - 15
app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt

@@ -3,6 +3,7 @@ package eu.kanade.presentation.library.components
 import androidx.compose.foundation.background
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -97,20 +98,27 @@ fun LibraryCompactGridItem(
                 .fillMaxWidth()
                 .align(Alignment.BottomCenter),
         )
-        Text(
-            text = manga.title,
-            modifier = Modifier
-                .padding(8.dp)
-                .align(Alignment.BottomStart),
-            color = Color.White,
-            fontSize = 12.sp,
-            maxLines = 2,
-            style = MaterialTheme.typography.titleSmall.copy(
-                shadow = Shadow(
-                    color = Color.Black,
-                    blurRadius = 4f,
-                ),
-            ),
-        )
+        MangaGridCompactText(manga.title)
     }
 }
+
+@Composable
+fun BoxScope.MangaGridCompactText(
+    text: String,
+) {
+    Text(
+        text = text,
+        modifier = Modifier
+            .padding(8.dp)
+            .align(Alignment.BottomStart),
+        color = Color.White,
+        fontSize = 12.sp,
+        maxLines = 2,
+        style = MaterialTheme.typography.titleSmall.copy(
+            shadow = Shadow(
+                color = Color.Black,
+                blurRadius = 4f,
+            ),
+        ),
+    )
+}

+ 62 - 39
app/src/main/java/eu/kanade/presentation/library/components/LibraryGridCover.kt

@@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
 
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -17,13 +18,11 @@ import eu.kanade.presentation.components.MangaCover
 import eu.kanade.tachiyomi.R
 
 @Composable
-fun LibraryGridCover(
+fun MangaGridCover(
     modifier: Modifier = Modifier,
-    mangaCover: eu.kanade.domain.manga.model.MangaCover,
-    downloadCount: Int,
-    unreadCount: Int,
-    isLocal: Boolean,
-    language: String,
+    cover: @Composable BoxScope.() -> Unit = {},
+    badgesStart: (@Composable RowScope.() -> Unit)? = null,
+    badgesEnd: (@Composable RowScope.() -> Unit)? = null,
     content: @Composable BoxScope.() -> Unit = {},
 ) {
     Box(
@@ -31,49 +30,73 @@ fun LibraryGridCover(
             .fillMaxWidth()
             .aspectRatio(MangaCover.Book.ratio),
     ) {
-        MangaCover.Book(
-            modifier = Modifier.fillMaxWidth(),
-            data = mangaCover,
-        )
+        cover()
         content()
-        if (downloadCount > 0 || unreadCount > 0) {
+        if (badgesStart != null) {
             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")
-                }
-            }
+                content = badgesStart,
+            )
         }
-        if (isLocal || language.isNotEmpty()) {
+
+        if (badgesEnd != null) {
             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,
-                    )
-                }
-            }
+                content = badgesEnd,
+            )
         }
     }
 }
+
+@Composable
+fun LibraryGridCover(
+    modifier: Modifier = Modifier,
+    mangaCover: eu.kanade.domain.manga.model.MangaCover,
+    downloadCount: Int,
+    unreadCount: Int,
+    isLocal: Boolean,
+    language: String,
+    content: @Composable BoxScope.() -> Unit = {},
+) {
+    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 (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,
+    )
+}

+ 99 - 50
app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt

@@ -2,6 +2,7 @@ package eu.kanade.presentation.library.components
 
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
@@ -19,6 +20,7 @@ import eu.kanade.domain.manga.model.MangaCover
 import eu.kanade.presentation.components.Badge
 import eu.kanade.presentation.components.BadgeGroup
 import eu.kanade.presentation.components.FastScrollLazyColumn
+import eu.kanade.presentation.components.MangaCover.Square
 import eu.kanade.presentation.components.TextButton
 import eu.kanade.presentation.util.bottomNavPaddingValues
 import eu.kanade.presentation.util.horizontalPadding
@@ -74,62 +76,109 @@ fun LibraryListItem(
     onLongClick: (LibraryManga) -> Unit,
 ) {
     val manga = item.manga
+    MangaListItem(
+        modifier = Modifier.selectedBackground(isSelected),
+        title = manga.title,
+        cover = MangaCover(
+            manga.id!!,
+            manga.source,
+            manga.favorite,
+            manga.thumbnail_url,
+            manga.cover_last_modified,
+        ),
+        onClick = { onClick(manga) },
+        onLongClick = { onLongClick(manga) },
+    ) {
+        if (item.downloadCount > 0) {
+            Badge(
+                text = "${item.downloadCount}",
+                color = MaterialTheme.colorScheme.tertiary,
+                textColor = MaterialTheme.colorScheme.onTertiary,
+            )
+        }
+        if (item.unreadCount > 0) {
+            Badge(text = "${item.unreadCount}")
+        }
+        if (item.isLocal) {
+            Badge(
+                text = stringResource(R.string.local_source_badge),
+                color = MaterialTheme.colorScheme.tertiary,
+                textColor = MaterialTheme.colorScheme.onTertiary,
+            )
+        }
+        if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) {
+            Badge(
+                text = item.sourceLanguage,
+                color = MaterialTheme.colorScheme.tertiary,
+                textColor = MaterialTheme.colorScheme.onTertiary,
+            )
+        }
+    }
+}
+
+@Composable
+fun MangaListItem(
+    modifier: Modifier = Modifier,
+    title: String,
+    cover: MangaCover,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit = onClick,
+    badges: @Composable RowScope.() -> Unit,
+) {
+    MangaListItem(
+        modifier = modifier,
+        coverContent = {
+            Square(
+                modifier = Modifier
+                    .padding(vertical = verticalPadding)
+                    .fillMaxHeight(),
+                data = cover,
+            )
+        },
+        badges = badges,
+        onClick = onClick,
+        onLongClick = onLongClick,
+        content = {
+            MangaListItemContent(title)
+        },
+    )
+}
+
+@Composable
+fun MangaListItem(
+    modifier: Modifier = Modifier,
+    coverContent: @Composable RowScope.() -> Unit,
+    badges: @Composable RowScope.() -> Unit,
+    onClick: () -> Unit,
+    onLongClick: () -> Unit,
+    content: @Composable RowScope.() -> Unit,
+) {
     Row(
-        modifier = Modifier
-            .selectedBackground(isSelected)
+        modifier = modifier
             .height(56.dp)
             .combinedClickable(
-                onClick = { onClick(manga) },
-                onLongClick = { onLongClick(manga) },
+                onClick = onClick,
+                onLongClick = onLongClick,
             )
             .padding(horizontal = horizontalPadding),
         verticalAlignment = Alignment.CenterVertically,
     ) {
-        eu.kanade.presentation.components.MangaCover.Square(
-            modifier = Modifier
-                .padding(vertical = verticalPadding)
-                .fillMaxHeight(),
-            data = MangaCover(
-                manga.id!!,
-                manga.source,
-                manga.favorite,
-                manga.thumbnail_url,
-                manga.cover_last_modified,
-            ),
-        )
-        Text(
-            text = manga.title,
-            modifier = Modifier
-                .padding(horizontal = horizontalPadding)
-                .weight(1f),
-            maxLines = 2,
-            style = MaterialTheme.typography.bodyMedium,
-        )
-        BadgeGroup {
-            if (item.downloadCount > 0) {
-                Badge(
-                    text = "${item.downloadCount}",
-                    color = MaterialTheme.colorScheme.tertiary,
-                    textColor = MaterialTheme.colorScheme.onTertiary,
-                )
-            }
-            if (item.unreadCount > 0) {
-                Badge(text = "${item.unreadCount}")
-            }
-            if (item.isLocal) {
-                Badge(
-                    text = stringResource(R.string.local_source_badge),
-                    color = MaterialTheme.colorScheme.tertiary,
-                    textColor = MaterialTheme.colorScheme.onTertiary,
-                )
-            }
-            if (item.isLocal.not() && item.sourceLanguage.isNotEmpty()) {
-                Badge(
-                    text = item.sourceLanguage,
-                    color = MaterialTheme.colorScheme.tertiary,
-                    textColor = MaterialTheme.colorScheme.onTertiary,
-                )
-            }
-        }
+        coverContent()
+        content()
+        BadgeGroup(content = badges)
     }
 }
+
+@Composable
+fun RowScope.MangaListItemContent(
+    text: String,
+) {
+    Text(
+        text = text,
+        modifier = Modifier
+            .padding(horizontal = horizontalPadding)
+            .weight(1f),
+        maxLines = 2,
+        style = MaterialTheme.typography.bodyMedium,
+    )
+}

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt

@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
 import java.io.File
 import java.io.IOException
 import java.io.InputStream
+import eu.kanade.domain.manga.model.Manga as DomainManga
 
 /**
  * Class used to create cover cache.
@@ -87,6 +88,20 @@ class CoverCache(private val context: Context) {
         return deleted
     }
 
+    fun deleteFromCache(manga: DomainManga, deleteCustomCover: Boolean = false): Int {
+        var amountDeleted = 0
+
+        getCoverFile(manga.thumbnailUrl)?.let {
+            if (it.exists() && it.delete()) amountDeleted++
+        }
+
+        if (deleteCustomCover && deleteCustomCover(manga.id)) {
+            amountDeleted++
+        }
+
+        return amountDeleted
+    }
+
     /**
      * Delete custom cover of the manga from the cache
      *

+ 27 - 21
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchController.kt

@@ -1,11 +1,13 @@
 package eu.kanade.tachiyomi.ui.browse.migration.search
 
 import android.os.Bundle
-import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.core.os.bundleOf
 import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.SourceSearchScreen
 import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
-import eu.kanade.tachiyomi.ui.browse.source.browse.SourceItem
 import eu.kanade.tachiyomi.util.system.getSerializableCompat
 
 class SourceSearchController(
@@ -13,30 +15,34 @@ class SourceSearchController(
 ) : BrowseSourceController(bundle) {
 
     constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this(
-        Bundle().apply {
-            putLong(SOURCE_ID_KEY, source.id)
-            putSerializable(MANGA_KEY, manga)
-            if (searchQuery != null) {
-                putString(SEARCH_QUERY_KEY, searchQuery)
-            }
-        },
+        bundleOf(
+            SOURCE_ID_KEY to source.id,
+            MANGA_KEY to manga,
+            SEARCH_QUERY_KEY to searchQuery,
+        ),
     )
+
     private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
     private var newManga: Manga? = null
 
-    override fun onItemClick(view: View, position: Int): Boolean {
-        val item = adapter?.getItem(position) as? SourceItem ?: return false
-        newManga = item.manga
-        val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController?
-        val dialog =
-            SearchController.MigrationDialog(oldManga, newManga, this)
-        dialog.targetController = searchController
-        dialog.showDialog(router)
-        return true
-    }
+    @Composable
+    override fun ComposeContent() {
+        SourceSearchScreen(
+            presenter = presenter,
+            navigateUp = { router.popCurrentController() },
+            onFabClick = { filterSheet?.show() },
+            onClickManga = {
+                newManga = it
+                val searchController = router.backstack.findLast { it.controller.javaClass == SearchController::class.java }?.controller as SearchController?
+                val dialog = SearchController.MigrationDialog(oldManga, newManga, this)
+                dialog.targetController = searchController
+                dialog.showDialog(router)
+            },
+        )
 
-    override fun onItemLongClick(position: Int) {
-        view?.let { super.onItemClick(it, position) }
+        LaunchedEffect(presenter.filters) {
+            initFilterSheet()
+        }
     }
 }
 

+ 37 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowsePagingSource.kt

@@ -0,0 +1,37 @@
+package eu.kanade.tachiyomi.ui.browse.source.browse
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.util.lang.withIOContext
+
+abstract class BrowsePagingSource : PagingSource<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
+        }
+    }
+}

+ 84 - 599
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt

@@ -1,328 +1,125 @@
 package eu.kanade.tachiyomi.ui.browse.source.browse
 
-import android.content.res.Configuration
 import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.view.isVisible
-import androidx.core.view.updatePadding
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import com.fredporciuncula.flow.preferences.Preference
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
-import com.google.android.material.snackbar.Snackbar
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.domain.category.model.Category
-import eu.kanade.domain.manga.model.Manga
-import eu.kanade.domain.manga.model.toDbManga
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.core.os.bundleOf
 import eu.kanade.domain.source.model.Source
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.databinding.SourceControllerBinding
+import eu.kanade.presentation.browse.BrowseSourceScreen
+import eu.kanade.presentation.browse.components.RemoveMangaDialog
+import eu.kanade.presentation.components.ChangeCategoryDialog
+import eu.kanade.presentation.components.DuplicateMangaDialog
 import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.model.Filter
-import eu.kanade.tachiyomi.source.model.FilterList
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.FabController
-import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
+import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
-import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
-import eu.kanade.tachiyomi.ui.library.setting.LibraryDisplayMode
-import eu.kanade.tachiyomi.ui.main.MainActivity
-import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog
+import eu.kanade.tachiyomi.ui.category.CategoryController
 import eu.kanade.tachiyomi.ui.manga.MangaController
-import eu.kanade.tachiyomi.ui.more.MoreController
-import eu.kanade.tachiyomi.ui.webview.WebViewActivity
 import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.lang.withUIContext
-import eu.kanade.tachiyomi.util.preference.asHotFlow
-import eu.kanade.tachiyomi.util.system.connectivityManager
-import eu.kanade.tachiyomi.util.system.logcat
-import eu.kanade.tachiyomi.util.system.openInBrowser
-import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.util.view.inflate
-import eu.kanade.tachiyomi.util.view.shrinkOnScroll
-import eu.kanade.tachiyomi.util.view.snack
-import eu.kanade.tachiyomi.widget.AutofitRecyclerView
-import eu.kanade.tachiyomi.widget.EmptyView
-import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import logcat.LogPriority
-import uy.kohesive.injekt.injectLazy
 
 open class BrowseSourceController(bundle: Bundle) :
-    SearchableNucleusController<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
-
-    /**
-     * Recycler view with the list of results.
-     */
-    private var recycler: RecyclerView? = null
-
-    /**
-     * Subscription for the number of manga per row.
-     */
-    private var numColumnsJob: Job? = null
-
-    /**
-     * Endless loading item.
-     */
-    private var progressItem: ProgressItem? = null
+    protected var filterSheet: SourceFilterSheet? = null
+
+    @Composable
+    override fun ComposeContent() {
+        val scope = rememberCoroutineScope()
+
+        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)
+                    }
+                }
+            },
+        )
 
-    init {
-        setHasOptionsMenu(true)
-    }
+        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 -> {}
+        }
 
-    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 = [email protected] }
-                    .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)
+        filterSheet?.setFilters(presenter.filterItems)
     }
 
     /**
@@ -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"

+ 223 - 267
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt

@@ -1,7 +1,25 @@
 package eu.kanade.tachiyomi.ui.browse.source.browse
 
+import android.content.res.Configuration
 import android.os.Bundle
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.unit.dp
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.PagingSource
+import androidx.paging.cachedIn
+import androidx.paging.map
 import eu.davidea.flexibleadapter.items.IFlexible
+import eu.kanade.core.prefs.CheckboxState
+import eu.kanade.core.prefs.mapAsCheckboxState
 import eu.kanade.domain.category.interactor.GetCategories
 import eu.kanade.domain.category.interactor.SetMangaCategories
 import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
@@ -14,6 +32,8 @@ import eu.kanade.domain.manga.model.toDbManga
 import eu.kanade.domain.manga.model.toMangaUpdate
 import eu.kanade.domain.track.interactor.InsertTrack
 import eu.kanade.domain.track.model.toDomainTrack
+import eu.kanade.presentation.browse.BrowseSourceState
+import eu.kanade.presentation.browse.BrowseSourceStateImpl
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.models.toDomainManga
@@ -22,6 +42,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.data.track.TrackService
 import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.Filter
 import eu.kanade.tachiyomi.source.model.FilterList
@@ -42,19 +63,17 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
 import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
 import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.lang.withUIContext
+import eu.kanade.tachiyomi.util.lang.withIOContext
 import eu.kanade.tachiyomi.util.removeCovers
 import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
@@ -65,6 +84,7 @@ import eu.kanade.domain.manga.model.Manga as DomainManga
 open class BrowseSourcePresenter(
     private val sourceId: Long,
     searchQuery: String? = null,
+    private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl,
     private val sourceManager: SourceManager = Injekt.get(),
     private val preferences: PreferencesHelper = Injekt.get(),
     private val coverCache: CoverCache = Injekt.get(),
@@ -77,55 +97,76 @@ open class BrowseSourcePresenter(
     private val updateManga: UpdateManga = Injekt.get(),
     private val insertTrack: InsertTrack = Injekt.get(),
     private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
-) : BasePresenter<BrowseSourceController>() {
-
-    /**
-     * Selected source.
-     */
-    lateinit var source: CatalogueSource
-
-    /**
-     * Modifiable list of filters.
-     */
-    var sourceFilters = FilterList()
-        set(value) {
-            field = value
-            filterItems = value.toItems()
+) : BasePresenter<BrowseSourceController>(), BrowseSourceState by state {
+
+    var displayMode by preferences.sourceDisplayMode().asState()
+
+    @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,97 +203,110 @@ 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.
+     * Adds or removes a manga from the library.
      *
-     * @param manga the manga to initialize.
-     * @return the initialized manga
+     * @param manga the manga to update.
      */
-    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()!!,
+    fun changeMangaFavorite(manga: DomainManga) {
+        presenterScope.launch {
+            var new = manga.copy(
+                favorite = !manga.favorite,
+                dateAdded = when (manga.favorite) {
+                    true -> Date().time
+                    false -> 0
+                },
             )
-        } catch (e: Exception) {
-            logcat(LogPriority.ERROR, e)
+
+            if (!new.favorite) {
+                new = new.removeCovers(coverCache)
+            } else {
+                ChapterSettingsHelper.applySettingDefaults(manga)
+
+                autoAddTrack(manga)
+            }
+
+            updateManga.await(new.toMangaUpdate())
         }
-        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
-        }
+    fun getSourceOrStub(manga: DomainManga): Source {
+        return sourceManager.getOrStub(manga.source)
+    }
 
-        if (!manga.favorite) {
-            manga.removeCovers(coverCache)
-        } else {
-            ChapterSettingsHelper.applySettingDefaults(manga.toDomainManga()!!)
+    fun addFavorite(manga: DomainManga) {
+        presenterScope.launch {
+            val categories = getCategories()
+            val defaultCategoryId = preferences.defaultCategory()
+            val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
 
-            autoAddTrack(manga)
-        }
+            when {
+                // Default category set
+                defaultCategory != null -> {
+                    moveMangaToCategories(manga, defaultCategory)
 
-        runBlocking {
-            updateManga.await(
-                manga
-                    .toDomainManga()
-                    ?.toMangaUpdate()!!,
-            )
+                    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 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()!!)
-
-                            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" }
+    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" }
                 }
-        }
+            }
     }
 
     /**
@@ -336,47 +315,11 @@ open class BrowseSourcePresenter(
      * @param filters a list of active filters.
      */
     fun setSourceFilter(filters: FilterList) {
-        restartPager(filters = filters)
-    }
-
-    open fun createPager(query: String, filters: FilterList): Pager {
-        return SourcePager(source, query, filters)
+        state.appliedFilters = 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))
+    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()
     }
+}
 
-    /**
-     * 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)
+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
+            }
         }
-
-        moveMangaToCategories(manga, selectedCategories)
     }
 }

+ 0 - 31
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/Pager.kt

@@ -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))
-    }
-}

+ 0 - 54
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/ProgressItem.kt

@@ -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)
-    }
-}

+ 20 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceBrowsePagingSource.kt

@@ -0,0 +1,20 @@
+package eu.kanade.tachiyomi.ui.browse.source.browse
+
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.util.lang.awaitSingle
+
+class SourceBrowsePagingSource(val source: CatalogueSource, val query: String, val filters: FilterList) : BrowsePagingSource() {
+
+    override suspend fun requestNextPage(currentPage: Int): MangasPage {
+        val observable = if (query.isBlank() && filters.isEmpty()) {
+            source.fetchPopularManga(currentPage)
+        } else {
+            source.fetchSearchManga(currentPage, query, filters)
+        }
+
+        return observable.awaitSingle()
+            .takeIf { it.mangas.isNotEmpty() } ?: throw NoResultsException()
+    }
+}

+ 0 - 53
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceComfortableGridHolder.kt

@@ -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)
-        }
-    }
-}

+ 0 - 53
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceCompactGridHolder.kt

@@ -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)
-        }
-    }
-}

+ 0 - 35
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceHolder.kt

@@ -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)
-}

+ 0 - 63
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceItem.kt

@@ -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()
-    }
-}

+ 0 - 60
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourceListHolder.kt

@@ -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)
-            }
-        }
-    }
-}

+ 0 - 26
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/SourcePager.kt

@@ -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()
-        }
-    }
-}

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesBrowsePagingSource.kt

@@ -0,0 +1,13 @@
+package eu.kanade.tachiyomi.ui.browse.source.latest
+
+import eu.kanade.tachiyomi.source.CatalogueSource
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowsePagingSource
+import eu.kanade.tachiyomi.util.lang.awaitSingle
+
+class LatestUpdatesBrowsePagingSource(val source: CatalogueSource) : BrowsePagingSource() {
+
+    override suspend fun requestNextPage(currentPage: Int): MangasPage {
+        return source.fetchLatestUpdates(currentPage).awaitSingle()
+    }
+}

+ 67 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesController.kt

@@ -1,12 +1,20 @@
 package eu.kanade.tachiyomi.ui.browse.source.latest
 
 import android.os.Bundle
-import android.view.Menu
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.core.os.bundleOf
 import eu.kanade.domain.source.model.Source
-import eu.kanade.tachiyomi.R
+import eu.kanade.presentation.browse.BrowseLatestScreen
+import eu.kanade.presentation.browse.components.RemoveMangaDialog
+import eu.kanade.presentation.components.ChangeCategoryDialog
+import eu.kanade.presentation.components.DuplicateMangaDialog
+import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
+import eu.kanade.tachiyomi.ui.category.CategoryController
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.util.lang.launchIO
 
 /**
  * Controller that shows the latest manga from the catalogue. Inherit [BrowseSourceController].
@@ -21,9 +29,63 @@ class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
         return LatestUpdatesPresenter(args.getLong(SOURCE_ID_KEY))
     }
 
-    override fun onPrepareOptionsMenu(menu: Menu) {
-        super.onPrepareOptionsMenu(menu)
-        menu.findItem(R.id.action_search).isVisible = false
+    @Composable
+    override fun ComposeContent() {
+        val scope = rememberCoroutineScope()
+
+        BrowseLatestScreen(
+            presenter = presenter,
+            navigateUp = { router.popCurrentController() },
+            onMangaClick = { router.pushController(MangaController(it.id, true)) },
+            onMangaLongClick = { manga ->
+                scope.launchIO {
+                    val duplicateManga = presenter.getDuplicateLibraryManga(manga)
+                    when {
+                        manga.favorite -> presenter.dialog = BrowseSourcePresenter.Dialog.RemoveManga(manga)
+                        duplicateManga != null -> presenter.dialog = BrowseSourcePresenter.Dialog.AddDuplicateManga(manga, duplicateManga)
+                        else -> presenter.addFavorite(manga)
+                    }
+                }
+            },
+        )
+
+        val onDismissRequest = { presenter.dialog = null }
+        when (val dialog = presenter.dialog) {
+            is BrowseSourcePresenter.Dialog.AddDuplicateManga -> {
+                DuplicateMangaDialog(
+                    onDismissRequest = onDismissRequest,
+                    onOpenManga = {
+                        router.pushController(MangaController(dialog.duplicate.id, true))
+                    },
+                    onConfirm = {
+                        presenter.addFavorite(dialog.manga)
+                    },
+                    duplicateFrom = presenter.getSourceOrStub(dialog.manga),
+                )
+            }
+            is BrowseSourcePresenter.Dialog.RemoveManga -> {
+                RemoveMangaDialog(
+                    onDismissRequest = onDismissRequest,
+                    onConfirm = {
+                        presenter.changeMangaFavorite(dialog.manga)
+                    },
+                )
+            }
+            is BrowseSourcePresenter.Dialog.ChangeMangaCategory -> {
+                ChangeCategoryDialog(
+                    initialSelection = dialog.initialSelection,
+                    onDismissRequest = onDismissRequest,
+                    onEditCategories = {
+                        router.pushController(CategoryController())
+                    },
+                    onConfirm = { include, _ ->
+                        presenter.changeMangaFavorite(dialog.manga)
+                        presenter.moveMangaToCategories(dialog.manga, include)
+                    },
+                )
+            }
+            null -> {}
+        }
     }
 
     override fun initFilterSheet() {

+ 0 - 13
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPager.kt

@@ -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)
-    }
-}

+ 4 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/latest/LatestUpdatesPresenter.kt

@@ -1,12 +1,13 @@
 package eu.kanade.tachiyomi.ui.browse.source.latest
 
+import androidx.paging.PagingSource
 import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.SManga
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
-import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
 
 class LatestUpdatesPresenter(sourceId: Long) : BrowseSourcePresenter(sourceId) {
 
-    override fun createPager(query: String, filters: FilterList): Pager {
-        return LatestUpdatesPager(source)
+    override fun createPager(query: String, filters: FilterList): PagingSource<Long, SManga> {
+        return LatestUpdatesBrowsePagingSource(source!!)
     }
 }

+ 0 - 81
app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt

@@ -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>())
-    }
-}

+ 0 - 48
app/src/main/java/eu/kanade/tachiyomi/ui/manga/AddDuplicateMangaDialog.kt

@@ -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()
-    }
-}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -22,12 +22,12 @@ import eu.kanade.data.chapter.NoChaptersException
 import eu.kanade.domain.manga.model.toDbManga
 import eu.kanade.presentation.components.ChangeCategoryDialog
 import eu.kanade.presentation.components.ChapterDownloadAction
+import eu.kanade.presentation.components.DuplicateMangaDialog
 import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.manga.DownloadAction
 import eu.kanade.presentation.manga.MangaScreen
 import eu.kanade.presentation.manga.components.DeleteChaptersDialog
 import eu.kanade.presentation.manga.components.DownloadCustomAmountDialog
-import eu.kanade.presentation.manga.components.DuplicateMangaDialog
 import eu.kanade.presentation.util.calculateWindowWidthSizeClass
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.download.DownloadService

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt

@@ -51,6 +51,12 @@ fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Int {
     return coverCache.deleteFromCache(this, true)
 }
 
+fun DomainManga.removeCovers(coverCache: CoverCache = Injekt.get()): DomainManga {
+    if (isLocal()) return this
+    coverCache.deleteFromCache(this, true)
+    return copy(coverLastModified = Date().time)
+}
+
 fun DomainManga.shouldDownloadNewChapters(dbCategories: List<Long>, preferences: PreferencesHelper): Boolean {
     if (!favorite) return false
 

+ 0 - 6
app/src/main/res/color/source_comfortable_item_title.xml

@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<selector xmlns:android="http://schemas.android.com/apk/res/android">
-    <item android:state_selected="true" android:color="?attr/colorOnPrimary" />
-    <item android:state_activated="true" android:color="?attr/colorOnPrimary" />
-    <item android:color="?android:attr/textColorPrimary" />
-</selector>

+ 0 - 50
app/src/main/res/layout/source_comfortable_grid_item.xml

@@ -1,50 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:layout_margin="2dp"
-    android:background="@drawable/library_item_selector"
-    android:foreground="@drawable/library_item_selector_overlay"
-    android:padding="4dp">
-
-    <com.google.android.material.imageview.ShapeableImageView
-        android:id="@+id/thumbnail"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:scaleType="centerCrop"
-        app:layout_constraintDimensionRatio="w,3:2"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
-        tools:ignore="ContentDescription"
-        tools:src="@mipmap/ic_launcher" />
-
-    <include
-        android:id="@+id/badges"
-        layout="@layout/source_grid_item_badges"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginHorizontal="4dp"
-        android:layout_marginTop="4dp"
-        app:layout_constraintEnd_toEndOf="@+id/thumbnail"
-        app:layout_constraintStart_toStartOf="@+id/thumbnail"
-        app:layout_constraintTop_toTopOf="@+id/thumbnail" />
-
-    <TextView
-        android:id="@+id/title"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:ellipsize="end"
-        android:maxLines="2"
-        android:padding="4dp"
-        android:textAppearance="?attr/textAppearanceTitleSmall"
-        android:textColor="@color/source_comfortable_item_title"
-        android:textSize="12sp"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/thumbnail"
-        tools:text="Sample name" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 58
app/src/main/res/layout/source_compact_grid_item.xml

@@ -1,58 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:layout_margin="2dp"
-    android:background="@drawable/library_item_selector"
-    android:foreground="@drawable/library_item_selector_overlay"
-    android:padding="4dp">
-
-    <com.google.android.material.imageview.ShapeableImageView
-        android:id="@+id/thumbnail"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:scaleType="centerCrop"
-        android:foreground="@drawable/card_gradient_shape"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintDimensionRatio="w,2:3"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Cover"
-        tools:ignore="ContentDescription"
-        tools:src="@mipmap/ic_launcher" />
-
-    <include
-        android:id="@+id/badges"
-        layout="@layout/source_grid_item_badges"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginHorizontal="4dp"
-        android:layout_marginTop="4dp"
-        app:layout_constraintEnd_toEndOf="@+id/thumbnail"
-        app:layout_constraintStart_toStartOf="@+id/thumbnail"
-        app:layout_constraintTop_toTopOf="@+id/thumbnail" />
-
-    <TextView
-        android:id="@+id/title"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom"
-        android:ellipsize="end"
-        android:maxLines="2"
-        android:padding="8dp"
-        android:shadowColor="@color/md_black_1000"
-        android:shadowDx="0"
-        android:shadowDy="0"
-        android:shadowRadius="4"
-        android:textAppearance="?attr/textAppearanceTitleSmall"
-        android:textColor="@color/md_white_1000"
-        android:textSize="12sp"
-        app:layout_constraintBottom_toBottomOf="@+id/thumbnail"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        tools:text="Sample name" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 38
app/src/main/res/layout/source_controller.xml

@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <LinearLayout
-        android:id="@+id/catalogue_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:orientation="vertical"
-        tools:context=".ui.browse.source.browse.BrowseSourceController">
-
-        <FrameLayout
-            android:id="@+id/progress"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:visibility="gone"
-            tools:visibility="visible">
-
-            <com.google.android.material.progressindicator.CircularProgressIndicator
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_gravity="center"
-                android:indeterminate="true" />
-
-        </FrameLayout>
-
-    </LinearLayout>
-
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:gravity="center"
-        android:visibility="gone" />
-
-</FrameLayout>

+ 0 - 116
app/src/main/res/layout/source_grid_item_badges.xml

@@ -1,116 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
-    android:layout_marginStart="4dp"
-    android:layout_marginTop="4dp"
-    android:layout_marginEnd="4dp">
-
-    <LinearLayout
-        android:id="@+id/left_badges"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="@drawable/rounded_rectangle">
-
-        <TextView
-            android:id="@+id/local_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            android:text="@string/local_source_badge"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/download_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-medium"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:text="120"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/unread_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorSecondary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-medium"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnSecondary"
-            android:visibility="gone"
-            tools:text="120"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/favorite_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorSecondary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            android:text="@string/in_library"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnSecondary"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-    </LinearLayout>
-
-    <View
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_weight="1" />
-
-    <LinearLayout
-        android:id="@+id/right_badges"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="@drawable/rounded_rectangle">
-
-        <TextView
-            android:id="@+id/language_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:text="EN"
-            tools:visibility="visible" />
-
-    </LinearLayout>
-
-</LinearLayout>

+ 0 - 138
app/src/main/res/layout/source_list_item.xml

@@ -1,138 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="56dp"
-    android:layout_gravity="center_vertical"
-    android:background="@drawable/list_item_selector_background"
-    android:paddingHorizontal="8dp">
-
-    <com.google.android.material.imageview.ShapeableImageView
-        android:id="@+id/thumbnail"
-        android:layout_width="56dp"
-        android:layout_height="56dp"
-        android:layout_gravity="center_vertical"
-        android:padding="8dp"
-        android:scaleType="centerCrop"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
-        tools:src="@mipmap/ic_launcher" />
-
-    <TextView
-        android:id="@+id/title"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="8dp"
-        android:layout_marginEnd="8dp"
-        android:ellipsize="end"
-        android:maxLines="1"
-        android:textAppearance="?attr/textAppearanceBodyMedium"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/badges"
-        app:layout_constraintHorizontal_bias="0.007"
-        app:layout_constraintStart_toEndOf="@+id/thumbnail"
-        app:layout_constraintTop_toTopOf="parent"
-        app:layout_constraintVertical_bias="0.523"
-        tools:text="Manga title" />
-
-    <LinearLayout
-        android:id="@+id/badges"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="8dp"
-        android:background="@drawable/rounded_rectangle"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <TextView
-            android:id="@+id/local_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            android:text="@string/local_source_badge"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/download_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-medium"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:text="122"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/unread_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorSecondary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-medium"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnSecondary"
-            android:visibility="gone"
-            tools:text="130"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/favorite_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorSecondary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            android:text="@string/in_library"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnSecondary"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-        <TextView
-            android:id="@+id/language_text"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorTertiary"
-            android:maxLines="1"
-            android:paddingStart="3dp"
-            android:paddingTop="1dp"
-            android:paddingEnd="3dp"
-            android:paddingBottom="1dp"
-            android:fontFamily="sans-serif-condensed"
-            tools:text="EN"
-            android:textAppearance="?attr/textAppearanceBodySmall"
-            android:textColor="?attr/colorOnTertiary"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-    </LinearLayout>
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 27
app/src/main/res/layout/source_progress_item.xml

@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:padding="8dp">
-
-    <com.google.android.material.progressindicator.CircularProgressIndicator
-        android:id="@+id/progress_bar"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:indeterminate="true"
-        app:indicatorSize="24dp"
-        app:trackThickness="3dp" />
-
-    <TextView
-        android:id="@+id/progress_message"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:text="@string/no_more_results"
-        android:visibility="gone"
-        tools:visibility="visible" />
-
-</FrameLayout>

+ 0 - 11
app/src/main/res/layout/source_recycler_autofit.xml

@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<eu.kanade.tachiyomi.widget.AutofitRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/source_grid"
-    style="@style/Widget.Tachiyomi.GridView.Source"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:clipToPadding="false"
-    android:columnWidth="140dp"
-    android:padding="5dp"
-    tools:listitem="@layout/source_compact_grid_item" />

+ 0 - 46
app/src/main/res/menu/source_browse.xml

@@ -1,46 +0,0 @@
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <item
-        android:id="@+id/action_search"
-        android:icon="@drawable/ic_search_24dp"
-        android:title="@string/action_search"
-        app:actionViewClass="eu.kanade.tachiyomi.widget.TachiyomiSearchView"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="collapseActionView|ifRoom" />
-
-    <item
-        android:icon="@drawable/ic_view_module_24dp"
-        android:title="@string/action_display_mode"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom">
-        <menu>
-            <group android:checkableBehavior="single">
-                <item
-                    android:id="@+id/action_compact_grid"
-                    android:title="@string/action_display_grid" />
-                <item
-                    android:id="@+id/action_comfortable_grid"
-                    android:title="@string/action_display_comfortable_grid" />
-                <item
-                    android:id="@+id/action_list"
-                    android:title="@string/action_display_list" />
-            </group>
-        </menu>
-    </item>
-
-    <item
-        android:id="@+id/action_open_in_web_view"
-        android:icon="@drawable/ic_public_24dp"
-        android:title="@string/action_open_in_web_view"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-
-    <item
-        android:id="@+id/action_local_source_help"
-        android:icon="@drawable/ic_help_24dp"
-        android:title="@string/local_source_help_guide"
-        app:iconTint="?attr/colorOnSurface"
-        app:showAsAction="ifRoom" />
-
-</menu>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -870,4 +870,5 @@
     <!-- App widget -->
     <string name="appwidget_updates_description">See your recently updated manga</string>
     <string name="appwidget_unavailable_locked">Widget not available when app lock is enabled</string>
+    <string name="remove_manga">You are about to remove this manga from your library</string>
 </resources>