Răsfoiți Sursa

Use Voyager on BrowseSource and SourceSearch screen (#8650)

Some navigation janks will be dealt with when the migration is complete
Ivan Iskandar 2 ani în urmă
părinte
comite
94d1b68598

+ 6 - 185
app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt

@@ -1,213 +1,37 @@
 package eu.kanade.presentation.browse
 
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.background
-import androidx.compose.foundation.horizontalScroll
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.rememberScrollState
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Favorite
-import androidx.compose.material.icons.outlined.FilterList
 import androidx.compose.material.icons.outlined.HelpOutline
-import androidx.compose.material.icons.outlined.NewReleases
 import androidx.compose.material.icons.outlined.Public
 import androidx.compose.material.icons.outlined.Refresh
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilterChipDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.SnackbarDuration
-import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.State
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
 import androidx.paging.compose.LazyPagingItems
-import androidx.paging.compose.collectAsLazyPagingItems
 import eu.kanade.data.source.NoResultsException
 import eu.kanade.domain.library.model.LibraryDisplayMode
 import eu.kanade.domain.manga.model.Manga
-import eu.kanade.domain.source.interactor.GetRemoteManga
 import eu.kanade.presentation.browse.components.BrowseSourceComfortableGrid
 import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
 import eu.kanade.presentation.browse.components.BrowseSourceList
-import eu.kanade.presentation.browse.components.BrowseSourceToolbar
-import eu.kanade.presentation.components.AppStateBanners
-import eu.kanade.presentation.components.Divider
 import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.EmptyScreenAction
-import eu.kanade.presentation.components.ExtendedFloatingActionButton
 import eu.kanade.presentation.components.LoadingScreen
-import eu.kanade.presentation.components.Scaffold
 import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
-import eu.kanade.tachiyomi.ui.more.MoreController
-
-@Composable
-fun BrowseSourceScreen(
-    presenter: BrowseSourcePresenter,
-    navigateUp: () -> Unit,
-    openFilterSheet: () -> Unit,
-    onMangaClick: (Manga) -> Unit,
-    onMangaLongClick: (Manga) -> Unit,
-    onWebViewClick: () -> Unit,
-    incognitoMode: Boolean,
-    downloadedOnlyMode: Boolean,
-) {
-    val columns by presenter.getColumnsPreferenceForCurrentOrientation()
-
-    val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
-
-    val snackbarHostState = remember { SnackbarHostState() }
-
-    val uriHandler = LocalUriHandler.current
-
-    val onHelpClick = {
-        uriHandler.openUri(LocalSource.HELP_URL)
-    }
-
-    Scaffold(
-        topBar = {
-            Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
-                BrowseSourceToolbar(
-                    state = presenter,
-                    source = presenter.source,
-                    displayMode = presenter.displayMode,
-                    onDisplayModeChange = { presenter.displayMode = it },
-                    navigateUp = navigateUp,
-                    onWebViewClick = onWebViewClick,
-                    onHelpClick = onHelpClick,
-                    onSearch = { presenter.search(it) },
-                )
-
-                Row(
-                    modifier = Modifier
-                        .horizontalScroll(rememberScrollState())
-                        .padding(horizontal = 8.dp),
-                    horizontalArrangement = Arrangement.spacedBy(8.dp),
-                ) {
-                    FilterChip(
-                        selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Popular,
-                        onClick = {
-                            presenter.reset()
-                            presenter.search(GetRemoteManga.QUERY_POPULAR)
-                        },
-                        leadingIcon = {
-                            Icon(
-                                imageVector = Icons.Outlined.Favorite,
-                                contentDescription = "",
-                                modifier = Modifier
-                                    .size(FilterChipDefaults.IconSize),
-                            )
-                        },
-                        label = {
-                            Text(text = stringResource(R.string.popular))
-                        },
-                    )
-                    if (presenter.source?.supportsLatest == true) {
-                        FilterChip(
-                            selected = presenter.currentFilter == BrowseSourcePresenter.Filter.Latest,
-                            onClick = {
-                                presenter.reset()
-                                presenter.search(GetRemoteManga.QUERY_LATEST)
-                            },
-                            leadingIcon = {
-                                Icon(
-                                    imageVector = Icons.Outlined.NewReleases,
-                                    contentDescription = "",
-                                    modifier = Modifier
-                                        .size(FilterChipDefaults.IconSize),
-                                )
-                            },
-                            label = {
-                                Text(text = stringResource(R.string.latest))
-                            },
-                        )
-                    }
-                    if (presenter.filters.isNotEmpty()) {
-                        FilterChip(
-                            selected = presenter.currentFilter is BrowseSourcePresenter.Filter.UserInput,
-                            onClick = openFilterSheet,
-                            leadingIcon = {
-                                Icon(
-                                    imageVector = Icons.Outlined.FilterList,
-                                    contentDescription = "",
-                                    modifier = Modifier
-                                        .size(FilterChipDefaults.IconSize),
-                                )
-                            },
-                            label = {
-                                Text(text = stringResource(R.string.action_filter))
-                            },
-                        )
-                    }
-                }
-
-                Divider()
-
-                AppStateBanners(downloadedOnlyMode, incognitoMode)
-            }
-        },
-        snackbarHost = {
-            SnackbarHost(hostState = snackbarHostState)
-        },
-    ) { paddingValues ->
-        BrowseSourceContent(
-            state = presenter,
-            mangaList = mangaList,
-            getMangaState = { presenter.getManga(it) },
-            columns = columns,
-            displayMode = presenter.displayMode,
-            snackbarHostState = snackbarHostState,
-            contentPadding = paddingValues,
-            onWebViewClick = onWebViewClick,
-            onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
-            onLocalSourceHelpClick = onHelpClick,
-            onMangaClick = onMangaClick,
-            onMangaLongClick = onMangaLongClick,
-        )
-    }
-}
-
-@Composable
-fun BrowseSourceFloatingActionButton(
-    modifier: Modifier = Modifier.navigationBarsPadding(),
-    isVisible: Boolean,
-    onFabClick: () -> Unit,
-) {
-    AnimatedVisibility(visible = isVisible) {
-        ExtendedFloatingActionButton(
-            modifier = modifier,
-            text = { Text(text = stringResource(R.string.action_filter)) },
-            icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
-            onClick = onFabClick,
-        )
-    }
-}
+import kotlinx.coroutines.flow.StateFlow
 
 @Composable
 fun BrowseSourceContent(
-    state: BrowseSourceState,
-    mangaList: LazyPagingItems<Manga>,
-    getMangaState: @Composable ((Manga) -> State<Manga>),
+    source: CatalogueSource?,
+    mangaList: LazyPagingItems<StateFlow<Manga>>,
     columns: GridCells,
     displayMode: LibraryDisplayMode,
     snackbarHostState: SnackbarHostState,
@@ -249,7 +73,7 @@ fun BrowseSourceContent(
     if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
         EmptyScreen(
             message = getErrorMessage(errorState),
-            actions = if (state.source is LocalSource) {
+            actions = if (source is LocalSource) {
                 listOf(
                     EmptyScreenAction(
                         stringResId = R.string.local_source_help_guide,
@@ -290,7 +114,6 @@ fun BrowseSourceContent(
         LibraryDisplayMode.ComfortableGrid -> {
             BrowseSourceComfortableGrid(
                 mangaList = mangaList,
-                getMangaState = getMangaState,
                 columns = columns,
                 contentPadding = contentPadding,
                 onMangaClick = onMangaClick,
@@ -300,16 +123,14 @@ fun BrowseSourceContent(
         LibraryDisplayMode.List -> {
             BrowseSourceList(
                 mangaList = mangaList,
-                getMangaState = getMangaState,
                 contentPadding = contentPadding,
                 onMangaClick = onMangaClick,
                 onMangaLongClick = onMangaLongClick,
             )
         }
-        else -> {
+        LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
             BrowseSourceCompactGrid(
                 mangaList = mangaList,
-                getMangaState = getMangaState,
                 columns = columns,
                 contentPadding = contentPadding,
                 onMangaClick = onMangaClick,

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

@@ -1,41 +0,0 @@
-package eu.kanade.presentation.browse
-
-import androidx.compose.runtime.Stable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.model.FilterList
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Filter
-import eu.kanade.tachiyomi.ui.browse.source.browse.toItems
-
-@Stable
-interface BrowseSourceState {
-    val source: CatalogueSource?
-    var searchQuery: String?
-    val currentFilter: Filter
-    val isUserQuery: Boolean
-    val filters: FilterList
-    val filterItems: List<IFlexible<*>>
-    var dialog: BrowseSourcePresenter.Dialog?
-}
-
-fun BrowseSourceState(initialQuery: String?): BrowseSourceState {
-    return when (val filter = Filter.valueOf(initialQuery ?: "")) {
-        Filter.Latest, Filter.Popular -> BrowseSourceStateImpl(initialCurrentFilter = filter)
-        is Filter.UserInput -> BrowseSourceStateImpl(initialQuery = initialQuery, initialCurrentFilter = filter)
-    }
-}
-
-class BrowseSourceStateImpl(initialQuery: String? = null, initialCurrentFilter: Filter) : BrowseSourceState {
-    override var source: CatalogueSource? by mutableStateOf(null)
-    override var searchQuery: String? by mutableStateOf(initialQuery)
-    override var currentFilter: Filter by mutableStateOf(initialCurrentFilter)
-    override val isUserQuery: Boolean by derivedStateOf { currentFilter is Filter.UserInput && currentFilter.query.isNotEmpty() }
-    override var filters: FilterList by mutableStateOf(FilterList())
-    override val filterItems: List<IFlexible<*>> by derivedStateOf { filters.toItems() }
-    override var dialog: BrowseSourcePresenter.Dialog? by mutableStateOf(null)
-}

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

@@ -1,72 +0,0 @@
-package eu.kanade.presentation.browse
-
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalUriHandler
-import androidx.paging.compose.collectAsLazyPagingItems
-import eu.kanade.domain.manga.model.Manga
-import eu.kanade.presentation.components.Scaffold
-import eu.kanade.presentation.components.SearchToolbar
-import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
-import eu.kanade.tachiyomi.ui.more.MoreController
-
-@Composable
-fun SourceSearchScreen(
-    presenter: BrowseSourcePresenter,
-    navigateUp: () -> Unit,
-    onFabClick: () -> Unit,
-    onMangaClick: (Manga) -> Unit,
-    onWebViewClick: () -> Unit,
-) {
-    val columns by presenter.getColumnsPreferenceForCurrentOrientation()
-
-    val mangaList = presenter.getMangaList().collectAsLazyPagingItems()
-
-    val snackbarHostState = remember { SnackbarHostState() }
-
-    val uriHandler = LocalUriHandler.current
-
-    val onHelpClick = {
-        uriHandler.openUri(LocalSource.HELP_URL)
-    }
-
-    Scaffold(
-        topBar = { scrollBehavior ->
-            SearchToolbar(
-                searchQuery = presenter.searchQuery ?: "",
-                onChangeSearchQuery = { presenter.searchQuery = it },
-                onClickCloseSearch = navigateUp,
-                onSearch = { presenter.search(it) },
-                scrollBehavior = scrollBehavior,
-            )
-        },
-        floatingActionButton = {
-            BrowseSourceFloatingActionButton(
-                isVisible = presenter.filters.isNotEmpty(),
-                onFabClick = onFabClick,
-            )
-        },
-        snackbarHost = {
-            SnackbarHost(hostState = snackbarHostState)
-        },
-    ) { paddingValues ->
-        BrowseSourceContent(
-            state = presenter,
-            mangaList = mangaList,
-            getMangaState = { presenter.getManga(it) },
-            columns = columns,
-            displayMode = presenter.displayMode,
-            snackbarHostState = snackbarHostState,
-            contentPadding = paddingValues,
-            onWebViewClick = onWebViewClick,
-            onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
-            onLocalSourceHelpClick = onHelpClick,
-            onMangaClick = onMangaClick,
-            onMangaLongClick = onMangaClick,
-        )
-    }
-}

+ 4 - 5
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt

@@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
@@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge
 import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.MangaComfortableGridItem
 import eu.kanade.presentation.util.plus
+import kotlinx.coroutines.flow.StateFlow
 
 @Composable
 fun BrowseSourceComfortableGrid(
-    mangaList: LazyPagingItems<Manga>,
-    getMangaState: @Composable ((Manga) -> State<Manga>),
+    mangaList: LazyPagingItems<StateFlow<Manga>>,
     columns: GridCells,
     contentPadding: PaddingValues,
     onMangaClick: (Manga) -> Unit,
@@ -40,8 +40,7 @@ fun BrowseSourceComfortableGrid(
         }
 
         items(mangaList.itemCount) { index ->
-            val initialManga = mangaList[index] ?: return@items
-            val manga by getMangaState(initialManga)
+            val manga by mangaList[index]?.collectAsState() ?: return@items
             BrowseSourceComfortableGridItem(
                 manga = manga,
                 onClick = { onMangaClick(manga) },

+ 4 - 5
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt

@@ -6,7 +6,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
@@ -17,11 +17,11 @@ import eu.kanade.presentation.browse.InLibraryBadge
 import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.MangaCompactGridItem
 import eu.kanade.presentation.util.plus
+import kotlinx.coroutines.flow.StateFlow
 
 @Composable
 fun BrowseSourceCompactGrid(
-    mangaList: LazyPagingItems<Manga>,
-    getMangaState: @Composable ((Manga) -> State<Manga>),
+    mangaList: LazyPagingItems<StateFlow<Manga>>,
     columns: GridCells,
     contentPadding: PaddingValues,
     onMangaClick: (Manga) -> Unit,
@@ -40,8 +40,7 @@ fun BrowseSourceCompactGrid(
         }
 
         items(mangaList.itemCount) { index ->
-            val initialManga = mangaList[index] ?: return@items
-            val manga by getMangaState(initialManga)
+            val manga by mangaList[index]?.collectAsState() ?: return@items
             BrowseSourceCompactGridItem(
                 manga = manga,
                 onClick = { onMangaClick(manga) },

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

@@ -2,7 +2,7 @@ package eu.kanade.presentation.browse.components
 
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
@@ -15,11 +15,11 @@ import eu.kanade.presentation.components.CommonMangaItemDefaults
 import eu.kanade.presentation.components.LazyColumn
 import eu.kanade.presentation.components.MangaListItem
 import eu.kanade.presentation.util.plus
+import kotlinx.coroutines.flow.StateFlow
 
 @Composable
 fun BrowseSourceList(
-    mangaList: LazyPagingItems<Manga>,
-    getMangaState: @Composable ((Manga) -> State<Manga>),
+    mangaList: LazyPagingItems<StateFlow<Manga>>,
     contentPadding: PaddingValues,
     onMangaClick: (Manga) -> Unit,
     onMangaLongClick: (Manga) -> Unit,
@@ -33,9 +33,9 @@ fun BrowseSourceList(
             }
         }
 
-        items(mangaList) { initialManga ->
-            initialManga ?: return@items
-            val manga by getMangaState(initialManga)
+        items(mangaList) { mangaflow ->
+            mangaflow ?: return@items
+            val manga by mangaflow.collectAsState()
             BrowseSourceListItem(
                 manga = manga,
                 onClick = { onMangaClick(manga) },

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

@@ -14,7 +14,6 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.res.stringResource
 import eu.kanade.domain.library.model.LibraryDisplayMode
-import eu.kanade.presentation.browse.BrowseSourceState
 import eu.kanade.presentation.components.AppBar
 import eu.kanade.presentation.components.AppBarActions
 import eu.kanade.presentation.components.AppBarTitle
@@ -27,7 +26,8 @@ import eu.kanade.tachiyomi.source.LocalSource
 
 @Composable
 fun BrowseSourceToolbar(
-    state: BrowseSourceState,
+    searchQuery: String?,
+    onSearchQueryChange: (String?) -> Unit,
     source: CatalogueSource?,
     displayMode: LibraryDisplayMode,
     onDisplayModeChange: (LibraryDisplayMode) -> Unit,
@@ -44,8 +44,8 @@ fun BrowseSourceToolbar(
     SearchToolbar(
         navigateUp = navigateUp,
         titleContent = { AppBarTitle(title) },
-        searchQuery = state.searchQuery,
-        onChangeSearchQuery = { state.searchQuery = it },
+        searchQuery = searchQuery,
+        onChangeSearchQuery = onSearchQueryChange,
         onSearch = onSearch,
         onClickCloseSearch = navigateUp,
         actions = {

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

@@ -2,26 +2,14 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
 
 import android.os.Bundle
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
 import androidx.core.os.bundleOf
+import cafe.adriel.voyager.navigator.Navigator
 import eu.kanade.domain.manga.model.Manga
-import eu.kanade.presentation.browse.SourceSearchScreen
-import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.base.controller.setRoot
-import eu.kanade.tachiyomi.ui.browse.BrowseController
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
-import eu.kanade.tachiyomi.ui.manga.MangaController
-import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 import eu.kanade.tachiyomi.util.system.getSerializableCompat
 
-class SourceSearchController(
-    bundle: Bundle,
-) : BrowseSourceController(bundle) {
+class SourceSearchController(bundle: Bundle) : BasicFullComposeController(bundle) {
 
     constructor(manga: Manga? = null, source: CatalogueSource, searchQuery: String? = null) : this(
         bundleOf(
@@ -31,49 +19,16 @@ class SourceSearchController(
         ),
     )
 
-    private var oldManga: Manga? = args.getSerializableCompat(MANGA_KEY)
+    private var oldManga: Manga = args.getSerializableCompat(MANGA_KEY)!!
+    private val sourceId = args.getLong(SOURCE_ID_KEY)
+    private val query = args.getString(SEARCH_QUERY_KEY)
 
     @Composable
     override fun ComposeContent() {
-        SourceSearchScreen(
-            presenter = presenter,
-            navigateUp = { router.popCurrentController() },
-            onFabClick = { filterSheet?.show() },
-            onMangaClick = {
-                presenter.dialog = BrowseSourcePresenter.Dialog.Migrate(it)
-            },
-            onWebViewClick = f@{
-                val source = presenter.source as? HttpSource ?: return@f
-                activity?.let { context ->
-                    val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
-                    context.startActivity(intent)
-                }
-            },
-        )
-
-        when (val dialog = presenter.dialog) {
-            is BrowseSourcePresenter.Dialog.Migrate -> {
-                MigrateDialog(
-                    oldManga = oldManga!!,
-                    newManga = dialog.newManga,
-                    // TODO: Move screen model down into Dialog when this screen is using Voyager
-                    screenModel = remember { MigrateDialogScreenModel() },
-                    onDismissRequest = { presenter.dialog = null },
-                    onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
-                    onPopScreen = {
-                        // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
-                        router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
-                        router.pushController(MangaController(dialog.newManga.id))
-                    },
-                )
-            }
-            else -> {}
-        }
-
-        LaunchedEffect(presenter.filters) {
-            initFilterSheet()
-        }
+        Navigator(screen = SourceSearchScreen(oldManga, sourceId, query))
     }
 }
 
 private const val MANGA_KEY = "oldManga"
+private const val SOURCE_ID_KEY = "sourceId"
+private const val SEARCH_QUERY_KEY = "searchQuery"

+ 134 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt

@@ -0,0 +1,134 @@
+package eu.kanade.tachiyomi.ui.browse.migration.search
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material3.Icon
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.paging.compose.collectAsLazyPagingItems
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.BrowseSourceContent
+import eu.kanade.presentation.components.ExtendedFloatingActionButton
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.components.SearchToolbar
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.base.controller.setRoot
+import eu.kanade.tachiyomi.ui.browse.BrowseController
+import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.ui.more.MoreController
+import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+
+data class SourceSearchScreen(
+    private val oldManga: Manga,
+    private val sourceId: Long,
+    private val query: String? = null,
+) : Screen {
+
+    @Composable
+    override fun Content() {
+        val context = LocalContext.current
+        val uriHandler = LocalUriHandler.current
+        val router = LocalRouter.currentOrThrow
+        val navigator = LocalNavigator.currentOrThrow
+
+        val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) }
+        val state by screenModel.state.collectAsState()
+
+        val snackbarHostState = remember { SnackbarHostState() }
+
+        val navigateUp: () -> Unit = {
+            when {
+                navigator.canPop -> navigator.pop()
+                router.backstackSize > 1 -> router.popCurrentController()
+            }
+        }
+
+        Scaffold(
+            topBar = { scrollBehavior ->
+                SearchToolbar(
+                    searchQuery = state.toolbarQuery ?: "",
+                    onChangeSearchQuery = screenModel::setToolbarQuery,
+                    onClickCloseSearch = navigateUp,
+                    onSearch = { screenModel.search(it) },
+                    scrollBehavior = scrollBehavior,
+                )
+            },
+            floatingActionButton = {
+                AnimatedVisibility(visible = state.filters.isNotEmpty()) {
+                    ExtendedFloatingActionButton(
+                        text = { Text(text = stringResource(R.string.action_filter)) },
+                        icon = { Icon(Icons.Outlined.FilterList, contentDescription = "") },
+                        onClick = screenModel::openFilterSheet,
+                    )
+                }
+            },
+            snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+        ) { paddingValues ->
+            val mangaList = remember(state.currentFilter) {
+                screenModel.getMangaListFlow(state.currentFilter)
+            }.collectAsLazyPagingItems()
+            val openMigrateDialog: (Manga) -> Unit = {
+                screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(it))
+            }
+            BrowseSourceContent(
+                source = screenModel.source,
+                mangaList = mangaList,
+                columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
+                displayMode = screenModel.displayMode,
+                snackbarHostState = snackbarHostState,
+                contentPadding = paddingValues,
+                onWebViewClick = {
+                    val source = screenModel.source as? HttpSource ?: return@BrowseSourceContent
+                    val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
+                    context.startActivity(intent)
+                },
+                onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
+                onLocalSourceHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) },
+                onMangaClick = openMigrateDialog,
+                onMangaLongClick = openMigrateDialog,
+            )
+        }
+
+        when (val dialog = state.dialog) {
+            is BrowseSourceScreenModel.Dialog.Migrate -> {
+                MigrateDialog(
+                    oldManga = oldManga,
+                    newManga = dialog.newManga,
+                    screenModel = rememberScreenModel { MigrateDialogScreenModel() },
+                    onDismissRequest = { screenModel.setDialog(null) },
+                    onClickTitle = { router.pushController(MangaController(dialog.newManga.id)) },
+                    onPopScreen = {
+                        // TODO: Push to manga screen and remove this and the previous screen when it moves to Voyager
+                        router.setRoot(BrowseController(toExtensions = false), R.id.nav_browse)
+                        router.pushController(MangaController(dialog.newManga.id))
+                    },
+                )
+            }
+            else -> {}
+        }
+
+        LaunchedEffect(state.filters) {
+            screenModel.initFilterSheet(context)
+        }
+    }
+}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt

@@ -49,7 +49,7 @@ fun Screen.sourcesTab(): TabContent {
                 contentPadding = contentPadding,
                 onClickItem = { source, query ->
                     screenModel.onOpenSource(source)
-                    router.pushController(BrowseSourceController(source, query))
+                    router.pushController(BrowseSourceController(source.id, query))
                 },
                 onClickPin = screenModel::togglePin,
                 onLongClickItem = screenModel::showSourceDialog,

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

@@ -1,32 +1,18 @@
 package eu.kanade.tachiyomi.ui.browse.source.browse
 
 import android.os.Bundle
-import androidx.activity.compose.BackHandler
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.core.os.bundleOf
-import eu.kanade.domain.source.model.Source
-import eu.kanade.presentation.browse.BrowseSourceScreen
-import eu.kanade.presentation.browse.components.RemoveMangaDialog
-import eu.kanade.presentation.components.ChangeCategoryDialog
-import eu.kanade.presentation.components.DuplicateMangaDialog
-import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.source.model.Filter
-import eu.kanade.tachiyomi.source.online.HttpSource
-import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter.Dialog
-import eu.kanade.tachiyomi.ui.category.CategoryController
-import eu.kanade.tachiyomi.ui.manga.MangaController
-import eu.kanade.tachiyomi.ui.webview.WebViewActivity
-import eu.kanade.tachiyomi.util.lang.launchIO
+import cafe.adriel.voyager.navigator.CurrentScreen
+import cafe.adriel.voyager.navigator.Navigator
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.consumeAsFlow
+import kotlinx.coroutines.launch
 
-open class BrowseSourceController(bundle: Bundle) :
-    FullComposeController<BrowseSourcePresenter>(bundle) {
+class BrowseSourceController(bundle: Bundle) : BasicFullComposeController(bundle) {
 
     constructor(sourceId: Long, query: String? = null) : this(
         bundleOf(
@@ -35,117 +21,27 @@ open class BrowseSourceController(bundle: Bundle) :
         ),
     )
 
-    constructor(source: CatalogueSource, query: String? = null) : this(source.id, query)
+    private val sourceId = args.getLong(SOURCE_ID_KEY)
+    private val initialQuery = args.getString(SEARCH_QUERY_KEY)
 
-    constructor(source: Source, query: String? = null) : this(source.id, query)
-
-    /**
-     * Sheet containing filter items.
-     */
-    protected var filterSheet: SourceFilterSheet? = null
-
-    override fun createPresenter(): BrowseSourcePresenter {
-        return BrowseSourcePresenter(args.getLong(SOURCE_ID_KEY), args.getString(SEARCH_QUERY_KEY))
-    }
+    private val queryEvent = Channel<BrowseSourceScreen.SearchType>()
 
     @Composable
     override fun ComposeContent() {
-        val scope = rememberCoroutineScope()
-        val context = LocalContext.current
-        val haptic = LocalHapticFeedback.current
-
-        BrowseSourceScreen(
-            presenter = presenter,
-            navigateUp = ::navigateUp,
-            openFilterSheet = { filterSheet?.show() },
-            onMangaClick = { router.pushController(MangaController(it.id, true)) },
-            onMangaLongClick = { manga ->
-                scope.launchIO {
-                    val duplicateManga = presenter.getDuplicateLibraryManga(manga)
-                    when {
-                        manga.favorite -> presenter.dialog = Dialog.RemoveManga(manga)
-                        duplicateManga != null -> presenter.dialog = Dialog.AddDuplicateManga(manga, duplicateManga)
-                        else -> presenter.addFavorite(manga)
+        Navigator(screen = BrowseSourceScreen(sourceId = sourceId, query = initialQuery)) { navigator ->
+            CurrentScreen()
+
+            LaunchedEffect(Unit) {
+                queryEvent.consumeAsFlow()
+                    .collectLatest {
+                        val screen = (navigator.lastItem as? BrowseSourceScreen)
+                        when (it) {
+                            is BrowseSourceScreen.SearchType.Genre -> screen?.searchGenre(it.txt)
+                            is BrowseSourceScreen.SearchType.Text -> screen?.search(it.txt)
+                        }
                     }
-                    haptic.performHapticFeedback(HapticFeedbackType.LongPress)
-                }
-            },
-            onWebViewClick = f@{
-                val source = presenter.source as? HttpSource ?: return@f
-                val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
-                context.startActivity(intent)
-            },
-            incognitoMode = presenter.isIncognitoMode,
-            downloadedOnlyMode = presenter.isDownloadOnly,
-        )
-
-        val onDismissRequest = { presenter.dialog = null }
-        when (val dialog = presenter.dialog) {
-            null -> {}
-            is Dialog.Migrate -> {}
-            is Dialog.AddDuplicateManga -> {
-                DuplicateMangaDialog(
-                    onDismissRequest = onDismissRequest,
-                    onConfirm = { presenter.addFavorite(dialog.manga) },
-                    onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
-                    duplicateFrom = presenter.getSourceOrStub(dialog.duplicate),
-                )
-            }
-            is Dialog.RemoveManga -> {
-                RemoveMangaDialog(
-                    onDismissRequest = onDismissRequest,
-                    onConfirm = {
-                        presenter.changeMangaFavorite(dialog.manga)
-                    },
-                    mangaToRemove = dialog.manga,
-                )
             }
-            is Dialog.ChangeMangaCategory -> {
-                ChangeCategoryDialog(
-                    initialSelection = dialog.initialSelection,
-                    onDismissRequest = onDismissRequest,
-                    onEditCategories = {
-                        router.pushController(CategoryController())
-                    },
-                    onConfirm = { include, _ ->
-                        presenter.changeMangaFavorite(dialog.manga)
-                        presenter.moveMangaToCategories(dialog.manga, include)
-                    },
-                )
-            }
-        }
-
-        BackHandler(onBack = ::navigateUp)
-
-        LaunchedEffect(presenter.filters) {
-            initFilterSheet()
-        }
-    }
-
-    private fun navigateUp() {
-        when {
-            !presenter.isUserQuery && presenter.searchQuery != null -> presenter.searchQuery = null
-            else -> router.popCurrentController()
-        }
-    }
-
-    open fun initFilterSheet() {
-        if (presenter.filters.isEmpty()) {
-            return
         }
-
-        filterSheet = SourceFilterSheet(
-            activity!!,
-            onFilterClicked = {
-                presenter.search(filters = presenter.filters)
-            },
-            onResetClicked = {
-                presenter.reset()
-                filterSheet?.setFilters(presenter.filterItems)
-            },
-        )
-
-        filterSheet?.setFilters(presenter.filterItems)
     }
 
     /**
@@ -154,7 +50,7 @@ open class BrowseSourceController(bundle: Bundle) :
      * @param newQuery the new query.
      */
     fun searchWithQuery(newQuery: String) {
-        presenter.search(newQuery)
+        viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Text(newQuery)) }
     }
 
     /**
@@ -165,46 +61,9 @@ open class BrowseSourceController(bundle: Bundle) :
      * @param genreName the name of the genre
      */
     fun searchWithGenre(genreName: String) {
-        val defaultFilters = presenter.source!!.getFilterList()
-
-        var genreExists = false
-
-        filter@ for (sourceFilter in defaultFilters) {
-            if (sourceFilter is Filter.Group<*>) {
-                for (filter in sourceFilter.state) {
-                    if (filter is Filter<*> && filter.name.equals(genreName, true)) {
-                        when (filter) {
-                            is Filter.TriState -> filter.state = 1
-                            is Filter.CheckBox -> filter.state = true
-                            else -> {}
-                        }
-                        genreExists = true
-                        break@filter
-                    }
-                }
-            } else if (sourceFilter is Filter.Select<*>) {
-                val index = sourceFilter.values.filterIsInstance<String>()
-                    .indexOfFirst { it.equals(genreName, true) }
-
-                if (index != -1) {
-                    sourceFilter.state = index
-                    genreExists = true
-                    break
-                }
-            }
-        }
-
-        if (genreExists) {
-            filterSheet?.setFilters(defaultFilters.toItems())
-
-            presenter.search(filters = defaultFilters)
-        } else {
-            searchWithQuery(genreName)
-        }
-    }
-
-    protected companion object {
-        const val SOURCE_ID_KEY = "sourceId"
-        const val SEARCH_QUERY_KEY = "searchQuery"
+        viewScope.launch { queryEvent.send(BrowseSourceScreen.SearchType.Genre(genreName)) }
     }
 }
+
+private const val SOURCE_ID_KEY = "sourceId"
+private const val SEARCH_QUERY_KEY = "searchQuery"

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

@@ -0,0 +1,283 @@
+package eu.kanade.tachiyomi.ui.browse.source.browse
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Favorite
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material.icons.outlined.NewReleases
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.paging.compose.collectAsLazyPagingItems
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.core.screen.uniqueScreenKey
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.domain.source.interactor.GetRemoteManga
+import eu.kanade.presentation.browse.BrowseSourceContent
+import eu.kanade.presentation.browse.components.BrowseSourceToolbar
+import eu.kanade.presentation.browse.components.RemoveMangaDialog
+import eu.kanade.presentation.components.AppStateBanners
+import eu.kanade.presentation.components.ChangeCategoryDialog
+import eu.kanade.presentation.components.Divider
+import eu.kanade.presentation.components.DuplicateMangaDialog
+import eu.kanade.presentation.components.Scaffold
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.category.CategoryController
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.ui.more.MoreController
+import eu.kanade.tachiyomi.ui.webview.WebViewActivity
+import eu.kanade.tachiyomi.util.lang.launchIO
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
+
+data class BrowseSourceScreen(
+    private val sourceId: Long,
+    private val query: String? = null,
+) : Screen {
+
+    override val key = uniqueScreenKey
+
+    @Composable
+    override fun Content() {
+        val router = LocalRouter.currentOrThrow
+        val navigator = LocalNavigator.currentOrThrow
+        val scope = rememberCoroutineScope()
+        val context = LocalContext.current
+        val haptic = LocalHapticFeedback.current
+        val uriHandler = LocalUriHandler.current
+
+        val screenModel = rememberScreenModel { BrowseSourceScreenModel(sourceId = sourceId, searchQuery = query) }
+        val state by screenModel.state.collectAsState()
+
+        val snackbarHostState = remember { SnackbarHostState() }
+
+        val onHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) }
+
+        val onWebViewClick = f@{
+            val source = screenModel.source as? HttpSource ?: return@f
+            val intent = WebViewActivity.newIntent(context, source.baseUrl, source.id, source.name)
+            context.startActivity(intent)
+        }
+
+        val navigateUp: () -> Unit = {
+            when {
+                navigator.canPop -> navigator.pop()
+                router.backstackSize > 1 -> router.popCurrentController()
+            }
+        }
+
+        Scaffold(
+            topBar = {
+                Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
+                    BrowseSourceToolbar(
+                        searchQuery = state.toolbarQuery,
+                        onSearchQueryChange = screenModel::setToolbarQuery,
+                        source = screenModel.source,
+                        displayMode = screenModel.displayMode,
+                        onDisplayModeChange = { screenModel.displayMode = it },
+                        navigateUp = navigateUp,
+                        onWebViewClick = onWebViewClick,
+                        onHelpClick = onHelpClick,
+                        onSearch = { screenModel.search(it) },
+                    )
+
+                    Row(
+                        modifier = Modifier
+                            .horizontalScroll(rememberScrollState())
+                            .padding(horizontal = 8.dp),
+                        horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    ) {
+                        FilterChip(
+                            selected = state.currentFilter == BrowseSourceScreenModel.Filter.Popular,
+                            onClick = {
+                                screenModel.reset()
+                                screenModel.search(GetRemoteManga.QUERY_POPULAR)
+                            },
+                            leadingIcon = {
+                                Icon(
+                                    imageVector = Icons.Outlined.Favorite,
+                                    contentDescription = "",
+                                    modifier = Modifier
+                                        .size(FilterChipDefaults.IconSize),
+                                )
+                            },
+                            label = {
+                                Text(text = stringResource(R.string.popular))
+                            },
+                        )
+                        if (screenModel.source.supportsLatest) {
+                            FilterChip(
+                                selected = state.currentFilter == BrowseSourceScreenModel.Filter.Latest,
+                                onClick = {
+                                    screenModel.reset()
+                                    screenModel.search(GetRemoteManga.QUERY_LATEST)
+                                },
+                                leadingIcon = {
+                                    Icon(
+                                        imageVector = Icons.Outlined.NewReleases,
+                                        contentDescription = "",
+                                        modifier = Modifier
+                                            .size(FilterChipDefaults.IconSize),
+                                    )
+                                },
+                                label = {
+                                    Text(text = stringResource(R.string.latest))
+                                },
+                            )
+                        }
+                        if (state.filters.isNotEmpty()) {
+                            FilterChip(
+                                selected = state.currentFilter is BrowseSourceScreenModel.Filter.UserInput,
+                                onClick = screenModel::openFilterSheet,
+                                leadingIcon = {
+                                    Icon(
+                                        imageVector = Icons.Outlined.FilterList,
+                                        contentDescription = "",
+                                        modifier = Modifier
+                                            .size(FilterChipDefaults.IconSize),
+                                    )
+                                },
+                                label = {
+                                    Text(text = stringResource(R.string.action_filter))
+                                },
+                            )
+                        }
+                    }
+
+                    Divider()
+
+                    AppStateBanners(screenModel.isDownloadOnly, screenModel.isIncognitoMode)
+                }
+            },
+            snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+        ) { paddingValues ->
+            val mangaList = remember(state.currentFilter) {
+                screenModel.getMangaListFlow(state.currentFilter)
+            }.collectAsLazyPagingItems()
+
+            BrowseSourceContent(
+                source = screenModel.source,
+                mangaList = mangaList,
+                columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
+                displayMode = screenModel.displayMode,
+                snackbarHostState = snackbarHostState,
+                contentPadding = paddingValues,
+                onWebViewClick = onWebViewClick,
+                onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
+                onLocalSourceHelpClick = onHelpClick,
+                onMangaClick = { router.pushController(MangaController(it.id, true)) },
+                onMangaLongClick = { manga ->
+                    scope.launchIO {
+                        val duplicateManga = screenModel.getDuplicateLibraryManga(manga)
+                        when {
+                            manga.favorite -> screenModel.setDialog(BrowseSourceScreenModel.Dialog.RemoveManga(manga))
+                            duplicateManga != null -> screenModel.setDialog(
+                                BrowseSourceScreenModel.Dialog.AddDuplicateManga(
+                                    manga,
+                                    duplicateManga,
+                                ),
+                            )
+                            else -> screenModel.addFavorite(manga)
+                        }
+                        haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+                    }
+                },
+            )
+        }
+
+        val onDismissRequest = { screenModel.setDialog(null) }
+        when (val dialog = state.dialog) {
+            is BrowseSourceScreenModel.Dialog.Migrate -> {}
+            is BrowseSourceScreenModel.Dialog.AddDuplicateManga -> {
+                DuplicateMangaDialog(
+                    onDismissRequest = onDismissRequest,
+                    onConfirm = { screenModel.addFavorite(dialog.manga) },
+                    onOpenManga = { router.pushController(MangaController(dialog.duplicate.id)) },
+                    duplicateFrom = screenModel.getSourceOrStub(dialog.duplicate),
+                )
+            }
+            is BrowseSourceScreenModel.Dialog.RemoveManga -> {
+                RemoveMangaDialog(
+                    onDismissRequest = onDismissRequest,
+                    onConfirm = {
+                        screenModel.changeMangaFavorite(dialog.manga)
+                    },
+                    mangaToRemove = dialog.manga,
+                )
+            }
+            is BrowseSourceScreenModel.Dialog.ChangeMangaCategory -> {
+                ChangeCategoryDialog(
+                    initialSelection = dialog.initialSelection,
+                    onDismissRequest = onDismissRequest,
+                    onEditCategories = {
+                        router.pushController(CategoryController())
+                    },
+                    onConfirm = { include, _ ->
+                        screenModel.changeMangaFavorite(dialog.manga)
+                        screenModel.moveMangaToCategories(dialog.manga, include)
+                    },
+                )
+            }
+            else -> {}
+        }
+
+        BackHandler(onBack = navigateUp)
+
+        LaunchedEffect(state.filters) {
+            screenModel.initFilterSheet(context)
+        }
+
+        LaunchedEffect(Unit) {
+            queryEvent.receiveAsFlow()
+                .collectLatest {
+                    when (it) {
+                        is SearchType.Genre -> screenModel.searchGenre(it.txt)
+                        is SearchType.Text -> screenModel.search(it.txt)
+                    }
+                }
+        }
+    }
+
+    private val queryEvent = Channel<SearchType>()
+    suspend fun search(query: String) = queryEvent.send(SearchType.Text(query))
+    suspend fun searchGenre(name: String) = queryEvent.send(SearchType.Genre(name))
+
+    sealed class SearchType(val txt: String) {
+        class Text(txt: String) : SearchType(txt)
+        class Genre(txt: String) : SearchType(txt)
+    }
+}

+ 172 - 92
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt

@@ -1,23 +1,22 @@
 package eu.kanade.tachiyomi.ui.browse.source.browse
 
+import android.content.Context
 import android.content.res.Configuration
-import android.os.Bundle
 import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
+import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.produceState
-import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.unit.dp
 import androidx.paging.Pager
 import androidx.paging.PagingConfig
 import androidx.paging.PagingData
 import androidx.paging.cachedIn
 import androidx.paging.map
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
 import eu.davidea.flexibleadapter.items.IFlexible
 import eu.kanade.core.prefs.CheckboxState
+import eu.kanade.core.prefs.asState
 import eu.kanade.core.prefs.mapAsCheckboxState
 import eu.kanade.domain.base.BasePreferences
 import eu.kanade.domain.category.interactor.GetCategories
@@ -39,8 +38,6 @@ import eu.kanade.domain.source.interactor.GetRemoteManga
 import eu.kanade.domain.source.service.SourcePreferences
 import eu.kanade.domain.track.interactor.InsertTrack
 import eu.kanade.domain.track.model.toDomainTrack
-import eu.kanade.presentation.browse.BrowseSourceState
-import eu.kanade.presentation.browse.BrowseSourceStateImpl
 import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 import eu.kanade.tachiyomi.data.track.TrackManager
@@ -48,9 +45,7 @@ import eu.kanade.tachiyomi.data.track.TrackService
 import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.SourceManager
-import eu.kanade.tachiyomi.source.model.Filter
 import eu.kanade.tachiyomi.source.model.FilterList
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.CheckboxSectionItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.GroupItem
@@ -70,19 +65,23 @@ import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
 import eu.kanade.tachiyomi.util.removeCovers
 import eu.kanade.tachiyomi.util.system.logcat
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.util.Date
+import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
 
-open class BrowseSourcePresenter(
+class BrowseSourceScreenModel(
     private val sourceId: Long,
-    searchQuery: String? = null,
-    private val state: BrowseSourceStateImpl = BrowseSourceState(searchQuery) as BrowseSourceStateImpl,
+    searchQuery: String?,
     private val sourceManager: SourceManager = Injekt.get(),
     preferences: BasePreferences = Injekt.get(),
     sourcePreferences: SourcePreferences = Injekt.get(),
@@ -99,86 +98,122 @@ open class BrowseSourcePresenter(
     private val updateManga: UpdateManga = Injekt.get(),
     private val insertTrack: InsertTrack = Injekt.get(),
     private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
-) : BasePresenter<BrowseSourceController>(), BrowseSourceState by state {
+) : StateScreenModel<BrowseSourceScreenModel.State>(State(Filter.valueOf(searchQuery))) {
 
     private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
 
-    var displayMode by sourcePreferences.sourceDisplayMode().asState()
+    var displayMode by sourcePreferences.sourceDisplayMode().asState(coroutineScope)
 
-    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
-    val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
+    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
+    val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
 
-    @Composable
-    fun getColumnsPreferenceForCurrentOrientation(): State<GridCells> {
-        val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
-        return produceState<GridCells>(initialValue = GridCells.Adaptive(128.dp), isLandscape) {
-            (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns())
-                .changes()
-                .collectLatest { columns ->
-                    value = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns)
-                }
-        }
+    val source = sourceManager.get(sourceId) as CatalogueSource
+
+    /**
+     * Sheet containing filter items.
+     */
+    private var filterSheet: SourceFilterSheet? = null
+
+    init {
+        mutableState.update { it.copy(filters = source.getFilterList()) }
     }
 
-    @Composable
-    fun getMangaList(): Flow<PagingData<Manga>> {
-        return remember(currentFilter) {
-            Pager(
-                PagingConfig(pageSize = 25),
-            ) {
-                getRemoteManga.subscribe(sourceId, currentFilter.query, currentFilter.filters)
-            }.flow
-                .map {
-                    it.map { sManga ->
-                        withIOContext {
-                            networkToLocalManga.await(sManga.toDomainManga(sourceId))
-                        }
-                    }
-                }
-                .cachedIn(presenterScope)
-        }
+    fun getColumnsPreference(orientation: Int): GridCells {
+        val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE
+        val columns = if (isLandscape) {
+            libraryPreferences.landscapeColumns()
+        } else {
+            libraryPreferences.portraitColumns()
+        }.get()
+        return if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns)
     }
 
-    @Composable
-    fun getManga(initialManga: Manga): State<Manga> {
-        return produceState(initialValue = initialManga) {
-            getManga.subscribe(initialManga.url, initialManga.source)
-                .collectLatest { manga ->
-                    if (manga == null) return@collectLatest
-                    withIOContext {
-                        initializeManga(manga)
-                    }
-                    value = manga
+    fun getMangaListFlow(currentFilter: Filter): Flow<PagingData<StateFlow<Manga>>> {
+        return Pager(
+            PagingConfig(pageSize = 25),
+        ) {
+            getRemoteManga.subscribe(sourceId, currentFilter.query ?: "", currentFilter.filters)
+        }.flow
+            .map { pagingData ->
+                pagingData.map { sManga ->
+                    val dbManga = withIOContext { networkToLocalManga.await(sManga.toDomainManga(sourceId)) }
+                    getManga.subscribe(dbManga.url, dbManga.source)
+                        .filterNotNull()
+                        .onEach { initializeManga(it) }
+                        .stateIn(coroutineScope)
                 }
-        }
+            }
+            .cachedIn(coroutineScope)
     }
 
     fun reset() {
-        val source = source ?: return
-        state.filters = source.getFilterList()
+        mutableState.update { it.copy(filters = source.getFilterList()) }
     }
 
     fun search(query: String? = null, filters: FilterList? = null) {
-        Filter.valueOf(query ?: "").let {
+        Filter.valueOf(query).let {
             if (it !is Filter.UserInput) {
-                state.currentFilter = it
-                state.searchQuery = null
+                mutableState.update { state -> state.copy(currentFilter = it) }
                 return
             }
         }
 
-        val input: Filter.UserInput = if (currentFilter is Filter.UserInput) currentFilter as Filter.UserInput else Filter.UserInput()
-        state.currentFilter = input.copy(
-            query = query ?: input.query,
-            filters = filters ?: input.filters,
-        )
+        val input = if (state.value.currentFilter is Filter.UserInput) {
+            state.value.currentFilter as Filter.UserInput
+        } else {
+            Filter.UserInput()
+        }
+        mutableState.update {
+            it.copy(
+                currentFilter = input.copy(
+                    query = query ?: input.query,
+                    filters = filters ?: input.filters,
+                ),
+                toolbarQuery = query ?: input.query,
+            )
+        }
     }
 
-    override fun onCreate(savedState: Bundle?) {
-        super.onCreate(savedState)
+    fun searchGenre(genreName: String) {
+        val defaultFilters = source.getFilterList()
+        var genreExists = false
+
+        filter@ for (sourceFilter in defaultFilters) {
+            if (sourceFilter is SourceModelFilter.Group<*>) {
+                for (filter in sourceFilter.state) {
+                    if (filter is SourceModelFilter<*> && filter.name.equals(genreName, true)) {
+                        when (filter) {
+                            is SourceModelFilter.TriState -> filter.state = 1
+                            is SourceModelFilter.CheckBox -> filter.state = true
+                            else -> {}
+                        }
+                        genreExists = true
+                        break@filter
+                    }
+                }
+            } else if (sourceFilter is SourceModelFilter.Select<*>) {
+                val index = sourceFilter.values.filterIsInstance<String>()
+                    .indexOfFirst { it.equals(genreName, true) }
+
+                if (index != -1) {
+                    sourceFilter.state = index
+                    genreExists = true
+                    break
+                }
+            }
+        }
 
-        state.source = sourceManager.get(sourceId) as? CatalogueSource ?: return
-        state.filters = source!!.getFilterList()
+        mutableState.update {
+            val filter = if (genreExists) {
+                Filter.UserInput(filters = defaultFilters)
+            } else {
+                Filter.UserInput(query = genreName)
+            }
+            it.copy(
+                filters = defaultFilters,
+                currentFilter = filter,
+            )
+        }
     }
 
     /**
@@ -190,7 +225,7 @@ open class BrowseSourcePresenter(
         if (manga.thumbnailUrl != null || manga.initialized) return
         withNonCancellableContext {
             try {
-                val networkManga = source!!.getMangaDetails(manga.toSManga())
+                val networkManga = source.getMangaDetails(manga.toSManga())
                 val updatedManga = manga.copyFrom(networkManga)
                     .copy(initialized = true)
 
@@ -207,7 +242,7 @@ open class BrowseSourcePresenter(
      * @param manga the manga to update.
      */
     fun changeMangaFavorite(manga: Manga) {
-        presenterScope.launch {
+        coroutineScope.launch {
             var new = manga.copy(
                 favorite = !manga.favorite,
                 dateAdded = when (manga.favorite) {
@@ -233,7 +268,7 @@ open class BrowseSourcePresenter(
     }
 
     fun addFavorite(manga: Manga) {
-        presenterScope.launch {
+        coroutineScope.launch {
             val categories = getCategories()
             val defaultCategoryId = libraryPreferences.defaultCategory().get()
             val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
@@ -256,7 +291,7 @@ open class BrowseSourcePresenter(
                 // Choose a category
                 else -> {
                     val preselectedIds = getCategories.await(manga.id).map { it.id }
-                    state.dialog = Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds })
+                    setDialog(Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }))
                 }
             }
         }
@@ -265,7 +300,7 @@ open class BrowseSourcePresenter(
     private suspend fun autoAddTrack(manga: Manga) {
         loggedServices
             .filterIsInstance<EnhancedTrackService>()
-            .filter { it.accept(source!!) }
+            .filter { it.accept(source) }
             .forEach { service ->
                 try {
                     service.match(manga.toDbManga())?.let { track ->
@@ -303,7 +338,7 @@ open class BrowseSourcePresenter(
     }
 
     fun moveMangaToCategories(manga: Manga, categoryIds: List<Long>) {
-        presenterScope.launchIO {
+        coroutineScope.launchIO {
             setMangaCategories.await(
                 mangaId = manga.id,
                 categoryIds = categoryIds.toList(),
@@ -311,13 +346,43 @@ open class BrowseSourcePresenter(
         }
     }
 
-    sealed class Filter(open val query: String, open val filters: FilterList) {
+    fun openFilterSheet() {
+        filterSheet?.show()
+    }
+
+    fun setDialog(dialog: Dialog?) {
+        mutableState.update { it.copy(dialog = dialog) }
+    }
+
+    fun setToolbarQuery(query: String?) {
+        mutableState.update { it.copy(toolbarQuery = query) }
+    }
+
+    fun initFilterSheet(context: Context) {
+        val state = state.value
+        if (state.filters.isEmpty()) {
+            return
+        }
+
+        filterSheet = SourceFilterSheet(
+            context = context,
+            onFilterClicked = { search(filters = state.filters) },
+            onResetClicked = {
+                reset()
+                filterSheet?.setFilters(state.filterItems)
+            },
+        )
+
+        filterSheet?.setFilters(state.filterItems)
+    }
+
+    sealed class Filter(open val query: String?, open val filters: FilterList) {
         object Popular : Filter(query = GetRemoteManga.QUERY_POPULAR, filters = FilterList())
         object Latest : Filter(query = GetRemoteManga.QUERY_LATEST, filters = FilterList())
-        data class UserInput(override val query: String = "", override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters)
+        data class UserInput(override val query: String? = null, override val filters: FilterList = FilterList()) : Filter(query = query, filters = filters)
 
         companion object {
-            fun valueOf(query: String): Filter {
+            fun valueOf(query: String?): Filter {
                 return when (query) {
                     GetRemoteManga.QUERY_POPULAR -> Popular
                     GetRemoteManga.QUERY_LATEST -> Latest
@@ -336,25 +401,40 @@ open class BrowseSourcePresenter(
         ) : Dialog()
         data class Migrate(val newManga: Manga) : Dialog()
     }
+
+    @Immutable
+    data class State(
+        val currentFilter: Filter,
+        val filters: FilterList = FilterList(),
+        val toolbarQuery: String? = null,
+        val dialog: Dialog? = null,
+    ) {
+        val filterItems = filters.toItems()
+        val isUserQuery = currentFilter is Filter.UserInput && !currentFilter.query.isNullOrEmpty()
+        val searchQuery = when (currentFilter) {
+            is Filter.UserInput -> currentFilter.query
+            Filter.Latest, Filter.Popular -> null
+        }
+    }
 }
 
-fun FilterList.toItems(): List<IFlexible<*>> {
+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<*> -> {
+            is SourceModelFilter.Header -> HeaderItem(filter)
+            is SourceModelFilter.Separator -> SeparatorItem(filter)
+            is SourceModelFilter.CheckBox -> CheckboxItem(filter)
+            is SourceModelFilter.TriState -> TriStateItem(filter)
+            is SourceModelFilter.Text -> TextItem(filter)
+            is SourceModelFilter.Select<*> -> SelectItem(filter)
+            is SourceModelFilter.Group<*> -> {
                 val group = GroupItem(filter)
                 val subItems = filter.state.mapNotNull {
                     when (it) {
-                        is Filter.CheckBox -> CheckboxSectionItem(it)
-                        is Filter.TriState -> TriStateSectionItem(it)
-                        is Filter.Text -> TextSectionItem(it)
-                        is Filter.Select<*> -> SelectSectionItem(it)
+                        is SourceModelFilter.CheckBox -> CheckboxSectionItem(it)
+                        is SourceModelFilter.TriState -> TriStateSectionItem(it)
+                        is SourceModelFilter.Text -> TextSectionItem(it)
+                        is SourceModelFilter.Select<*> -> SelectSectionItem(it)
                         else -> null
                     }
                 }
@@ -362,7 +442,7 @@ fun FilterList.toItems(): List<IFlexible<*>> {
                 group.subItems = subItems
                 group
             }
-            is Filter.Sort -> {
+            is SourceModelFilter.Sort -> {
                 val group = SortGroup(filter)
                 val subItems = filter.values.map {
                     SortItem(it, group)

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

@@ -1,6 +1,5 @@
 package eu.kanade.tachiyomi.ui.browse.source.browse
 
-import android.app.Activity
 import android.content.Context
 import android.util.AttributeSet
 import android.view.LayoutInflater
@@ -13,12 +12,12 @@ import eu.kanade.tachiyomi.widget.SimpleNavigationView
 import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
 
 class SourceFilterSheet(
-    activity: Activity,
+    context: Context,
     private val onFilterClicked: () -> Unit,
     private val onResetClicked: () -> Unit,
-) : BaseBottomSheetDialog(activity) {
+) : BaseBottomSheetDialog(context) {
 
-    private var filterNavView: FilterNavigationView = FilterNavigationView(activity)
+    private var filterNavView: FilterNavigationView = FilterNavigationView(context)
 
     override fun createView(inflater: LayoutInflater): View {
         filterNavView.onFilterClicked = {

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt

@@ -44,7 +44,7 @@ class GlobalSearchScreen(
                 if (!screenModel.incognitoMode.get()) {
                     screenModel.lastUsedSourceId.set(it.id)
                 }
-                router.pushController(BrowseSourceController(it, state.searchQuery))
+                router.pushController(BrowseSourceController(it.id, state.searchQuery))
             },
             onClickItem = { router.pushController(MangaController(it.id, true)) },
             onLongClickItem = { router.pushController(MangaController(it.id, true)) },