Use Voyager on Browse tab (#8605)

This commit is contained in:
Ivan Iskandar 2022-11-24 10:28:25 +07:00 committed by GitHub
parent 0347d3970a
commit f4ac754d02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 465 additions and 508 deletions

View File

@ -51,12 +51,12 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsState
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable @Composable
fun ExtensionScreen( fun ExtensionScreen(
presenter: ExtensionsPresenter, state: ExtensionsState,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onLongClickItem: (Extension) -> Unit, onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit,
@ -69,19 +69,19 @@ fun ExtensionScreen(
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
SwipeRefresh( SwipeRefresh(
refreshing = presenter.isRefreshing, refreshing = state.isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
enabled = !presenter.isLoading, enabled = !state.isLoading,
) { ) {
when { when {
presenter.isLoading -> LoadingScreen() state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
presenter.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.empty_screen, textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
) )
else -> { else -> {
ExtensionContent( ExtensionContent(
state = presenter, state = state,
contentPadding = contentPadding, contentPadding = contentPadding,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
onClickItemCancel = onClickItemCancel, onClickItemCancel = onClickItemCancel,

View File

@ -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() }
}

View File

@ -39,35 +39,37 @@ import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.presentation.util.topSmallPaddingValues import eu.kanade.presentation.util.topSmallPaddingValues
import eu.kanade.tachiyomi.R 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 import eu.kanade.tachiyomi.util.system.copyToClipboard
@Composable @Composable
fun MigrateSourceScreen( fun MigrateSourceScreen(
presenter: MigrationSourcesPresenter, state: MigrateSourceState,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onClickItem: (Source) -> Unit, onClickItem: (Source) -> Unit,
onToggleSortingDirection: () -> Unit,
onToggleSortingMode: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
when { when {
presenter.isLoading -> LoadingScreen() state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
presenter.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.information_empty_library, textResource = R.string.information_empty_library,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
) )
else -> else ->
MigrateSourceList( MigrateSourceList(
list = presenter.items, list = state.items,
contentPadding = contentPadding, contentPadding = contentPadding,
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = { source -> onLongClickItem = { source ->
val sourceId = source.id.toString() val sourceId = source.id.toString()
context.copyToClipboard(sourceId, sourceId) context.copyToClipboard(sourceId, sourceId)
}, },
sortingMode = presenter.sortingMode, sortingMode = state.sortingMode,
onToggleSortingMode = { presenter.toggleSortingMode() }, onToggleSortingMode = onToggleSortingMode,
sortingDirection = presenter.sortingDirection, sortingDirection = state.sortingDirection,
onToggleSortingDirection = { presenter.toggleSortingDirection() }, onToggleSortingDirection = onToggleSortingDirection,
) )
} }
} }

View File

@ -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)
}

View File

@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.presentation.util.topSmallPaddingValues
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource 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.LocaleHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun SourcesScreen( fun SourcesScreen(
presenter: SourcesPresenter, state: SourcesState,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onClickItem: (Source, String) -> Unit, onClickItem: (Source, String) -> Unit,
onClickDisable: (Source) -> Unit,
onClickPin: (Source) -> Unit, onClickPin: (Source) -> Unit,
onLongClickItem: (Source) -> Unit,
) { ) {
val context = LocalContext.current
when { when {
presenter.isLoading -> LoadingScreen() state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
presenter.isEmpty -> EmptyScreen( state.isEmpty -> EmptyScreen(
textResource = R.string.source_empty_screen, textResource = R.string.source_empty_screen,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
) )
else -> { else -> {
SourceList( ScrollbarLazyColumn(
state = presenter, contentPadding = contentPadding + topSmallPaddingValues,
contentPadding = contentPadding, ) {
onClickItem = onClickItem, items(
onClickDisable = onClickDisable, items = state.items,
onClickPin = onClickPin, contentType = {
) when (it) {
} is SourceUiModel.Header -> "header"
} is SourceUiModel.Item -> "item"
LaunchedEffect(Unit) { }
presenter.events.collectLatest { event -> },
when (event) { key = {
SourcesPresenter.Event.FailedFetchingSources -> { when (it) {
context.toast(R.string.internal_error) 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 @Composable
private fun SourceHeader( private fun SourceHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -201,7 +155,7 @@ private fun SourcePinButton(
} }
@Composable @Composable
private fun SourceOptionsDialog( fun SourceOptionsDialog(
source: Source, source: Source,
onClickPin: () -> Unit, onClickPin: () -> Unit,
onClickDisable: () -> Unit, onClickDisable: () -> Unit,

View File

@ -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() }
}

View File

@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -32,6 +35,7 @@ fun TabbedScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state = rememberPagerState() val state = rememberPagerState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(startIndex) { LaunchedEffect(startIndex) {
if (startIndex != null) { if (startIndex != null) {
@ -52,6 +56,7 @@ fun TabbedScreen(
actions = { AppBarActions(tab.actions) }, actions = { AppBarActions(tab.actions) },
) )
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding -> ) { contentPadding ->
Column( Column(
modifier = Modifier.padding( modifier = Modifier.padding(
@ -86,6 +91,7 @@ fun TabbedScreen(
TachiyomiBottomNavigationView.withBottomNavPadding( TachiyomiBottomNavigationView.withBottomNavPadding(
PaddingValues(bottom = contentPadding.calculateBottomPadding()), PaddingValues(bottom = contentPadding.calculateBottomPadding()),
), ),
snackbarHostState,
) )
} }
} }
@ -97,5 +103,5 @@ data class TabContent(
val badgeNumber: Int? = null, val badgeNumber: Int? = null,
val searchEnabled: Boolean = false, val searchEnabled: Boolean = false,
val actions: List<AppBar.Action> = emptyList(), val actions: List<AppBar.Action> = emptyList(),
val content: @Composable (contentPadding: PaddingValues) -> Unit, val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
) )

View File

@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.screen package eu.kanade.presentation.more.settings.screen
import android.Manifest
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -22,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.accompanist.permissions.rememberPermissionState
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.domain.backup.service.BackupPreferences import eu.kanade.domain.backup.service.BackupPreferences
import eu.kanade.presentation.components.Divider 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.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.backup.models.Backup 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.DeviceUtil
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -70,7 +68,7 @@ object SettingsBackupScreen : SearchableSettings {
override fun getPreferences(): List<Preference> { override fun getPreferences(): List<Preference> {
val backupPreferences = Injekt.get<BackupPreferences>() val backupPreferences = Injekt.get<BackupPreferences>()
RequestStoragePermission() DiskUtil.RequestStoragePermission()
return listOf( return listOf(
getCreateBackupPref(), 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 @Composable
private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference { private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()

View File

@ -1,24 +1,13 @@
package eu.kanade.tachiyomi.ui.browse package eu.kanade.tachiyomi.ui.browse
import android.Manifest
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable 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 androidx.core.os.bundleOf
import eu.kanade.presentation.components.TabbedScreen import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController 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") @Suppress("unused")
constructor(bundle: Bundle? = null) : this(bundle?.getBoolean(TO_EXTENSIONS_EXTRA) ?: false) 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) private val toExtensions = args.getBoolean(TO_EXTENSIONS_EXTRA, false)
override fun createPresenter() = BrowsePresenter()
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
val query by presenter.extensionsPresenter.query.collectAsState() Navigator(screen = BrowseScreen(toExtensions = toExtensions))
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)
} }
} }

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -2,11 +2,10 @@ package eu.kanade.tachiyomi.ui.browse.extension
import android.app.Application import android.app.Application
import androidx.annotation.StringRes 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.extension.interactor.GetExtensionsByType
import eu.kanade.domain.source.service.SourcePreferences 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.R
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension 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.source.online.HttpSource
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -23,26 +20,23 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class ExtensionsPresenter( class ExtensionsScreenModel(
private val presenterScope: CoroutineScope, preferences: SourcePreferences = Injekt.get(),
private val state: ExtensionsStateImpl = ExtensionState() as ExtensionsStateImpl,
private val preferences: SourcePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(),
) : ExtensionsState by state { ) : StateScreenModel<ExtensionsState>(ExtensionsState()) {
private val _query: MutableStateFlow<String?> = MutableStateFlow(null) private val _query: MutableStateFlow<String?> = MutableStateFlow(null)
val query: StateFlow<String?> = _query.asStateFlow() val query: StateFlow<String?> = _query.asStateFlow()
private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf()) private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
fun onCreate() { init {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map -> val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
{ {
@ -76,7 +70,7 @@ class ExtensionsPresenter(
} }
} }
presenterScope.launchIO { coroutineScope.launchIO {
combine( combine(
_query, _query,
_currentDownloads, _currentDownloads,
@ -117,39 +111,44 @@ class ExtensionsPresenter(
items items
} }
.onStart { delay(500) } // Defer to avoid crashing on initial render
.collectLatest { .collectLatest {
state.isLoading = false mutableState.update { state ->
state.items = it state.copy(
isLoading = false,
items = it,
)
}
} }
} }
presenterScope.launchIO { findAvailableExtensions() } coroutineScope.launchIO { findAvailableExtensions() }
preferences.extensionUpdatesCount().changes() preferences.extensionUpdatesCount().changes()
.onEach { state.updates = it } .onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(presenterScope) .launchIn(coroutineScope)
} }
fun search(query: String?) { fun search(query: String?) {
presenterScope.launchIO { coroutineScope.launchIO {
_query.emit(query) _query.emit(query)
} }
} }
fun updateAllExtensions() { fun updateAllExtensions() {
presenterScope.launchIO { coroutineScope.launchIO {
if (state.isEmpty) return@launchIO with(state.value) {
state.items if (isEmpty) return@launchIO
.mapNotNull { items
when { .mapNotNull {
it !is ExtensionUiModel.Item -> null when {
it.extension !is Extension.Installed -> null it !is ExtensionUiModel.Item -> null
!it.extension.hasUpdate -> null it.extension !is Extension.Installed -> null
else -> it.extension !it.extension.hasUpdate -> null
else -> it.extension
}
} }
} .forEach { updateExtension(it) }
.forEach { updateExtension(it) } }
} }
} }
@ -195,11 +194,11 @@ class ExtensionsPresenter(
} }
fun findAvailableExtensions() { fun findAvailableExtensions() {
presenterScope.launchIO { mutableState.update { it.copy(isRefreshing = true) }
state.isRefreshing = true coroutineScope.launchIO {
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
state.isRefreshing = false
} }
mutableState.update { it.copy(isRefreshing = false) }
} }
fun trustSignature(signatureHash: String) { 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 ExtensionUiModel {
sealed interface Header : ExtensionUiModel { sealed interface Header : ExtensionUiModel {
data class Resource(@StringRes val textRes: Int) : Header data class Resource(@StringRes val textRes: Int) : Header

View File

@ -3,11 +3,14 @@ package eu.kanade.tachiyomi.ui.browse.extension
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.Translate
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource 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.browse.ExtensionScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
@ -15,53 +18,41 @@ import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsControlle
@Composable @Composable
fun extensionsTab( fun extensionsTab(
router: Router?, extensionsScreenModel: ExtensionsScreenModel,
presenter: ExtensionsPresenter, ): TabContent {
) = TabContent( val router = LocalRouter.currentOrThrow
titleRes = R.string.label_extensions, val state by extensionsScreenModel.state.collectAsState()
badgeNumber = presenter.updates.takeIf { it > 0 },
searchEnabled = true, return TabContent(
actions = listOf( titleRes = R.string.label_extensions,
AppBar.Action( badgeNumber = state.updates.takeIf { it > 0 },
title = stringResource(R.string.action_filter), searchEnabled = true,
icon = Icons.Outlined.Translate, actions = listOf(
onClick = { router?.pushController(ExtensionFilterController()) }, AppBar.Action(
title = stringResource(R.string.action_filter),
icon = Icons.Outlined.Translate,
onClick = { router.pushController(ExtensionFilterController()) },
),
), ),
), content = { contentPadding, _ ->
content = { contentPadding -> ExtensionScreen(
ExtensionScreen( state = state,
presenter = presenter, contentPadding = contentPadding,
contentPadding = contentPadding, onLongClickItem = { extension ->
onLongClickItem = { extension -> when (extension) {
when (extension) { is Extension.Available -> extensionsScreenModel.installExtension(extension)
is Extension.Available -> presenter.installExtension(extension) else -> extensionsScreenModel.uninstallExtension(extension.pkgName)
else -> presenter.uninstallExtension(extension.pkgName) }
} },
}, onClickItemCancel = extensionsScreenModel::cancelInstallUpdateExtension,
onClickItemCancel = { extension -> onClickUpdateAll = extensionsScreenModel::updateAllExtensions,
presenter.cancelInstallUpdateExtension(extension) onInstallExtension = extensionsScreenModel::installExtension,
}, onOpenExtension = { router.pushController(ExtensionDetailsController(it.pkgName)) },
onClickUpdateAll = { onTrustExtension = { extensionsScreenModel.trustSignature(it.signatureHash) },
presenter.updateAllExtensions() onUninstallExtension = { extensionsScreenModel.uninstallExtension(it.pkgName) },
}, onUpdateExtension = extensionsScreenModel::updateExtension,
onInstallExtension = { onRefresh = extensionsScreenModel::findAvailableExtensions,
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()
},
)
},
)

View File

@ -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()
}

View File

@ -3,22 +3,27 @@ package eu.kanade.tachiyomi.ui.browse.migration.sources
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.runtime.Composable 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.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource 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.browse.MigrateSourceScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController
@Composable @Composable
fun migrateSourcesTab( fun Screen.migrateSourceTab(): TabContent {
router: Router?,
presenter: MigrationSourcesPresenter,
): TabContent {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val router = LocalRouter.currentOrThrow
val screenModel = rememberScreenModel { MigrateSourceScreenModel() }
val state by screenModel.state.collectAsState()
return TabContent( return TabContent(
titleRes = R.string.label_migration, titleRes = R.string.label_migration,
@ -31,18 +36,20 @@ fun migrateSourcesTab(
}, },
), ),
), ),
content = { contentPadding -> content = { contentPadding, _ ->
MigrateSourceScreen( MigrateSourceScreen(
presenter = presenter, state = state,
contentPadding = contentPadding, contentPadding = contentPadding,
onClickItem = { source -> onClickItem = { source ->
router?.pushController( router.pushController(
MigrationMangaController( MigrationMangaController(
source.id, source.id,
source.name, source.name,
), ),
) )
}, },
onToggleSortingDirection = screenModel::toggleSortingDirection,
onToggleSortingMode = screenModel::toggleSortingMode,
) )
}, },
) )

View File

@ -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()
}
}

View File

@ -1,5 +1,8 @@
package eu.kanade.tachiyomi.ui.browse.source 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.base.BasePreferences
import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.ToggleSource 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.model.Source
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.browse.SourceUiModel 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.lang.launchIO
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import logcat.LogPriority import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.TreeMap import java.util.TreeMap
class SourcesPresenter( class SourcesScreenModel(
private val presenterScope: CoroutineScope,
private val state: SourcesStateImpl = SourcesState() as SourcesStateImpl,
private val preferences: BasePreferences = Injekt.get(), private val preferences: BasePreferences = Injekt.get(),
private val sourcePreferences: SourcePreferences = Injekt.get(), private val sourcePreferences: SourcePreferences = Injekt.get(),
private val getEnabledSources: GetEnabledSources = Injekt.get(), private val getEnabledSources: GetEnabledSources = Injekt.get(),
private val toggleSource: ToggleSource = Injekt.get(), private val toggleSource: ToggleSource = Injekt.get(),
private val toggleSourcePin: ToggleSourcePin = Injekt.get(), private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
) : SourcesState by state { ) : StateScreenModel<SourcesState>(SourcesState()) {
private val _events = Channel<Event>(Int.MAX_VALUE) private val _events = Channel<Event>(Int.MAX_VALUE)
val events = _events.receiveAsFlow() val events = _events.receiveAsFlow()
fun onCreate() { init {
presenterScope.launchIO { coroutineScope.launchIO {
getEnabledSources.subscribe() getEnabledSources.subscribe()
.catch { .catch {
logcat(LogPriority.ERROR, it) logcat(LogPriority.ERROR, it)
_events.send(Event.FailedFetchingSources) _events.send(Event.FailedFetchingSources)
} }
.onStart { delay(500) } // Defer to avoid crashing on initial render
.collectLatest(::collectLatestSources) .collectLatest(::collectLatestSources)
} }
} }
private fun collectLatestSources(sources: List<Source>) { private fun collectLatestSources(sources: List<Source>) {
val map = TreeMap<String, MutableList<Source>> { d1, d2 -> mutableState.update { state ->
// Sources without a lang defined will be placed at the end val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
when { // Sources without a lang defined will be placed at the end
d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1 when {
d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1 d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
d1 == PINNED_KEY && d2 != PINNED_KEY -> -1 d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
d2 == PINNED_KEY && d1 != PINNED_KEY -> 1 d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
d1 == "" && d2 != "" -> 1 d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
d2 == "" && d1 != "" -> -1 d1 == "" && d2 != "" -> 1
else -> d1.compareTo(d2) d2 == "" && d1 != "" -> -1
else -> d1.compareTo(d2)
}
} }
} val byLang = sources.groupByTo(map) {
val byLang = sources.groupByTo(map) { when {
when { it.isUsedLast -> LAST_USED_KEY
it.isUsedLast -> LAST_USED_KEY Pin.Actual in it.pin -> PINNED_KEY
Pin.Actual in it.pin -> PINNED_KEY else -> it.lang
else -> it.lang }
} }
}
val uiModels = byLang.flatMap { state.copy(
listOf( isLoading = false,
SourceUiModel.Header(it.key), items = byLang.flatMap {
*it.value.map { source -> listOf(
SourceUiModel.Item(source) SourceUiModel.Header(it.key),
}.toTypedArray(), *it.value.map { source ->
SourceUiModel.Item(source)
}.toTypedArray(),
)
},
) )
} }
state.isLoading = false
state.items = uiModels
} }
fun onOpenSource(source: Source) { fun onOpenSource(source: Source) {
@ -96,6 +95,14 @@ class SourcesPresenter(
toggleSourcePin.await(source) 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 { sealed class Event {
object FailedFetchingSources : Event() object FailedFetchingSources : Event()
} }
@ -107,3 +114,12 @@ class SourcesPresenter(
const val LAST_USED_KEY = "last_used" 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()
}

View File

@ -4,48 +4,83 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.TravelExplore import androidx.compose.material.icons.outlined.TravelExplore
import androidx.compose.runtime.Composable 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 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.browse.SourcesScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Composable @Composable
fun sourcesTab( fun Screen.sourcesTab(): TabContent {
router: Router?, val router = LocalRouter.currentOrThrow
presenter: SourcesPresenter, val screenModel = rememberScreenModel { SourcesScreenModel() }
) = TabContent( val state by screenModel.state.collectAsState()
titleRes = R.string.label_sources,
actions = listOf( return TabContent(
AppBar.Action( titleRes = R.string.label_sources,
title = stringResource(R.string.action_global_search), actions = listOf(
icon = Icons.Outlined.TravelExplore, AppBar.Action(
onClick = { router?.pushController(GlobalSearchController()) }, 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( content = { contentPadding, snackbarHostState ->
title = stringResource(R.string.action_filter), SourcesScreen(
icon = Icons.Outlined.FilterList, state = state,
onClick = { router?.pushController(SourceFilterController()) }, contentPadding = contentPadding,
), onClickItem = { source, query ->
), screenModel.onOpenSource(source)
content = { contentPadding -> router.pushController(BrowseSourceController(source, query))
SourcesScreen( },
presenter = presenter, onClickPin = screenModel::togglePin,
contentPadding = contentPadding, onLongClickItem = screenModel::showSourceDialog,
onClickItem = { source, query -> )
presenter.onOpenSource(source)
router?.pushController(BrowseSourceController(source, query)) state.dialog?.let { dialog ->
}, val source = dialog.source
onClickDisable = { source -> SourceOptionsDialog(
presenter.toggleSource(source) source = source,
}, onClickPin = {
onClickPin = { source -> screenModel.togglePin(source)
presenter.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) }
}
}
}
}
},
)
}

View File

@ -1,11 +1,15 @@
package eu.kanade.tachiyomi.util.storage package eu.kanade.tachiyomi.util.storage
import android.Manifest
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.rememberPermissionState
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File 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" const val NOMEDIA_FILE = ".nomedia"
} }

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.util.system
import android.content.Context import android.content.Context
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import eu.kanade.tachiyomi.R 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 import java.util.Locale
/** /**
@ -16,8 +16,8 @@ object LocaleHelper {
*/ */
fun getSourceDisplayName(lang: String?, context: Context): String { fun getSourceDisplayName(lang: String?, context: Context): String {
return when (lang) { return when (lang) {
SourcesPresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source) SourcesScreenModel.LAST_USED_KEY -> context.getString(R.string.last_used_source)
SourcesPresenter.PINNED_KEY -> context.getString(R.string.pinned_sources) SourcesScreenModel.PINNED_KEY -> context.getString(R.string.pinned_sources)
"other" -> context.getString(R.string.other_source) "other" -> context.getString(R.string.other_source)
"all" -> context.getString(R.string.multi_lang) "all" -> context.getString(R.string.multi_lang)
else -> getDisplayName(lang) else -> getDisplayName(lang)