From f4ac754d02242f33e78a15f98959d6e59bd967c9 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Thu, 24 Nov 2022 10:28:25 +0700 Subject: [PATCH] Use Voyager on Browse tab (#8605) --- .../presentation/browse/ExtensionsScreen.kt | 14 +- .../presentation/browse/ExtensionsState.kt | 27 ---- .../browse/MigrateSourceScreen.kt | 20 +-- .../presentation/browse/MigrateSourceState.kt | 28 ---- .../presentation/browse/SourcesScreen.kt | 124 ++++++------------ .../presentation/browse/SourcesState.kt | 27 ---- .../presentation/components/TabbedScreen.kt | 8 +- .../settings/screen/SettingsBackupScreen.kt | 14 +- .../tachiyomi/ui/browse/BrowseController.kt | 44 +------ .../tachiyomi/ui/browse/BrowsePresenter.kt | 31 ----- .../tachiyomi/ui/browse/BrowseScreen.kt | 66 ++++++++++ ...sPresenter.kt => ExtensionsScreenModel.kt} | 76 ++++++----- .../ui/browse/extension/ExtensionsTab.kt | 91 ++++++------- .../sources/MigrateSourceScreenModel.kt | 91 +++++++++++++ ...grateSourcesTab.kt => MigrateSourceTab.kt} | 23 ++-- .../sources/MigrationSourcesPresenter.kt | 75 ----------- ...rcesPresenter.kt => SourcesScreenModel.kt} | 90 +++++++------ .../tachiyomi/ui/browse/source/SourcesTab.kt | 103 ++++++++++----- .../kanade/tachiyomi/util/storage/DiskUtil.kt | 15 +++ .../tachiyomi/util/system/LocaleHelper.kt | 6 +- 20 files changed, 465 insertions(+), 508 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt delete mode 100644 app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/{ExtensionsPresenter.kt => ExtensionsScreenModel.kt} (81%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceScreenModel.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/{MigrateSourcesTab.kt => MigrateSourceTab.kt} (65%) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/{SourcesPresenter.kt => SourcesScreenModel.kt} (53%) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index e16acf6a9..af06f42f6 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/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, diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt deleted file mode 100644 index 7d3271172..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsState.kt +++ /dev/null @@ -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 - 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 by mutableStateOf(emptyList()) - override var updates: Int by mutableStateOf(0) - override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt index f61f313af..981e1ae34 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt +++ b/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, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt deleted file mode 100644 index c5d9f1f5f..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceState.kt +++ /dev/null @@ -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> - 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> 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) -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt index a0366df5c..d12503805 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt +++ b/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, diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt deleted file mode 100644 index 87894a229..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt +++ /dev/null @@ -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 - 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 by mutableStateOf(emptyList()) - override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } -} diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index 0cfbc5632..3eb46c15a 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/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 = emptyList(), - val content: @Composable (contentPadding: PaddingValues) -> Unit, + val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit, ) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt index 904a8ec76..d20fc98ce 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt +++ b/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 { val backupPreferences = Injekt.get() - 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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt index ecf2de02d..18f6c44c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseController.kt +++ b/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, 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, 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)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt deleted file mode 100644 index 5447bc12c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt +++ /dev/null @@ -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() { - - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt new file mode 100644 index 000000000..1246f8144 --- /dev/null +++ b/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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt similarity index 81% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt index da55bc14e..0887e5973 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsPresenter.kt +++ b/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()) { private val _query: MutableStateFlow = MutableStateFlow(null) val query: StateFlow = _query.asStateFlow() private var _currentDownloads = MutableStateFlow>(hashMapOf()) - fun onCreate() { + init { val context = Injekt.get() val extensionMapper: (Map) -> ((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 = emptyList(), + val updates: Int = 0, +) { + val isEmpty = items.isEmpty() +} + sealed interface ExtensionUiModel { sealed interface Header : ExtensionUiModel { data class Resource(@StringRes val textRes: Int) : Header diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt index aae704d88..9e9dd6d81 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt +++ b/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, + ) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceScreenModel.kt new file mode 100644 index 000000000..3e1952fb3 --- /dev/null +++ b/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()) { + + private val _channel = Channel(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> = emptyList(), + val sortingMode: SetMigrateSorting.Mode = SetMigrateSorting.Mode.ALPHABETICAL, + val sortingDirection: SetMigrateSorting.Direction = SetMigrateSorting.Direction.ASCENDING, +) { + val isEmpty = items.isEmpty() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourcesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt similarity index 65% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourcesTab.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt index 14d530bc6..eb1383551 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourcesTab.kt +++ b/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, ) }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt deleted file mode 100644 index aabb376c2..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenter.kt +++ /dev/null @@ -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(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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt similarity index 53% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt index 1c73a4052..0f5b72414 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesPresenter.kt +++ b/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()) { private val _events = Channel(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) { - val map = TreeMap> { 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> { 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 = emptyList(), +) { + val isEmpty = items.isEmpty() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt index 019f9d88b..84166ae13 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt +++ b/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()) }, +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()) }, + ), ), - 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) } + } + } + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 8e3141a27..4a6322080 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index 4b1ca855e..21a2e7d7c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt +++ b/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)