Browse Source

Use Voyager on Browse tab (#8605)

Ivan Iskandar 2 years ago
parent
commit
f4ac754d02
20 changed files with 465 additions and 508 deletions
  1. 7 7
      app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt
  2. 0 27
      app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt
  3. 11 9
      app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt
  4. 0 28
      app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt
  5. 39 85
      app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt
  6. 0 27
      app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt
  7. 7 1
      app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt
  8. 2 12
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt
  9. 4 40
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt
  10. 0 31
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt
  11. 66 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt
  12. 42 34
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt
  13. 41 50
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt
  14. 91 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceScreenModel.kt
  15. 15 8
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt
  16. 0 75
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt
  17. 53 37
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt
  18. 69 34
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt
  19. 15 0
      app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
  20. 3 3
      app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt

+ 7 - 7
app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt

@@ -51,12 +51,12 @@ import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.extension.model.InstallStep
 import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
-import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsState
 import eu.kanade.tachiyomi.util.system.LocaleHelper
 
 @Composable
 fun ExtensionScreen(
-    presenter: ExtensionsPresenter,
+    state: ExtensionsState,
     contentPadding: PaddingValues,
     onLongClickItem: (Extension) -> Unit,
     onClickItemCancel: (Extension) -> Unit,
@@ -69,19 +69,19 @@ fun ExtensionScreen(
     onRefresh: () -> Unit,
 ) {
     SwipeRefresh(
-        refreshing = presenter.isRefreshing,
+        refreshing = state.isRefreshing,
         onRefresh = onRefresh,
-        enabled = !presenter.isLoading,
+        enabled = !state.isLoading,
     ) {
         when {
-            presenter.isLoading -> LoadingScreen()
-            presenter.isEmpty -> EmptyScreen(
+            state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
+            state.isEmpty -> EmptyScreen(
                 textResource = R.string.empty_screen,
                 modifier = Modifier.padding(contentPadding),
             )
             else -> {
                 ExtensionContent(
-                    state = presenter,
+                    state = state,
                     contentPadding = contentPadding,
                     onLongClickItem = onLongClickItem,
                     onClickItemCancel = onClickItemCancel,

+ 0 - 27
app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt

@@ -1,27 +0,0 @@
-package eu.kanade.presentation.browse
-
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
-
-interface ExtensionsState {
-    val isLoading: Boolean
-    val isRefreshing: Boolean
-    val items: List<ExtensionUiModel>
-    val updates: Int
-    val isEmpty: Boolean
-}
-
-fun ExtensionState(): ExtensionsState {
-    return ExtensionsStateImpl()
-}
-
-class ExtensionsStateImpl : ExtensionsState {
-    override var isLoading: Boolean by mutableStateOf(true)
-    override var isRefreshing: Boolean by mutableStateOf(false)
-    override var items: List<ExtensionUiModel> by mutableStateOf(emptyList())
-    override var updates: Int by mutableStateOf(0)
-    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
-}

+ 11 - 9
app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt

@@ -39,35 +39,37 @@ import eu.kanade.presentation.util.plus
 import eu.kanade.presentation.util.secondaryItemAlpha
 import eu.kanade.presentation.util.topSmallPaddingValues
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
+import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceState
 import eu.kanade.tachiyomi.util.system.copyToClipboard
 
 @Composable
 fun MigrateSourceScreen(
-    presenter: MigrationSourcesPresenter,
+    state: MigrateSourceState,
     contentPadding: PaddingValues,
     onClickItem: (Source) -> Unit,
+    onToggleSortingDirection: () -> Unit,
+    onToggleSortingMode: () -> Unit,
 ) {
     val context = LocalContext.current
     when {
-        presenter.isLoading -> LoadingScreen()
-        presenter.isEmpty -> EmptyScreen(
+        state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
+        state.isEmpty -> EmptyScreen(
             textResource = R.string.information_empty_library,
             modifier = Modifier.padding(contentPadding),
         )
         else ->
             MigrateSourceList(
-                list = presenter.items,
+                list = state.items,
                 contentPadding = contentPadding,
                 onClickItem = onClickItem,
                 onLongClickItem = { source ->
                     val sourceId = source.id.toString()
                     context.copyToClipboard(sourceId, sourceId)
                 },
-                sortingMode = presenter.sortingMode,
-                onToggleSortingMode = { presenter.toggleSortingMode() },
-                sortingDirection = presenter.sortingDirection,
-                onToggleSortingDirection = { presenter.toggleSortingDirection() },
+                sortingMode = state.sortingMode,
+                onToggleSortingMode = onToggleSortingMode,
+                sortingDirection = state.sortingDirection,
+                onToggleSortingDirection = onToggleSortingDirection,
             )
     }
 }

+ 0 - 28
app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt

@@ -1,28 +0,0 @@
-package eu.kanade.presentation.browse
-
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import eu.kanade.domain.source.interactor.SetMigrateSorting
-import eu.kanade.domain.source.model.Source
-
-interface MigrateSourceState {
-    val isLoading: Boolean
-    val items: List<Pair<Source, Long>>
-    val isEmpty: Boolean
-    val sortingMode: SetMigrateSorting.Mode
-    val sortingDirection: SetMigrateSorting.Direction
-}
-
-fun MigrateSourceState(): MigrateSourceState {
-    return MigrateSourceStateImpl()
-}
-
-class MigrateSourceStateImpl : MigrateSourceState {
-    override var isLoading: Boolean by mutableStateOf(true)
-    override var items: List<Pair<Source, Long>> by mutableStateOf(emptyList())
-    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
-    override var sortingMode: SetMigrateSorting.Mode by mutableStateOf(SetMigrateSorting.Mode.ALPHABETICAL)
-    override var sortingDirection: SetMigrateSorting.Direction by mutableStateOf(SetMigrateSorting.Direction.ASCENDING)
-}

+ 39 - 85
app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt

@@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
@@ -35,108 +34,63 @@ import eu.kanade.presentation.util.plus
 import eu.kanade.presentation.util.topSmallPaddingValues
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
+import eu.kanade.tachiyomi.ui.browse.source.SourcesState
 import eu.kanade.tachiyomi.util.system.LocaleHelper
-import eu.kanade.tachiyomi.util.system.toast
-import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun SourcesScreen(
-    presenter: SourcesPresenter,
+    state: SourcesState,
     contentPadding: PaddingValues,
     onClickItem: (Source, String) -> Unit,
-    onClickDisable: (Source) -> Unit,
     onClickPin: (Source) -> Unit,
+    onLongClickItem: (Source) -> Unit,
 ) {
-    val context = LocalContext.current
     when {
-        presenter.isLoading -> LoadingScreen()
-        presenter.isEmpty -> EmptyScreen(
+        state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
+        state.isEmpty -> EmptyScreen(
             textResource = R.string.source_empty_screen,
             modifier = Modifier.padding(contentPadding),
         )
         else -> {
-            SourceList(
-                state = presenter,
-                contentPadding = contentPadding,
-                onClickItem = onClickItem,
-                onClickDisable = onClickDisable,
-                onClickPin = onClickPin,
-            )
-        }
-    }
-    LaunchedEffect(Unit) {
-        presenter.events.collectLatest { event ->
-            when (event) {
-                SourcesPresenter.Event.FailedFetchingSources -> {
-                    context.toast(R.string.internal_error)
+            ScrollbarLazyColumn(
+                contentPadding = contentPadding + topSmallPaddingValues,
+            ) {
+                items(
+                    items = state.items,
+                    contentType = {
+                        when (it) {
+                            is SourceUiModel.Header -> "header"
+                            is SourceUiModel.Item -> "item"
+                        }
+                    },
+                    key = {
+                        when (it) {
+                            is SourceUiModel.Header -> it.hashCode()
+                            is SourceUiModel.Item -> "source-${it.source.key()}"
+                        }
+                    },
+                ) { model ->
+                    when (model) {
+                        is SourceUiModel.Header -> {
+                            SourceHeader(
+                                modifier = Modifier.animateItemPlacement(),
+                                language = model.language,
+                            )
+                        }
+                        is SourceUiModel.Item -> SourceItem(
+                            modifier = Modifier.animateItemPlacement(),
+                            source = model.source,
+                            onClickItem = onClickItem,
+                            onLongClickItem = onLongClickItem,
+                            onClickPin = onClickPin,
+                        )
+                    }
                 }
             }
         }
     }
 }
 
-@Composable
-private fun SourceList(
-    state: SourcesState,
-    contentPadding: PaddingValues,
-    onClickItem: (Source, String) -> Unit,
-    onClickDisable: (Source) -> Unit,
-    onClickPin: (Source) -> Unit,
-) {
-    ScrollbarLazyColumn(
-        contentPadding = contentPadding + topSmallPaddingValues,
-    ) {
-        items(
-            items = state.items,
-            contentType = {
-                when (it) {
-                    is SourceUiModel.Header -> "header"
-                    is SourceUiModel.Item -> "item"
-                }
-            },
-            key = {
-                when (it) {
-                    is SourceUiModel.Header -> it.hashCode()
-                    is SourceUiModel.Item -> "source-${it.source.key()}"
-                }
-            },
-        ) { model ->
-            when (model) {
-                is SourceUiModel.Header -> {
-                    SourceHeader(
-                        modifier = Modifier.animateItemPlacement(),
-                        language = model.language,
-                    )
-                }
-                is SourceUiModel.Item -> SourceItem(
-                    modifier = Modifier.animateItemPlacement(),
-                    source = model.source,
-                    onClickItem = onClickItem,
-                    onLongClickItem = { state.dialog = SourcesPresenter.Dialog(it) },
-                    onClickPin = onClickPin,
-                )
-            }
-        }
-    }
-
-    if (state.dialog != null) {
-        val source = state.dialog!!.source
-        SourceOptionsDialog(
-            source = source,
-            onClickPin = {
-                onClickPin(source)
-                state.dialog = null
-            },
-            onClickDisable = {
-                onClickDisable(source)
-                state.dialog = null
-            },
-            onDismiss = { state.dialog = null },
-        )
-    }
-}
-
 @Composable
 private fun SourceHeader(
     modifier: Modifier = Modifier,
@@ -201,7 +155,7 @@ private fun SourcePinButton(
 }
 
 @Composable
-private fun SourceOptionsDialog(
+fun SourceOptionsDialog(
     source: Source,
     onClickPin: () -> Unit,
     onClickDisable: () -> Unit,

+ 0 - 27
app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt

@@ -1,27 +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.kanade.tachiyomi.ui.browse.source.SourcesPresenter
-
-@Stable
-interface SourcesState {
-    var dialog: SourcesPresenter.Dialog?
-    val isLoading: Boolean
-    val items: List<SourceUiModel>
-    val isEmpty: Boolean
-}
-
-fun SourcesState(): SourcesState {
-    return SourcesStateImpl()
-}
-
-class SourcesStateImpl : SourcesState {
-    override var dialog: SourcesPresenter.Dialog? by mutableStateOf(null)
-    override var isLoading: Boolean by mutableStateOf(true)
-    override var items: List<SourceUiModel> by mutableStateOf(emptyList())
-    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
-}

+ 7 - 1
app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt

@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.calculateStartPadding
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Tab
 import androidx.compose.material3.TabRow
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -32,6 +35,7 @@ fun TabbedScreen(
 ) {
     val scope = rememberCoroutineScope()
     val state = rememberPagerState()
+    val snackbarHostState = remember { SnackbarHostState() }
 
     LaunchedEffect(startIndex) {
         if (startIndex != null) {
@@ -52,6 +56,7 @@ fun TabbedScreen(
                 actions = { AppBarActions(tab.actions) },
             )
         },
+        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
     ) { contentPadding ->
         Column(
             modifier = Modifier.padding(
@@ -86,6 +91,7 @@ fun TabbedScreen(
                     TachiyomiBottomNavigationView.withBottomNavPadding(
                         PaddingValues(bottom = contentPadding.calculateBottomPadding()),
                     ),
+                    snackbarHostState,
                 )
             }
         }
@@ -97,5 +103,5 @@ data class TabContent(
     val badgeNumber: Int? = null,
     val searchEnabled: Boolean = false,
     val actions: List<AppBar.Action> = emptyList(),
-    val content: @Composable (contentPadding: PaddingValues) -> Unit,
+    val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
 )

+ 2 - 12
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt

@@ -1,6 +1,5 @@
 package eu.kanade.presentation.more.settings.screen
 
-import android.Manifest
 import android.content.ActivityNotFoundException
 import android.content.Context
 import android.content.Intent
@@ -22,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -37,7 +35,6 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.core.net.toUri
-import com.google.accompanist.permissions.rememberPermissionState
 import com.hippo.unifile.UniFile
 import eu.kanade.domain.backup.service.BackupPreferences
 import eu.kanade.presentation.components.Divider
@@ -52,6 +49,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
 import eu.kanade.tachiyomi.data.backup.BackupFileValidator
 import eu.kanade.tachiyomi.data.backup.BackupRestoreService
 import eu.kanade.tachiyomi.data.backup.models.Backup
+import eu.kanade.tachiyomi.util.storage.DiskUtil
 import eu.kanade.tachiyomi.util.system.DeviceUtil
 import eu.kanade.tachiyomi.util.system.copyToClipboard
 import eu.kanade.tachiyomi.util.system.toast
@@ -70,7 +68,7 @@ object SettingsBackupScreen : SearchableSettings {
     override fun getPreferences(): List<Preference> {
         val backupPreferences = Injekt.get<BackupPreferences>()
 
-        RequestStoragePermission()
+        DiskUtil.RequestStoragePermission()
 
         return listOf(
             getCreateBackupPref(),
@@ -79,14 +77,6 @@ object SettingsBackupScreen : SearchableSettings {
         )
     }
 
-    @Composable
-    private fun RequestStoragePermission() {
-        val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
-        LaunchedEffect(Unit) {
-            permissionState.launchPermissionRequest()
-        }
-    }
-
     @Composable
     private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference {
         val scope = rememberCoroutineScope()

+ 4 - 40
app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt

@@ -1,24 +1,13 @@
 package eu.kanade.tachiyomi.ui.browse
 
-import android.Manifest
 import android.os.Bundle
-import android.view.View
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
 import androidx.core.os.bundleOf
-import eu.kanade.presentation.components.TabbedScreen
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
+import cafe.adriel.voyager.navigator.Navigator
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
-import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
-import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab
-import eu.kanade.tachiyomi.ui.browse.migration.sources.migrateSourcesTab
-import eu.kanade.tachiyomi.ui.browse.source.sourcesTab
-import eu.kanade.tachiyomi.ui.main.MainActivity
 
-class BrowseController : FullComposeController<BrowsePresenter>, RootController {
+class BrowseController : BasicFullComposeController, RootController {
 
     @Suppress("unused")
     constructor(bundle: Bundle? = null) : this(bundle?.getBoolean(TO_EXTENSIONS_EXTRA) ?: false)
@@ -29,34 +18,9 @@ class BrowseController : FullComposeController<BrowsePresenter>, RootController
 
     private val toExtensions = args.getBoolean(TO_EXTENSIONS_EXTRA, false)
 
-    override fun createPresenter() = BrowsePresenter()
-
     @Composable
     override fun ComposeContent() {
-        val query by presenter.extensionsPresenter.query.collectAsState()
-
-        TabbedScreen(
-            titleRes = R.string.browse,
-            tabs = listOf(
-                sourcesTab(router, presenter.sourcesPresenter),
-                extensionsTab(router, presenter.extensionsPresenter),
-                migrateSourcesTab(router, presenter.migrationSourcesPresenter),
-            ),
-            startIndex = 1.takeIf { toExtensions },
-            searchQuery = query,
-            onChangeSearchQuery = { presenter.extensionsPresenter.search(it) },
-            incognitoMode = presenter.isIncognitoMode,
-            downloadedOnlyMode = presenter.isDownloadOnly,
-        )
-
-        LaunchedEffect(Unit) {
-            (activity as? MainActivity)?.ready = true
-        }
-    }
-
-    override fun onViewCreated(view: View) {
-        super.onViewCreated(view)
-        requestPermissionsSafe(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 301)
+        Navigator(screen = BrowseScreen(toExtensions = toExtensions))
     }
 }
 

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

@@ -1,31 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse
-
-import android.os.Bundle
-import androidx.compose.runtime.getValue
-import eu.kanade.domain.base.BasePreferences
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
-import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter
-import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
-import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-
-class BrowsePresenter(
-    preferences: BasePreferences = Injekt.get(),
-) : BasePresenter<BrowseController>() {
-
-    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
-    val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
-
-    val sourcesPresenter = SourcesPresenter(presenterScope)
-    val extensionsPresenter = ExtensionsPresenter(presenterScope)
-    val migrationSourcesPresenter = MigrationSourcesPresenter(presenterScope)
-
-    override fun onCreate(savedState: Bundle?) {
-        super.onCreate(savedState)
-
-        sourcesPresenter.onCreate()
-        extensionsPresenter.onCreate()
-        migrationSourcesPresenter.onCreate()
-    }
-}

+ 66 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt

@@ -0,0 +1,66 @@
+package eu.kanade.tachiyomi.ui.browse
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import eu.kanade.core.prefs.asState
+import eu.kanade.domain.base.BasePreferences
+import eu.kanade.presentation.components.TabbedScreen
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
+import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab
+import eu.kanade.tachiyomi.ui.browse.migration.sources.migrateSourceTab
+import eu.kanade.tachiyomi.ui.browse.source.sourcesTab
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.util.storage.DiskUtil
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+data class BrowseScreen(
+    private val toExtensions: Boolean,
+) : Screen {
+
+    @Composable
+    override fun Content() {
+        val context = LocalContext.current
+        val screenModel = rememberScreenModel { BrowseScreenModel() }
+
+        // Hoisted for extensions tab's search bar
+        val extensionsScreenModel = rememberScreenModel { ExtensionsScreenModel() }
+        val extensionsQuery by extensionsScreenModel.query.collectAsState()
+
+        TabbedScreen(
+            titleRes = R.string.browse,
+            tabs = listOf(
+                sourcesTab(),
+                extensionsTab(extensionsScreenModel),
+                migrateSourceTab(),
+            ),
+            startIndex = 1.takeIf { toExtensions },
+            searchQuery = extensionsQuery,
+            onChangeSearchQuery = extensionsScreenModel::search,
+            incognitoMode = screenModel.isIncognitoMode,
+            downloadedOnlyMode = screenModel.isDownloadOnly,
+        )
+
+        // For local source
+        DiskUtil.RequestStoragePermission()
+
+        LaunchedEffect(Unit) {
+            (context as? MainActivity)?.ready = true
+        }
+    }
+}
+
+private class BrowseScreenModel(
+    preferences: BasePreferences = Injekt.get(),
+) : ScreenModel {
+    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
+    val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
+}

+ 42 - 34
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt

@@ -2,11 +2,10 @@ package eu.kanade.tachiyomi.ui.browse.extension
 
 import android.app.Application
 import androidx.annotation.StringRes
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
 import eu.kanade.domain.extension.interactor.GetExtensionsByType
 import eu.kanade.domain.source.service.SourcePreferences
-import eu.kanade.presentation.browse.ExtensionState
-import eu.kanade.presentation.browse.ExtensionsState
-import eu.kanade.presentation.browse.ExtensionsStateImpl
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.extension.model.Extension
@@ -14,8 +13,6 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.LocaleHelper
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -23,26 +20,23 @@ import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.update
 import rx.Observable
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
-class ExtensionsPresenter(
-    private val presenterScope: CoroutineScope,
-    private val state: ExtensionsStateImpl = ExtensionState() as ExtensionsStateImpl,
-    private val preferences: SourcePreferences = Injekt.get(),
+class ExtensionsScreenModel(
+    preferences: SourcePreferences = Injekt.get(),
     private val extensionManager: ExtensionManager = Injekt.get(),
     private val getExtensions: GetExtensionsByType = Injekt.get(),
-) : ExtensionsState by state {
+) : StateScreenModel<ExtensionsState>(ExtensionsState()) {
 
     private val _query: MutableStateFlow<String?> = MutableStateFlow(null)
     val query: StateFlow<String?> = _query.asStateFlow()
 
     private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
 
-    fun onCreate() {
+    init {
         val context = Injekt.get<Application>()
         val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
             {
@@ -76,7 +70,7 @@ class ExtensionsPresenter(
             }
         }
 
-        presenterScope.launchIO {
+        coroutineScope.launchIO {
             combine(
                 _query,
                 _currentDownloads,
@@ -117,39 +111,44 @@ class ExtensionsPresenter(
 
                 items
             }
-                .onStart { delay(500) } // Defer to avoid crashing on initial render
                 .collectLatest {
-                    state.isLoading = false
-                    state.items = it
+                    mutableState.update { state ->
+                        state.copy(
+                            isLoading = false,
+                            items = it,
+                        )
+                    }
                 }
         }
 
-        presenterScope.launchIO { findAvailableExtensions() }
+        coroutineScope.launchIO { findAvailableExtensions() }
 
         preferences.extensionUpdatesCount().changes()
-            .onEach { state.updates = it }
-            .launchIn(presenterScope)
+            .onEach { mutableState.update { state -> state.copy(updates = it) } }
+            .launchIn(coroutineScope)
     }
 
     fun search(query: String?) {
-        presenterScope.launchIO {
+        coroutineScope.launchIO {
             _query.emit(query)
         }
     }
 
     fun updateAllExtensions() {
-        presenterScope.launchIO {
-            if (state.isEmpty) return@launchIO
-            state.items
-                .mapNotNull {
-                    when {
-                        it !is ExtensionUiModel.Item -> null
-                        it.extension !is Extension.Installed -> null
-                        !it.extension.hasUpdate -> null
-                        else -> it.extension
+        coroutineScope.launchIO {
+            with(state.value) {
+                if (isEmpty) return@launchIO
+                items
+                    .mapNotNull {
+                        when {
+                            it !is ExtensionUiModel.Item -> null
+                            it.extension !is Extension.Installed -> null
+                            !it.extension.hasUpdate -> null
+                            else -> it.extension
+                        }
                     }
-                }
-                .forEach { updateExtension(it) }
+                    .forEach { updateExtension(it) }
+            }
         }
     }
 
@@ -195,11 +194,11 @@ class ExtensionsPresenter(
     }
 
     fun findAvailableExtensions() {
-        presenterScope.launchIO {
-            state.isRefreshing = true
+        mutableState.update { it.copy(isRefreshing = true) }
+        coroutineScope.launchIO {
             extensionManager.findAvailableExtensions()
-            state.isRefreshing = false
         }
+        mutableState.update { it.copy(isRefreshing = false) }
     }
 
     fun trustSignature(signatureHash: String) {
@@ -207,6 +206,15 @@ class ExtensionsPresenter(
     }
 }
 
+data class ExtensionsState(
+    val isLoading: Boolean = true,
+    val isRefreshing: Boolean = false,
+    val items: List<ExtensionUiModel> = emptyList(),
+    val updates: Int = 0,
+) {
+    val isEmpty = items.isEmpty()
+}
+
 sealed interface ExtensionUiModel {
     sealed interface Header : ExtensionUiModel {
         data class Resource(@StringRes val textRes: Int) : Header

+ 41 - 50
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt

@@ -3,11 +3,14 @@ package eu.kanade.tachiyomi.ui.browse.extension
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.Translate
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.res.stringResource
-import com.bluelinelabs.conductor.Router
+import cafe.adriel.voyager.navigator.currentOrThrow
 import eu.kanade.presentation.browse.ExtensionScreen
 import eu.kanade.presentation.components.AppBar
 import eu.kanade.presentation.components.TabContent
+import eu.kanade.presentation.util.LocalRouter
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.ui.base.controller.pushController
@@ -15,53 +18,41 @@ import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsControlle
 
 @Composable
 fun extensionsTab(
-    router: Router?,
-    presenter: ExtensionsPresenter,
-) = TabContent(
-    titleRes = R.string.label_extensions,
-    badgeNumber = presenter.updates.takeIf { it > 0 },
-    searchEnabled = true,
-    actions = listOf(
-        AppBar.Action(
-            title = stringResource(R.string.action_filter),
-            icon = Icons.Outlined.Translate,
-            onClick = { router?.pushController(ExtensionFilterController()) },
+    extensionsScreenModel: ExtensionsScreenModel,
+): TabContent {
+    val router = LocalRouter.currentOrThrow
+    val state by extensionsScreenModel.state.collectAsState()
+
+    return TabContent(
+        titleRes = R.string.label_extensions,
+        badgeNumber = state.updates.takeIf { it > 0 },
+        searchEnabled = true,
+        actions = listOf(
+            AppBar.Action(
+                title = stringResource(R.string.action_filter),
+                icon = Icons.Outlined.Translate,
+                onClick = { router.pushController(ExtensionFilterController()) },
+            ),
         ),
-    ),
-    content = { contentPadding ->
-        ExtensionScreen(
-            presenter = presenter,
-            contentPadding = contentPadding,
-            onLongClickItem = { extension ->
-                when (extension) {
-                    is Extension.Available -> presenter.installExtension(extension)
-                    else -> presenter.uninstallExtension(extension.pkgName)
-                }
-            },
-            onClickItemCancel = { extension ->
-                presenter.cancelInstallUpdateExtension(extension)
-            },
-            onClickUpdateAll = {
-                presenter.updateAllExtensions()
-            },
-            onInstallExtension = {
-                presenter.installExtension(it)
-            },
-            onOpenExtension = {
-                router?.pushController(ExtensionDetailsController(it.pkgName))
-            },
-            onTrustExtension = {
-                presenter.trustSignature(it.signatureHash)
-            },
-            onUninstallExtension = {
-                presenter.uninstallExtension(it.pkgName)
-            },
-            onUpdateExtension = {
-                presenter.updateExtension(it)
-            },
-            onRefresh = {
-                presenter.findAvailableExtensions()
-            },
-        )
-    },
-)
+        content = { contentPadding, _ ->
+            ExtensionScreen(
+                state = state,
+                contentPadding = contentPadding,
+                onLongClickItem = { extension ->
+                    when (extension) {
+                        is Extension.Available -> extensionsScreenModel.installExtension(extension)
+                        else -> extensionsScreenModel.uninstallExtension(extension.pkgName)
+                    }
+                },
+                onClickItemCancel = extensionsScreenModel::cancelInstallUpdateExtension,
+                onClickUpdateAll = extensionsScreenModel::updateAllExtensions,
+                onInstallExtension = extensionsScreenModel::installExtension,
+                onOpenExtension = { router.pushController(ExtensionDetailsController(it.pkgName)) },
+                onTrustExtension = { extensionsScreenModel.trustSignature(it.signatureHash) },
+                onUninstallExtension = { extensionsScreenModel.uninstallExtension(it.pkgName) },
+                onUpdateExtension = extensionsScreenModel::updateExtension,
+                onRefresh = extensionsScreenModel::findAvailableExtensions,
+            )
+        },
+    )
+}

+ 91 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceScreenModel.kt

@@ -0,0 +1,91 @@
+package eu.kanade.tachiyomi.ui.browse.migration.sources
+
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
+import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
+import eu.kanade.domain.source.interactor.SetMigrateSorting
+import eu.kanade.domain.source.model.Source
+import eu.kanade.domain.source.service.SourcePreferences
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import logcat.LogPriority
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class MigrateSourceScreenModel(
+    preferences: SourcePreferences = Injekt.get(),
+    private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
+    private val setMigrateSorting: SetMigrateSorting = Injekt.get(),
+) : StateScreenModel<MigrateSourceState>(MigrateSourceState()) {
+
+    private val _channel = Channel<Event>(Int.MAX_VALUE)
+    val channel = _channel.receiveAsFlow()
+
+    init {
+        coroutineScope.launchIO {
+            getSourcesWithFavoriteCount.subscribe()
+                .catch {
+                    logcat(LogPriority.ERROR, it)
+                    _channel.send(Event.FailedFetchingSourcesWithCount)
+                }
+                .collectLatest { sources ->
+                    mutableState.update {
+                        it.copy(
+                            isLoading = false,
+                            items = sources,
+                        )
+                    }
+                }
+        }
+
+        preferences.migrationSortingDirection().changes()
+            .onEach { mutableState.update { state -> state.copy(sortingDirection = it) } }
+            .launchIn(coroutineScope)
+
+        preferences.migrationSortingMode().changes()
+            .onEach { mutableState.update { state -> state.copy(sortingMode = it) } }
+            .launchIn(coroutineScope)
+    }
+
+    fun toggleSortingMode() {
+        with(state.value) {
+            val newMode = when (sortingMode) {
+                SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL
+                SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL
+            }
+
+            setMigrateSorting.await(newMode, sortingDirection)
+        }
+    }
+
+    fun toggleSortingDirection() {
+        with(state.value) {
+            val newDirection = when (sortingDirection) {
+                SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING
+                SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING
+            }
+
+            setMigrateSorting.await(sortingMode, newDirection)
+        }
+    }
+
+    sealed class Event {
+        object FailedFetchingSourcesWithCount : Event()
+    }
+}
+
+data class MigrateSourceState(
+    val isLoading: Boolean = true,
+    val items: List<Pair<Source, Long>> = emptyList(),
+    val sortingMode: SetMigrateSorting.Mode = SetMigrateSorting.Mode.ALPHABETICAL,
+    val sortingDirection: SetMigrateSorting.Direction = SetMigrateSorting.Direction.ASCENDING,
+) {
+    val isEmpty = items.isEmpty()
+}

+ 15 - 8
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourcesTab.kt → app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt

@@ -3,22 +3,27 @@ package eu.kanade.tachiyomi.ui.browse.migration.sources
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.HelpOutline
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.platform.LocalUriHandler
 import androidx.compose.ui.res.stringResource
-import com.bluelinelabs.conductor.Router
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.currentOrThrow
 import eu.kanade.presentation.browse.MigrateSourceScreen
 import eu.kanade.presentation.components.AppBar
 import eu.kanade.presentation.components.TabContent
+import eu.kanade.presentation.util.LocalRouter
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController
 
 @Composable
-fun migrateSourcesTab(
-    router: Router?,
-    presenter: MigrationSourcesPresenter,
-): TabContent {
+fun Screen.migrateSourceTab(): TabContent {
     val uriHandler = LocalUriHandler.current
+    val router = LocalRouter.currentOrThrow
+    val screenModel = rememberScreenModel { MigrateSourceScreenModel() }
+    val state by screenModel.state.collectAsState()
 
     return TabContent(
         titleRes = R.string.label_migration,
@@ -31,18 +36,20 @@ fun migrateSourcesTab(
                 },
             ),
         ),
-        content = { contentPadding ->
+        content = { contentPadding, _ ->
             MigrateSourceScreen(
-                presenter = presenter,
+                state = state,
                 contentPadding = contentPadding,
                 onClickItem = { source ->
-                    router?.pushController(
+                    router.pushController(
                         MigrationMangaController(
                             source.id,
                             source.name,
                         ),
                     )
                 },
+                onToggleSortingDirection = screenModel::toggleSortingDirection,
+                onToggleSortingMode = screenModel::toggleSortingMode,
             )
         },
     )

+ 0 - 75
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt

@@ -1,75 +0,0 @@
-package eu.kanade.tachiyomi.ui.browse.migration.sources
-
-import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
-import eu.kanade.domain.source.interactor.SetMigrateSorting
-import eu.kanade.domain.source.service.SourcePreferences
-import eu.kanade.presentation.browse.MigrateSourceState
-import eu.kanade.presentation.browse.MigrateSourceStateImpl
-import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.receiveAsFlow
-import logcat.LogPriority
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-
-class MigrationSourcesPresenter(
-    private val presenterScope: CoroutineScope,
-    private val state: MigrateSourceStateImpl = MigrateSourceState() as MigrateSourceStateImpl,
-    private val preferences: SourcePreferences = Injekt.get(),
-    private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
-    private val setMigrateSorting: SetMigrateSorting = Injekt.get(),
-) : MigrateSourceState by state {
-
-    private val _channel = Channel<Event>(Int.MAX_VALUE)
-    val channel = _channel.receiveAsFlow()
-
-    fun onCreate() {
-        presenterScope.launchIO {
-            getSourcesWithFavoriteCount.subscribe()
-                .catch {
-                    logcat(LogPriority.ERROR, it)
-                    _channel.send(Event.FailedFetchingSourcesWithCount)
-                }
-                .collectLatest { sources ->
-                    state.items = sources
-                    state.isLoading = false
-                }
-        }
-
-        preferences.migrationSortingDirection().changes()
-            .onEach { state.sortingDirection = it }
-            .launchIn(presenterScope)
-
-        preferences.migrationSortingMode().changes()
-            .onEach { state.sortingMode = it }
-            .launchIn(presenterScope)
-    }
-
-    fun toggleSortingMode() {
-        val newMode = when (state.sortingMode) {
-            SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL
-            SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL
-        }
-
-        setMigrateSorting.await(newMode, state.sortingDirection)
-    }
-
-    fun toggleSortingDirection() {
-        val newDirection = when (state.sortingDirection) {
-            SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING
-            SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING
-        }
-
-        setMigrateSorting.await(state.sortingMode, newDirection)
-    }
-
-    sealed class Event {
-        object FailedFetchingSourcesWithCount : Event()
-    }
-}

+ 53 - 37
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt

@@ -1,5 +1,8 @@
 package eu.kanade.tachiyomi.ui.browse.source
 
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.coroutineScope
 import eu.kanade.domain.base.BasePreferences
 import eu.kanade.domain.source.interactor.GetEnabledSources
 import eu.kanade.domain.source.interactor.ToggleSource
@@ -8,78 +11,74 @@ import eu.kanade.domain.source.model.Pin
 import eu.kanade.domain.source.model.Source
 import eu.kanade.domain.source.service.SourcePreferences
 import eu.kanade.presentation.browse.SourceUiModel
-import eu.kanade.presentation.browse.SourcesState
-import eu.kanade.presentation.browse.SourcesStateImpl
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
 import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.util.TreeMap
 
-class SourcesPresenter(
-    private val presenterScope: CoroutineScope,
-    private val state: SourcesStateImpl = SourcesState() as SourcesStateImpl,
+class SourcesScreenModel(
     private val preferences: BasePreferences = Injekt.get(),
     private val sourcePreferences: SourcePreferences = Injekt.get(),
     private val getEnabledSources: GetEnabledSources = Injekt.get(),
     private val toggleSource: ToggleSource = Injekt.get(),
     private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
-) : SourcesState by state {
+) : StateScreenModel<SourcesState>(SourcesState()) {
 
     private val _events = Channel<Event>(Int.MAX_VALUE)
     val events = _events.receiveAsFlow()
 
-    fun onCreate() {
-        presenterScope.launchIO {
+    init {
+        coroutineScope.launchIO {
             getEnabledSources.subscribe()
                 .catch {
                     logcat(LogPriority.ERROR, it)
                     _events.send(Event.FailedFetchingSources)
                 }
-                .onStart { delay(500) } // Defer to avoid crashing on initial render
                 .collectLatest(::collectLatestSources)
         }
     }
 
     private fun collectLatestSources(sources: List<Source>) {
-        val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
-            // Sources without a lang defined will be placed at the end
-            when {
-                d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
-                d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
-                d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
-                d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
-                d1 == "" && d2 != "" -> 1
-                d2 == "" && d1 != "" -> -1
-                else -> d1.compareTo(d2)
+        mutableState.update { state ->
+            val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
+                // Sources without a lang defined will be placed at the end
+                when {
+                    d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
+                    d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
+                    d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
+                    d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
+                    d1 == "" && d2 != "" -> 1
+                    d2 == "" && d1 != "" -> -1
+                    else -> d1.compareTo(d2)
+                }
             }
-        }
-        val byLang = sources.groupByTo(map) {
-            when {
-                it.isUsedLast -> LAST_USED_KEY
-                Pin.Actual in it.pin -> PINNED_KEY
-                else -> it.lang
+            val byLang = sources.groupByTo(map) {
+                when {
+                    it.isUsedLast -> LAST_USED_KEY
+                    Pin.Actual in it.pin -> PINNED_KEY
+                    else -> it.lang
+                }
             }
-        }
 
-        val uiModels = byLang.flatMap {
-            listOf(
-                SourceUiModel.Header(it.key),
-                *it.value.map { source ->
-                    SourceUiModel.Item(source)
-                }.toTypedArray(),
+            state.copy(
+                isLoading = false,
+                items = byLang.flatMap {
+                    listOf(
+                        SourceUiModel.Header(it.key),
+                        *it.value.map { source ->
+                            SourceUiModel.Item(source)
+                        }.toTypedArray(),
+                    )
+                },
             )
         }
-        state.isLoading = false
-        state.items = uiModels
     }
 
     fun onOpenSource(source: Source) {
@@ -96,6 +95,14 @@ class SourcesPresenter(
         toggleSourcePin.await(source)
     }
 
+    fun showSourceDialog(source: Source) {
+        mutableState.update { it.copy(dialog = Dialog(source)) }
+    }
+
+    fun closeDialog() {
+        mutableState.update { it.copy(dialog = null) }
+    }
+
     sealed class Event {
         object FailedFetchingSources : Event()
     }
@@ -107,3 +114,12 @@ class SourcesPresenter(
         const val LAST_USED_KEY = "last_used"
     }
 }
+
+@Immutable
+data class SourcesState(
+    val dialog: SourcesScreenModel.Dialog? = null,
+    val isLoading: Boolean = true,
+    val items: List<SourceUiModel> = emptyList(),
+) {
+    val isEmpty = items.isEmpty()
+}

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

@@ -4,48 +4,83 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.FilterList
 import androidx.compose.material.icons.outlined.TravelExplore
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.res.stringResource
-import com.bluelinelabs.conductor.Router
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.presentation.browse.SourceOptionsDialog
 import eu.kanade.presentation.browse.SourcesScreen
 import eu.kanade.presentation.components.AppBar
 import eu.kanade.presentation.components.TabContent
+import eu.kanade.presentation.util.LocalRouter
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
 
 @Composable
-fun sourcesTab(
-    router: Router?,
-    presenter: SourcesPresenter,
-) = TabContent(
-    titleRes = R.string.label_sources,
-    actions = listOf(
-        AppBar.Action(
-            title = stringResource(R.string.action_global_search),
-            icon = Icons.Outlined.TravelExplore,
-            onClick = { router?.pushController(GlobalSearchController()) },
-        ),
-        AppBar.Action(
-            title = stringResource(R.string.action_filter),
-            icon = Icons.Outlined.FilterList,
-            onClick = { router?.pushController(SourceFilterController()) },
+fun Screen.sourcesTab(): TabContent {
+    val router = LocalRouter.currentOrThrow
+    val screenModel = rememberScreenModel { SourcesScreenModel() }
+    val state by screenModel.state.collectAsState()
+
+    return TabContent(
+        titleRes = R.string.label_sources,
+        actions = listOf(
+            AppBar.Action(
+                title = stringResource(R.string.action_global_search),
+                icon = Icons.Outlined.TravelExplore,
+                onClick = { router.pushController(GlobalSearchController()) },
+            ),
+            AppBar.Action(
+                title = stringResource(R.string.action_filter),
+                icon = Icons.Outlined.FilterList,
+                onClick = { router.pushController(SourceFilterController()) },
+            ),
         ),
-    ),
-    content = { contentPadding ->
-        SourcesScreen(
-            presenter = presenter,
-            contentPadding = contentPadding,
-            onClickItem = { source, query ->
-                presenter.onOpenSource(source)
-                router?.pushController(BrowseSourceController(source, query))
-            },
-            onClickDisable = { source ->
-                presenter.toggleSource(source)
-            },
-            onClickPin = { source ->
-                presenter.togglePin(source)
-            },
-        )
-    },
-)
+        content = { contentPadding, snackbarHostState ->
+            SourcesScreen(
+                state = state,
+                contentPadding = contentPadding,
+                onClickItem = { source, query ->
+                    screenModel.onOpenSource(source)
+                    router.pushController(BrowseSourceController(source, query))
+                },
+                onClickPin = screenModel::togglePin,
+                onLongClickItem = screenModel::showSourceDialog,
+            )
+
+            state.dialog?.let { dialog ->
+                val source = dialog.source
+                SourceOptionsDialog(
+                    source = source,
+                    onClickPin = {
+                        screenModel.togglePin(source)
+                        screenModel.closeDialog()
+                    },
+                    onClickDisable = {
+                        screenModel.toggleSource(source)
+                        screenModel.closeDialog()
+                    },
+                    onDismiss = screenModel::closeDialog,
+                )
+            }
+
+            val internalErrString = stringResource(R.string.internal_error)
+            LaunchedEffect(Unit) {
+                screenModel.events.collectLatest { event ->
+                    when (event) {
+                        SourcesScreenModel.Event.FailedFetchingSources -> {
+                            launch { snackbarHostState.showSnackbar(internalErrString) }
+                        }
+                    }
+                }
+            }
+        },
+    )
+}

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt

@@ -1,11 +1,15 @@
 package eu.kanade.tachiyomi.util.storage
 
+import android.Manifest
 import android.content.Context
 import android.media.MediaScannerConnection
 import android.net.Uri
 import android.os.Environment
 import android.os.StatFs
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.core.content.ContextCompat
+import com.google.accompanist.permissions.rememberPermissionState
 import com.hippo.unifile.UniFile
 import eu.kanade.tachiyomi.util.lang.Hash
 import java.io.File
@@ -113,5 +117,16 @@ object DiskUtil {
         }
     }
 
+    /**
+     * Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission
+     */
+    @Composable
+    fun RequestStoragePermission() {
+        val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
+        LaunchedEffect(Unit) {
+            permissionState.launchPermissionRequest()
+        }
+    }
+
     const val NOMEDIA_FILE = ".nomedia"
 }

+ 3 - 3
app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt

@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.util.system
 import android.content.Context
 import androidx.core.os.LocaleListCompat
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
+import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
 import java.util.Locale
 
 /**
@@ -16,8 +16,8 @@ object LocaleHelper {
      */
     fun getSourceDisplayName(lang: String?, context: Context): String {
         return when (lang) {
-            SourcesPresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source)
-            SourcesPresenter.PINNED_KEY -> context.getString(R.string.pinned_sources)
+            SourcesScreenModel.LAST_USED_KEY -> context.getString(R.string.last_used_source)
+            SourcesScreenModel.PINNED_KEY -> context.getString(R.string.pinned_sources)
             "other" -> context.getString(R.string.other_source)
             "all" -> context.getString(R.string.multi_lang)
             else -> getDisplayName(lang)