diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetShowLatest.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetShowLatest.kt index 6141663dd..cdb5b9f67 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/GetShowLatest.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetShowLatest.kt @@ -1,7 +1,6 @@ package eu.kanade.domain.source.interactor import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.ui.browse.source.SourcesController import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -9,10 +8,10 @@ class GetShowLatest( private val preferences: UiPreferences, ) { - fun subscribe(mode: SourcesController.Mode): Flow { + fun subscribe(hasSmartSearchConfig: Boolean): Flow { return preferences.useNewSourceNavigation().changes() .map { - mode == SourcesController.Mode.CATALOGUE && !it + !hasSmartSearchConfig && !it } } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/BrowseTabWrapper.kt b/app/src/main/java/eu/kanade/presentation/browse/BrowseTabWrapper.kt index d7277cad1..d66f8f112 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/BrowseTabWrapper.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/BrowseTabWrapper.kt @@ -1,6 +1,9 @@ package eu.kanade.presentation.browse +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions @@ -9,6 +12,7 @@ import eu.kanade.presentation.components.TabContent @Composable fun BrowseTabWrapper(tab: TabContent) { + val snackbarHostState = remember { SnackbarHostState() } Scaffold( topBar = { scrollBehavior -> AppBar( @@ -19,7 +23,8 @@ fun BrowseTabWrapper(tab: TabContent) { scrollBehavior = scrollBehavior, ) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { paddingValues -> - tab.content(paddingValues) + tab.content(paddingValues, snackbarHostState) } } 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 1eae26323..90a26a3a6 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -53,13 +53,13 @@ import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.source.ConfigurableSource 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 import exh.source.anyIs @Composable fun ExtensionScreen( - presenter: ExtensionsPresenter, + state: ExtensionsState, contentPadding: PaddingValues, onLongClickItem: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit, @@ -72,19 +72,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/FeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt index 897504775..74abba55a 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/FeedScreen.kt @@ -53,7 +53,7 @@ import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.topSmallPaddingValues import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.CatalogueSource -import eu.kanade.tachiyomi.ui.browse.feed.FeedPresenter +import eu.kanade.tachiyomi.ui.browse.feed.FeedScreenState import exh.savedsearches.models.FeedSavedSearch import exh.savedsearches.models.SavedSearch import eu.kanade.domain.manga.model.MangaCover as MangaCoverData @@ -69,97 +69,39 @@ data class FeedItemUI( @Composable fun FeedScreen( - presenter: FeedPresenter, + state: FeedScreenState, contentPadding: PaddingValues, - onClickAdd: (CatalogueSource) -> Unit, - onClickCreate: (CatalogueSource, SavedSearch?) -> Unit, onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit, onClickSource: (CatalogueSource) -> Unit, onClickDelete: (FeedSavedSearch) -> Unit, - onClickDeleteConfirm: (FeedSavedSearch) -> Unit, onClickManga: (Manga) -> Unit, + getMangaState: @Composable (Manga, CatalogueSource?) -> State, ) { when { - presenter.isLoading -> LoadingScreen() - presenter.isEmpty -> EmptyScreen( + state.isLoading -> LoadingScreen() + state.isEmpty -> EmptyScreen( textResource = R.string.feed_tab_empty, modifier = Modifier.padding(contentPadding), ) else -> { - FeedList( - state = presenter, - contentPadding = contentPadding, - getMangaState = { item, source -> presenter.getManga(item, source) }, - onClickSavedSearch = onClickSavedSearch, - onClickSource = onClickSource, - onClickDelete = onClickDelete, - onClickManga = onClickManga, - ) - } - } - - when (val dialog = presenter.dialog) { - is FeedPresenter.Dialog.AddFeed -> { - FeedAddDialog( - sources = dialog.options, - onDismiss = { presenter.dialog = null }, - onClickAdd = { - presenter.dialog = null - onClickAdd(it ?: return@FeedAddDialog) - }, - ) - } - is FeedPresenter.Dialog.AddFeedSearch -> { - FeedAddSearchDialog( - source = dialog.source, - savedSearches = dialog.options, - onDismiss = { presenter.dialog = null }, - onClickAdd = { source, savedSearch -> - presenter.dialog = null - onClickCreate(source, savedSearch) - }, - ) - } - is FeedPresenter.Dialog.DeleteFeed -> { - FeedDeleteConfirmDialog( - feed = dialog.feed, - onDismiss = { presenter.dialog = null }, - onClickDeleteConfirm = { - presenter.dialog = null - onClickDeleteConfirm(it) - }, - ) - } - null -> Unit - } -} - -@Composable -fun FeedList( - state: FeedState, - contentPadding: PaddingValues, - getMangaState: @Composable ((Manga, CatalogueSource?) -> State), - onClickSavedSearch: (SavedSearch, CatalogueSource) -> Unit, - onClickSource: (CatalogueSource) -> Unit, - onClickDelete: (FeedSavedSearch) -> Unit, - onClickManga: (Manga) -> Unit, -) { - ScrollbarLazyColumn( - contentPadding = contentPadding + topSmallPaddingValues, - ) { - items( - state.items.orEmpty(), - key = { it.feed.id }, - ) { item -> - FeedItem( - modifier = Modifier.animateItemPlacement(), - item = item, - getMangaState = { getMangaState(it, item.source) }, - onClickSavedSearch = onClickSavedSearch, - onClickSource = onClickSource, - onClickDelete = onClickDelete, - onClickManga = onClickManga, - ) + ScrollbarLazyColumn( + contentPadding = contentPadding + topSmallPaddingValues, + ) { + items( + state.items.orEmpty(), + key = { it.feed.id }, + ) { item -> + FeedItem( + modifier = Modifier.animateItemPlacement(), + item = item, + getMangaState = { getMangaState(it, item.source) }, + onClickSavedSearch = onClickSavedSearch, + onClickSource = onClickSource, + onClickDelete = onClickDelete, + onClickManga = onClickManga, + ) + } + } } } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt b/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt deleted file mode 100644 index 8d0a93bf6..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/FeedState.kt +++ /dev/null @@ -1,26 +0,0 @@ -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.ui.browse.feed.FeedPresenter - -@Stable -interface FeedState { - var dialog: FeedPresenter.Dialog? - val isLoading: Boolean - val isEmpty: Boolean - val items: List? -} - -fun FeedState(): FeedState { - return FeedStateImpl() -} - -class FeedStateImpl : FeedState { - override var dialog: FeedPresenter.Dialog? by mutableStateOf(null) - override var isLoading: Boolean by mutableStateOf(true) - override var isEmpty: Boolean by mutableStateOf(false) - override var items: List? by mutableStateOf(null) -} 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 ee1dbb7fc..ad2a263b8 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt @@ -41,38 +41,40 @@ 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, // SY --> onClickAll: (Source) -> Unit, // SY <-- ) { 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, // SY --> onClickAll = onClickAll, // SY <-- 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 9a5124e4a..d97449d10 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt @@ -19,7 +19,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.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -40,153 +39,70 @@ 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.SourcesPresenter.Dialog +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, - // SY --> - onClickSetCategories: (Source, List) -> Unit, - onClickToggleDataSaver: (Source) -> Unit, - // SY <-- + 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, - // SY --> - onClickSetCategories = onClickSetCategories, - onClickToggleDataSaver = onClickToggleDataSaver, - // SY <-- - ) - } - } - 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, + // SY --> + isCategory = model.isCategory, + // SY <-- + ) + } + is SourceUiModel.Item -> SourceItem( + modifier = Modifier.animateItemPlacement(), + source = model.source, + // SY --> + showLatest = state.showLatest, + showPin = state.showPin, + // SY <-- + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPin = onClickPin, + ) + } } } } } } -@Composable -private fun SourceList( - state: SourcesState, - contentPadding: PaddingValues, - onClickItem: (Source, String) -> Unit, - onClickDisable: (Source) -> Unit, - onClickPin: (Source) -> Unit, - // SY --> - onClickSetCategories: (Source, List) -> Unit, - onClickToggleDataSaver: (Source) -> Unit, - // SY <-- -) { - 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, - // SY --> - isCategory = model.isCategory, - // SY <-- - ) - } - is SourceUiModel.Item -> SourceItem( - modifier = Modifier.animateItemPlacement(), - source = model.source, - // SY --> - showLatest = state.showLatest, - showPin = state.showPin, - // SY <-- - onClickItem = onClickItem, - // SY --> - onLongClickItem = { state.dialog = Dialog.SourceLongClick(it) }, - // SY <-- - onClickPin = onClickPin, - ) - } - } - } - - // SY --> - when (val dialog = state.dialog) { - is Dialog.SourceCategories -> { - SourceCategoriesDialog( - source = dialog.source, - categories = state.categories, - onClickCategories = { source, newCategories -> - onClickSetCategories(source, newCategories) - state.dialog = null - }, - onDismiss = { state.dialog = null }, - ) - } - is Dialog.SourceLongClick -> { - val source = dialog.source - SourceOptionsDialog( - source = source, - onClickPin = { - onClickPin(source) - state.dialog = null - }, - onClickDisable = { - onClickDisable(source) - state.dialog = null - }, - onClickSetCategories = { - state.dialog = Dialog.SourceCategories(source) - }, - onClickToggleDataSaver = { - onClickToggleDataSaver(source) - state.dialog = null - }, - onDismiss = { state.dialog = null }, - ) - } - null -> Unit - } - // SY <-- -} - @Composable private fun SourceHeader( modifier: Modifier = Modifier, @@ -268,7 +184,7 @@ private fun SourcePinButton( } @Composable -private fun SourceOptionsDialog( +fun SourceOptionsDialog( source: Source, onClickPin: () -> Unit, onClickDisable: () -> Unit, @@ -338,7 +254,7 @@ sealed class SourceUiModel { fun SourceCategoriesDialog( source: Source?, categories: List, - onClickCategories: (Source, List) -> Unit, + onClickCategories: (List) -> Unit, onDismiss: () -> Unit, ) { source ?: return @@ -379,7 +295,7 @@ fun SourceCategoriesDialog( }, onDismissRequest = onDismiss, confirmButton = { - TextButton(onClick = { onClickCategories(source, newCategories.toList()) }) { + TextButton(onClick = { onClickCategories(newCategories.toList()) }) { Text(text = stringResource(android.R.string.ok)) } }, 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 0d0d67529..000000000 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesState.kt +++ /dev/null @@ -1,39 +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 - - // SY --> - val showPin: Boolean - val showLatest: Boolean - val categories: List - // SY <-- -} - -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() } - - // SY --> - override var showLatest: Boolean by mutableStateOf(true) - override var showPin: Boolean by mutableStateOf(true) - override var categories: List by mutableStateOf(emptyList()) - // SY <-- -} 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 c7989edf5..8080b00c5 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 fc16fb920..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,25 +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.feed.feedTab -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) @@ -30,47 +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, - // SY --> - tabs = ( - if (presenter.feedTabInFront) { - listOf( - feedTab(router, presenter.feedPresenter), - sourcesTab(router, presenter.sourcesPresenter), - ) - } else { - listOf( - sourcesTab(router, presenter.sourcesPresenter), - feedTab(router, presenter.feedPresenter), - ) - } - ) + listOf( - extensionsTab(router, presenter.extensionsPresenter), - migrateSourcesTab(router, presenter.migrationSourcesPresenter), - ), - startIndex = 2.takeIf { toExtensions }, - // SY <-- - 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 6e27248a4..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowsePresenter.kt +++ /dev/null @@ -1,55 +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.domain.ui.UiPreferences -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter -import eu.kanade.tachiyomi.ui.browse.feed.FeedPresenter -import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter -import eu.kanade.tachiyomi.ui.browse.source.SourcesController -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(), - // SY --> - uiPreferences: UiPreferences = Injekt.get(), - // SY <-- -) : BasePresenter() { - - val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() - val isIncognitoMode: Boolean by preferences.incognitoMode().asState() - - // SY --> - val feedTabInFront = uiPreferences.feedTabInFront().get() - // SY <-- - - // SY --> - val sourcesPresenter = SourcesPresenter(presenterScope, controllerMode = SourcesController.Mode.CATALOGUE, smartSearchConfig = null) - val feedPresenter = FeedPresenter(presenterScope) - - // SY <-- - val extensionsPresenter = ExtensionsPresenter(presenterScope) - val migrationSourcesPresenter = MigrationSourcesPresenter(presenterScope) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - - sourcesPresenter.onCreate() - // SY --> - feedPresenter.onCreate() - // SY <-- - extensionsPresenter.onCreate() - migrationSourcesPresenter.onCreate() - } - - // SY --> - override fun onDestroy() { - super.onDestroy() - feedPresenter.onDestroy() - } - // SY <-- -} 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..926a4e130 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseScreen.kt @@ -0,0 +1,87 @@ +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.domain.ui.UiPreferences +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.feed.feedTab +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, + // SY --> + tabs = if (screenModel.feedTabInFront) { + listOf( + feedTab(), + sourcesTab(), + extensionsTab(extensionsScreenModel), + migrateSourceTab(), + ) + } else { + listOf( + sourcesTab(), + feedTab(), + extensionsTab(extensionsScreenModel), + migrateSourceTab(), + ) + }, + startIndex = 2.takeIf { toExtensions }, + // SY <-- + 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(), + // SY --> + uiPreferences: UiPreferences = Injekt.get(), + // SY <-- +) : ScreenModel { + val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope) + val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope) + + // SY --> + val feedTabInFront = uiPreferences.feedTabInFront().get() + // SY <-- +} 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/feed/FeedPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt similarity index 82% rename from app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt index 143dcd955..5ec7d5893 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedScreenModel.kt @@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.browse.feed import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.produceState +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope import eu.kanade.domain.manga.interactor.GetManga import eu.kanade.domain.manga.interactor.NetworkToLocalManga import eu.kanade.domain.manga.interactor.UpdateManga @@ -16,8 +18,6 @@ import eu.kanade.domain.source.interactor.GetSavedSearchGlobalFeed import eu.kanade.domain.source.interactor.InsertFeedSavedSearch import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.presentation.browse.FeedItemUI -import eu.kanade.presentation.browse.FeedState -import eu.kanade.presentation.browse.FeedStateImpl import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.FilterList @@ -30,11 +30,13 @@ import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.logcat import exh.savedsearches.models.FeedSavedSearch import exh.savedsearches.models.SavedSearch -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -51,9 +53,7 @@ import eu.kanade.domain.manga.model.Manga as DomainManga /** * Presenter of [feedTab] */ -open class FeedPresenter( - private val presenterScope: CoroutineScope, - private val state: FeedStateImpl = FeedState() as FeedStateImpl, +open class FeedScreenModel( val sourceManager: SourceManager = Injekt.get(), val sourcePreferences: SourcePreferences = Injekt.get(), private val getManga: GetManga = Injekt.get(), @@ -65,14 +65,17 @@ open class FeedPresenter( private val getSavedSearchBySourceId: GetSavedSearchBySourceId = Injekt.get(), private val insertFeedSavedSearch: InsertFeedSavedSearch = Injekt.get(), private val deleteFeedSavedSearchById: DeleteFeedSavedSearchById = Injekt.get(), -) : FeedState by state { +) : StateScreenModel(FeedScreenState()) { + + private val _events = Channel(Int.MAX_VALUE) + val events = _events.receiveAsFlow() /** * Fetches the different sources by user settings. */ private var fetchSourcesSubscription: Subscription? = null - fun onCreate() { + init { getFeedSavedSearchGlobal.subscribe() .distinctUntilChanged() .onEach { @@ -84,30 +87,46 @@ open class FeedPresenter( results = null, ) } - state.items = items - state.isEmpty = items.isEmpty() - state.isLoading = false + mutableState.update { state -> + state.copy( + items = items, + ) + } getFeed(items) } - .launchIn(presenterScope) - } - - fun onDestroy() { - fetchSourcesSubscription?.unsubscribe() + .launchIn(coroutineScope) } fun openAddDialog() { - presenterScope.launchIO { + coroutineScope.launchIO { if (hasTooManyFeeds()) { return@launchIO } - dialog = Dialog.AddFeed(getEnabledSources()) + mutableState.update { state -> + state.copy( + dialog = Dialog.AddFeed(getEnabledSources()), + ) + } } } fun openAddSearchDialog(source: CatalogueSource) { - presenterScope.launchIO { - dialog = Dialog.AddFeedSearch(source, (if (source.supportsLatest) listOf(null) else emptyList()) + getSourceSavedSearches(source.id)) + coroutineScope.launchIO { + mutableState.update { state -> + state.copy( + dialog = Dialog.AddFeedSearch(source, (if (source.supportsLatest) listOf(null) else emptyList()) + getSourceSavedSearches(source.id)), + ) + } + } + } + + fun openDeleteDialog(feed: FeedSavedSearch) { + coroutineScope.launchIO { + mutableState.update { state -> + state.copy( + dialog = Dialog.DeleteFeed(feed), + ) + } } } @@ -134,7 +153,7 @@ open class FeedPresenter( } fun createFeed(source: CatalogueSource, savedSearch: SavedSearch?) { - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { insertFeedSavedSearch.await( FeedSavedSearch( id = -1, @@ -147,7 +166,7 @@ open class FeedPresenter( } fun deleteFeed(feed: FeedSavedSearch) { - presenterScope.launchNonCancellable { + coroutineScope.launchNonCancellable { deleteFeedSavedSearchById.await(feed.id) } } @@ -212,8 +231,10 @@ open class FeedPresenter( .observeOn(AndroidSchedulers.mainThread()) // Update matching source with the obtained results .doOnNext { result -> - synchronized(state) { - state.items = state.items?.map { if (it.feed.id == result.feed.id) result else it } + mutableState.update { state -> + state.copy( + items = state.items?.map { if (it.feed.id == result.feed.id) result else it }, + ) } } .subscribe( @@ -272,9 +293,34 @@ open class FeedPresenter( } } + override fun onDispose() { + super.onDispose() + fetchSourcesSubscription?.unsubscribe() + } + + fun dismissDialog() { + mutableState.update { it.copy(dialog = null) } + } + sealed class Dialog { data class AddFeed(val options: List) : Dialog() data class AddFeedSearch(val source: CatalogueSource, val options: List) : Dialog() data class DeleteFeed(val feed: FeedSavedSearch) : Dialog() } + + sealed class Event { + object FailedFetchingSources : Event() + object TooManyFeeds : Event() + } +} + +data class FeedScreenState( + val dialog: FeedScreenModel.Dialog? = null, + val items: List? = null, +) { + val isLoading + get() = items == null + + val isEmpty + get() = items.isNullOrEmpty() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt index bfdaf70a9..0b0799fd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/feed/FeedTab.kt @@ -3,59 +3,126 @@ package eu.kanade.tachiyomi.ui.browse.feed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add 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.domain.source.interactor.GetRemoteManga +import eu.kanade.presentation.browse.FeedAddDialog +import eu.kanade.presentation.browse.FeedAddSearchDialog +import eu.kanade.presentation.browse.FeedDeleteConfirmDialog import eu.kanade.presentation.browse.FeedScreen 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.manga.MangaController +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @Composable -fun feedTab( - router: Router?, - presenter: FeedPresenter, -) = TabContent( - titleRes = R.string.feed, - actions = listOf( - AppBar.Action( - title = stringResource(R.string.action_add), - icon = Icons.Outlined.Add, - onClick = { - presenter.openAddDialog() - }, +fun Screen.feedTab(): TabContent { + val router = LocalRouter.currentOrThrow + val screenModel = rememberScreenModel { FeedScreenModel() } + val state by screenModel.state.collectAsState() + + return TabContent( + titleRes = R.string.feed, + actions = listOf( + AppBar.Action( + title = stringResource(R.string.action_add), + icon = Icons.Outlined.Add, + onClick = { + screenModel.openAddDialog() + }, + ), ), - ), - content = { contentPadding -> - FeedScreen( - presenter = presenter, - contentPadding = contentPadding, - onClickAdd = { - presenter.openAddSearchDialog(it) - }, - onClickCreate = { source, savedSearch -> - presenter.createFeed(source, savedSearch) - }, - onClickSavedSearch = { savedSearch, source -> - presenter.sourcePreferences.lastUsedSource().set(savedSearch.source) - router?.pushController(BrowseSourceController(source, savedSearch = savedSearch.id)) - }, - onClickSource = { source -> - presenter.sourcePreferences.lastUsedSource().set(source.id) - router?.pushController(BrowseSourceController(source, GetRemoteManga.QUERY_LATEST)) - }, - onClickDelete = { - presenter.dialog = FeedPresenter.Dialog.DeleteFeed(it) - }, - onClickDeleteConfirm = { - presenter.deleteFeed(it) - }, - onClickManga = { manga -> - router?.pushController(MangaController(manga.id, true)) - }, - ) - }, -) + content = { contentPadding, snackbarHostState -> + FeedScreen( + state = state, + contentPadding = contentPadding, + onClickSavedSearch = { savedSearch, source -> + screenModel.sourcePreferences.lastUsedSource().set(savedSearch.source) + router.pushController( + BrowseSourceController( + source, + savedSearch = savedSearch.id, + ), + ) + }, + onClickSource = { source -> + screenModel.sourcePreferences.lastUsedSource().set(source.id) + router.pushController( + BrowseSourceController( + source, + GetRemoteManga.QUERY_LATEST, + ), + ) + }, + onClickDelete = screenModel::openDeleteDialog, + onClickManga = { manga -> + router.pushController(MangaController(manga.id, true)) + }, + getMangaState = { manga, source -> screenModel.getManga(initialManga = manga, source = source) }, + ) + + state.dialog?.let { dialog -> + when (dialog) { + is FeedScreenModel.Dialog.AddFeed -> { + FeedAddDialog( + sources = dialog.options, + onDismiss = screenModel::dismissDialog, + onClickAdd = { + if (it != null) { + screenModel.openAddSearchDialog(it) + } + screenModel.dismissDialog() + }, + ) + } + is FeedScreenModel.Dialog.AddFeedSearch -> { + FeedAddSearchDialog( + source = dialog.source, + savedSearches = dialog.options, + onDismiss = screenModel::dismissDialog, + onClickAdd = { source, savedSearch -> + screenModel.createFeed(source, savedSearch) + screenModel.dismissDialog() + }, + ) + } + is FeedScreenModel.Dialog.DeleteFeed -> { + FeedDeleteConfirmDialog( + feed = dialog.feed, + onDismiss = screenModel::dismissDialog, + onClickDeleteConfirm = { + screenModel.deleteFeed(it) + screenModel.dismissDialog() + }, + ) + } + } + } + + val internalErrString = stringResource(R.string.internal_error) + val tooManyFeedsString = stringResource(R.string.too_many_in_feed) + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + when (event) { + FeedScreenModel.Event.FailedFetchingSources -> { + launch { snackbarHostState.showSnackbar(internalErrString) } + } + FeedScreenModel.Event.TooManyFeeds -> { + launch { snackbarHostState.showSnackbar(tooManyFeedsString) } + } + } + } + } + }, + ) +} 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 68% 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 945599ff5..5d97cbb6d 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,14 +3,19 @@ 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.domain.UnsortedPreferences import eu.kanade.domain.manga.interactor.GetFavorites 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.advanced.design.PreMigrationController @@ -21,11 +26,11 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @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, @@ -38,18 +43,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, // SY --> onClickAll = { source -> // TODO: Jay wtf, need to clean this up sometime @@ -58,13 +65,11 @@ fun migrateSourcesTab( val sourceMangas = manga.asSequence().filter { it.source == source.id }.map { it.id }.toList() withUIContext { - if (router != null) { - PreMigrationController.navigateToMigration( - Injekt.get().skipPreMigration().get(), - router, - sourceMangas, - ) - } + PreMigrationController.navigateToMigration( + Injekt.get().skipPreMigration().get(), + router, + sourceMangas, + ) } } }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt index 5b953927f..09c555e8d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesController.kt @@ -1,16 +1,14 @@ package eu.kanade.tachiyomi.ui.browse.migration.sources import androidx.compose.runtime.Composable -import eu.kanade.presentation.browse.BrowseTabWrapper -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController -class MigrationSourcesController : FullComposeController() { - - override fun createPresenter() = MigrationSourcesPresenterWrapper() +class MigrationSourcesController : BasicFullComposeController() { @Composable override fun ComposeContent() { - BrowseTabWrapper(migrateSourcesTab(router, presenter = presenter.presenter)) + Navigator(screen = MigrationSourcesScreen()) } } 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/migration/sources/MigrationSourcesPresenterWrapper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenterWrapper.kt deleted file mode 100644 index b367b7eb7..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesPresenterWrapper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package eu.kanade.tachiyomi.ui.browse.migration.sources - -import android.os.Bundle -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter - -class MigrationSourcesPresenterWrapper() : BasePresenter() { - - val presenter = MigrationSourcesPresenter(presenterScope) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - presenter.onCreate() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesScreen.kt new file mode 100644 index 000000000..d50139719 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrationSourcesScreen.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.ui.browse.migration.sources + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.screen.Screen +import eu.kanade.presentation.browse.BrowseTabWrapper + +class MigrationSourcesScreen : Screen { + + @Composable + override fun Content() { + BrowseTabWrapper(migrateSourceTab()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt index 5ef05a964..86814dd4b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesController.kt @@ -2,24 +2,20 @@ package eu.kanade.tachiyomi.ui.browse.source import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.os.Bundle -import android.os.Parcelable import android.view.View import androidx.compose.runtime.Composable -import eu.kanade.presentation.browse.BrowseTabWrapper -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.requestPermissionsSafe -import eu.kanade.tachiyomi.util.system.getParcelableCompat -import kotlinx.parcelize.Parcelize +import eu.kanade.tachiyomi.util.system.getSerializableCompat +import java.io.Serializable -class SourcesController(bundle: Bundle? = null) : FullComposeController(bundle) { - private val smartSearchConfig = args.getParcelableCompat(SMART_SEARCH_CONFIG) - private val mode = if (smartSearchConfig == null) Mode.CATALOGUE else Mode.SMART_SEARCH - - override fun createPresenter() = SourcesPresenterWrapper(controllerMode = mode, smartSearchConfig = smartSearchConfig) +class SourcesController(bundle: Bundle? = null) : BasicFullComposeController(bundle) { + private val smartSearchConfig = args.getSerializableCompat(SMART_SEARCH_CONFIG) @Composable override fun ComposeContent() { - BrowseTabWrapper(sourcesTab(router, presenter = presenter.presenter)) + Navigator(screen = SourcesScreen(smartSearchConfig)) } override fun onViewCreated(view: View) { @@ -27,13 +23,7 @@ class SourcesController(bundle: Bundle? = null) : FullComposeController() { - - val presenter = SourcesPresenter(presenterScope, controllerMode = controllerMode, smartSearchConfig = smartSearchConfig) - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - presenter.onCreate() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt new file mode 100644 index 000000000..24c1e77fa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreen.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.ui.browse.source + +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.core.screen.Screen +import eu.kanade.presentation.browse.BrowseTabWrapper + +class SourcesScreen(private val smartSearchConfig: SourcesController.SmartSearchConfig?) : Screen { + + @Composable + override fun Content() { + BrowseTabWrapper(sourcesTab(smartSearchConfig)) + } +} 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 55% 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 26306cef2..c623d243a 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.GetShowLatest @@ -13,28 +16,22 @@ import eu.kanade.domain.source.model.Source import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.browse.SourceUiModel -import eu.kanade.presentation.browse.SourcesState -import eu.kanade.presentation.browse.SourcesStateImpl import eu.kanade.tachiyomi.util.system.logcat -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn -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(), @@ -46,78 +43,79 @@ class SourcesPresenter( private val getShowLatest: GetShowLatest = Injekt.get(), private val toggleExcludeFromDataSaver: ToggleExcludeFromDataSaver = Injekt.get(), private val setSourceCategories: SetSourceCategories = Injekt.get(), - val controllerMode: SourcesController.Mode, val smartSearchConfig: SourcesController.SmartSearchConfig?, // SY <-- -) : SourcesState by state { +) : StateScreenModel(SourcesState()) { private val _events = Channel(Int.MAX_VALUE) val events = _events.receiveAsFlow() val useNewSourceNavigation = uiPreferences.useNewSourceNavigation().get() - fun onCreate() { + init { // SY --> combine( getEnabledSources.subscribe(), getSourceCategories.subscribe(), - getShowLatest.subscribe(controllerMode), - flowOf(controllerMode == SourcesController.Mode.CATALOGUE), + getShowLatest.subscribe(smartSearchConfig != null), + flowOf(smartSearchConfig == null), ::collectLatestSources, ) .catch { logcat(LogPriority.ERROR, it) _events.send(Event.FailedFetchingSources) } - .onStart { delay(500) } // Defer to avoid crashing on initial render .flowOn(Dispatchers.IO) - .launchIn(presenterScope) + .launchIn(coroutineScope) // SY <-- } private fun collectLatestSources(sources: List, categories: List, showLatest: Boolean, showPin: Boolean) { - 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 - // SY --> - d1.startsWith(CATEGORY_KEY_PREFIX) && !d2.startsWith(CATEGORY_KEY_PREFIX) -> -1 - d2.startsWith(CATEGORY_KEY_PREFIX) && !d1.startsWith(CATEGORY_KEY_PREFIX) -> 1 - // SY <-- - 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 + // SY --> + d1.startsWith(CATEGORY_KEY_PREFIX) && !d2.startsWith(CATEGORY_KEY_PREFIX) -> -1 + d2.startsWith(CATEGORY_KEY_PREFIX) && !d1.startsWith(CATEGORY_KEY_PREFIX) -> 1 + // SY <-- + d1 == "" && d2 != "" -> 1 + d2 == "" && d1 != "" -> -1 + else -> d1.compareTo(d2) + } } - } - val byLang = sources.groupByTo(map) { - when { - // SY --> - it.category != null -> "$CATEGORY_KEY_PREFIX${it.category}" - // SY <-- - it.isUsedLast -> LAST_USED_KEY - Pin.Actual in it.pin -> PINNED_KEY - else -> it.lang + val byLang = sources.groupByTo(map) { + when { + // SY --> + it.category != null -> "$CATEGORY_KEY_PREFIX${it.category}" + // SY <-- + it.isUsedLast -> LAST_USED_KEY + Pin.Actual in it.pin -> PINNED_KEY + else -> it.lang + } } - } - val uiModels = byLang.flatMap { - listOf( - SourceUiModel.Header(it.key.removePrefix(CATEGORY_KEY_PREFIX), it.value.firstOrNull()?.category != null), - *it.value.map { source -> - SourceUiModel.Item(source) - }.toTypedArray(), + state.copy( + isLoading = false, + items = byLang.flatMap { + listOf( + SourceUiModel.Header(it.key.removePrefix(CATEGORY_KEY_PREFIX), it.value.firstOrNull()?.category != null), + *it.value.map { source -> + SourceUiModel.Item(source) + }.toTypedArray(), + ) + }, + // SY --> + categories = categories.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it }), + showPin = showPin, + showLatest = showLatest, + // SY <-- ) } - // SY --> - state.showPin = showPin - state.showLatest = showLatest - state.categories = categories.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it }) - // SY <-- - state.isLoading = false - state.items = uiModels } fun onOpenSource(source: Source) { @@ -134,6 +132,7 @@ class SourcesPresenter( toggleSourcePin.await(source) } + // SY --> fun toggleExcludeFromDataSaver(source: Source) { toggleExcludeFromDataSaver.await(source) } @@ -142,6 +141,19 @@ class SourcesPresenter( setSourceCategories.await(source, categories) } + fun showSourceCategoriesDialog(source: Source) { + mutableState.update { it.copy(dialog = Dialog.SourceCategories(source)) } + } + // SY <-- + + fun showSourceDialog(source: Source) { + mutableState.update { it.copy(dialog = Dialog.SourceLongClick(source)) } + } + + fun closeDialog() { + mutableState.update { it.copy(dialog = null) } + } + sealed class Event { object FailedFetchingSources : Event() } @@ -160,3 +172,17 @@ class SourcesPresenter( // SY <-- } } + +@Immutable +data class SourcesState( + val dialog: SourcesScreenModel.Dialog? = null, + val isLoading: Boolean = true, + val items: List = emptyList(), + // SY --> + val categories: List = emptyList(), + val showPin: Boolean = true, + val showLatest: Boolean = false, + // SY <-- +) { + 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 64fc9df9d..d89c75a4c 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,76 +4,131 @@ 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.domain.source.interactor.GetRemoteManga.Companion.QUERY_POPULAR +import eu.kanade.presentation.browse.SourceCategoriesDialog +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.SourcesController.SmartSearchConfig import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.feed.SourceFeedController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import exh.ui.smartsearch.SmartSearchController +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @Composable -fun sourcesTab( - router: Router?, - presenter: SourcesPresenter, -) = TabContent( - // SY --> - titleRes = when (presenter.controllerMode) { - SourcesController.Mode.CATALOGUE -> R.string.label_sources - SourcesController.Mode.SMART_SEARCH -> R.string.find_in_another_source - }, - actions = if (presenter.controllerMode == SourcesController.Mode.CATALOGUE) { - 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()) }, - ), - ) - } else { - emptyList() - }, - // SY <-- - content = { contentPadding -> - SourcesScreen( - presenter = presenter, - contentPadding = contentPadding, - onClickItem = { source, query -> - // SY --> - val controller = when { - presenter.controllerMode == SourcesController.Mode.SMART_SEARCH -> - SmartSearchController(source.id, presenter.smartSearchConfig!!) - (query.isBlank() || query == QUERY_POPULAR) && presenter.useNewSourceNavigation -> SourceFeedController(source.id) - else -> BrowseSourceController(source, query) +fun Screen.sourcesTab( + smartSearchConfig: SmartSearchConfig? = null, +): TabContent { + val router = LocalRouter.currentOrThrow + val screenModel = rememberScreenModel { SourcesScreenModel(smartSearchConfig = smartSearchConfig) } + val state by screenModel.state.collectAsState() + + return TabContent( + // SY --> + titleRes = when (smartSearchConfig == null) { + true -> R.string.label_sources + false -> R.string.find_in_another_source + }, + actions = if (smartSearchConfig == null) { + 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()) }, + ), + ) + } else { + emptyList() + }, + // SY <-- + content = { contentPadding, snackbarHostState -> + SourcesScreen( + state = state, + contentPadding = contentPadding, + onClickItem = { source, query -> + // SY --> + val controller = when { + smartSearchConfig != null -> SmartSearchController(source.id, smartSearchConfig) + (query.isBlank() || query == QUERY_POPULAR) && screenModel.useNewSourceNavigation -> SourceFeedController(source.id) + else -> BrowseSourceController(source, query) + } + screenModel.onOpenSource(source) + router.pushController(controller) + // SY <-- + }, + onClickPin = screenModel::togglePin, + onLongClickItem = screenModel::showSourceDialog, + ) + + state.dialog?.let { dialog -> + when (dialog) { + is SourcesScreenModel.Dialog.SourceCategories -> { + val source = dialog.source + SourceOptionsDialog( + source = source, + onClickPin = { + screenModel.togglePin(source) + screenModel.closeDialog() + }, + onClickDisable = { + screenModel.toggleSource(source) + screenModel.closeDialog() + }, + // SY --> + onClickSetCategories = { + screenModel.showSourceCategoriesDialog(source) + screenModel.closeDialog() + }, + onClickToggleDataSaver = { + screenModel.toggleExcludeFromDataSaver(source) + screenModel.closeDialog() + }, + onDismiss = screenModel::closeDialog, + ) + } + is SourcesScreenModel.Dialog.SourceLongClick -> { + val source = dialog.source + SourceCategoriesDialog( + source = source, + categories = state.categories, + onClickCategories = { categories -> + screenModel.setSourceCategories(source, categories) + screenModel.closeDialog() + }, + onDismiss = screenModel::closeDialog, + ) + } } - presenter.onOpenSource(source) - router?.pushController(controller) - // SY <-- - }, - onClickDisable = { source -> - presenter.toggleSource(source) - }, - onClickPin = { source -> - presenter.togglePin(source) - }, - // SY --> - onClickSetCategories = { source, categories -> - presenter.setSourceCategories(source, categories) - }, - onClickToggleDataSaver = { source -> - presenter.toggleExcludeFromDataSaver(source) - }, - // SY <-- - ) - }, -) + } + + 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/ui/browse/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt index 075ca9dee..657b16b5e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt @@ -52,7 +52,7 @@ open class BrowseSourceController(bundle: Bundle) : // SY --> if (smartSearchConfig != null) { - putParcelable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig) + putSerializable(SMART_SEARCH_CONFIG_KEY, smartSearchConfig) } if (savedSearch != null) { 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 efb7a5998..513fb88c0 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 /** @@ -21,8 +21,8 @@ object LocaleHelper { } // SY <-- 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)