Add migration config screen to select and prioritize target sources (#2144)

This commit is contained in:
AntsyLich
2025-05-28 21:04:44 +06:00
committed by GitHub
parent 0f59fc1dd4
commit 2e180005a0
11 changed files with 475 additions and 46 deletions

View File

@@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getLongArray
import tachiyomi.domain.library.model.LibraryDisplayMode
class SourcePreferences(
@@ -20,6 +21,8 @@ class SourcePreferences(
fun enabledLanguages() = preferenceStore.getStringSet("source_languages", LocaleHelper.getDefaultEnabledLanguages())
fun migrationSources() = preferenceStore.getLongArray("migration_sources", emptyList())
fun disabledSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet())
fun incognitoExtensions() = preferenceStore.getStringSet("incognito_extensions", emptySet())

View File

@@ -40,6 +40,7 @@ fun GlobalSearchScreen(
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
hideSourceFilter = false,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,

View File

@@ -32,6 +32,7 @@ fun MigrateSearchScreen(
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
hideSourceFilter = true,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,

View File

@@ -40,6 +40,7 @@ fun GlobalSearchToolbar(
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
onSearch: (String) -> Unit,
hideSourceFilter: Boolean,
sourceFilter: SourceFilter,
onChangeSearchFilter: (SourceFilter) -> Unit,
onlyShowHasResults: Boolean,
@@ -73,6 +74,7 @@ fun GlobalSearchToolbar(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
// TODO: make this UX better; it only applies when triggering a new search
if (!hideSourceFilter) {
FilterChip(
selected = sourceFilter == SourceFilter.PinnedOnly,
onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) },
@@ -105,6 +107,7 @@ fun GlobalSearchToolbar(
)
VerticalDivider()
}
FilterChip(
selected = onlyShowHasResults,

View File

@@ -10,10 +10,10 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.MigrateMangaScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import mihon.feature.migration.MigrateMangaConfigScreen
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.screens.LoadingScreen
@@ -38,7 +38,7 @@ data class MigrateMangaScreen(
navigateUp = navigator::pop,
title = state.source!!.name,
state = state,
onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
onClickItem = { navigator.push(MigrateMangaConfigScreen(it.id)) },
onClickCover = { navigator.push(MangaScreen(it.id)) },
)

View File

@@ -1,20 +1,33 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrateSearchScreenModel(
val mangaId: Long,
getManga: GetManga = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val sourcePreferences: SourcePreferences = Injekt.get(),
) : SearchScreenModel() {
private val migrationSources by lazy { sourcePreferences.migrationSources().get() }
override val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
compareBy<CatalogueSource>(
{ (map[it] as? SearchItemResult.Success)?.isEmpty ?: true },
{ migrationSources.indexOf(it.id) },
)
}
init {
screenModelScope.launch {
val manga = getManga.await(mangaId)!!
@@ -29,14 +42,6 @@ class MigrateSearchScreenModel(
}
override fun getEnabledSources(): List<CatalogueSource> {
return super.getEnabledSources()
.filter { state.value.sourceFilter != SourceFilter.PinnedOnly || "${it.id}" in pinnedSources }
.sortedWith(
compareBy(
{ it.id != state.value.fromSourceId },
{ "${it.id}" !in pinnedSources },
{ "${it.name.lowercase()} (${it.lang})" },
),
)
return migrationSources.mapNotNull { sourceManager.get(it) as? CatalogueSource }
}
}

View File

@@ -55,7 +55,7 @@ abstract class SearchScreenModel(
protected var extensionFilter: String? = null
private val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
open val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
compareBy<CatalogueSource>(
{ (map[it] as? SearchItemResult.Success)?.isEmpty ?: true },
{ "${it.id}" !in pinnedSources },

View File

@@ -45,7 +45,6 @@ import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
@@ -60,6 +59,7 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.feature.migration.MigrateMangaConfigScreen
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
@@ -163,7 +163,7 @@ class MangaScreen(
successState.manga.favorite
},
onMigrateClicked = {
navigator.push(MigrateSearchScreen(successState.manga.id))
navigator.push(MigrateMangaConfigScreen(successState.manga.id))
}.takeIf { successState.manga.favorite },
onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
onMultiBookmarkClicked = screenModel::bookmarkChapters,

View File

@@ -0,0 +1,396 @@
package mihon.feature.migration
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.updateAndGet
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.DISABLED_ALPHA
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.util.shouldExpandFAB
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrateMangaConfigScreen(private val mangaId: Long) : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ScreenModel() }
val state by screenModel.state.collectAsState()
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 = { navigator.replace(MigrateSearchScreen(mangaId)) },
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.source,
showLanguage = showLanguage,
isSelected = item.isSelected,
dragEnabled = selectedSourceList && sources.size > 1,
state = reorderableState,
key = { if (selectedSourceList) it.id else "available-${it.id}" },
onClick = { screenModel.toggleSelection(item.id) },
)
}
}
}
}
}
@Composable
private fun LazyItemScope.SourceItemContainer(
firstItem: Boolean,
lastItem: Boolean,
source: Source,
showLanguage: Boolean,
isSelected: Boolean,
dragEnabled: Boolean,
state: ReorderableLazyListState,
key: (Source) -> 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,
isSelected = isSelected,
dragEnabled = dragEnabled,
scope = this@ReorderableItem,
onClick = onClick,
)
}
}
if (!lastItem) {
HorizontalDivider(modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium))
}
}
@Composable
private fun SourceItem(
source: Source,
showLanguage: Boolean,
isSelected: Boolean,
dragEnabled: Boolean,
scope: ReorderableCollectionItemScope,
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = source)
Text(
text = source.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (showLanguage) {
Pill(
text = LocaleHelper.getLocalizedDisplayName(source.lang),
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)
.alpha(if (isSelected) 1f else DISABLED_ALPHA),
)
}
private class ScreenModel(
private val sourceManager: SourceManager = Injekt.get(),
private val sourcePreferences: SourcePreferences = Injekt.get(),
) : StateScreenModel<ScreenModel.State>(State()) {
init {
screenModelScope.launchIO {
initSources()
}
}
private val sourcesComparator = { includedSources: List<Long> ->
compareBy<MigrationSource>(
{ !it.isSelected },
{ includedSources.indexOf(it.source.id) },
{ with(it.source) { "$name (${LocaleHelper.getLocalizedDisplayName(lang)})" } },
)
}
private fun updateSources(save: Boolean = true, action: (List<MigrationSource>) -> List<MigrationSource>) {
val state = mutableState.updateAndGet { 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) return
state.sources
.filter { source -> source.isSelected }
.map { source -> source.source.id }
.let { sources -> sourcePreferences.migrationSources().set(sources) }
}
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()
}
}
data class State(
val sources: List<MigrationSource> = emptyList(),
)
enum class SelectionConfig {
All,
None,
Pinned,
Enabled,
}
}
data class MigrationSource(
val source: Source,
val isSelected: Boolean,
) {
val id = source.id
val visualName = source.visualName
}
}

View File

@@ -24,6 +24,18 @@ interface PreferenceStore {
fun getAll(): Map<String, *>
}
fun PreferenceStore.getLongArray(
key: String,
defaultValue: List<Long>,
): Preference<List<Long>> {
return getObject(
key = key,
defaultValue = defaultValue,
serializer = { it.joinToString(",") },
deserializer = { it.split(",").mapNotNull { l -> l.toLongOrNull() } },
)
}
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
key: String,
defaultValue: T,

View File

@@ -995,4 +995,12 @@
<!-- Notes screen -->
<string name="notes_placeholder">Enjoyed the part where…</string>
<string name="migrationConfigScreen.selectedHeader">Selected</string>
<string name="migrationConfigScreen.availableHeader">Available</string>
<string name="migrationConfigScreen.selectAllLabel">Select all</string>
<string name="migrationConfigScreen.selectNoneLabel">Select none</string>
<string name="migrationConfigScreen.selectEnabledLabel">Select enabled sources</string>
<string name="migrationConfigScreen.selectPinnedLabel">Select pinned sources</string>
<string name="migrationConfigScreen.continueButtonText">Continue</string>
</resources>