Files
mihon-bookoasis/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt
T

443 lines
18 KiB
Kotlin

package mihon.feature.migration.config
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Deselect
import androidx.compose.material.icons.outlined.DragHandle
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.update
import mihon.feature.migration.list.MigrationListScreen
import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.ReorderableLazyListState
import sh.calvin.reorderable.rememberReorderableLazyListState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.source.model.Source
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.Pill
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.shouldExpandFAB
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
constructor(mangaId: Long) : this(listOf(mangaId))
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ScreenModel() }
val state by screenModel.state.collectAsState()
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) {
val mangaId = mangaIds.singleOrNull()
if (mangaId == null && openSheet) {
migrationSheetOpen = true
return
}
val screen = if (mangaId == null) {
MigrationListScreen(mangaIds, extraSearchQuery)
} else {
MigrateSearchScreen(mangaId)
}
navigator.replace(screen)
}
if (state.isLoading) {
LoadingScreen()
return
}
val (selectedSources, availableSources) = state.sources.partition { it.isSelected }
val showLanguage by remember(state) {
derivedStateOf {
state.sources.distinctBy { it.source.lang }.size > 1
}
}
val lazyListState = rememberLazyListState()
Scaffold(
topBar = {
AppBar(
title = null,
navigateUp = navigator::pop,
scrollBehavior = it,
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.migrationConfigScreen_selectAllLabel),
icon = Icons.Outlined.SelectAll,
onClick = { screenModel.toggleSelection(ScreenModel.SelectionConfig.All) },
),
AppBar.Action(
title = stringResource(MR.strings.migrationConfigScreen_selectNoneLabel),
icon = Icons.Outlined.Deselect,
onClick = { screenModel.toggleSelection(ScreenModel.SelectionConfig.None) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.migrationConfigScreen_selectEnabledLabel),
onClick = { screenModel.toggleSelection(ScreenModel.SelectionConfig.Enabled) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.migrationConfigScreen_selectPinnedLabel),
onClick = { screenModel.toggleSelection(ScreenModel.SelectionConfig.Pinned) },
),
),
)
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) },
onClick = {
screenModel.saveSources()
continueMigration(openSheet = true, extraSearchQuery = null)
},
expanded = lazyListState.shouldExpandFAB(),
)
},
) { contentPadding ->
val reorderableState = rememberReorderableLazyListState(lazyListState, contentPadding) { from, to ->
val fromIndex = selectedSources.indexOfFirst { it.id == from.key }
val toIndex = selectedSources.indexOfFirst { it.id == to.key }
if (fromIndex == -1 || toIndex == -1) return@rememberReorderableLazyListState
screenModel.orderSource(fromIndex, toIndex)
}
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = contentPadding,
) {
listOf(selectedSources, availableSources).fastForEachIndexed { listIndex, sources ->
val selectedSourceList = listIndex == 0
if (sources.isNotEmpty()) {
val headerPrefix = if (selectedSourceList) "selected" else "available"
item("$headerPrefix-header") {
Text(
text = stringResource(
resource = if (selectedSourceList) {
MR.strings.migrationConfigScreen_selectedHeader
} else {
MR.strings.migrationConfigScreen_availableHeader
},
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(MaterialTheme.padding.medium)
.animateItem(),
)
}
}
itemsIndexed(
items = sources,
key = { _, item -> item.id },
) { index, item ->
SourceItemContainer(
firstItem = index == 0,
lastItem = index == (sources.size - 1),
source = item,
showLanguage = showLanguage,
dragEnabled = selectedSourceList && sources.size > 1,
state = reorderableState,
key = { if (selectedSourceList) it.id else "available-${it.id}" },
onClick = { screenModel.toggleSelection(item.id) },
)
}
}
}
}
if (migrationSheetOpen) {
MigrationConfigScreenSheet(
preferences = screenModel.sourcePreferences,
onDismissRequest = { migrationSheetOpen = false },
onStartMigration = { extraSearchQuery ->
migrationSheetOpen = false
continueMigration(openSheet = false, extraSearchQuery = extraSearchQuery)
},
)
}
}
@Composable
private fun LazyItemScope.SourceItemContainer(
firstItem: Boolean,
lastItem: Boolean,
source: MigrationSource,
showLanguage: Boolean,
dragEnabled: Boolean,
state: ReorderableLazyListState,
key: (MigrationSource) -> Any,
onClick: () -> Unit,
) {
val shape = remember(firstItem, lastItem) {
val top = if (firstItem) 12.dp else 0.dp
val bottom = if (lastItem) 12.dp else 0.dp
RoundedCornerShape(top, top, bottom, bottom)
}
ReorderableItem(
state = state,
key = key(source),
enabled = dragEnabled,
) { _ ->
ElevatedCard(
shape = shape,
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium)
.animateItem(),
) {
SourceItem(
source = source,
showLanguage = showLanguage,
dragEnabled = dragEnabled,
scope = this@ReorderableItem,
onClick = onClick,
)
}
}
if (!lastItem) {
HorizontalDivider(modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium))
}
}
@Composable
private fun SourceItem(
source: MigrationSource,
showLanguage: Boolean,
dragEnabled: Boolean,
scope: ReorderableCollectionItemScope,
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = source.source)
Text(
text = source.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (showLanguage) {
Pill(
text = LocaleHelper.getShortDisplayName(source.shortLanguage, uppercase = true),
style = MaterialTheme.typography.bodySmall,
)
}
}
},
trailingContent = if (dragEnabled) {
{
Icon(
imageVector = Icons.Outlined.DragHandle,
contentDescription = null,
modifier = with(scope) {
Modifier.draggableHandle()
},
)
}
} else {
null
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
modifier = Modifier.clickable(onClick = onClick),
)
}
private class ScreenModel(
val sourcePreferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<ScreenModel.State>(State()) {
private val sourcesComparator = { includedSources: List<Long> ->
compareBy<MigrationSource>(
{ !it.isSelected },
{ includedSources.indexOf(it.id) },
{ with(it) { "$name ($shortLanguage)" } },
)
}
init {
screenModelScope.launchIO {
initSources()
mutableState.update { it.copy(isLoading = false) }
}
}
private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) {
mutableState.update { state ->
val updatedSources = action(state.sources)
val includedSources = updatedSources.mapNotNull { if (!it.isSelected) null else it.id }
state.copy(sources = updatedSources.sortedWith(sourcesComparator(includedSources)))
}
if (save) saveSources()
}
private fun initSources() {
val languages = sourcePreferences.enabledLanguages().get()
val pinnedSources = sourcePreferences.pinnedSources().get().mapNotNull { it.toLongOrNull() }
val includedSources = sourcePreferences.migrationSources().get()
val disabledSources = sourcePreferences.disabledSources().get()
.mapNotNull { it.toLongOrNull() }
val sources = sourceManager.getCatalogueSources()
.asSequence()
.filterIsInstance<HttpSource>()
.filter { it.lang in languages }
.map {
val source = Source(
id = it.id,
lang = it.lang,
name = it.name,
supportsLatest = false,
isStub = false,
)
MigrationSource(
source = source,
isSelected = when {
includedSources.isNotEmpty() -> source.id in includedSources
pinnedSources.isNotEmpty() -> source.id in pinnedSources
else -> source.id !in disabledSources
},
)
}
.toList()
updateSources(save = false) { sources }
}
fun toggleSelection(id: Long) {
updateSources { sources ->
sources.map { source ->
source.copy(isSelected = if (source.source.id == id) !source.isSelected else source.isSelected)
}
}
}
fun toggleSelection(config: SelectionConfig) {
val pinnedSources = sourcePreferences.pinnedSources().get().mapNotNull { it.toLongOrNull() }
val disabledSources = sourcePreferences.disabledSources().get().mapNotNull { it.toLongOrNull() }
val isSelected: (Long) -> Boolean = {
when (config) {
SelectionConfig.All -> true
SelectionConfig.None -> false
SelectionConfig.Pinned -> it in pinnedSources
SelectionConfig.Enabled -> it !in disabledSources
}
}
updateSources { sources ->
sources.map { source ->
source.copy(isSelected = isSelected(source.source.id))
}
}
}
fun orderSource(from: Int, to: Int) {
updateSources {
it.toMutableList()
.apply {
add(to, removeAt(from))
}
.toList()
}
}
fun saveSources() {
state.value.sources
.filter { source -> source.isSelected }
.map { source -> source.source.id }
.let { sources -> sourcePreferences.migrationSources().set(sources) }
}
data class State(
val isLoading: Boolean = true,
val sources: List<MigrationSource> = emptyList(),
)
enum class SelectionConfig {
All,
None,
Pinned,
Enabled,
}
}
data class MigrationSource(
val source: Source,
val isSelected: Boolean,
) {
val id: Long
inline get() = source.id
val name: String
inline get() = source.name
val shortLanguage: String = LocaleHelper.getShortDisplayName(source.lang)
}
}