Browse Source

Use Stable interface for Browse screens (#7544)

Andreas 2 years ago
parent
commit
018ca71336
26 changed files with 506 additions and 308 deletions
  1. 58 52
      app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt
  2. 25 0
      app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsState.kt
  3. 24 21
      app/src/main/java/eu/kanade/presentation/browse/ExtensionFilterScreen.kt
  4. 25 0
      app/src/main/java/eu/kanade/presentation/browse/ExtensionFilterState.kt
  5. 10 12
      app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt
  6. 25 0
      app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt
  7. 24 19
      app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt
  8. 23 0
      app/src/main/java/eu/kanade/presentation/browse/MigrateMangaState.kt
  9. 12 15
      app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt
  10. 23 0
      app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt
  11. 21 17
      app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt
  12. 23 0
      app/src/main/java/eu/kanade/presentation/browse/SourcesFilterState.kt
  13. 38 34
      app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt
  14. 27 0
      app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt
  15. 10 0
      app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
  16. 0 3
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt
  17. 17 14
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterPresenter.kt
  18. 12 23
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt
  19. 0 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt
  20. 34 33
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt
  21. 19 15
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaPresenter.kt
  22. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt
  23. 0 5
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt
  24. 17 14
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt
  25. 17 14
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterPresenter.kt
  26. 20 14
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt

+ 58 - 52
app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt

@@ -1,5 +1,8 @@
 package eu.kanade.presentation.browse
 
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
 import android.util.DisplayMetrics
 import androidx.annotation.StringRes
 import androidx.compose.foundation.background
@@ -32,7 +35,6 @@ import androidx.compose.material3.Switch
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -51,6 +53,7 @@ import eu.kanade.presentation.browse.components.ExtensionIcon
 import eu.kanade.presentation.components.DIVIDER_ALPHA
 import eu.kanade.presentation.components.Divider
 import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.components.PreferenceRow
 import eu.kanade.presentation.components.ScrollbarLazyColumn
 import eu.kanade.presentation.util.horizontalPadding
@@ -66,66 +69,69 @@ fun ExtensionDetailsScreen(
     nestedScrollInterop: NestedScrollConnection,
     presenter: ExtensionDetailsPresenter,
     onClickUninstall: () -> Unit,
-    onClickAppInfo: () -> Unit,
     onClickSourcePreferences: (sourceId: Long) -> Unit,
     onClickSource: (sourceId: Long) -> Unit,
 ) {
-    val extension = presenter.extension
-
-    if (extension == null) {
-        EmptyScreen(textResource = R.string.empty_screen)
-        return
-    }
-
-    val sources by presenter.sourcesState.collectAsState()
-
-    var showNsfwWarning by remember { mutableStateOf(false) }
-
-    ScrollbarLazyColumn(
-        modifier = Modifier.nestedScroll(nestedScrollInterop),
-        contentPadding = WindowInsets.navigationBars.asPaddingValues(),
-    ) {
-        when {
-            extension.isUnofficial ->
-                item {
-                    WarningBanner(R.string.unofficial_extension_message)
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.extension == null -> EmptyScreen(textResource = R.string.empty_screen)
+        else -> {
+            val context = LocalContext.current
+            val extension = presenter.extension
+            var showNsfwWarning by remember { mutableStateOf(false) }
+
+            ScrollbarLazyColumn(
+                modifier = Modifier.nestedScroll(nestedScrollInterop),
+                contentPadding = WindowInsets.navigationBars.asPaddingValues(),
+            ) {
+                when {
+                    extension.isUnofficial ->
+                        item {
+                            WarningBanner(R.string.unofficial_extension_message)
+                        }
+                    extension.isObsolete ->
+                        item {
+                            WarningBanner(R.string.obsolete_extension_message)
+                        }
                 }
-            extension.isObsolete ->
+
                 item {
-                    WarningBanner(R.string.obsolete_extension_message)
+                    DetailsHeader(
+                        extension = extension,
+                        onClickUninstall = onClickUninstall,
+                        onClickAppInfo = {
+                            Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+                                data = Uri.fromParts("package", extension.pkgName, null)
+                                context.startActivity(this)
+                            }
+                        },
+                        onClickAgeRating = {
+                            showNsfwWarning = true
+                        },
+                    )
                 }
-        }
 
-        item {
-            DetailsHeader(
-                extension = extension,
-                onClickUninstall = onClickUninstall,
-                onClickAppInfo = onClickAppInfo,
-                onClickAgeRating = {
-                    showNsfwWarning = true
-                },
-            )
-        }
-
-        items(
-            items = sources,
-            key = { it.source.id },
-        ) { source ->
-            SourceSwitchPreference(
-                modifier = Modifier.animateItemPlacement(),
-                source = source,
-                onClickSourcePreferences = onClickSourcePreferences,
-                onClickSource = onClickSource,
-            )
+                items(
+                    items = presenter.sources,
+                    key = { it.source.id },
+                ) { source ->
+                    SourceSwitchPreference(
+                        modifier = Modifier.animateItemPlacement(),
+                        source = source,
+                        onClickSourcePreferences = onClickSourcePreferences,
+                        onClickSource = onClickSource,
+                    )
+                }
+            }
+            if (showNsfwWarning) {
+                NsfwWarningDialog(
+                    onClickConfirm = {
+                        showNsfwWarning = false
+                    },
+                )
+            }
         }
     }
-    if (showNsfwWarning) {
-        NsfwWarningDialog(
-            onClickConfirm = {
-                showNsfwWarning = false
-            },
-        )
-    }
 }
 
 @Composable

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

@@ -0,0 +1,25 @@
+package eu.kanade.presentation.browse
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import eu.kanade.tachiyomi.extension.model.Extension
+import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
+
+@Stable
+interface ExtensionDetailsState {
+    val isLoading: Boolean
+    val extension: Extension.Installed?
+    val sources: List<ExtensionSourceItem>
+}
+
+fun ExtensionDetailsState(): ExtensionDetailsState {
+    return ExtensionDetailsStateImpl()
+}
+
+class ExtensionDetailsStateImpl : ExtensionDetailsState {
+    override var isLoading: Boolean by mutableStateOf(true)
+    override var extension: Extension.Installed? by mutableStateOf(null)
+    override var sources: List<ExtensionSourceItem> by mutableStateOf(emptyList())
+}

+ 24 - 21
app/src/main/java/eu/kanade/presentation/browse/ExtensionLangFilterScreen.kt → app/src/main/java/eu/kanade/presentation/browse/ExtensionFilterScreen.kt

@@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.lazy.items
 import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -19,47 +17,52 @@ import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.components.PreferenceRow
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.browse.extension.ExtensionFilterPresenter
-import eu.kanade.tachiyomi.ui.browse.extension.ExtensionFilterState
-import eu.kanade.tachiyomi.ui.browse.extension.FilterUiModel
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun ExtensionFilterScreen(
     nestedScrollInterop: NestedScrollConnection,
     presenter: ExtensionFilterPresenter,
-    onClickLang: (String) -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-
-    when (state) {
-        is ExtensionFilterState.Loading -> LoadingScreen()
-        is ExtensionFilterState.Error -> Text(text = (state as ExtensionFilterState.Error).error.message!!)
-        is ExtensionFilterState.Success ->
+    val context = LocalContext.current
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.isEmpty -> EmptyScreen(textResource = R.string.empty_screen)
+        else -> {
             SourceFilterContent(
                 nestedScrollInterop = nestedScrollInterop,
-                items = (state as ExtensionFilterState.Success).models,
-                onClickLang = onClickLang,
+                state = presenter,
+                onClickLang = {
+                    presenter.toggleLanguage(it)
+                },
             )
+        }
+    }
+    LaunchedEffect(Unit) {
+        presenter.events.collectLatest {
+            when (it) {
+                ExtensionFilterPresenter.Event.FailedFetchingLanguages -> {
+                    context.toast(R.string.internal_error)
+                }
+            }
+        }
     }
 }
 
 @Composable
 fun SourceFilterContent(
     nestedScrollInterop: NestedScrollConnection,
-    items: List<FilterUiModel>,
+    state: ExtensionFilterState,
     onClickLang: (String) -> Unit,
 ) {
-    if (items.isEmpty()) {
-        EmptyScreen(textResource = R.string.empty_screen)
-        return
-    }
-
     LazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
         contentPadding = WindowInsets.navigationBars.asPaddingValues(),
     ) {
         items(
-            items = items,
+            items = state.items,
         ) { model ->
             ExtensionFilterItem(
                 modifier = Modifier.animateItemPlacement(),

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

@@ -0,0 +1,25 @@
+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.extension.FilterUiModel
+
+@Stable
+interface ExtensionFilterState {
+    val isLoading: Boolean
+    val items: List<FilterUiModel>
+    val isEmpty: Boolean
+}
+
+fun ExtensionFilterState(): ExtensionFilterState {
+    return ExtensionFilterStateImpl()
+}
+
+class ExtensionFilterStateImpl : ExtensionFilterState {
+    override var isLoading: Boolean by mutableStateOf(true)
+    override var items: List<FilterUiModel> by mutableStateOf(emptyList())
+    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
+}

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

@@ -23,7 +23,6 @@ import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -40,7 +39,9 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
 import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
 import eu.kanade.presentation.browse.components.BaseBrowseItem
 import eu.kanade.presentation.browse.components.ExtensionIcon
+import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.FastScrollLazyColumn
+import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.components.SwipeRefreshIndicator
 import eu.kanade.presentation.theme.header
 import eu.kanade.presentation.util.horizontalPadding
@@ -49,7 +50,6 @@ import eu.kanade.presentation.util.topPaddingValues
 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.ExtensionState
 import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
 import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter
 import eu.kanade.tachiyomi.util.system.LocaleHelper
@@ -69,19 +69,18 @@ fun ExtensionScreen(
     onRefresh: () -> Unit,
     onLaunched: () -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-    val isRefreshing = presenter.isRefreshing
-
     SwipeRefresh(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
-        state = rememberSwipeRefreshState(isRefreshing),
+        state = rememberSwipeRefreshState(presenter.isRefreshing),
         indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) },
         onRefresh = onRefresh,
     ) {
-        when (state) {
-            is ExtensionState.Initialized -> {
+        when {
+            presenter.isLoading -> LoadingScreen()
+            presenter.isEmpty -> EmptyScreen(R.string.empty_screen)
+            else -> {
                 ExtensionContent(
-                    items = (state as ExtensionState.Initialized).list,
+                    state = presenter,
                     onLongClickItem = onLongClickItem,
                     onClickItemCancel = onClickItemCancel,
                     onInstallExtension = onInstallExtension,
@@ -93,14 +92,13 @@ fun ExtensionScreen(
                     onLaunched = onLaunched,
                 )
             }
-            ExtensionState.Uninitialized -> {}
         }
     }
 }
 
 @Composable
 fun ExtensionContent(
-    items: List<ExtensionUiModel>,
+    state: ExtensionsState,
     onLongClickItem: (Extension) -> Unit,
     onClickItemCancel: (Extension) -> Unit,
     onInstallExtension: (Extension.Available) -> Unit,
@@ -117,7 +115,7 @@ fun ExtensionContent(
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
     ) {
         items(
-            items = items,
+            items = state.items,
             key = {
                 when (it) {
                     is ExtensionUiModel.Header.Resource -> it.textRes

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

@@ -0,0 +1,25 @@
+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 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 val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
+}

+ 24 - 19
app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt

@@ -4,61 +4,66 @@ import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
 import eu.kanade.domain.manga.model.Manga
 import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.LoadingScreen
 import eu.kanade.presentation.components.ScrollbarLazyColumn
 import eu.kanade.presentation.manga.components.BaseMangaListItem
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaState
-import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaPresenter
+import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaPresenter
+import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaPresenter.Event
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun MigrateMangaScreen(
     nestedScrollInterop: NestedScrollConnection,
-    presenter: MigrationMangaPresenter,
+    presenter: MigrateMangaPresenter,
     onClickItem: (Manga) -> Unit,
     onClickCover: (Manga) -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-
-    when (state) {
-        MigrateMangaState.Loading -> LoadingScreen()
-        is MigrateMangaState.Error -> Text(text = (state as MigrateMangaState.Error).error.message!!)
-        is MigrateMangaState.Success -> {
+    val context = LocalContext.current
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.isEmpty -> EmptyScreen(textResource = R.string.empty_screen)
+        else -> {
             MigrateMangaContent(
                 nestedScrollInterop = nestedScrollInterop,
-                list = (state as MigrateMangaState.Success).list,
+                state = presenter,
                 onClickItem = onClickItem,
                 onClickCover = onClickCover,
             )
         }
     }
+    LaunchedEffect(Unit) {
+        presenter.events.collectLatest { event ->
+            when (event) {
+                Event.FailedFetchingFavorites -> {
+                    context.toast(R.string.internal_error)
+                }
+            }
+        }
+    }
 }
 
 @Composable
 fun MigrateMangaContent(
     nestedScrollInterop: NestedScrollConnection,
-    list: List<Manga>,
+    state: MigrateMangaState,
     onClickItem: (Manga) -> Unit,
     onClickCover: (Manga) -> Unit,
 ) {
-    if (list.isEmpty()) {
-        EmptyScreen(textResource = R.string.empty_screen)
-        return
-    }
     ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
         contentPadding = WindowInsets.navigationBars.asPaddingValues(),
     ) {
-        items(list) { manga ->
+        items(state.items) { manga ->
             MigrateMangaItem(
                 manga = manga,
                 onClickItem = onClickItem,

+ 23 - 0
app/src/main/java/eu/kanade/presentation/browse/MigrateMangaState.kt

@@ -0,0 +1,23 @@
+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.manga.model.Manga
+
+interface MigrateMangaState {
+    val isLoading: Boolean
+    val items: List<Manga>
+    val isEmpty: Boolean
+}
+
+fun MigrationMangaState(): MigrateMangaState {
+    return MigrateMangaStateImpl()
+}
+
+class MigrateMangaStateImpl : MigrateMangaState {
+    override var isLoading: Boolean by mutableStateOf(true)
+    override var items: List<Manga> by mutableStateOf(emptyList())
+    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
+}

+ 12 - 15
app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt

@@ -11,12 +11,12 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
@@ -32,27 +32,29 @@ import eu.kanade.presentation.util.horizontalPadding
 import eu.kanade.presentation.util.plus
 import eu.kanade.presentation.util.topPaddingValues
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceState
 import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import eu.kanade.tachiyomi.util.system.copyToClipboard
 
 @Composable
 fun MigrateSourceScreen(
     nestedScrollInterop: NestedScrollConnection,
     presenter: MigrationSourcesPresenter,
     onClickItem: (Source) -> Unit,
-    onLongClickItem: (Source) -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-    when (state) {
-        is MigrateSourceState.Loading -> LoadingScreen()
-        is MigrateSourceState.Error -> Text(text = (state as MigrateSourceState.Error).error.message!!)
-        is MigrateSourceState.Success ->
+    val context = LocalContext.current
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.isEmpty -> EmptyScreen(textResource = R.string.information_empty_library)
+        else ->
             MigrateSourceList(
                 nestedScrollInterop = nestedScrollInterop,
-                list = (state as MigrateSourceState.Success).sources,
+                list = presenter.items,
                 onClickItem = onClickItem,
-                onLongClickItem = onLongClickItem,
+                onLongClickItem = { source ->
+                    val sourceId = source.id.toString()
+                    context.copyToClipboard(sourceId, sourceId)
+                },
             )
     }
 }
@@ -64,11 +66,6 @@ fun MigrateSourceList(
     onClickItem: (Source) -> Unit,
     onLongClickItem: (Source) -> Unit,
 ) {
-    if (list.isEmpty()) {
-        EmptyScreen(textResource = R.string.information_empty_library)
-        return
-    }
-
     ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,

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

@@ -0,0 +1,23 @@
+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.model.Source
+
+interface MigrateSourceState {
+    val isLoading: Boolean
+    val items: List<Pair<Source, Long>>
+    val isEmpty: Boolean
+}
+
+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() }
+}

+ 21 - 17
app/src/main/java/eu/kanade/presentation/browse/SourcesFilterScreen.kt

@@ -6,9 +6,8 @@ import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.lazy.items
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -22,9 +21,10 @@ import eu.kanade.presentation.components.PreferenceRow
 import eu.kanade.presentation.components.ScrollbarLazyColumn
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.browse.source.FilterUiModel
-import eu.kanade.tachiyomi.ui.browse.source.SourceFilterState
 import eu.kanade.tachiyomi.ui.browse.source.SourcesFilterPresenter
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun SourcesFilterScreen(
@@ -33,39 +33,43 @@ fun SourcesFilterScreen(
     onClickLang: (String) -> Unit,
     onClickSource: (Source) -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-
-    when (state) {
-        is SourceFilterState.Loading -> LoadingScreen()
-        is SourceFilterState.Error -> Text(text = (state as SourceFilterState.Error).error.message!!)
-        is SourceFilterState.Success ->
+    val context = LocalContext.current
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.isEmpty -> EmptyScreen(textResource = R.string.source_filter_empty_screen)
+        else -> {
             SourcesFilterContent(
                 nestedScrollInterop = nestedScrollInterop,
-                items = (state as SourceFilterState.Success).models,
+                state = presenter,
                 onClickLang = onClickLang,
                 onClickSource = onClickSource,
             )
+        }
+    }
+    LaunchedEffect(Unit) {
+        presenter.events.collectLatest { event ->
+            when (event) {
+                SourcesFilterPresenter.Event.FailedFetchingLanguages -> {
+                    context.toast(R.string.internal_error)
+                }
+            }
+        }
     }
 }
 
 @Composable
 fun SourcesFilterContent(
     nestedScrollInterop: NestedScrollConnection,
-    items: List<FilterUiModel>,
+    state: SourcesFilterState,
     onClickLang: (String) -> Unit,
     onClickSource: (Source) -> Unit,
 ) {
-    if (items.isEmpty()) {
-        EmptyScreen(textResource = R.string.source_filter_empty_screen)
-        return
-    }
-
     ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollInterop),
         contentPadding = WindowInsets.navigationBars.asPaddingValues(),
     ) {
         items(
-            items = items,
+            items = state.items,
             contentType = {
                 when (it) {
                     is FilterUiModel.Header -> "header"

+ 23 - 0
app/src/main/java/eu/kanade/presentation/browse/SourcesFilterState.kt

@@ -0,0 +1,23 @@
+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.source.FilterUiModel
+
+interface SourcesFilterState {
+    val isLoading: Boolean
+    val items: List<FilterUiModel>
+    val isEmpty: Boolean
+}
+
+fun SourcesFilterState(): SourcesFilterState {
+    return SourcesFilterStateImpl()
+}
+
+class SourcesFilterStateImpl : SourcesFilterState {
+    override var isLoading: Boolean by mutableStateOf(true)
+    override var items: List<FilterUiModel> by mutableStateOf(emptyList())
+    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
+}

+ 38 - 34
app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt

@@ -19,10 +19,8 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -42,9 +40,11 @@ import eu.kanade.presentation.util.plus
 import eu.kanade.presentation.util.topPaddingValues
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.ui.browse.source.SourceState
 import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
+import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter.Dialog
 import eu.kanade.tachiyomi.util.system.LocaleHelper
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.collectLatest
 
 @Composable
 fun SourcesScreen(
@@ -55,44 +55,47 @@ fun SourcesScreen(
     onClickLatest: (Source) -> Unit,
     onClickPin: (Source) -> Unit,
 ) {
-    val state by presenter.state.collectAsState()
-
-    when (state) {
-        is SourceState.Loading -> LoadingScreen()
-        is SourceState.Error -> Text(text = (state as SourceState.Error).error.message!!)
-        is SourceState.Success -> SourceList(
-            nestedScrollConnection = nestedScrollInterop,
-            list = (state as SourceState.Success).uiModels,
-            onClickItem = onClickItem,
-            onClickDisable = onClickDisable,
-            onClickLatest = onClickLatest,
-            onClickPin = onClickPin,
-        )
+    val context = LocalContext.current
+    when {
+        presenter.isLoading -> LoadingScreen()
+        presenter.isEmpty -> EmptyScreen(R.string.source_empty_screen)
+        else -> {
+            SourceList(
+                nestedScrollConnection = nestedScrollInterop,
+                state = presenter,
+                onClickItem = onClickItem,
+                onClickDisable = onClickDisable,
+                onClickLatest = onClickLatest,
+                onClickPin = onClickPin,
+            )
+        }
+    }
+    LaunchedEffect(Unit) {
+        presenter.events.collectLatest { event ->
+            when (event) {
+                SourcesPresenter.Event.FailedFetchingSources -> {
+                    context.toast(R.string.internal_error)
+                }
+            }
+        }
     }
 }
 
 @Composable
 fun SourceList(
     nestedScrollConnection: NestedScrollConnection,
-    list: List<SourceUiModel>,
+    state: SourcesState,
     onClickItem: (Source) -> Unit,
     onClickDisable: (Source) -> Unit,
     onClickLatest: (Source) -> Unit,
     onClickPin: (Source) -> Unit,
 ) {
-    if (list.isEmpty()) {
-        EmptyScreen(textResource = R.string.source_empty_screen)
-        return
-    }
-
-    var sourceState by remember { mutableStateOf<Source?>(null) }
-
     ScrollbarLazyColumn(
         modifier = Modifier.nestedScroll(nestedScrollConnection),
         contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
     ) {
         items(
-            items = list,
+            items = state.items,
             contentType = {
                 when (it) {
                     is SourceUiModel.Header -> "header"
@@ -117,7 +120,7 @@ fun SourceList(
                     modifier = Modifier.animateItemPlacement(),
                     source = model.source,
                     onClickItem = onClickItem,
-                    onLongClickItem = { sourceState = it },
+                    onLongClickItem = { state.dialog = Dialog(it) },
                     onClickLatest = onClickLatest,
                     onClickPin = onClickPin,
                 )
@@ -125,18 +128,19 @@ fun SourceList(
         }
     }
 
-    if (sourceState != null) {
+    if (state.dialog != null) {
+        val source = state.dialog!!.source
         SourceOptionsDialog(
-            source = sourceState!!,
+            source = source,
             onClickPin = {
-                onClickPin(sourceState!!)
-                sourceState = null
+                onClickPin(source)
+                state.dialog = null
             },
             onClickDisable = {
-                onClickDisable(sourceState!!)
-                sourceState = null
+                onClickDisable(source)
+                state.dialog = null
             },
-            onDismiss = { sourceState = null },
+            onDismiss = { state.dialog = null },
         )
     }
 }

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

@@ -0,0 +1,27 @@
+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() }
+}

+ 10 - 0
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -20,6 +20,9 @@ import eu.kanade.tachiyomi.util.preference.plusAssign
 import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.toast
 import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import logcat.LogPriority
 import rx.Observable
 import uy.kohesive.injekt.Injekt
@@ -63,9 +66,16 @@ class ExtensionManager(
     var installedExtensions = emptyList<Extension.Installed>()
         private set(value) {
             field = value
+            installedExtensionsFlow.value = field
             installedExtensionsRelay.call(value)
         }
 
+    private val installedExtensionsFlow = MutableStateFlow(installedExtensions)
+
+    fun getInstalledExtensionsFlow(): StateFlow<List<Extension.Installed>> {
+        return installedExtensionsFlow.asStateFlow()
+    }
+
     fun getAppIconForSource(source: Source): Drawable? {
         return getAppIconForSource(source.id)
     }

+ 0 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterController.kt

@@ -17,9 +17,6 @@ class ExtensionFilterController : ComposeController<ExtensionFilterPresenter>()
         ExtensionFilterScreen(
             nestedScrollInterop = nestedScrollInterop,
             presenter = presenter,
-            onClickLang = { language ->
-                presenter.toggleLanguage(language)
-            },
         )
     }
 }

+ 17 - 14
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionFilterPresenter.kt

@@ -3,32 +3,37 @@ package eu.kanade.tachiyomi.ui.browse.extension
 import android.os.Bundle
 import eu.kanade.domain.extension.interactor.GetExtensionLanguages
 import eu.kanade.domain.source.interactor.ToggleLanguage
+import eu.kanade.presentation.browse.ExtensionFilterState
+import eu.kanade.presentation.browse.ExtensionFilterStateImpl
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+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.receiveAsFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
 class ExtensionFilterPresenter(
+    private val state: ExtensionFilterStateImpl = ExtensionFilterState() as ExtensionFilterStateImpl,
     private val getExtensionLanguages: GetExtensionLanguages = Injekt.get(),
     private val toggleLanguage: ToggleLanguage = Injekt.get(),
     private val preferences: PreferencesHelper = Injekt.get(),
-) : BasePresenter<ExtensionFilterController>() {
+) : BasePresenter<ExtensionFilterController>(), ExtensionFilterState by state {
 
-    private val _state: MutableStateFlow<ExtensionFilterState> = MutableStateFlow(ExtensionFilterState.Loading)
-    val state: StateFlow<ExtensionFilterState> = _state.asStateFlow()
+    private val _events = Channel<Event>(Int.MAX_VALUE)
+    val events = _events.receiveAsFlow()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
         presenterScope.launchIO {
             getExtensionLanguages.subscribe()
                 .catch { exception ->
-                    _state.value = ExtensionFilterState.Error(exception)
+                    logcat(LogPriority.ERROR, exception)
+                    _events.send(Event.FailedFetchingLanguages)
                 }
                 .collectLatest(::collectLatestSourceLangMap)
         }
@@ -36,19 +41,17 @@ class ExtensionFilterPresenter(
 
     private fun collectLatestSourceLangMap(extLangs: List<String>) {
         val enabledLanguages = preferences.enabledLanguages().get()
-        val uiModels = extLangs.map {
+        state.items = extLangs.map {
             FilterUiModel(it, it in enabledLanguages)
         }
-        _state.value = ExtensionFilterState.Success(uiModels)
+        state.isLoading = false
     }
 
     fun toggleLanguage(language: String) {
         toggleLanguage.await(language)
     }
-}
 
-sealed class ExtensionFilterState {
-    object Loading : ExtensionFilterState()
-    data class Error(val error: Throwable) : ExtensionFilterState()
-    data class Success(val models: List<FilterUiModel>) : ExtensionFilterState()
+    sealed class Event {
+        object FailedFetchingLanguages : Event()
+    }
 }

+ 12 - 23
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt

@@ -3,11 +3,11 @@ package eu.kanade.tachiyomi.ui.browse.extension
 import android.app.Application
 import android.os.Bundle
 import androidx.annotation.StringRes
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
 import eu.kanade.domain.extension.interactor.GetExtensionUpdates
 import eu.kanade.domain.extension.interactor.GetExtensions
+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
@@ -17,8 +17,6 @@ import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.LocaleHelper
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.update
@@ -27,20 +25,16 @@ import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
 class ExtensionsPresenter(
+    private val state: ExtensionsStateImpl = ExtensionState() as ExtensionsStateImpl,
     private val extensionManager: ExtensionManager = Injekt.get(),
     private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(),
     private val getExtensions: GetExtensions = Injekt.get(),
-) : BasePresenter<ExtensionsController>() {
+) : BasePresenter<ExtensionsController>(), ExtensionsState by state {
 
     private val _query: MutableStateFlow<String> = MutableStateFlow("")
 
     private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
 
-    private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized)
-    val state: StateFlow<ExtensionState> = _state.asStateFlow()
-
-    var isRefreshing: Boolean by mutableStateOf(true)
-
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
@@ -86,8 +80,6 @@ class ExtensionsPresenter(
                 getExtensionUpdates.subscribe(),
                 _currentDownloads,
             ) { query, (installed, untrusted, available), updates, downloads ->
-                isRefreshing = false
-
                 val languagesWithExtensions = available
                     .filter(queryFilter(query))
                     .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
@@ -121,7 +113,9 @@ class ExtensionsPresenter(
 
                 items
             }.collectLatest {
-                _state.value = ExtensionState.Initialized(it)
+                state.isRefreshing = false
+                state.isLoading = false
+                state.items = it
             }
         }
     }
@@ -134,9 +128,9 @@ class ExtensionsPresenter(
 
     fun updateAllExtensions() {
         launchIO {
-            val state = _state.value
-            if (state !is ExtensionState.Initialized) return@launchIO
-            state.list.mapNotNull {
+            if (state.isEmpty) return@launchIO
+            val items = state.items
+            items.mapNotNull {
                 if (it !is ExtensionUiModel.Item) return@mapNotNull null
                 if (it.extension !is Extension.Installed) return@mapNotNull null
                 if (it.extension.hasUpdate.not()) return@mapNotNull null
@@ -189,7 +183,7 @@ class ExtensionsPresenter(
     }
 
     fun findAvailableExtensions() {
-        isRefreshing = true
+        state.isRefreshing = true
         extensionManager.findAvailableExtensions()
     }
 
@@ -217,8 +211,3 @@ sealed interface ExtensionUiModel {
         }
     }
 }
-
-sealed class ExtensionState {
-    object Uninitialized : ExtensionState()
-    data class Initialized(val list: List<ExtensionUiModel>) : ExtensionState()
-}

+ 0 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt

@@ -43,7 +43,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
             nestedScrollInterop = nestedScrollInterop,
             presenter = presenter,
             onClickUninstall = { presenter.uninstallExtension() },
-            onClickAppInfo = { presenter.openInSettings() },
             onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
             onClickSource = { presenter.toggleSource(it) },
         )

+ 34 - 33
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsPresenter.kt

@@ -1,48 +1,52 @@
 package eu.kanade.tachiyomi.ui.browse.extension.details
 
 import android.app.Application
-import android.content.Intent
-import android.net.Uri
 import android.os.Bundle
-import android.provider.Settings
 import eu.kanade.domain.extension.interactor.GetExtensionSources
 import eu.kanade.domain.source.interactor.ToggleSource
+import eu.kanade.presentation.browse.ExtensionDetailsState
+import eu.kanade.presentation.browse.ExtensionDetailsStateImpl
 import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.system.LocaleHelper
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.map
-import rx.android.schedulers.AndroidSchedulers
+import kotlinx.coroutines.flow.take
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
 class ExtensionDetailsPresenter(
     private val pkgName: String,
+    private val state: ExtensionDetailsStateImpl = ExtensionDetailsState() as ExtensionDetailsStateImpl,
     private val context: Application = Injekt.get(),
     private val getExtensionSources: GetExtensionSources = Injekt.get(),
     private val toggleSource: ToggleSource = Injekt.get(),
     private val extensionManager: ExtensionManager = Injekt.get(),
-) : BasePresenter<ExtensionDetailsController>() {
-
-    val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName }
-
-    private val _state: MutableStateFlow<List<ExtensionSourceItem>> = MutableStateFlow(emptyList())
-    val sourcesState: StateFlow<List<ExtensionSourceItem>> = _state.asStateFlow()
+) : BasePresenter<ExtensionDetailsController>(), ExtensionDetailsState by state {
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
-        val extension = extension ?: return
+        presenterScope.launchIO {
+            extensionManager.getInstalledExtensionsFlow()
+                .map { it.firstOrNull { it.pkgName == pkgName } }
+                .collectLatest {
+                    state.extension = it
+                    fetchExtensionSources()
+                }
+        }
 
         bindToUninstalledExtension()
+    }
 
-        presenterScope.launchIO {
-            getExtensionSources.subscribe(extension)
+    private fun CoroutineScope.fetchExtensionSources() {
+        launchIO {
+            getExtensionSources.subscribe(extension!!)
                 .map {
                     it.sortedWith(
                         compareBy(
@@ -51,20 +55,24 @@ class ExtensionDetailsPresenter(
                         ),
                     )
                 }
-                .collectLatest { _state.value = it }
+                .collectLatest {
+                    state.isLoading = false
+                    state.sources = it
+                }
         }
     }
 
     private fun bindToUninstalledExtension() {
-        extensionManager.getInstalledExtensionsObservable()
-            .skip(1)
-            .filter { extensions -> extensions.none { it.pkgName == pkgName } }
-            .map { }
-            .take(1)
-            .observeOn(AndroidSchedulers.mainThread())
-            .subscribeFirst({ view, _ ->
-                view.onExtensionUninstalled()
-            },)
+        presenterScope.launchIO {
+            extensionManager.getInstalledExtensionsFlow()
+                .drop(1)
+                .filter { extensions -> extensions.none { it.pkgName == pkgName } }
+                .map { }
+                .take(1)
+                .collectLatest {
+                    view?.onExtensionUninstalled()
+                }
+        }
     }
 
     fun uninstallExtension() {
@@ -72,13 +80,6 @@ class ExtensionDetailsPresenter(
         extensionManager.uninstallExtension(extension.pkgName)
     }
 
-    fun openInSettings() {
-        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
-            data = Uri.fromParts("package", pkgName, null)
-        }
-        view?.startActivity(intent)
-    }
-
     fun toggleSource(sourceId: Long) {
         toggleSource.await(sourceId)
     }

+ 19 - 15
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaPresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaPresenter.kt

@@ -2,25 +2,29 @@ package eu.kanade.tachiyomi.ui.browse.migration.manga
 
 import android.os.Bundle
 import eu.kanade.domain.manga.interactor.GetFavorites
-import eu.kanade.domain.manga.model.Manga
+import eu.kanade.presentation.browse.MigrateMangaState
+import eu.kanade.presentation.browse.MigrateMangaStateImpl
+import eu.kanade.presentation.browse.MigrationMangaState
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+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.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
-class MigrationMangaPresenter(
+class MigrateMangaPresenter(
     private val sourceId: Long,
+    private val state: MigrateMangaStateImpl = MigrationMangaState() as MigrateMangaStateImpl,
     private val getFavorites: GetFavorites = Injekt.get(),
-) : BasePresenter<MigrationMangaController>() {
+) : BasePresenter<MigrationMangaController>(), MigrateMangaState by state {
 
-    private val _state: MutableStateFlow<MigrateMangaState> = MutableStateFlow(MigrateMangaState.Loading)
-    val state: StateFlow<MigrateMangaState> = _state.asStateFlow()
+    private val _events = Channel<Event>(Int.MAX_VALUE)
+    val events = _events.receiveAsFlow()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -28,20 +32,20 @@ class MigrationMangaPresenter(
             getFavorites
                 .subscribe(sourceId)
                 .catch { exception ->
-                    _state.value = MigrateMangaState.Error(exception)
+                    logcat(LogPriority.ERROR, exception)
+                    _events.send(Event.FailedFetchingFavorites)
                 }
                 .map { list ->
                     list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
                 }
                 .collectLatest { sortedList ->
-                    _state.value = MigrateMangaState.Success(sortedList)
+                    state.isLoading = false
+                    state.items = sortedList
                 }
         }
     }
-}
 
-sealed class MigrateMangaState {
-    object Loading : MigrateMangaState()
-    data class Error(val error: Throwable) : MigrateMangaState()
-    data class Success(val list: List<Manga>) : MigrateMangaState()
+    sealed class Event {
+        object FailedFetchingFavorites : Event()
+    }
 }

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrationMangaController.kt

@@ -10,7 +10,7 @@ import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
 import eu.kanade.tachiyomi.ui.manga.MangaController
 
-class MigrationMangaController : ComposeController<MigrationMangaPresenter> {
+class MigrationMangaController : ComposeController<MigrateMangaPresenter> {
 
     constructor(sourceId: Long, sourceName: String?) : super(
         bundleOf(
@@ -30,7 +30,7 @@ class MigrationMangaController : ComposeController<MigrationMangaPresenter> {
 
     override fun getTitle(): String? = sourceName
 
-    override fun createPresenter(): MigrationMangaPresenter = MigrationMangaPresenter(sourceId)
+    override fun createPresenter(): MigrateMangaPresenter = MigrateMangaPresenter(sourceId)
 
     @Composable
     override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {

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

@@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.ui.base.controller.ComposeController
 import eu.kanade.tachiyomi.ui.base.controller.pushController
 import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController
-import eu.kanade.tachiyomi.util.system.copyToClipboard
 import eu.kanade.tachiyomi.util.system.openInBrowser
 
 class MigrationSourcesController : ComposeController<MigrationSourcesPresenter>() {
@@ -34,10 +33,6 @@ class MigrationSourcesController : ComposeController<MigrationSourcesPresenter>(
                     ),
                 )
             },
-            onLongClickItem = { source ->
-                val sourceId = source.id.toString()
-                activity?.copyToClipboard(sourceId, sourceId)
-            },
         )
     }
 

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

@@ -3,24 +3,27 @@ package eu.kanade.tachiyomi.ui.browse.migration.sources
 import android.os.Bundle
 import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
 import eu.kanade.domain.source.interactor.SetMigrateSorting
-import eu.kanade.domain.source.model.Source
+import eu.kanade.presentation.browse.MigrateSourceState
+import eu.kanade.presentation.browse.MigrateSourceStateImpl
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+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.receiveAsFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
 class MigrationSourcesPresenter(
+    private val state: MigrateSourceStateImpl = MigrateSourceState() as MigrateSourceStateImpl,
     private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
     private val setMigrateSorting: SetMigrateSorting = Injekt.get(),
-) : BasePresenter<MigrationSourcesController>() {
+) : BasePresenter<MigrationSourcesController>(), MigrateSourceState by state {
 
-    private val _state: MutableStateFlow<MigrateSourceState> = MutableStateFlow(MigrateSourceState.Loading)
-    val state: StateFlow<MigrateSourceState> = _state.asStateFlow()
+    private val _channel = Channel<Event>(Int.MAX_VALUE)
+    val channel = _channel.receiveAsFlow()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -28,10 +31,12 @@ class MigrationSourcesPresenter(
         presenterScope.launchIO {
             getSourcesWithFavoriteCount.subscribe()
                 .catch { exception ->
-                    _state.value = MigrateSourceState.Error(exception)
+                    logcat(LogPriority.ERROR, exception)
+                    _channel.send(Event.FailedFetchingSourcesWithCount)
                 }
                 .collectLatest { sources ->
-                    _state.value = MigrateSourceState.Success(sources)
+                    state.items = sources
+                    state.isLoading = false
                 }
         }
     }
@@ -43,10 +48,8 @@ class MigrationSourcesPresenter(
     fun setTotalSorting(isAscending: Boolean) {
         setMigrateSorting.await(SetMigrateSorting.Mode.TOTAL, isAscending)
     }
-}
 
-sealed class MigrateSourceState {
-    object Loading : MigrateSourceState()
-    data class Error(val error: Throwable) : MigrateSourceState()
-    data class Success(val sources: List<Pair<Source, Long>>) : MigrateSourceState()
+    sealed class Event {
+        object FailedFetchingSourcesWithCount : Event()
+    }
 }

+ 17 - 14
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesFilterPresenter.kt

@@ -5,26 +5,30 @@ import eu.kanade.domain.source.interactor.GetLanguagesWithSources
 import eu.kanade.domain.source.interactor.ToggleLanguage
 import eu.kanade.domain.source.interactor.ToggleSource
 import eu.kanade.domain.source.model.Source
+import eu.kanade.presentation.browse.SourcesFilterState
+import eu.kanade.presentation.browse.SourcesFilterStateImpl
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+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.receiveAsFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
 class SourcesFilterPresenter(
+    private val state: SourcesFilterStateImpl = SourcesFilterState() as SourcesFilterStateImpl,
     private val getLanguagesWithSources: GetLanguagesWithSources = Injekt.get(),
     private val toggleSource: ToggleSource = Injekt.get(),
     private val toggleLanguage: ToggleLanguage = Injekt.get(),
     private val preferences: PreferencesHelper = Injekt.get(),
-) : BasePresenter<SourceFilterController>() {
+) : BasePresenter<SourceFilterController>(), SourcesFilterState by state {
 
-    private val _state: MutableStateFlow<SourceFilterState> = MutableStateFlow(SourceFilterState.Loading)
-    val state: StateFlow<SourceFilterState> = _state.asStateFlow()
+    private val _events = Channel<Event>(Int.MAX_VALUE)
+    val events = _events.receiveAsFlow()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -32,14 +36,15 @@ class SourcesFilterPresenter(
         presenterScope.launchIO {
             getLanguagesWithSources.subscribe()
                 .catch { exception ->
-                    _state.value = SourceFilterState.Error(exception)
+                    logcat(LogPriority.ERROR, exception)
+                    _events.send(Event.FailedFetchingLanguages)
                 }
                 .collectLatest(::collectLatestSourceLangMap)
         }
     }
 
     private fun collectLatestSourceLangMap(sourceLangMap: Map<String, List<Source>>) {
-        val uiModels = sourceLangMap.flatMap {
+        state.items = sourceLangMap.flatMap {
             val isLangEnabled = it.key in preferences.enabledLanguages().get()
             val header = listOf(FilterUiModel.Header(it.key, isLangEnabled))
 
@@ -51,7 +56,7 @@ class SourcesFilterPresenter(
                 )
             }
         }
-        _state.value = SourceFilterState.Success(uiModels)
+        state.isLoading = false
     }
 
     fun toggleSource(source: Source) {
@@ -61,10 +66,8 @@ class SourcesFilterPresenter(
     fun toggleLanguage(language: String) {
         toggleLanguage.await(language)
     }
-}
 
-sealed class SourceFilterState {
-    object Loading : SourceFilterState()
-    data class Error(val error: Throwable) : SourceFilterState()
-    data class Success(val models: List<FilterUiModel>) : SourceFilterState()
+    sealed class Event {
+        object FailedFetchingLanguages : Event()
+    }
 }

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

@@ -7,32 +7,37 @@ import eu.kanade.domain.source.interactor.ToggleSourcePin
 import eu.kanade.domain.source.model.Pin
 import eu.kanade.domain.source.model.Source
 import eu.kanade.presentation.browse.SourceUiModel
+import eu.kanade.presentation.browse.SourcesState
+import eu.kanade.presentation.browse.SourcesStateImpl
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.launchIO
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+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.receiveAsFlow
+import logcat.LogPriority
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.util.TreeMap
 
 class SourcesPresenter(
+    private val state: SourcesStateImpl = SourcesState() as SourcesStateImpl,
     private val getEnabledSources: GetEnabledSources = Injekt.get(),
     private val toggleSource: ToggleSource = Injekt.get(),
     private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
-) : BasePresenter<SourcesController>() {
+) : BasePresenter<SourcesController>(), SourcesState by state {
 
-    private val _state: MutableStateFlow<SourceState> = MutableStateFlow(SourceState.Loading)
-    val state: StateFlow<SourceState> = _state.asStateFlow()
+    private val _events = Channel<Event>(Int.MAX_VALUE)
+    val events = _events.receiveAsFlow()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
         presenterScope.launchIO {
             getEnabledSources.subscribe()
                 .catch { exception ->
-                    _state.value = SourceState.Error(exception)
+                    logcat(LogPriority.ERROR, exception)
+                    _events.send(Event.FailedFetchingSources)
                 }
                 .collectLatest(::collectLatestSources)
         }
@@ -67,7 +72,8 @@ class SourcesPresenter(
                 }.toTypedArray(),
             )
         }
-        _state.value = SourceState.Success(uiModels)
+        state.isLoading = false
+        state.items = uiModels
     }
 
     fun toggleSource(source: Source) {
@@ -78,14 +84,14 @@ class SourcesPresenter(
         toggleSourcePin.await(source)
     }
 
+    sealed class Event {
+        object FailedFetchingSources : Event()
+    }
+
+    data class Dialog(val source: Source)
+
     companion object {
         const val PINNED_KEY = "pinned"
         const val LAST_USED_KEY = "last_used"
     }
 }
-
-sealed class SourceState {
-    object Loading : SourceState()
-    data class Error(val error: Throwable) : SourceState()
-    data class Success(val uiModels: List<SourceUiModel>) : SourceState()
-}