Use Voyager on Browse tab (#8605)
This commit is contained in:
parent
0347d3970a
commit
f4ac754d02
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user