mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-03 23:58:55 +01:00 
			
		
		
		
	Use Voyager on Browse tab (#8605)
This commit is contained in:
		@@ -51,12 +51,12 @@ import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.extension.model.Extension
 | 
			
		||||
import eu.kanade.tachiyomi.extension.model.InstallStep
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsState
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun ExtensionScreen(
 | 
			
		||||
    presenter: ExtensionsPresenter,
 | 
			
		||||
    state: ExtensionsState,
 | 
			
		||||
    contentPadding: PaddingValues,
 | 
			
		||||
    onLongClickItem: (Extension) -> Unit,
 | 
			
		||||
    onClickItemCancel: (Extension) -> Unit,
 | 
			
		||||
@@ -69,19 +69,19 @@ fun ExtensionScreen(
 | 
			
		||||
    onRefresh: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    SwipeRefresh(
 | 
			
		||||
        refreshing = presenter.isRefreshing,
 | 
			
		||||
        refreshing = state.isRefreshing,
 | 
			
		||||
        onRefresh = onRefresh,
 | 
			
		||||
        enabled = !presenter.isLoading,
 | 
			
		||||
        enabled = !state.isLoading,
 | 
			
		||||
    ) {
 | 
			
		||||
        when {
 | 
			
		||||
            presenter.isLoading -> LoadingScreen()
 | 
			
		||||
            presenter.isEmpty -> EmptyScreen(
 | 
			
		||||
            state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
 | 
			
		||||
            state.isEmpty -> EmptyScreen(
 | 
			
		||||
                textResource = R.string.empty_screen,
 | 
			
		||||
                modifier = Modifier.padding(contentPadding),
 | 
			
		||||
            )
 | 
			
		||||
            else -> {
 | 
			
		||||
                ExtensionContent(
 | 
			
		||||
                    state = presenter,
 | 
			
		||||
                    state = state,
 | 
			
		||||
                    contentPadding = contentPadding,
 | 
			
		||||
                    onLongClickItem = onLongClickItem,
 | 
			
		||||
                    onClickItemCancel = onClickItemCancel,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
package eu.kanade.presentation.browse
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.derivedStateOf
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
 | 
			
		||||
 | 
			
		||||
interface ExtensionsState {
 | 
			
		||||
    val isLoading: Boolean
 | 
			
		||||
    val isRefreshing: Boolean
 | 
			
		||||
    val items: List<ExtensionUiModel>
 | 
			
		||||
    val updates: Int
 | 
			
		||||
    val isEmpty: Boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun ExtensionState(): ExtensionsState {
 | 
			
		||||
    return ExtensionsStateImpl()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ExtensionsStateImpl : ExtensionsState {
 | 
			
		||||
    override var isLoading: Boolean by mutableStateOf(true)
 | 
			
		||||
    override var isRefreshing: Boolean by mutableStateOf(false)
 | 
			
		||||
    override var items: List<ExtensionUiModel> by mutableStateOf(emptyList())
 | 
			
		||||
    override var updates: Int by mutableStateOf(0)
 | 
			
		||||
    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
 | 
			
		||||
}
 | 
			
		||||
@@ -39,35 +39,37 @@ import eu.kanade.presentation.util.plus
 | 
			
		||||
import eu.kanade.presentation.util.secondaryItemAlpha
 | 
			
		||||
import eu.kanade.presentation.util.topSmallPaddingValues
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrateSourceState
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun MigrateSourceScreen(
 | 
			
		||||
    presenter: MigrationSourcesPresenter,
 | 
			
		||||
    state: MigrateSourceState,
 | 
			
		||||
    contentPadding: PaddingValues,
 | 
			
		||||
    onClickItem: (Source) -> Unit,
 | 
			
		||||
    onToggleSortingDirection: () -> Unit,
 | 
			
		||||
    onToggleSortingMode: () -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val context = LocalContext.current
 | 
			
		||||
    when {
 | 
			
		||||
        presenter.isLoading -> LoadingScreen()
 | 
			
		||||
        presenter.isEmpty -> EmptyScreen(
 | 
			
		||||
        state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
 | 
			
		||||
        state.isEmpty -> EmptyScreen(
 | 
			
		||||
            textResource = R.string.information_empty_library,
 | 
			
		||||
            modifier = Modifier.padding(contentPadding),
 | 
			
		||||
        )
 | 
			
		||||
        else ->
 | 
			
		||||
            MigrateSourceList(
 | 
			
		||||
                list = presenter.items,
 | 
			
		||||
                list = state.items,
 | 
			
		||||
                contentPadding = contentPadding,
 | 
			
		||||
                onClickItem = onClickItem,
 | 
			
		||||
                onLongClickItem = { source ->
 | 
			
		||||
                    val sourceId = source.id.toString()
 | 
			
		||||
                    context.copyToClipboard(sourceId, sourceId)
 | 
			
		||||
                },
 | 
			
		||||
                sortingMode = presenter.sortingMode,
 | 
			
		||||
                onToggleSortingMode = { presenter.toggleSortingMode() },
 | 
			
		||||
                sortingDirection = presenter.sortingDirection,
 | 
			
		||||
                onToggleSortingDirection = { presenter.toggleSortingDirection() },
 | 
			
		||||
                sortingMode = state.sortingMode,
 | 
			
		||||
                onToggleSortingMode = onToggleSortingMode,
 | 
			
		||||
                sortingDirection = state.sortingDirection,
 | 
			
		||||
                onToggleSortingDirection = onToggleSortingDirection,
 | 
			
		||||
            )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
package eu.kanade.presentation.browse
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.derivedStateOf
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import eu.kanade.domain.source.interactor.SetMigrateSorting
 | 
			
		||||
import eu.kanade.domain.source.model.Source
 | 
			
		||||
 | 
			
		||||
interface MigrateSourceState {
 | 
			
		||||
    val isLoading: Boolean
 | 
			
		||||
    val items: List<Pair<Source, Long>>
 | 
			
		||||
    val isEmpty: Boolean
 | 
			
		||||
    val sortingMode: SetMigrateSorting.Mode
 | 
			
		||||
    val sortingDirection: SetMigrateSorting.Direction
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun MigrateSourceState(): MigrateSourceState {
 | 
			
		||||
    return MigrateSourceStateImpl()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class MigrateSourceStateImpl : MigrateSourceState {
 | 
			
		||||
    override var isLoading: Boolean by mutableStateOf(true)
 | 
			
		||||
    override var items: List<Pair<Source, Long>> by mutableStateOf(emptyList())
 | 
			
		||||
    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
 | 
			
		||||
    override var sortingMode: SetMigrateSorting.Mode by mutableStateOf(SetMigrateSorting.Mode.ALPHABETICAL)
 | 
			
		||||
    override var sortingDirection: SetMigrateSorting.Direction by mutableStateOf(SetMigrateSorting.Direction.ASCENDING)
 | 
			
		||||
}
 | 
			
		||||
@@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.TextButton
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
@@ -35,108 +34,63 @@ import eu.kanade.presentation.util.plus
 | 
			
		||||
import eu.kanade.presentation.util.topSmallPaddingValues
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.source.LocalSource
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesState
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.toast
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun SourcesScreen(
 | 
			
		||||
    presenter: SourcesPresenter,
 | 
			
		||||
    state: SourcesState,
 | 
			
		||||
    contentPadding: PaddingValues,
 | 
			
		||||
    onClickItem: (Source, String) -> Unit,
 | 
			
		||||
    onClickDisable: (Source) -> Unit,
 | 
			
		||||
    onClickPin: (Source) -> Unit,
 | 
			
		||||
    onLongClickItem: (Source) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    val context = LocalContext.current
 | 
			
		||||
    when {
 | 
			
		||||
        presenter.isLoading -> LoadingScreen()
 | 
			
		||||
        presenter.isEmpty -> EmptyScreen(
 | 
			
		||||
        state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
 | 
			
		||||
        state.isEmpty -> EmptyScreen(
 | 
			
		||||
            textResource = R.string.source_empty_screen,
 | 
			
		||||
            modifier = Modifier.padding(contentPadding),
 | 
			
		||||
        )
 | 
			
		||||
        else -> {
 | 
			
		||||
            SourceList(
 | 
			
		||||
                state = presenter,
 | 
			
		||||
                contentPadding = contentPadding,
 | 
			
		||||
                onClickItem = onClickItem,
 | 
			
		||||
                onClickDisable = onClickDisable,
 | 
			
		||||
                onClickPin = onClickPin,
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    LaunchedEffect(Unit) {
 | 
			
		||||
        presenter.events.collectLatest { event ->
 | 
			
		||||
            when (event) {
 | 
			
		||||
                SourcesPresenter.Event.FailedFetchingSources -> {
 | 
			
		||||
                    context.toast(R.string.internal_error)
 | 
			
		||||
            ScrollbarLazyColumn(
 | 
			
		||||
                contentPadding = contentPadding + topSmallPaddingValues,
 | 
			
		||||
            ) {
 | 
			
		||||
                items(
 | 
			
		||||
                    items = state.items,
 | 
			
		||||
                    contentType = {
 | 
			
		||||
                        when (it) {
 | 
			
		||||
                            is SourceUiModel.Header -> "header"
 | 
			
		||||
                            is SourceUiModel.Item -> "item"
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    key = {
 | 
			
		||||
                        when (it) {
 | 
			
		||||
                            is SourceUiModel.Header -> it.hashCode()
 | 
			
		||||
                            is SourceUiModel.Item -> "source-${it.source.key()}"
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                ) { model ->
 | 
			
		||||
                    when (model) {
 | 
			
		||||
                        is SourceUiModel.Header -> {
 | 
			
		||||
                            SourceHeader(
 | 
			
		||||
                                modifier = Modifier.animateItemPlacement(),
 | 
			
		||||
                                language = model.language,
 | 
			
		||||
                            )
 | 
			
		||||
                        }
 | 
			
		||||
                        is SourceUiModel.Item -> SourceItem(
 | 
			
		||||
                            modifier = Modifier.animateItemPlacement(),
 | 
			
		||||
                            source = model.source,
 | 
			
		||||
                            onClickItem = onClickItem,
 | 
			
		||||
                            onLongClickItem = onLongClickItem,
 | 
			
		||||
                            onClickPin = onClickPin,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun SourceList(
 | 
			
		||||
    state: SourcesState,
 | 
			
		||||
    contentPadding: PaddingValues,
 | 
			
		||||
    onClickItem: (Source, String) -> Unit,
 | 
			
		||||
    onClickDisable: (Source) -> Unit,
 | 
			
		||||
    onClickPin: (Source) -> Unit,
 | 
			
		||||
) {
 | 
			
		||||
    ScrollbarLazyColumn(
 | 
			
		||||
        contentPadding = contentPadding + topSmallPaddingValues,
 | 
			
		||||
    ) {
 | 
			
		||||
        items(
 | 
			
		||||
            items = state.items,
 | 
			
		||||
            contentType = {
 | 
			
		||||
                when (it) {
 | 
			
		||||
                    is SourceUiModel.Header -> "header"
 | 
			
		||||
                    is SourceUiModel.Item -> "item"
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            key = {
 | 
			
		||||
                when (it) {
 | 
			
		||||
                    is SourceUiModel.Header -> it.hashCode()
 | 
			
		||||
                    is SourceUiModel.Item -> "source-${it.source.key()}"
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
        ) { model ->
 | 
			
		||||
            when (model) {
 | 
			
		||||
                is SourceUiModel.Header -> {
 | 
			
		||||
                    SourceHeader(
 | 
			
		||||
                        modifier = Modifier.animateItemPlacement(),
 | 
			
		||||
                        language = model.language,
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                is SourceUiModel.Item -> SourceItem(
 | 
			
		||||
                    modifier = Modifier.animateItemPlacement(),
 | 
			
		||||
                    source = model.source,
 | 
			
		||||
                    onClickItem = onClickItem,
 | 
			
		||||
                    onLongClickItem = { state.dialog = SourcesPresenter.Dialog(it) },
 | 
			
		||||
                    onClickPin = onClickPin,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (state.dialog != null) {
 | 
			
		||||
        val source = state.dialog!!.source
 | 
			
		||||
        SourceOptionsDialog(
 | 
			
		||||
            source = source,
 | 
			
		||||
            onClickPin = {
 | 
			
		||||
                onClickPin(source)
 | 
			
		||||
                state.dialog = null
 | 
			
		||||
            },
 | 
			
		||||
            onClickDisable = {
 | 
			
		||||
                onClickDisable(source)
 | 
			
		||||
                state.dialog = null
 | 
			
		||||
            },
 | 
			
		||||
            onDismiss = { state.dialog = null },
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun SourceHeader(
 | 
			
		||||
    modifier: Modifier = Modifier,
 | 
			
		||||
@@ -201,7 +155,7 @@ private fun SourcePinButton(
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
private fun SourceOptionsDialog(
 | 
			
		||||
fun SourceOptionsDialog(
 | 
			
		||||
    source: Source,
 | 
			
		||||
    onClickPin: () -> Unit,
 | 
			
		||||
    onClickDisable: () -> Unit,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
package eu.kanade.presentation.browse
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.Stable
 | 
			
		||||
import androidx.compose.runtime.derivedStateOf
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
import androidx.compose.runtime.setValue
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
 | 
			
		||||
 | 
			
		||||
@Stable
 | 
			
		||||
interface SourcesState {
 | 
			
		||||
    var dialog: SourcesPresenter.Dialog?
 | 
			
		||||
    val isLoading: Boolean
 | 
			
		||||
    val items: List<SourceUiModel>
 | 
			
		||||
    val isEmpty: Boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fun SourcesState(): SourcesState {
 | 
			
		||||
    return SourcesStateImpl()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SourcesStateImpl : SourcesState {
 | 
			
		||||
    override var dialog: SourcesPresenter.Dialog? by mutableStateOf(null)
 | 
			
		||||
    override var isLoading: Boolean by mutableStateOf(true)
 | 
			
		||||
    override var items: List<SourceUiModel> by mutableStateOf(emptyList())
 | 
			
		||||
    override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
 | 
			
		||||
}
 | 
			
		||||
@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.calculateStartPadding
 | 
			
		||||
import androidx.compose.foundation.layout.fillMaxSize
 | 
			
		||||
import androidx.compose.foundation.layout.padding
 | 
			
		||||
import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.SnackbarHost
 | 
			
		||||
import androidx.compose.material3.SnackbarHostState
 | 
			
		||||
import androidx.compose.material3.Tab
 | 
			
		||||
import androidx.compose.material3.TabRow
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.remember
 | 
			
		||||
import androidx.compose.runtime.rememberCoroutineScope
 | 
			
		||||
import androidx.compose.ui.Alignment
 | 
			
		||||
import androidx.compose.ui.Modifier
 | 
			
		||||
@@ -32,6 +35,7 @@ fun TabbedScreen(
 | 
			
		||||
) {
 | 
			
		||||
    val scope = rememberCoroutineScope()
 | 
			
		||||
    val state = rememberPagerState()
 | 
			
		||||
    val snackbarHostState = remember { SnackbarHostState() }
 | 
			
		||||
 | 
			
		||||
    LaunchedEffect(startIndex) {
 | 
			
		||||
        if (startIndex != null) {
 | 
			
		||||
@@ -52,6 +56,7 @@ fun TabbedScreen(
 | 
			
		||||
                actions = { AppBarActions(tab.actions) },
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
 | 
			
		||||
    ) { contentPadding ->
 | 
			
		||||
        Column(
 | 
			
		||||
            modifier = Modifier.padding(
 | 
			
		||||
@@ -86,6 +91,7 @@ fun TabbedScreen(
 | 
			
		||||
                    TachiyomiBottomNavigationView.withBottomNavPadding(
 | 
			
		||||
                        PaddingValues(bottom = contentPadding.calculateBottomPadding()),
 | 
			
		||||
                    ),
 | 
			
		||||
                    snackbarHostState,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
@@ -97,5 +103,5 @@ data class TabContent(
 | 
			
		||||
    val badgeNumber: Int? = null,
 | 
			
		||||
    val searchEnabled: Boolean = false,
 | 
			
		||||
    val actions: List<AppBar.Action> = emptyList(),
 | 
			
		||||
    val content: @Composable (contentPadding: PaddingValues) -> Unit,
 | 
			
		||||
    val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
package eu.kanade.presentation.more.settings.screen
 | 
			
		||||
 | 
			
		||||
import android.Manifest
 | 
			
		||||
import android.content.ActivityNotFoundException
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
@@ -22,7 +21,6 @@ import androidx.compose.material3.MaterialTheme
 | 
			
		||||
import androidx.compose.material3.Text
 | 
			
		||||
import androidx.compose.material3.TextButton
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.ReadOnlyComposable
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.runtime.mutableStateOf
 | 
			
		||||
@@ -37,7 +35,6 @@ import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import androidx.compose.ui.unit.dp
 | 
			
		||||
import androidx.core.net.toUri
 | 
			
		||||
import com.google.accompanist.permissions.rememberPermissionState
 | 
			
		||||
import com.hippo.unifile.UniFile
 | 
			
		||||
import eu.kanade.domain.backup.service.BackupPreferences
 | 
			
		||||
import eu.kanade.presentation.components.Divider
 | 
			
		||||
@@ -52,6 +49,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
 | 
			
		||||
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
 | 
			
		||||
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
 | 
			
		||||
import eu.kanade.tachiyomi.data.backup.models.Backup
 | 
			
		||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.DeviceUtil
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.toast
 | 
			
		||||
@@ -70,7 +68,7 @@ object SettingsBackupScreen : SearchableSettings {
 | 
			
		||||
    override fun getPreferences(): List<Preference> {
 | 
			
		||||
        val backupPreferences = Injekt.get<BackupPreferences>()
 | 
			
		||||
 | 
			
		||||
        RequestStoragePermission()
 | 
			
		||||
        DiskUtil.RequestStoragePermission()
 | 
			
		||||
 | 
			
		||||
        return listOf(
 | 
			
		||||
            getCreateBackupPref(),
 | 
			
		||||
@@ -79,14 +77,6 @@ object SettingsBackupScreen : SearchableSettings {
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Composable
 | 
			
		||||
    private fun RequestStoragePermission() {
 | 
			
		||||
        val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            permissionState.launchPermissionRequest()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Composable
 | 
			
		||||
    private fun getCreateBackupPref(): Preference.PreferenceItem.TextPreference {
 | 
			
		||||
        val scope = rememberCoroutineScope()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +1,13 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse
 | 
			
		||||
 | 
			
		||||
import android.Manifest
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import android.view.View
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.collectAsState
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.core.os.bundleOf
 | 
			
		||||
import eu.kanade.presentation.components.TabbedScreen
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
 | 
			
		||||
import cafe.adriel.voyager.navigator.Navigator
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.RootController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.migrateSourcesTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.sourcesTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.main.MainActivity
 | 
			
		||||
 | 
			
		||||
class BrowseController : FullComposeController<BrowsePresenter>, RootController {
 | 
			
		||||
class BrowseController : BasicFullComposeController, RootController {
 | 
			
		||||
 | 
			
		||||
    @Suppress("unused")
 | 
			
		||||
    constructor(bundle: Bundle? = null) : this(bundle?.getBoolean(TO_EXTENSIONS_EXTRA) ?: false)
 | 
			
		||||
@@ -29,34 +18,9 @@ class BrowseController : FullComposeController<BrowsePresenter>, RootController
 | 
			
		||||
 | 
			
		||||
    private val toExtensions = args.getBoolean(TO_EXTENSIONS_EXTRA, false)
 | 
			
		||||
 | 
			
		||||
    override fun createPresenter() = BrowsePresenter()
 | 
			
		||||
 | 
			
		||||
    @Composable
 | 
			
		||||
    override fun ComposeContent() {
 | 
			
		||||
        val query by presenter.extensionsPresenter.query.collectAsState()
 | 
			
		||||
 | 
			
		||||
        TabbedScreen(
 | 
			
		||||
            titleRes = R.string.browse,
 | 
			
		||||
            tabs = listOf(
 | 
			
		||||
                sourcesTab(router, presenter.sourcesPresenter),
 | 
			
		||||
                extensionsTab(router, presenter.extensionsPresenter),
 | 
			
		||||
                migrateSourcesTab(router, presenter.migrationSourcesPresenter),
 | 
			
		||||
            ),
 | 
			
		||||
            startIndex = 1.takeIf { toExtensions },
 | 
			
		||||
            searchQuery = query,
 | 
			
		||||
            onChangeSearchQuery = { presenter.extensionsPresenter.search(it) },
 | 
			
		||||
            incognitoMode = presenter.isIncognitoMode,
 | 
			
		||||
            downloadedOnlyMode = presenter.isDownloadOnly,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            (activity as? MainActivity)?.ready = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun onViewCreated(view: View) {
 | 
			
		||||
        super.onViewCreated(view)
 | 
			
		||||
        requestPermissionsSafe(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 301)
 | 
			
		||||
        Navigator(screen = BrowseScreen(toExtensions = toExtensions))
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse
 | 
			
		||||
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import eu.kanade.domain.base.BasePreferences
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
 | 
			
		||||
class BrowsePresenter(
 | 
			
		||||
    preferences: BasePreferences = Injekt.get(),
 | 
			
		||||
) : BasePresenter<BrowseController>() {
 | 
			
		||||
 | 
			
		||||
    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
 | 
			
		||||
    val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
 | 
			
		||||
 | 
			
		||||
    val sourcesPresenter = SourcesPresenter(presenterScope)
 | 
			
		||||
    val extensionsPresenter = ExtensionsPresenter(presenterScope)
 | 
			
		||||
    val migrationSourcesPresenter = MigrationSourcesPresenter(presenterScope)
 | 
			
		||||
 | 
			
		||||
    override fun onCreate(savedState: Bundle?) {
 | 
			
		||||
        super.onCreate(savedState)
 | 
			
		||||
 | 
			
		||||
        sourcesPresenter.onCreate()
 | 
			
		||||
        extensionsPresenter.onCreate()
 | 
			
		||||
        migrationSourcesPresenter.onCreate()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,66 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.collectAsState
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.ui.platform.LocalContext
 | 
			
		||||
import cafe.adriel.voyager.core.model.ScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.model.coroutineScope
 | 
			
		||||
import cafe.adriel.voyager.core.model.rememberScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.screen.Screen
 | 
			
		||||
import eu.kanade.core.prefs.asState
 | 
			
		||||
import eu.kanade.domain.base.BasePreferences
 | 
			
		||||
import eu.kanade.presentation.components.TabbedScreen
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.migrateSourceTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.sourcesTab
 | 
			
		||||
import eu.kanade.tachiyomi.ui.main.MainActivity
 | 
			
		||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
 | 
			
		||||
data class BrowseScreen(
 | 
			
		||||
    private val toExtensions: Boolean,
 | 
			
		||||
) : Screen {
 | 
			
		||||
 | 
			
		||||
    @Composable
 | 
			
		||||
    override fun Content() {
 | 
			
		||||
        val context = LocalContext.current
 | 
			
		||||
        val screenModel = rememberScreenModel { BrowseScreenModel() }
 | 
			
		||||
 | 
			
		||||
        // Hoisted for extensions tab's search bar
 | 
			
		||||
        val extensionsScreenModel = rememberScreenModel { ExtensionsScreenModel() }
 | 
			
		||||
        val extensionsQuery by extensionsScreenModel.query.collectAsState()
 | 
			
		||||
 | 
			
		||||
        TabbedScreen(
 | 
			
		||||
            titleRes = R.string.browse,
 | 
			
		||||
            tabs = listOf(
 | 
			
		||||
                sourcesTab(),
 | 
			
		||||
                extensionsTab(extensionsScreenModel),
 | 
			
		||||
                migrateSourceTab(),
 | 
			
		||||
            ),
 | 
			
		||||
            startIndex = 1.takeIf { toExtensions },
 | 
			
		||||
            searchQuery = extensionsQuery,
 | 
			
		||||
            onChangeSearchQuery = extensionsScreenModel::search,
 | 
			
		||||
            incognitoMode = screenModel.isIncognitoMode,
 | 
			
		||||
            downloadedOnlyMode = screenModel.isDownloadOnly,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        // For local source
 | 
			
		||||
        DiskUtil.RequestStoragePermission()
 | 
			
		||||
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            (context as? MainActivity)?.ready = true
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private class BrowseScreenModel(
 | 
			
		||||
    preferences: BasePreferences = Injekt.get(),
 | 
			
		||||
) : ScreenModel {
 | 
			
		||||
    val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
 | 
			
		||||
    val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
 | 
			
		||||
}
 | 
			
		||||
@@ -2,11 +2,10 @@ package eu.kanade.tachiyomi.ui.browse.extension
 | 
			
		||||
 | 
			
		||||
import android.app.Application
 | 
			
		||||
import androidx.annotation.StringRes
 | 
			
		||||
import cafe.adriel.voyager.core.model.StateScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.model.coroutineScope
 | 
			
		||||
import eu.kanade.domain.extension.interactor.GetExtensionsByType
 | 
			
		||||
import eu.kanade.domain.source.service.SourcePreferences
 | 
			
		||||
import eu.kanade.presentation.browse.ExtensionState
 | 
			
		||||
import eu.kanade.presentation.browse.ExtensionsState
 | 
			
		||||
import eu.kanade.presentation.browse.ExtensionsStateImpl
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.extension.ExtensionManager
 | 
			
		||||
import eu.kanade.tachiyomi.extension.model.Extension
 | 
			
		||||
@@ -14,8 +13,6 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
 | 
			
		||||
import eu.kanade.tachiyomi.source.online.HttpSource
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.launchIO
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
 | 
			
		||||
import kotlinx.coroutines.CoroutineScope
 | 
			
		||||
import kotlinx.coroutines.delay
 | 
			
		||||
import kotlinx.coroutines.flow.MutableStateFlow
 | 
			
		||||
import kotlinx.coroutines.flow.StateFlow
 | 
			
		||||
import kotlinx.coroutines.flow.asStateFlow
 | 
			
		||||
@@ -23,26 +20,23 @@ import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.flow.combine
 | 
			
		||||
import kotlinx.coroutines.flow.launchIn
 | 
			
		||||
import kotlinx.coroutines.flow.onEach
 | 
			
		||||
import kotlinx.coroutines.flow.onStart
 | 
			
		||||
import kotlinx.coroutines.flow.update
 | 
			
		||||
import rx.Observable
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
 | 
			
		||||
class ExtensionsPresenter(
 | 
			
		||||
    private val presenterScope: CoroutineScope,
 | 
			
		||||
    private val state: ExtensionsStateImpl = ExtensionState() as ExtensionsStateImpl,
 | 
			
		||||
    private val preferences: SourcePreferences = Injekt.get(),
 | 
			
		||||
class ExtensionsScreenModel(
 | 
			
		||||
    preferences: SourcePreferences = Injekt.get(),
 | 
			
		||||
    private val extensionManager: ExtensionManager = Injekt.get(),
 | 
			
		||||
    private val getExtensions: GetExtensionsByType = Injekt.get(),
 | 
			
		||||
) : ExtensionsState by state {
 | 
			
		||||
) : StateScreenModel<ExtensionsState>(ExtensionsState()) {
 | 
			
		||||
 | 
			
		||||
    private val _query: MutableStateFlow<String?> = MutableStateFlow(null)
 | 
			
		||||
    val query: StateFlow<String?> = _query.asStateFlow()
 | 
			
		||||
 | 
			
		||||
    private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
 | 
			
		||||
 | 
			
		||||
    fun onCreate() {
 | 
			
		||||
    init {
 | 
			
		||||
        val context = Injekt.get<Application>()
 | 
			
		||||
        val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
 | 
			
		||||
            {
 | 
			
		||||
@@ -76,7 +70,7 @@ class ExtensionsPresenter(
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            combine(
 | 
			
		||||
                _query,
 | 
			
		||||
                _currentDownloads,
 | 
			
		||||
@@ -117,39 +111,44 @@ class ExtensionsPresenter(
 | 
			
		||||
 | 
			
		||||
                items
 | 
			
		||||
            }
 | 
			
		||||
                .onStart { delay(500) } // Defer to avoid crashing on initial render
 | 
			
		||||
                .collectLatest {
 | 
			
		||||
                    state.isLoading = false
 | 
			
		||||
                    state.items = it
 | 
			
		||||
                    mutableState.update { state ->
 | 
			
		||||
                        state.copy(
 | 
			
		||||
                            isLoading = false,
 | 
			
		||||
                            items = it,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        presenterScope.launchIO { findAvailableExtensions() }
 | 
			
		||||
        coroutineScope.launchIO { findAvailableExtensions() }
 | 
			
		||||
 | 
			
		||||
        preferences.extensionUpdatesCount().changes()
 | 
			
		||||
            .onEach { state.updates = it }
 | 
			
		||||
            .launchIn(presenterScope)
 | 
			
		||||
            .onEach { mutableState.update { state -> state.copy(updates = it) } }
 | 
			
		||||
            .launchIn(coroutineScope)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun search(query: String?) {
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            _query.emit(query)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun updateAllExtensions() {
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
            if (state.isEmpty) return@launchIO
 | 
			
		||||
            state.items
 | 
			
		||||
                .mapNotNull {
 | 
			
		||||
                    when {
 | 
			
		||||
                        it !is ExtensionUiModel.Item -> null
 | 
			
		||||
                        it.extension !is Extension.Installed -> null
 | 
			
		||||
                        !it.extension.hasUpdate -> null
 | 
			
		||||
                        else -> it.extension
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            with(state.value) {
 | 
			
		||||
                if (isEmpty) return@launchIO
 | 
			
		||||
                items
 | 
			
		||||
                    .mapNotNull {
 | 
			
		||||
                        when {
 | 
			
		||||
                            it !is ExtensionUiModel.Item -> null
 | 
			
		||||
                            it.extension !is Extension.Installed -> null
 | 
			
		||||
                            !it.extension.hasUpdate -> null
 | 
			
		||||
                            else -> it.extension
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                .forEach { updateExtension(it) }
 | 
			
		||||
                    .forEach { updateExtension(it) }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -195,11 +194,11 @@ class ExtensionsPresenter(
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun findAvailableExtensions() {
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
            state.isRefreshing = true
 | 
			
		||||
        mutableState.update { it.copy(isRefreshing = true) }
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            extensionManager.findAvailableExtensions()
 | 
			
		||||
            state.isRefreshing = false
 | 
			
		||||
        }
 | 
			
		||||
        mutableState.update { it.copy(isRefreshing = false) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun trustSignature(signatureHash: String) {
 | 
			
		||||
@@ -207,6 +206,15 @@ class ExtensionsPresenter(
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
data class ExtensionsState(
 | 
			
		||||
    val isLoading: Boolean = true,
 | 
			
		||||
    val isRefreshing: Boolean = false,
 | 
			
		||||
    val items: List<ExtensionUiModel> = emptyList(),
 | 
			
		||||
    val updates: Int = 0,
 | 
			
		||||
) {
 | 
			
		||||
    val isEmpty = items.isEmpty()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
sealed interface ExtensionUiModel {
 | 
			
		||||
    sealed interface Header : ExtensionUiModel {
 | 
			
		||||
        data class Resource(@StringRes val textRes: Int) : Header
 | 
			
		||||
@@ -3,11 +3,14 @@ package eu.kanade.tachiyomi.ui.browse.extension
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.outlined.Translate
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.collectAsState
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import com.bluelinelabs.conductor.Router
 | 
			
		||||
import cafe.adriel.voyager.navigator.currentOrThrow
 | 
			
		||||
import eu.kanade.presentation.browse.ExtensionScreen
 | 
			
		||||
import eu.kanade.presentation.components.AppBar
 | 
			
		||||
import eu.kanade.presentation.components.TabContent
 | 
			
		||||
import eu.kanade.presentation.util.LocalRouter
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.extension.model.Extension
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
 | 
			
		||||
@@ -15,53 +18,41 @@ import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsControlle
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun extensionsTab(
 | 
			
		||||
    router: Router?,
 | 
			
		||||
    presenter: ExtensionsPresenter,
 | 
			
		||||
) = TabContent(
 | 
			
		||||
    titleRes = R.string.label_extensions,
 | 
			
		||||
    badgeNumber = presenter.updates.takeIf { it > 0 },
 | 
			
		||||
    searchEnabled = true,
 | 
			
		||||
    actions = listOf(
 | 
			
		||||
        AppBar.Action(
 | 
			
		||||
            title = stringResource(R.string.action_filter),
 | 
			
		||||
            icon = Icons.Outlined.Translate,
 | 
			
		||||
            onClick = { router?.pushController(ExtensionFilterController()) },
 | 
			
		||||
    extensionsScreenModel: ExtensionsScreenModel,
 | 
			
		||||
): TabContent {
 | 
			
		||||
    val router = LocalRouter.currentOrThrow
 | 
			
		||||
    val state by extensionsScreenModel.state.collectAsState()
 | 
			
		||||
 | 
			
		||||
    return TabContent(
 | 
			
		||||
        titleRes = R.string.label_extensions,
 | 
			
		||||
        badgeNumber = state.updates.takeIf { it > 0 },
 | 
			
		||||
        searchEnabled = true,
 | 
			
		||||
        actions = listOf(
 | 
			
		||||
            AppBar.Action(
 | 
			
		||||
                title = stringResource(R.string.action_filter),
 | 
			
		||||
                icon = Icons.Outlined.Translate,
 | 
			
		||||
                onClick = { router.pushController(ExtensionFilterController()) },
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ),
 | 
			
		||||
    content = { contentPadding ->
 | 
			
		||||
        ExtensionScreen(
 | 
			
		||||
            presenter = presenter,
 | 
			
		||||
            contentPadding = contentPadding,
 | 
			
		||||
            onLongClickItem = { extension ->
 | 
			
		||||
                when (extension) {
 | 
			
		||||
                    is Extension.Available -> presenter.installExtension(extension)
 | 
			
		||||
                    else -> presenter.uninstallExtension(extension.pkgName)
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            onClickItemCancel = { extension ->
 | 
			
		||||
                presenter.cancelInstallUpdateExtension(extension)
 | 
			
		||||
            },
 | 
			
		||||
            onClickUpdateAll = {
 | 
			
		||||
                presenter.updateAllExtensions()
 | 
			
		||||
            },
 | 
			
		||||
            onInstallExtension = {
 | 
			
		||||
                presenter.installExtension(it)
 | 
			
		||||
            },
 | 
			
		||||
            onOpenExtension = {
 | 
			
		||||
                router?.pushController(ExtensionDetailsController(it.pkgName))
 | 
			
		||||
            },
 | 
			
		||||
            onTrustExtension = {
 | 
			
		||||
                presenter.trustSignature(it.signatureHash)
 | 
			
		||||
            },
 | 
			
		||||
            onUninstallExtension = {
 | 
			
		||||
                presenter.uninstallExtension(it.pkgName)
 | 
			
		||||
            },
 | 
			
		||||
            onUpdateExtension = {
 | 
			
		||||
                presenter.updateExtension(it)
 | 
			
		||||
            },
 | 
			
		||||
            onRefresh = {
 | 
			
		||||
                presenter.findAvailableExtensions()
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
    },
 | 
			
		||||
)
 | 
			
		||||
        content = { contentPadding, _ ->
 | 
			
		||||
            ExtensionScreen(
 | 
			
		||||
                state = state,
 | 
			
		||||
                contentPadding = contentPadding,
 | 
			
		||||
                onLongClickItem = { extension ->
 | 
			
		||||
                    when (extension) {
 | 
			
		||||
                        is Extension.Available -> extensionsScreenModel.installExtension(extension)
 | 
			
		||||
                        else -> extensionsScreenModel.uninstallExtension(extension.pkgName)
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                onClickItemCancel = extensionsScreenModel::cancelInstallUpdateExtension,
 | 
			
		||||
                onClickUpdateAll = extensionsScreenModel::updateAllExtensions,
 | 
			
		||||
                onInstallExtension = extensionsScreenModel::installExtension,
 | 
			
		||||
                onOpenExtension = { router.pushController(ExtensionDetailsController(it.pkgName)) },
 | 
			
		||||
                onTrustExtension = { extensionsScreenModel.trustSignature(it.signatureHash) },
 | 
			
		||||
                onUninstallExtension = { extensionsScreenModel.uninstallExtension(it.pkgName) },
 | 
			
		||||
                onUpdateExtension = extensionsScreenModel::updateExtension,
 | 
			
		||||
                onRefresh = extensionsScreenModel::findAvailableExtensions,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,91 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse.migration.sources
 | 
			
		||||
 | 
			
		||||
import cafe.adriel.voyager.core.model.StateScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.model.coroutineScope
 | 
			
		||||
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
 | 
			
		||||
import eu.kanade.domain.source.interactor.SetMigrateSorting
 | 
			
		||||
import eu.kanade.domain.source.model.Source
 | 
			
		||||
import eu.kanade.domain.source.service.SourcePreferences
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.launchIO
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.logcat
 | 
			
		||||
import kotlinx.coroutines.channels.Channel
 | 
			
		||||
import kotlinx.coroutines.flow.catch
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.flow.launchIn
 | 
			
		||||
import kotlinx.coroutines.flow.onEach
 | 
			
		||||
import kotlinx.coroutines.flow.receiveAsFlow
 | 
			
		||||
import kotlinx.coroutines.flow.update
 | 
			
		||||
import logcat.LogPriority
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
 | 
			
		||||
class MigrateSourceScreenModel(
 | 
			
		||||
    preferences: SourcePreferences = Injekt.get(),
 | 
			
		||||
    private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
 | 
			
		||||
    private val setMigrateSorting: SetMigrateSorting = Injekt.get(),
 | 
			
		||||
) : StateScreenModel<MigrateSourceState>(MigrateSourceState()) {
 | 
			
		||||
 | 
			
		||||
    private val _channel = Channel<Event>(Int.MAX_VALUE)
 | 
			
		||||
    val channel = _channel.receiveAsFlow()
 | 
			
		||||
 | 
			
		||||
    init {
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            getSourcesWithFavoriteCount.subscribe()
 | 
			
		||||
                .catch {
 | 
			
		||||
                    logcat(LogPriority.ERROR, it)
 | 
			
		||||
                    _channel.send(Event.FailedFetchingSourcesWithCount)
 | 
			
		||||
                }
 | 
			
		||||
                .collectLatest { sources ->
 | 
			
		||||
                    mutableState.update {
 | 
			
		||||
                        it.copy(
 | 
			
		||||
                            isLoading = false,
 | 
			
		||||
                            items = sources,
 | 
			
		||||
                        )
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        preferences.migrationSortingDirection().changes()
 | 
			
		||||
            .onEach { mutableState.update { state -> state.copy(sortingDirection = it) } }
 | 
			
		||||
            .launchIn(coroutineScope)
 | 
			
		||||
 | 
			
		||||
        preferences.migrationSortingMode().changes()
 | 
			
		||||
            .onEach { mutableState.update { state -> state.copy(sortingMode = it) } }
 | 
			
		||||
            .launchIn(coroutineScope)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun toggleSortingMode() {
 | 
			
		||||
        with(state.value) {
 | 
			
		||||
            val newMode = when (sortingMode) {
 | 
			
		||||
                SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL
 | 
			
		||||
                SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            setMigrateSorting.await(newMode, sortingDirection)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun toggleSortingDirection() {
 | 
			
		||||
        with(state.value) {
 | 
			
		||||
            val newDirection = when (sortingDirection) {
 | 
			
		||||
                SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING
 | 
			
		||||
                SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            setMigrateSorting.await(sortingMode, newDirection)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sealed class Event {
 | 
			
		||||
        object FailedFetchingSourcesWithCount : Event()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
data class MigrateSourceState(
 | 
			
		||||
    val isLoading: Boolean = true,
 | 
			
		||||
    val items: List<Pair<Source, Long>> = emptyList(),
 | 
			
		||||
    val sortingMode: SetMigrateSorting.Mode = SetMigrateSorting.Mode.ALPHABETICAL,
 | 
			
		||||
    val sortingDirection: SetMigrateSorting.Direction = SetMigrateSorting.Direction.ASCENDING,
 | 
			
		||||
) {
 | 
			
		||||
    val isEmpty = items.isEmpty()
 | 
			
		||||
}
 | 
			
		||||
@@ -3,22 +3,27 @@ package eu.kanade.tachiyomi.ui.browse.migration.sources
 | 
			
		||||
import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.outlined.HelpOutline
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.collectAsState
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.ui.platform.LocalUriHandler
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import com.bluelinelabs.conductor.Router
 | 
			
		||||
import cafe.adriel.voyager.core.model.rememberScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.screen.Screen
 | 
			
		||||
import cafe.adriel.voyager.navigator.currentOrThrow
 | 
			
		||||
import eu.kanade.presentation.browse.MigrateSourceScreen
 | 
			
		||||
import eu.kanade.presentation.components.AppBar
 | 
			
		||||
import eu.kanade.presentation.components.TabContent
 | 
			
		||||
import eu.kanade.presentation.util.LocalRouter
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun migrateSourcesTab(
 | 
			
		||||
    router: Router?,
 | 
			
		||||
    presenter: MigrationSourcesPresenter,
 | 
			
		||||
): TabContent {
 | 
			
		||||
fun Screen.migrateSourceTab(): TabContent {
 | 
			
		||||
    val uriHandler = LocalUriHandler.current
 | 
			
		||||
    val router = LocalRouter.currentOrThrow
 | 
			
		||||
    val screenModel = rememberScreenModel { MigrateSourceScreenModel() }
 | 
			
		||||
    val state by screenModel.state.collectAsState()
 | 
			
		||||
 | 
			
		||||
    return TabContent(
 | 
			
		||||
        titleRes = R.string.label_migration,
 | 
			
		||||
@@ -31,18 +36,20 @@ fun migrateSourcesTab(
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        content = { contentPadding ->
 | 
			
		||||
        content = { contentPadding, _ ->
 | 
			
		||||
            MigrateSourceScreen(
 | 
			
		||||
                presenter = presenter,
 | 
			
		||||
                state = state,
 | 
			
		||||
                contentPadding = contentPadding,
 | 
			
		||||
                onClickItem = { source ->
 | 
			
		||||
                    router?.pushController(
 | 
			
		||||
                    router.pushController(
 | 
			
		||||
                        MigrationMangaController(
 | 
			
		||||
                            source.id,
 | 
			
		||||
                            source.name,
 | 
			
		||||
                        ),
 | 
			
		||||
                    )
 | 
			
		||||
                },
 | 
			
		||||
                onToggleSortingDirection = screenModel::toggleSortingDirection,
 | 
			
		||||
                onToggleSortingMode = screenModel::toggleSortingMode,
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
@@ -1,75 +0,0 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse.migration.sources
 | 
			
		||||
 | 
			
		||||
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
 | 
			
		||||
import eu.kanade.domain.source.interactor.SetMigrateSorting
 | 
			
		||||
import eu.kanade.domain.source.service.SourcePreferences
 | 
			
		||||
import eu.kanade.presentation.browse.MigrateSourceState
 | 
			
		||||
import eu.kanade.presentation.browse.MigrateSourceStateImpl
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.launchIO
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.logcat
 | 
			
		||||
import kotlinx.coroutines.CoroutineScope
 | 
			
		||||
import kotlinx.coroutines.channels.Channel
 | 
			
		||||
import kotlinx.coroutines.flow.catch
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.flow.launchIn
 | 
			
		||||
import kotlinx.coroutines.flow.onEach
 | 
			
		||||
import kotlinx.coroutines.flow.receiveAsFlow
 | 
			
		||||
import logcat.LogPriority
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
 | 
			
		||||
class MigrationSourcesPresenter(
 | 
			
		||||
    private val presenterScope: CoroutineScope,
 | 
			
		||||
    private val state: MigrateSourceStateImpl = MigrateSourceState() as MigrateSourceStateImpl,
 | 
			
		||||
    private val preferences: SourcePreferences = Injekt.get(),
 | 
			
		||||
    private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
 | 
			
		||||
    private val setMigrateSorting: SetMigrateSorting = Injekt.get(),
 | 
			
		||||
) : MigrateSourceState by state {
 | 
			
		||||
 | 
			
		||||
    private val _channel = Channel<Event>(Int.MAX_VALUE)
 | 
			
		||||
    val channel = _channel.receiveAsFlow()
 | 
			
		||||
 | 
			
		||||
    fun onCreate() {
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
            getSourcesWithFavoriteCount.subscribe()
 | 
			
		||||
                .catch {
 | 
			
		||||
                    logcat(LogPriority.ERROR, it)
 | 
			
		||||
                    _channel.send(Event.FailedFetchingSourcesWithCount)
 | 
			
		||||
                }
 | 
			
		||||
                .collectLatest { sources ->
 | 
			
		||||
                    state.items = sources
 | 
			
		||||
                    state.isLoading = false
 | 
			
		||||
                }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        preferences.migrationSortingDirection().changes()
 | 
			
		||||
            .onEach { state.sortingDirection = it }
 | 
			
		||||
            .launchIn(presenterScope)
 | 
			
		||||
 | 
			
		||||
        preferences.migrationSortingMode().changes()
 | 
			
		||||
            .onEach { state.sortingMode = it }
 | 
			
		||||
            .launchIn(presenterScope)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun toggleSortingMode() {
 | 
			
		||||
        val newMode = when (state.sortingMode) {
 | 
			
		||||
            SetMigrateSorting.Mode.ALPHABETICAL -> SetMigrateSorting.Mode.TOTAL
 | 
			
		||||
            SetMigrateSorting.Mode.TOTAL -> SetMigrateSorting.Mode.ALPHABETICAL
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setMigrateSorting.await(newMode, state.sortingDirection)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun toggleSortingDirection() {
 | 
			
		||||
        val newDirection = when (state.sortingDirection) {
 | 
			
		||||
            SetMigrateSorting.Direction.ASCENDING -> SetMigrateSorting.Direction.DESCENDING
 | 
			
		||||
            SetMigrateSorting.Direction.DESCENDING -> SetMigrateSorting.Direction.ASCENDING
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        setMigrateSorting.await(state.sortingMode, newDirection)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sealed class Event {
 | 
			
		||||
        object FailedFetchingSourcesWithCount : Event()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,8 @@
 | 
			
		||||
package eu.kanade.tachiyomi.ui.browse.source
 | 
			
		||||
 | 
			
		||||
import androidx.compose.runtime.Immutable
 | 
			
		||||
import cafe.adriel.voyager.core.model.StateScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.model.coroutineScope
 | 
			
		||||
import eu.kanade.domain.base.BasePreferences
 | 
			
		||||
import eu.kanade.domain.source.interactor.GetEnabledSources
 | 
			
		||||
import eu.kanade.domain.source.interactor.ToggleSource
 | 
			
		||||
@@ -8,78 +11,74 @@ import eu.kanade.domain.source.model.Pin
 | 
			
		||||
import eu.kanade.domain.source.model.Source
 | 
			
		||||
import eu.kanade.domain.source.service.SourcePreferences
 | 
			
		||||
import eu.kanade.presentation.browse.SourceUiModel
 | 
			
		||||
import eu.kanade.presentation.browse.SourcesState
 | 
			
		||||
import eu.kanade.presentation.browse.SourcesStateImpl
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.launchIO
 | 
			
		||||
import eu.kanade.tachiyomi.util.system.logcat
 | 
			
		||||
import kotlinx.coroutines.CoroutineScope
 | 
			
		||||
import kotlinx.coroutines.channels.Channel
 | 
			
		||||
import kotlinx.coroutines.delay
 | 
			
		||||
import kotlinx.coroutines.flow.catch
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.flow.onStart
 | 
			
		||||
import kotlinx.coroutines.flow.receiveAsFlow
 | 
			
		||||
import kotlinx.coroutines.flow.update
 | 
			
		||||
import logcat.LogPriority
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
import java.util.TreeMap
 | 
			
		||||
 | 
			
		||||
class SourcesPresenter(
 | 
			
		||||
    private val presenterScope: CoroutineScope,
 | 
			
		||||
    private val state: SourcesStateImpl = SourcesState() as SourcesStateImpl,
 | 
			
		||||
class SourcesScreenModel(
 | 
			
		||||
    private val preferences: BasePreferences = Injekt.get(),
 | 
			
		||||
    private val sourcePreferences: SourcePreferences = Injekt.get(),
 | 
			
		||||
    private val getEnabledSources: GetEnabledSources = Injekt.get(),
 | 
			
		||||
    private val toggleSource: ToggleSource = Injekt.get(),
 | 
			
		||||
    private val toggleSourcePin: ToggleSourcePin = Injekt.get(),
 | 
			
		||||
) : SourcesState by state {
 | 
			
		||||
) : StateScreenModel<SourcesState>(SourcesState()) {
 | 
			
		||||
 | 
			
		||||
    private val _events = Channel<Event>(Int.MAX_VALUE)
 | 
			
		||||
    val events = _events.receiveAsFlow()
 | 
			
		||||
 | 
			
		||||
    fun onCreate() {
 | 
			
		||||
        presenterScope.launchIO {
 | 
			
		||||
    init {
 | 
			
		||||
        coroutineScope.launchIO {
 | 
			
		||||
            getEnabledSources.subscribe()
 | 
			
		||||
                .catch {
 | 
			
		||||
                    logcat(LogPriority.ERROR, it)
 | 
			
		||||
                    _events.send(Event.FailedFetchingSources)
 | 
			
		||||
                }
 | 
			
		||||
                .onStart { delay(500) } // Defer to avoid crashing on initial render
 | 
			
		||||
                .collectLatest(::collectLatestSources)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun collectLatestSources(sources: List<Source>) {
 | 
			
		||||
        val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
 | 
			
		||||
            // Sources without a lang defined will be placed at the end
 | 
			
		||||
            when {
 | 
			
		||||
                d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
 | 
			
		||||
                d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
 | 
			
		||||
                d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
 | 
			
		||||
                d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
 | 
			
		||||
                d1 == "" && d2 != "" -> 1
 | 
			
		||||
                d2 == "" && d1 != "" -> -1
 | 
			
		||||
                else -> d1.compareTo(d2)
 | 
			
		||||
        mutableState.update { state ->
 | 
			
		||||
            val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
 | 
			
		||||
                // Sources without a lang defined will be placed at the end
 | 
			
		||||
                when {
 | 
			
		||||
                    d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
 | 
			
		||||
                    d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
 | 
			
		||||
                    d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
 | 
			
		||||
                    d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
 | 
			
		||||
                    d1 == "" && d2 != "" -> 1
 | 
			
		||||
                    d2 == "" && d1 != "" -> -1
 | 
			
		||||
                    else -> d1.compareTo(d2)
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        val byLang = sources.groupByTo(map) {
 | 
			
		||||
            when {
 | 
			
		||||
                it.isUsedLast -> LAST_USED_KEY
 | 
			
		||||
                Pin.Actual in it.pin -> PINNED_KEY
 | 
			
		||||
                else -> it.lang
 | 
			
		||||
            val byLang = sources.groupByTo(map) {
 | 
			
		||||
                when {
 | 
			
		||||
                    it.isUsedLast -> LAST_USED_KEY
 | 
			
		||||
                    Pin.Actual in it.pin -> PINNED_KEY
 | 
			
		||||
                    else -> it.lang
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val uiModels = byLang.flatMap {
 | 
			
		||||
            listOf(
 | 
			
		||||
                SourceUiModel.Header(it.key),
 | 
			
		||||
                *it.value.map { source ->
 | 
			
		||||
                    SourceUiModel.Item(source)
 | 
			
		||||
                }.toTypedArray(),
 | 
			
		||||
            state.copy(
 | 
			
		||||
                isLoading = false,
 | 
			
		||||
                items = byLang.flatMap {
 | 
			
		||||
                    listOf(
 | 
			
		||||
                        SourceUiModel.Header(it.key),
 | 
			
		||||
                        *it.value.map { source ->
 | 
			
		||||
                            SourceUiModel.Item(source)
 | 
			
		||||
                        }.toTypedArray(),
 | 
			
		||||
                    )
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        state.isLoading = false
 | 
			
		||||
        state.items = uiModels
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun onOpenSource(source: Source) {
 | 
			
		||||
@@ -96,6 +95,14 @@ class SourcesPresenter(
 | 
			
		||||
        toggleSourcePin.await(source)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun showSourceDialog(source: Source) {
 | 
			
		||||
        mutableState.update { it.copy(dialog = Dialog(source)) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun closeDialog() {
 | 
			
		||||
        mutableState.update { it.copy(dialog = null) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sealed class Event {
 | 
			
		||||
        object FailedFetchingSources : Event()
 | 
			
		||||
    }
 | 
			
		||||
@@ -107,3 +114,12 @@ class SourcesPresenter(
 | 
			
		||||
        const val LAST_USED_KEY = "last_used"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Immutable
 | 
			
		||||
data class SourcesState(
 | 
			
		||||
    val dialog: SourcesScreenModel.Dialog? = null,
 | 
			
		||||
    val isLoading: Boolean = true,
 | 
			
		||||
    val items: List<SourceUiModel> = emptyList(),
 | 
			
		||||
) {
 | 
			
		||||
    val isEmpty = items.isEmpty()
 | 
			
		||||
}
 | 
			
		||||
@@ -4,48 +4,83 @@ import androidx.compose.material.icons.Icons
 | 
			
		||||
import androidx.compose.material.icons.outlined.FilterList
 | 
			
		||||
import androidx.compose.material.icons.outlined.TravelExplore
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.compose.runtime.collectAsState
 | 
			
		||||
import androidx.compose.runtime.getValue
 | 
			
		||||
import androidx.compose.ui.res.stringResource
 | 
			
		||||
import com.bluelinelabs.conductor.Router
 | 
			
		||||
import cafe.adriel.voyager.core.model.rememberScreenModel
 | 
			
		||||
import cafe.adriel.voyager.core.screen.Screen
 | 
			
		||||
import cafe.adriel.voyager.navigator.currentOrThrow
 | 
			
		||||
import eu.kanade.presentation.browse.SourceOptionsDialog
 | 
			
		||||
import eu.kanade.presentation.browse.SourcesScreen
 | 
			
		||||
import eu.kanade.presentation.components.AppBar
 | 
			
		||||
import eu.kanade.presentation.components.TabContent
 | 
			
		||||
import eu.kanade.presentation.util.LocalRouter
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.base.controller.pushController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 | 
			
		||||
import kotlinx.coroutines.flow.collectLatest
 | 
			
		||||
import kotlinx.coroutines.launch
 | 
			
		||||
 | 
			
		||||
@Composable
 | 
			
		||||
fun sourcesTab(
 | 
			
		||||
    router: Router?,
 | 
			
		||||
    presenter: SourcesPresenter,
 | 
			
		||||
) = TabContent(
 | 
			
		||||
    titleRes = R.string.label_sources,
 | 
			
		||||
    actions = listOf(
 | 
			
		||||
        AppBar.Action(
 | 
			
		||||
            title = stringResource(R.string.action_global_search),
 | 
			
		||||
            icon = Icons.Outlined.TravelExplore,
 | 
			
		||||
            onClick = { router?.pushController(GlobalSearchController()) },
 | 
			
		||||
fun Screen.sourcesTab(): TabContent {
 | 
			
		||||
    val router = LocalRouter.currentOrThrow
 | 
			
		||||
    val screenModel = rememberScreenModel { SourcesScreenModel() }
 | 
			
		||||
    val state by screenModel.state.collectAsState()
 | 
			
		||||
 | 
			
		||||
    return TabContent(
 | 
			
		||||
        titleRes = R.string.label_sources,
 | 
			
		||||
        actions = listOf(
 | 
			
		||||
            AppBar.Action(
 | 
			
		||||
                title = stringResource(R.string.action_global_search),
 | 
			
		||||
                icon = Icons.Outlined.TravelExplore,
 | 
			
		||||
                onClick = { router.pushController(GlobalSearchController()) },
 | 
			
		||||
            ),
 | 
			
		||||
            AppBar.Action(
 | 
			
		||||
                title = stringResource(R.string.action_filter),
 | 
			
		||||
                icon = Icons.Outlined.FilterList,
 | 
			
		||||
                onClick = { router.pushController(SourceFilterController()) },
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        AppBar.Action(
 | 
			
		||||
            title = stringResource(R.string.action_filter),
 | 
			
		||||
            icon = Icons.Outlined.FilterList,
 | 
			
		||||
            onClick = { router?.pushController(SourceFilterController()) },
 | 
			
		||||
        ),
 | 
			
		||||
    ),
 | 
			
		||||
    content = { contentPadding ->
 | 
			
		||||
        SourcesScreen(
 | 
			
		||||
            presenter = presenter,
 | 
			
		||||
            contentPadding = contentPadding,
 | 
			
		||||
            onClickItem = { source, query ->
 | 
			
		||||
                presenter.onOpenSource(source)
 | 
			
		||||
                router?.pushController(BrowseSourceController(source, query))
 | 
			
		||||
            },
 | 
			
		||||
            onClickDisable = { source ->
 | 
			
		||||
                presenter.toggleSource(source)
 | 
			
		||||
            },
 | 
			
		||||
            onClickPin = { source ->
 | 
			
		||||
                presenter.togglePin(source)
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
    },
 | 
			
		||||
)
 | 
			
		||||
        content = { contentPadding, snackbarHostState ->
 | 
			
		||||
            SourcesScreen(
 | 
			
		||||
                state = state,
 | 
			
		||||
                contentPadding = contentPadding,
 | 
			
		||||
                onClickItem = { source, query ->
 | 
			
		||||
                    screenModel.onOpenSource(source)
 | 
			
		||||
                    router.pushController(BrowseSourceController(source, query))
 | 
			
		||||
                },
 | 
			
		||||
                onClickPin = screenModel::togglePin,
 | 
			
		||||
                onLongClickItem = screenModel::showSourceDialog,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            state.dialog?.let { dialog ->
 | 
			
		||||
                val source = dialog.source
 | 
			
		||||
                SourceOptionsDialog(
 | 
			
		||||
                    source = source,
 | 
			
		||||
                    onClickPin = {
 | 
			
		||||
                        screenModel.togglePin(source)
 | 
			
		||||
                        screenModel.closeDialog()
 | 
			
		||||
                    },
 | 
			
		||||
                    onClickDisable = {
 | 
			
		||||
                        screenModel.toggleSource(source)
 | 
			
		||||
                        screenModel.closeDialog()
 | 
			
		||||
                    },
 | 
			
		||||
                    onDismiss = screenModel::closeDialog,
 | 
			
		||||
                )
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            val internalErrString = stringResource(R.string.internal_error)
 | 
			
		||||
            LaunchedEffect(Unit) {
 | 
			
		||||
                screenModel.events.collectLatest { event ->
 | 
			
		||||
                    when (event) {
 | 
			
		||||
                        SourcesScreenModel.Event.FailedFetchingSources -> {
 | 
			
		||||
                            launch { snackbarHostState.showSnackbar(internalErrString) }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,15 @@
 | 
			
		||||
package eu.kanade.tachiyomi.util.storage
 | 
			
		||||
 | 
			
		||||
import android.Manifest
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.media.MediaScannerConnection
 | 
			
		||||
import android.net.Uri
 | 
			
		||||
import android.os.Environment
 | 
			
		||||
import android.os.StatFs
 | 
			
		||||
import androidx.compose.runtime.Composable
 | 
			
		||||
import androidx.compose.runtime.LaunchedEffect
 | 
			
		||||
import androidx.core.content.ContextCompat
 | 
			
		||||
import com.google.accompanist.permissions.rememberPermissionState
 | 
			
		||||
import com.hippo.unifile.UniFile
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.Hash
 | 
			
		||||
import java.io.File
 | 
			
		||||
@@ -113,5 +117,16 @@ object DiskUtil {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission
 | 
			
		||||
     */
 | 
			
		||||
    @Composable
 | 
			
		||||
    fun RequestStoragePermission() {
 | 
			
		||||
        val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
 | 
			
		||||
        LaunchedEffect(Unit) {
 | 
			
		||||
            permissionState.launchPermissionRequest()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const val NOMEDIA_FILE = ".nomedia"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.util.system
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import androidx.core.os.LocaleListCompat
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesPresenter
 | 
			
		||||
import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel
 | 
			
		||||
import java.util.Locale
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -16,8 +16,8 @@ object LocaleHelper {
 | 
			
		||||
     */
 | 
			
		||||
    fun getSourceDisplayName(lang: String?, context: Context): String {
 | 
			
		||||
        return when (lang) {
 | 
			
		||||
            SourcesPresenter.LAST_USED_KEY -> context.getString(R.string.last_used_source)
 | 
			
		||||
            SourcesPresenter.PINNED_KEY -> context.getString(R.string.pinned_sources)
 | 
			
		||||
            SourcesScreenModel.LAST_USED_KEY -> context.getString(R.string.last_used_source)
 | 
			
		||||
            SourcesScreenModel.PINNED_KEY -> context.getString(R.string.pinned_sources)
 | 
			
		||||
            "other" -> context.getString(R.string.other_source)
 | 
			
		||||
            "all" -> context.getString(R.string.multi_lang)
 | 
			
		||||
            else -> getDisplayName(lang)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user