mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	Use Voyager on Browse tab (#8605)
This commit is contained in:
		| @@ -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, | ||||
|   | ||||
| @@ -1,27 +0,0 @@ | ||||
| package eu.kanade.presentation.browse | ||||
|  | ||||
| import androidx.compose.runtime.derivedStateOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.setValue | ||||
| import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel | ||||
|  | ||||
| interface ExtensionsState { | ||||
|     val isLoading: Boolean | ||||
|     val isRefreshing: Boolean | ||||
|     val items: List<ExtensionUiModel> | ||||
|     val updates: Int | ||||
|     val isEmpty: Boolean | ||||
| } | ||||
|  | ||||
| fun ExtensionState(): ExtensionsState { | ||||
|     return ExtensionsStateImpl() | ||||
| } | ||||
|  | ||||
| class ExtensionsStateImpl : ExtensionsState { | ||||
|     override var isLoading: Boolean by mutableStateOf(true) | ||||
|     override var isRefreshing: Boolean by mutableStateOf(false) | ||||
|     override var items: List<ExtensionUiModel> by mutableStateOf(emptyList()) | ||||
|     override var updates: Int by mutableStateOf(0) | ||||
|     override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } | ||||
| } | ||||
| @@ -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, | ||||
|             ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,28 +0,0 @@ | ||||
| package eu.kanade.presentation.browse | ||||
|  | ||||
| import androidx.compose.runtime.derivedStateOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.setValue | ||||
| import eu.kanade.domain.source.interactor.SetMigrateSorting | ||||
| import eu.kanade.domain.source.model.Source | ||||
|  | ||||
| interface MigrateSourceState { | ||||
|     val isLoading: Boolean | ||||
|     val items: List<Pair<Source, Long>> | ||||
|     val isEmpty: Boolean | ||||
|     val sortingMode: SetMigrateSorting.Mode | ||||
|     val sortingDirection: SetMigrateSorting.Direction | ||||
| } | ||||
|  | ||||
| fun MigrateSourceState(): MigrateSourceState { | ||||
|     return MigrateSourceStateImpl() | ||||
| } | ||||
|  | ||||
| class MigrateSourceStateImpl : MigrateSourceState { | ||||
|     override var isLoading: Boolean by mutableStateOf(true) | ||||
|     override var items: List<Pair<Source, Long>> by mutableStateOf(emptyList()) | ||||
|     override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } | ||||
|     override var sortingMode: SetMigrateSorting.Mode by mutableStateOf(SetMigrateSorting.Mode.ALPHABETICAL) | ||||
|     override var sortingDirection: SetMigrateSorting.Direction by mutableStateOf(SetMigrateSorting.Direction.ASCENDING) | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -1,27 +0,0 @@ | ||||
| package eu.kanade.presentation.browse | ||||
|  | ||||
| import androidx.compose.runtime.Stable | ||||
| import androidx.compose.runtime.derivedStateOf | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.setValue | ||||
| import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter | ||||
|  | ||||
| @Stable | ||||
| interface SourcesState { | ||||
|     var dialog: SourcesPresenter.Dialog? | ||||
|     val isLoading: Boolean | ||||
|     val items: List<SourceUiModel> | ||||
|     val isEmpty: Boolean | ||||
| } | ||||
|  | ||||
| fun SourcesState(): SourcesState { | ||||
|     return SourcesStateImpl() | ||||
| } | ||||
|  | ||||
| class SourcesStateImpl : SourcesState { | ||||
|     override var dialog: SourcesPresenter.Dialog? by mutableStateOf(null) | ||||
|     override var isLoading: Boolean by mutableStateOf(true) | ||||
|     override var items: List<SourceUiModel> by mutableStateOf(emptyList()) | ||||
|     override val isEmpty: Boolean by derivedStateOf { items.isEmpty() } | ||||
| } | ||||
| @@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.calculateStartPadding | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.SnackbarHost | ||||
| import androidx.compose.material3.SnackbarHostState | ||||
| import androidx.compose.material3.Tab | ||||
| import androidx.compose.material3.TabRow | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| @@ -32,6 +35,7 @@ fun TabbedScreen( | ||||
| ) { | ||||
|     val scope = rememberCoroutineScope() | ||||
|     val state = rememberPagerState() | ||||
|     val snackbarHostState = remember { SnackbarHostState() } | ||||
|  | ||||
|     LaunchedEffect(startIndex) { | ||||
|         if (startIndex != null) { | ||||
| @@ -52,6 +56,7 @@ fun TabbedScreen( | ||||
|                 actions = { AppBarActions(tab.actions) }, | ||||
|             ) | ||||
|         }, | ||||
|         snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, | ||||
|     ) { contentPadding -> | ||||
|         Column( | ||||
|             modifier = Modifier.padding( | ||||
| @@ -86,6 +91,7 @@ fun TabbedScreen( | ||||
|                     TachiyomiBottomNavigationView.withBottomNavPadding( | ||||
|                         PaddingValues(bottom = contentPadding.calculateBottomPadding()), | ||||
|                     ), | ||||
|                     snackbarHostState, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
| @@ -97,5 +103,5 @@ data class TabContent( | ||||
|     val badgeNumber: Int? = null, | ||||
|     val searchEnabled: Boolean = false, | ||||
|     val actions: List<AppBar.Action> = emptyList(), | ||||
|     val content: @Composable (contentPadding: PaddingValues) -> Unit, | ||||
|     val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit, | ||||
| ) | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| package eu.kanade.presentation.more.settings.screen | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.ActivityNotFoundException | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| @@ -22,7 +21,6 @@ import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TextButton | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.ReadOnlyComposable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| @@ -37,7 +35,6 @@ import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.core.net.toUri | ||||
| import com.google.accompanist.permissions.rememberPermissionState | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.domain.backup.service.BackupPreferences | ||||
| import eu.kanade.presentation.components.Divider | ||||
| @@ -52,6 +49,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreatorJob | ||||
| import eu.kanade.tachiyomi.data.backup.BackupFileValidator | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreService | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.copyToClipboard | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| @@ -70,7 +68,7 @@ object SettingsBackupScreen : SearchableSettings { | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val backupPreferences = Injekt.get<BackupPreferences>() | ||||
|  | ||||
|         RequestStoragePermission() | ||||
|         DiskUtil.RequestStoragePermission() | ||||
|  | ||||
|         return listOf( | ||||
|             getCreateBackupPref(), | ||||
| @@ -79,14 +77,6 @@ object SettingsBackupScreen : SearchableSettings { | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun RequestStoragePermission() { | ||||
|         val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) | ||||
|         LaunchedEffect(Unit) { | ||||
|             permissionState.launchPermissionRequest() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Composable | ||||
|     private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference { | ||||
|         val scope = rememberCoroutineScope() | ||||
|   | ||||
| @@ -1,24 +1,13 @@ | ||||
| package eu.kanade.tachiyomi.ui.browse | ||||
|  | ||||
| import android.Manifest | ||||
| import android.os.Bundle | ||||
| import android.view.View | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.core.os.bundleOf | ||||
| import eu.kanade.presentation.components.TabbedScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.FullComposeController | ||||
| import cafe.adriel.voyager.navigator.Navigator | ||||
| import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RootController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe | ||||
| import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab | ||||
| import eu.kanade.tachiyomi.ui.browse.migration.sources.migrateSourcesTab | ||||
| import eu.kanade.tachiyomi.ui.browse.source.sourcesTab | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
|  | ||||
| class BrowseController : FullComposeController<BrowsePresenter>, RootController { | ||||
| class BrowseController : BasicFullComposeController, RootController { | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle? = null) : this(bundle?.getBoolean(TO_EXTENSIONS_EXTRA) ?: false) | ||||
| @@ -29,34 +18,9 @@ class BrowseController : FullComposeController<BrowsePresenter>, RootController | ||||
|  | ||||
|     private val toExtensions = args.getBoolean(TO_EXTENSIONS_EXTRA, false) | ||||
|  | ||||
|     override fun createPresenter() = BrowsePresenter() | ||||
|  | ||||
|     @Composable | ||||
|     override fun ComposeContent() { | ||||
|         val query by presenter.extensionsPresenter.query.collectAsState() | ||||
|  | ||||
|         TabbedScreen( | ||||
|             titleRes = R.string.browse, | ||||
|             tabs = listOf( | ||||
|                 sourcesTab(router, presenter.sourcesPresenter), | ||||
|                 extensionsTab(router, presenter.extensionsPresenter), | ||||
|                 migrateSourcesTab(router, presenter.migrationSourcesPresenter), | ||||
|             ), | ||||
|             startIndex = 1.takeIf { toExtensions }, | ||||
|             searchQuery = query, | ||||
|             onChangeSearchQuery = { presenter.extensionsPresenter.search(it) }, | ||||
|             incognitoMode = presenter.isIncognitoMode, | ||||
|             downloadedOnlyMode = presenter.isDownloadOnly, | ||||
|         ) | ||||
|  | ||||
|         LaunchedEffect(Unit) { | ||||
|             (activity as? MainActivity)?.ready = true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|         requestPermissionsSafe(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 301) | ||||
|         Navigator(screen = BrowseScreen(toExtensions = toExtensions)) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,31 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.browse | ||||
|  | ||||
| import android.os.Bundle | ||||
| import androidx.compose.runtime.getValue | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter | ||||
| import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter | ||||
| import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class BrowsePresenter( | ||||
|     preferences: BasePreferences = Injekt.get(), | ||||
| ) : BasePresenter<BrowseController>() { | ||||
|  | ||||
|     val isDownloadOnly: Boolean by preferences.downloadedOnly().asState() | ||||
|     val isIncognitoMode: Boolean by preferences.incognitoMode().asState() | ||||
|  | ||||
|     val sourcesPresenter = SourcesPresenter(presenterScope) | ||||
|     val extensionsPresenter = ExtensionsPresenter(presenterScope) | ||||
|     val migrationSourcesPresenter = MigrationSourcesPresenter(presenterScope) | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|  | ||||
|         sourcesPresenter.onCreate() | ||||
|         extensionsPresenter.onCreate() | ||||
|         migrationSourcesPresenter.onCreate() | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
| } | ||||
| @@ -2,11 +2,10 @@ package eu.kanade.tachiyomi.ui.browse.extension | ||||
| 
 | ||||
| import android.app.Application | ||||
| import androidx.annotation.StringRes | ||||
| import cafe.adriel.voyager.core.model.StateScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import eu.kanade.domain.extension.interactor.GetExtensionsByType | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.presentation.browse.ExtensionState | ||||
| import eu.kanade.presentation.browse.ExtensionsState | ||||
| import eu.kanade.presentation.browse.ExtensionsStateImpl | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| import eu.kanade.tachiyomi.extension.model.Extension | ||||
| @@ -14,8 +13,6 @@ import eu.kanade.tachiyomi.extension.model.InstallStep | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.system.LocaleHelper | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.delay | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.asStateFlow | ||||
| @@ -23,26 +20,23 @@ import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.combine | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import kotlinx.coroutines.flow.onStart | ||||
| import kotlinx.coroutines.flow.update | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| 
 | ||||
| class ExtensionsPresenter( | ||||
|     private val presenterScope: CoroutineScope, | ||||
|     private val state: ExtensionsStateImpl = ExtensionState() as ExtensionsStateImpl, | ||||
|     private val preferences: SourcePreferences = Injekt.get(), | ||||
| class ExtensionsScreenModel( | ||||
|     preferences: SourcePreferences = Injekt.get(), | ||||
|     private val extensionManager: ExtensionManager = Injekt.get(), | ||||
|     private val getExtensions: GetExtensionsByType = Injekt.get(), | ||||
| ) : ExtensionsState by state { | ||||
| ) : StateScreenModel<ExtensionsState>(ExtensionsState()) { | ||||
| 
 | ||||
|     private val _query: MutableStateFlow<String?> = MutableStateFlow(null) | ||||
|     val query: StateFlow<String?> = _query.asStateFlow() | ||||
| 
 | ||||
|     private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf()) | ||||
| 
 | ||||
|     fun onCreate() { | ||||
|     init { | ||||
|         val context = Injekt.get<Application>() | ||||
|         val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map -> | ||||
|             { | ||||
| @@ -76,7 +70,7 @@ class ExtensionsPresenter( | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         presenterScope.launchIO { | ||||
|         coroutineScope.launchIO { | ||||
|             combine( | ||||
|                 _query, | ||||
|                 _currentDownloads, | ||||
| @@ -117,39 +111,44 @@ class ExtensionsPresenter( | ||||
| 
 | ||||
|                 items | ||||
|             } | ||||
|                 .onStart { delay(500) } // Defer to avoid crashing on initial render | ||||
|                 .collectLatest { | ||||
|                     state.isLoading = false | ||||
|                     state.items = it | ||||
|                     mutableState.update { state -> | ||||
|                         state.copy( | ||||
|                             isLoading = false, | ||||
|                             items = it, | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
| 
 | ||||
|         presenterScope.launchIO { findAvailableExtensions() } | ||||
|         coroutineScope.launchIO { findAvailableExtensions() } | ||||
| 
 | ||||
|         preferences.extensionUpdatesCount().changes() | ||||
|             .onEach { state.updates = it } | ||||
|             .launchIn(presenterScope) | ||||
|             .onEach { mutableState.update { state -> state.copy(updates = it) } } | ||||
|             .launchIn(coroutineScope) | ||||
|     } | ||||
| 
 | ||||
|     fun search(query: String?) { | ||||
|         presenterScope.launchIO { | ||||
|         coroutineScope.launchIO { | ||||
|             _query.emit(query) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun updateAllExtensions() { | ||||
|         presenterScope.launchIO { | ||||
|             if (state.isEmpty) return@launchIO | ||||
|             state.items | ||||
|                 .mapNotNull { | ||||
|                     when { | ||||
|                         it !is ExtensionUiModel.Item -> null | ||||
|                         it.extension !is Extension.Installed -> null | ||||
|                         !it.extension.hasUpdate -> null | ||||
|                         else -> it.extension | ||||
|         coroutineScope.launchIO { | ||||
|             with(state.value) { | ||||
|                 if (isEmpty) return@launchIO | ||||
|                 items | ||||
|                     .mapNotNull { | ||||
|                         when { | ||||
|                             it !is ExtensionUiModel.Item -> null | ||||
|                             it.extension !is Extension.Installed -> null | ||||
|                             !it.extension.hasUpdate -> null | ||||
|                             else -> it.extension | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .forEach { updateExtension(it) } | ||||
|                     .forEach { updateExtension(it) } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @@ -195,11 +194,11 @@ class ExtensionsPresenter( | ||||
|     } | ||||
| 
 | ||||
|     fun findAvailableExtensions() { | ||||
|         presenterScope.launchIO { | ||||
|             state.isRefreshing = true | ||||
|         mutableState.update { it.copy(isRefreshing = true) } | ||||
|         coroutineScope.launchIO { | ||||
|             extensionManager.findAvailableExtensions() | ||||
|             state.isRefreshing = false | ||||
|         } | ||||
|         mutableState.update { it.copy(isRefreshing = false) } | ||||
|     } | ||||
| 
 | ||||
|     fun trustSignature(signatureHash: String) { | ||||
| @@ -207,6 +206,15 @@ class ExtensionsPresenter( | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| data class ExtensionsState( | ||||
|     val isLoading: Boolean = true, | ||||
|     val isRefreshing: Boolean = false, | ||||
|     val items: List<ExtensionUiModel> = emptyList(), | ||||
|     val updates: Int = 0, | ||||
| ) { | ||||
|     val isEmpty = items.isEmpty() | ||||
| } | ||||
| 
 | ||||
| sealed interface ExtensionUiModel { | ||||
|     sealed interface Header : ExtensionUiModel { | ||||
|         data class Resource(@StringRes val textRes: Int) : Header | ||||
| @@ -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, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,91 @@ | ||||
| package eu.kanade.tachiyomi.ui.browse.migration.sources | ||||
|  | ||||
| import cafe.adriel.voyager.core.model.StateScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount | ||||
| import eu.kanade.domain.source.interactor.SetMigrateSorting | ||||
| import eu.kanade.domain.source.model.Source | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import kotlinx.coroutines.channels.Channel | ||||
| import kotlinx.coroutines.flow.catch | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import kotlinx.coroutines.flow.receiveAsFlow | ||||
| import kotlinx.coroutines.flow.update | ||||
| import logcat.LogPriority | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class MigrateSourceScreenModel( | ||||
|     preferences: SourcePreferences = Injekt.get(), | ||||
|     private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(), | ||||
|     private val setMigrateSorting: SetMigrateSorting = Injekt.get(), | ||||
| ) : StateScreenModel<MigrateSourceState>(MigrateSourceState()) { | ||||
|  | ||||
|     private val _channel = Channel<Event>(Int.MAX_VALUE) | ||||
|     val channel = _channel.receiveAsFlow() | ||||
|  | ||||
|     init { | ||||
|         coroutineScope.launchIO { | ||||
|             getSourcesWithFavoriteCount.subscribe() | ||||
|                 .catch { | ||||
|                     logcat(LogPriority.ERROR, it) | ||||
|                     _channel.send(Event.FailedFetchingSourcesWithCount) | ||||
|                 } | ||||
|                 .collectLatest { sources -> | ||||
|                     mutableState.update { | ||||
|                         it.copy( | ||||
|                             isLoading = false, | ||||
|                             items = sources, | ||||
|                         ) | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
|  | ||||
|         preferences.migrationSortingDirection().changes() | ||||
|             .onEach { mutableState.update { state -> state.copy(sortingDirection = it) } } | ||||
|             .launchIn(coroutineScope) | ||||
|  | ||||
|         preferences.migrationSortingMode().changes() | ||||
|             .onEach { mutableState.update { state -> state.copy(sortingMode = it) } } | ||||
|             .launchIn(coroutineScope) | ||||
|     } | ||||
|  | ||||
|     fun toggleSortingMode() { | ||||
|         with(state.value) { | ||||
|             val newMode = when (sortingMode) { | ||||
|                 SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL | ||||
|                 SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL | ||||
|             } | ||||
|  | ||||
|             setMigrateSorting.await(newMode, sortingDirection) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun toggleSortingDirection() { | ||||
|         with(state.value) { | ||||
|             val newDirection = when (sortingDirection) { | ||||
|                 SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING | ||||
|                 SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING | ||||
|             } | ||||
|  | ||||
|             setMigrateSorting.await(sortingMode, newDirection) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     sealed class Event { | ||||
|         object FailedFetchingSourcesWithCount : Event() | ||||
|     } | ||||
| } | ||||
|  | ||||
| data class MigrateSourceState( | ||||
|     val isLoading: Boolean = true, | ||||
|     val items: List<Pair<Source, Long>> = emptyList(), | ||||
|     val sortingMode: SetMigrateSorting.Mode = SetMigrateSorting.Mode.ALPHABETICAL, | ||||
|     val sortingDirection: SetMigrateSorting.Direction = SetMigrateSorting.Direction.ASCENDING, | ||||
| ) { | ||||
|     val isEmpty = items.isEmpty() | ||||
| } | ||||
| @@ -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, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| @@ -1,75 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.ui.browse.migration.sources | ||||
|  | ||||
| import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount | ||||
| import eu.kanade.domain.source.interactor.SetMigrateSorting | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.presentation.browse.MigrateSourceState | ||||
| import eu.kanade.presentation.browse.MigrateSourceStateImpl | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.channels.Channel | ||||
| import kotlinx.coroutines.flow.catch | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import kotlinx.coroutines.flow.receiveAsFlow | ||||
| import logcat.LogPriority | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class MigrationSourcesPresenter( | ||||
|     private val presenterScope: CoroutineScope, | ||||
|     private val state: MigrateSourceStateImpl = MigrateSourceState() as MigrateSourceStateImpl, | ||||
|     private val preferences: SourcePreferences = Injekt.get(), | ||||
|     private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(), | ||||
|     private val setMigrateSorting: SetMigrateSorting = Injekt.get(), | ||||
| ) : MigrateSourceState by state { | ||||
|  | ||||
|     private val _channel = Channel<Event>(Int.MAX_VALUE) | ||||
|     val channel = _channel.receiveAsFlow() | ||||
|  | ||||
|     fun onCreate() { | ||||
|         presenterScope.launchIO { | ||||
|             getSourcesWithFavoriteCount.subscribe() | ||||
|                 .catch { | ||||
|                     logcat(LogPriority.ERROR, it) | ||||
|                     _channel.send(Event.FailedFetchingSourcesWithCount) | ||||
|                 } | ||||
|                 .collectLatest { sources -> | ||||
|                     state.items = sources | ||||
|                     state.isLoading = false | ||||
|                 } | ||||
|         } | ||||
|  | ||||
|         preferences.migrationSortingDirection().changes() | ||||
|             .onEach { state.sortingDirection = it } | ||||
|             .launchIn(presenterScope) | ||||
|  | ||||
|         preferences.migrationSortingMode().changes() | ||||
|             .onEach { state.sortingMode = it } | ||||
|             .launchIn(presenterScope) | ||||
|     } | ||||
|  | ||||
|     fun toggleSortingMode() { | ||||
|         val newMode = when (state.sortingMode) { | ||||
|             SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL | ||||
|             SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL | ||||
|         } | ||||
|  | ||||
|         setMigrateSorting.await(newMode, state.sortingDirection) | ||||
|     } | ||||
|  | ||||
|     fun toggleSortingDirection() { | ||||
|         val newDirection = when (state.sortingDirection) { | ||||
|             SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING | ||||
|             SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING | ||||
|         } | ||||
|  | ||||
|         setMigrateSorting.await(state.sortingMode, newDirection) | ||||
|     } | ||||
|  | ||||
|     sealed class Event { | ||||
|         object FailedFetchingSourcesWithCount : Event() | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,8 @@ | ||||
| package eu.kanade.tachiyomi.ui.browse.source | ||||
| 
 | ||||
| import androidx.compose.runtime.Immutable | ||||
| import cafe.adriel.voyager.core.model.StateScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.domain.source.interactor.GetEnabledSources | ||||
| import eu.kanade.domain.source.interactor.ToggleSource | ||||
| @@ -8,78 +11,74 @@ import eu.kanade.domain.source.model.Pin | ||||
| import eu.kanade.domain.source.model.Source | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.presentation.browse.SourceUiModel | ||||
| import eu.kanade.presentation.browse.SourcesState | ||||
| import eu.kanade.presentation.browse.SourcesStateImpl | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.channels.Channel | ||||
| import kotlinx.coroutines.delay | ||||
| import kotlinx.coroutines.flow.catch | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.onStart | ||||
| import kotlinx.coroutines.flow.receiveAsFlow | ||||
| import kotlinx.coroutines.flow.update | ||||
| import logcat.LogPriority | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.TreeMap | ||||
| 
 | ||||
| class SourcesPresenter( | ||||
|     private val presenterScope: CoroutineScope, | ||||
|     private val state: SourcesStateImpl = SourcesState() as SourcesStateImpl, | ||||
| class SourcesScreenModel( | ||||
|     private val preferences: BasePreferences = Injekt.get(), | ||||
|     private val sourcePreferences: SourcePreferences = Injekt.get(), | ||||
|     private val getEnabledSources: GetEnabledSources = Injekt.get(), | ||||
|     private val toggleSource: ToggleSource = Injekt.get(), | ||||
|     private val toggleSourcePin: ToggleSourcePin = Injekt.get(), | ||||
| ) : SourcesState by state { | ||||
| ) : StateScreenModel<SourcesState>(SourcesState()) { | ||||
| 
 | ||||
|     private val _events = Channel<Event>(Int.MAX_VALUE) | ||||
|     val events = _events.receiveAsFlow() | ||||
| 
 | ||||
|     fun onCreate() { | ||||
|         presenterScope.launchIO { | ||||
|     init { | ||||
|         coroutineScope.launchIO { | ||||
|             getEnabledSources.subscribe() | ||||
|                 .catch { | ||||
|                     logcat(LogPriority.ERROR, it) | ||||
|                     _events.send(Event.FailedFetchingSources) | ||||
|                 } | ||||
|                 .onStart { delay(500) } // Defer to avoid crashing on initial render | ||||
|                 .collectLatest(::collectLatestSources) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun collectLatestSources(sources: List<Source>) { | ||||
|         val map = TreeMap<String, MutableList<Source>> { d1, d2 -> | ||||
|             // Sources without a lang defined will be placed at the end | ||||
|             when { | ||||
|                 d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1 | ||||
|                 d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1 | ||||
|                 d1 == PINNED_KEY && d2 != PINNED_KEY -> -1 | ||||
|                 d2 == PINNED_KEY && d1 != PINNED_KEY -> 1 | ||||
|                 d1 == "" && d2 != "" -> 1 | ||||
|                 d2 == "" && d1 != "" -> -1 | ||||
|                 else -> d1.compareTo(d2) | ||||
|         mutableState.update { state -> | ||||
|             val map = TreeMap<String, MutableList<Source>> { d1, d2 -> | ||||
|                 // Sources without a lang defined will be placed at the end | ||||
|                 when { | ||||
|                     d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1 | ||||
|                     d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1 | ||||
|                     d1 == PINNED_KEY && d2 != PINNED_KEY -> -1 | ||||
|                     d2 == PINNED_KEY && d1 != PINNED_KEY -> 1 | ||||
|                     d1 == "" && d2 != "" -> 1 | ||||
|                     d2 == "" && d1 != "" -> -1 | ||||
|                     else -> d1.compareTo(d2) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         val byLang = sources.groupByTo(map) { | ||||
|             when { | ||||
|                 it.isUsedLast -> LAST_USED_KEY | ||||
|                 Pin.Actual in it.pin -> PINNED_KEY | ||||
|                 else -> it.lang | ||||
|             val byLang = sources.groupByTo(map) { | ||||
|                 when { | ||||
|                     it.isUsedLast -> LAST_USED_KEY | ||||
|                     Pin.Actual in it.pin -> PINNED_KEY | ||||
|                     else -> it.lang | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         val uiModels = byLang.flatMap { | ||||
|             listOf( | ||||
|                 SourceUiModel.Header(it.key), | ||||
|                 *it.value.map { source -> | ||||
|                     SourceUiModel.Item(source) | ||||
|                 }.toTypedArray(), | ||||
|             state.copy( | ||||
|                 isLoading = false, | ||||
|                 items = byLang.flatMap { | ||||
|                     listOf( | ||||
|                         SourceUiModel.Header(it.key), | ||||
|                         *it.value.map { source -> | ||||
|                             SourceUiModel.Item(source) | ||||
|                         }.toTypedArray(), | ||||
|                     ) | ||||
|                 }, | ||||
|             ) | ||||
|         } | ||||
|         state.isLoading = false | ||||
|         state.items = uiModels | ||||
|     } | ||||
| 
 | ||||
|     fun onOpenSource(source: Source) { | ||||
| @@ -96,6 +95,14 @@ class SourcesPresenter( | ||||
|         toggleSourcePin.await(source) | ||||
|     } | ||||
| 
 | ||||
|     fun showSourceDialog(source: Source) { | ||||
|         mutableState.update { it.copy(dialog = Dialog(source)) } | ||||
|     } | ||||
| 
 | ||||
|     fun closeDialog() { | ||||
|         mutableState.update { it.copy(dialog = null) } | ||||
|     } | ||||
| 
 | ||||
|     sealed class Event { | ||||
|         object FailedFetchingSources : Event() | ||||
|     } | ||||
| @@ -107,3 +114,12 @@ class SourcesPresenter( | ||||
|         const val LAST_USED_KEY = "last_used" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Immutable | ||||
| data class SourcesState( | ||||
|     val dialog: SourcesScreenModel.Dialog? = null, | ||||
|     val isLoading: Boolean = true, | ||||
|     val items: List<SourceUiModel> = emptyList(), | ||||
| ) { | ||||
|     val isEmpty = items.isEmpty() | ||||
| } | ||||
| @@ -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) } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,15 @@ | ||||
| package eu.kanade.tachiyomi.util.storage | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.Context | ||||
| import android.media.MediaScannerConnection | ||||
| import android.net.Uri | ||||
| import android.os.Environment | ||||
| import android.os.StatFs | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.core.content.ContextCompat | ||||
| import com.google.accompanist.permissions.rememberPermissionState | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.util.lang.Hash | ||||
| import java.io.File | ||||
| @@ -113,5 +117,16 @@ object DiskUtil { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission | ||||
|      */ | ||||
|     @Composable | ||||
|     fun RequestStoragePermission() { | ||||
|         val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) | ||||
|         LaunchedEffect(Unit) { | ||||
|             permissionState.launchPermissionRequest() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const val NOMEDIA_FILE = ".nomedia" | ||||
| } | ||||
|   | ||||
| @@ -3,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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user