mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 22:37:56 +01:00 
			
		
		
		
	Add migration config screen to select and prioritize target sources (#2144)
This commit is contained in:
		| @@ -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()) | ||||
|   | ||||
| @@ -40,6 +40,7 @@ fun GlobalSearchScreen( | ||||
|                 navigateUp = navigateUp, | ||||
|                 onChangeSearchQuery = onChangeSearchQuery, | ||||
|                 onSearch = onSearch, | ||||
|                 hideSourceFilter = false, | ||||
|                 sourceFilter = state.sourceFilter, | ||||
|                 onChangeSearchFilter = onChangeSearchFilter, | ||||
|                 onlyShowHasResults = state.onlyShowHasResults, | ||||
|   | ||||
| @@ -32,6 +32,7 @@ fun MigrateSearchScreen( | ||||
|                 navigateUp = navigateUp, | ||||
|                 onChangeSearchQuery = onChangeSearchQuery, | ||||
|                 onSearch = onSearch, | ||||
|                 hideSourceFilter = true, | ||||
|                 sourceFilter = state.sourceFilter, | ||||
|                 onChangeSearchFilter = onChangeSearchFilter, | ||||
|                 onlyShowHasResults = state.onlyShowHasResults, | ||||
|   | ||||
| @@ -40,6 +40,7 @@ fun GlobalSearchToolbar( | ||||
|     navigateUp: () -> Unit, | ||||
|     onChangeSearchQuery: (String?) -> Unit, | ||||
|     onSearch: (String) -> Unit, | ||||
|     hideSourceFilter: Boolean, | ||||
|     sourceFilter: SourceFilter, | ||||
|     onChangeSearchFilter: (SourceFilter) -> Unit, | ||||
|     onlyShowHasResults: Boolean, | ||||
| @@ -73,38 +74,40 @@ fun GlobalSearchToolbar( | ||||
|             horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), | ||||
|         ) { | ||||
|             // TODO: make this UX better; it only applies when triggering a new search | ||||
|             FilterChip( | ||||
|                 selected = sourceFilter == SourceFilter.PinnedOnly, | ||||
|                 onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) }, | ||||
|                 leadingIcon = { | ||||
|                     Icon( | ||||
|                         imageVector = Icons.Outlined.PushPin, | ||||
|                         contentDescription = null, | ||||
|                         modifier = Modifier | ||||
|                             .size(FilterChipDefaults.IconSize), | ||||
|                     ) | ||||
|                 }, | ||||
|                 label = { | ||||
|                     Text(text = stringResource(MR.strings.pinned_sources)) | ||||
|                 }, | ||||
|             ) | ||||
|             FilterChip( | ||||
|                 selected = sourceFilter == SourceFilter.All, | ||||
|                 onClick = { onChangeSearchFilter(SourceFilter.All) }, | ||||
|                 leadingIcon = { | ||||
|                     Icon( | ||||
|                         imageVector = Icons.Outlined.DoneAll, | ||||
|                         contentDescription = null, | ||||
|                         modifier = Modifier | ||||
|                             .size(FilterChipDefaults.IconSize), | ||||
|                     ) | ||||
|                 }, | ||||
|                 label = { | ||||
|                     Text(text = stringResource(MR.strings.all)) | ||||
|                 }, | ||||
|             ) | ||||
|             if (!hideSourceFilter) { | ||||
|                 FilterChip( | ||||
|                     selected = sourceFilter == SourceFilter.PinnedOnly, | ||||
|                     onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) }, | ||||
|                     leadingIcon = { | ||||
|                         Icon( | ||||
|                             imageVector = Icons.Outlined.PushPin, | ||||
|                             contentDescription = null, | ||||
|                             modifier = Modifier | ||||
|                                 .size(FilterChipDefaults.IconSize), | ||||
|                         ) | ||||
|                     }, | ||||
|                     label = { | ||||
|                         Text(text = stringResource(MR.strings.pinned_sources)) | ||||
|                     }, | ||||
|                 ) | ||||
|                 FilterChip( | ||||
|                     selected = sourceFilter == SourceFilter.All, | ||||
|                     onClick = { onChangeSearchFilter(SourceFilter.All) }, | ||||
|                     leadingIcon = { | ||||
|                         Icon( | ||||
|                             imageVector = Icons.Outlined.DoneAll, | ||||
|                             contentDescription = null, | ||||
|                             modifier = Modifier | ||||
|                                 .size(FilterChipDefaults.IconSize), | ||||
|                         ) | ||||
|                     }, | ||||
|                     label = { | ||||
|                         Text(text = stringResource(MR.strings.all)) | ||||
|                     }, | ||||
|                 ) | ||||
|  | ||||
|             VerticalDivider() | ||||
|                 VerticalDivider() | ||||
|             } | ||||
|  | ||||
|             FilterChip( | ||||
|                 selected = onlyShowHasResults, | ||||
|   | ||||
| @@ -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)) }, | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -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 } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 }, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user