Bulk favorite for GlobalSearchScreen & MigrateSearchScreen

This commit is contained in:
Cuong M. Tran 2024-03-07 11:45:48 +07:00 committed by Cuong Tran
parent 8d46ab8fc2
commit c30a8873a2
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
6 changed files with 445 additions and 35 deletions

View file

@ -10,7 +10,9 @@ import eu.kanade.presentation.browse.components.GlobalSearchErrorResultItem
import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem
import eu.kanade.presentation.browse.components.GlobalSearchResultItem
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.presentation.components.SelectionToolbar
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreenModel
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
@ -22,6 +24,9 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun GlobalSearchScreen(
// KMK -->
screenModel: GlobalSearchScreenModel,
// KMK <--
state: SearchScreenModel.State,
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
@ -35,19 +40,31 @@ fun GlobalSearchScreen(
) {
Scaffold(
topBar = { scrollBehavior ->
GlobalSearchToolbar(
searchQuery = state.searchQuery,
progress = state.progress,
total = state.total,
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,
onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior,
)
// KMK -->
if (state.selectionMode)
SelectionToolbar(
selectedCount = state.selection.size,
onClickClearSelection = screenModel::toggleSelectionMode,
onChangeCategoryClicked = screenModel::addFavorite,
)
else
// KMK <--
GlobalSearchToolbar(
searchQuery = state.searchQuery,
progress = state.progress,
total = state.total,
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,
onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior,
// KMK -->
toggleBulkSelectionMode = screenModel::toggleSelectionMode
// KMK <--
)
},
) { paddingValues ->
GlobalSearchContent(
@ -57,6 +74,9 @@ fun GlobalSearchScreen(
onClickSource = onClickSource,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
// KMK -->
selection = state.selection,
// KMK <--
)
}
}
@ -70,6 +90,9 @@ internal fun GlobalSearchContent(
onClickItem: (Manga) -> Unit,
onLongClickItem: (Manga) -> Unit,
fromSourceId: Long? = null,
// KMK -->
selection: List<Manga>,
// KMK <--
) {
LazyColumn(
contentPadding = contentPadding,
@ -107,6 +130,9 @@ internal fun GlobalSearchContent(
getManga = getManga,
onClick = onClickItem,
onLongClick = onLongClickItem,
// KMK -->
selection = selection,
// KMK <--
)
}
is SearchItemResult.Error -> {

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import eu.kanade.presentation.browse.components.GlobalSearchToolbar
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import tachiyomi.domain.manga.model.Manga
@ -11,6 +12,9 @@ import tachiyomi.presentation.core.components.material.Scaffold
@Composable
fun MigrateSearchScreen(
// KMK -->
screenModel: MigrateSearchScreenModel,
// KMK <--
state: SearchScreenModel.State,
fromSourceId: Long?,
navigateUp: () -> Unit,
@ -25,19 +29,31 @@ fun MigrateSearchScreen(
) {
Scaffold(
topBar = { scrollBehavior ->
GlobalSearchToolbar(
searchQuery = state.searchQuery,
progress = state.progress,
total = state.total,
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,
onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior,
)
// KMK -->
if (state.selectionMode)
eu.kanade.presentation.components.SelectionToolbar(
selectedCount = state.selection.size,
onClickClearSelection = screenModel::toggleSelectionMode,
onChangeCategoryClicked = screenModel::addFavorite,
)
else
// KMK <--
GlobalSearchToolbar(
searchQuery = state.searchQuery,
progress = state.progress,
total = state.total,
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,
onToggleResults = onToggleResults,
scrollBehavior = scrollBehavior,
// KMK -->
toggleBulkSelectionMode = screenModel::toggleSelectionMode
// KMK <--
)
},
) { paddingValues ->
GlobalSearchContent(
@ -48,6 +64,9 @@ fun MigrateSearchScreen(
onClickSource = onClickSource,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
// KMK -->
selection = state.selection,
// KMK <--
)
}
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.PushPin
@ -26,8 +27,11 @@ import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
@ -45,6 +49,9 @@ fun GlobalSearchToolbar(
onlyShowHasResults: Boolean,
onToggleResults: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
// KMK -->
toggleBulkSelectionMode: () -> Unit,
// KMK <--
) {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
Box {
@ -55,6 +62,23 @@ fun GlobalSearchToolbar(
onClickCloseSearch = navigateUp,
navigateUp = navigateUp,
scrollBehavior = scrollBehavior,
// KMK -->
actions = {
AppBarActions(
actions = persistentListOf<AppBar.AppBarAction>().builder()
.apply {
add(
AppBar.Action(
title = stringResource(MR.strings.action_bulk_select),
icon = Icons.Outlined.Checklist,
onClick = toggleBulkSelectionMode,
),
)
}
.build(),
)
},
// KMK <--
)
if (progress in 1..<total) {
LinearProgressIndicator(

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.browse.migration.search
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -7,8 +8,12 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.MigrateSearchScreen
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.manga.AllowDuplicateDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
class MigrateSearchScreen(private val mangaId: Long, private val validSources: List<Long>) : Screen() {
@ -23,7 +28,18 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
val dialogState by dialogScreenModel.state.collectAsState()
// KMK -->
BackHandler(enabled = state.selectionMode) {
when {
state.selectionMode -> screenModel.toggleSelectionMode()
}
}
// KMK <--
MigrateSearchScreen(
// KMK -->
screenModel = screenModel,
// KMK <--
state = state,
fromSourceId = state.fromSourceId,
navigateUp = navigator::pop,
@ -38,15 +54,62 @@ class MigrateSearchScreen(private val mangaId: Long, private val validSources: L
// SY <--
},
onClickItem = {
// SY -->
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = mangaId to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
// KMK -->
if (state.selectionMode) {
screenModel.toggleSelection(it)
}
else
// KMK <--
{
// SY -->
navigator.items
.filterIsInstance<MigrationListScreen>()
.last()
.newSelectedItem = mangaId to it.id
navigator.popUntil { it is MigrationListScreen }
// SY <--
}
},
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
)
// KMK -->
val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
is SearchScreenModel.Dialog.ChangeMangasCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, exclude ->
screenModel.setMangaCategories(dialog.mangas, include, exclude)
},
)
}
is SearchScreenModel.Dialog.AllowDuplicate -> {
AllowDuplicateDialog(
onDismissRequest = onDismissRequest,
onAllowAllDuplicate = {
screenModel.addFavoriteDuplicate()
},
onSkipAllDuplicate = {
screenModel.addFavoriteDuplicate(skipAllDuplicates = true)
},
onOpenManga = {
navigator.push(MangaScreen(dialog.duplicatedManga.second.id))
},
onAllowDuplicate = {
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1)
},
onSkipDuplicate = {
screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first)
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first)
},
duplicatedName = dialog.duplicatedManga.second.title,
)
}
else -> {}
}
// KMK <--
}
}

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -11,8 +12,11 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.GlobalSearchScreen
import eu.kanade.presentation.category.components.ChangeCategoryDialog
import eu.kanade.presentation.manga.AllowDuplicateDialog
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import tachiyomi.presentation.core.screens.LoadingScreen
@ -36,6 +40,14 @@ class GlobalSearchScreen(
mutableStateOf(searchQuery.isNotEmpty() && !extensionFilter.isNullOrEmpty() && state.total == 1)
}
// KMK -->
BackHandler(enabled = state.selectionMode) {
when {
state.selectionMode -> screenModel.toggleSelectionMode()
}
}
// KMK <--
if (showSingleLoadingScreen) {
LoadingScreen()
@ -56,6 +68,9 @@ class GlobalSearchScreen(
}
} else {
GlobalSearchScreen(
// KMK -->
screenModel = screenModel,
// KMK <--
state = state,
navigateUp = navigator::pop,
onChangeSearchQuery = screenModel::updateSearchQuery,
@ -66,9 +81,57 @@ class GlobalSearchScreen(
onClickSource = {
navigator.push(BrowseSourceScreen(it.id, state.searchQuery))
},
onClickItem = { navigator.push(MangaScreen(it.id, true)) },
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
onClickItem = {
// KMK -->
if (state.selectionMode)
screenModel.toggleSelection(it)
else
// KMK <--
navigator.push(MangaScreen(it.id, true))
},
onLongClickItem = {
navigator.push(MangaScreen(it.id, true))
},
)
}
// KMK -->
val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
is SearchScreenModel.Dialog.ChangeMangasCategory -> {
ChangeCategoryDialog(
initialSelection = dialog.initialSelection,
onDismissRequest = onDismissRequest,
onEditCategories = { navigator.push(CategoryScreen()) },
onConfirm = { include, exclude ->
screenModel.setMangaCategories(dialog.mangas, include, exclude)
},
)
}
is SearchScreenModel.Dialog.AllowDuplicate -> {
AllowDuplicateDialog(
onDismissRequest = onDismissRequest,
onAllowAllDuplicate = {
screenModel.addFavoriteDuplicate()
},
onSkipAllDuplicate = {
screenModel.addFavoriteDuplicate(skipAllDuplicates = true)
},
onOpenManga = {
navigator.push(MangaScreen(dialog.duplicatedManga.second.id))
},
onAllowDuplicate = {
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first + 1)
},
onSkipDuplicate = {
screenModel.removeDuplicateSelectedManga(index = dialog.duplicatedManga.first)
screenModel.addFavorite(startIdx = dialog.duplicatedManga.first)
},
duplicatedName = dialog.duplicatedManga.second.title,
)
}
else -> {}
}
// KMK <--
}
}

View file

@ -3,15 +3,24 @@ package eu.kanade.tachiyomi.ui.browse.source.globalsearch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.produceState
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachIndexed
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.toDomainManga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.util.ioCoroutineScope
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.CatalogueSource
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.Job
@ -20,10 +29,19 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga
@ -39,6 +57,13 @@ abstract class SearchScreenModel(
private val extensionManager: ExtensionManager = Injekt.get(),
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
// KMK -->
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
// KMK <--
) : StateScreenModel<SearchScreenModel.State>(initialState) {
private val coroutineDispatcher = Executors.newFixedThreadPool(5).asCoroutineDispatcher()
@ -193,6 +218,191 @@ abstract class SearchScreenModel(
updateItems(newItems)
}
// KMK -->
fun toggleSelectionMode() {
if (state.value.selectionMode)
clearSelection()
mutableState.update { it.copy(selectionMode = !it.selectionMode) }
}
private fun clearSelection() {
mutableState.update { it.copy(selection = persistentListOf()) }
}
fun toggleSelection(manga: Manga) {
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
if (list.fastAny { it.id == manga.id }) {
list.removeAll { it.id == manga.id }
} else {
list.add(manga)
}
}
state.copy(selection = newSelection)
}
}
fun addFavorite(startIdx: Int = 0) {
screenModelScope.launch {
val mangaWithDup = getDuplicateLibraryManga(startIdx)
if (mangaWithDup != null)
setDialog(Dialog.AllowDuplicate(mangaWithDup))
else
addFavoriteDuplicate()
}
}
fun addFavoriteDuplicate(skipAllDuplicates: Boolean = false) {
screenModelScope.launch {
val mangaList = if (skipAllDuplicates) getNotDuplicateLibraryMangas() else state.value.selection
val categories = getCategories()
val defaultCategoryId = libraryPreferences.defaultCategory().get()
val defaultCategory = categories.find { it.id == defaultCategoryId.toLong() }
when {
// Default category set
defaultCategory != null -> {
setMangaCategories(mangaList, listOf(defaultCategory.id), emptyList())
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
// Automatic 'Default' or no categories
setMangaCategories(mangaList, emptyList(), emptyList())
}
else -> {
// Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect.
val mix = getMixCategories(mangaList)
val preselected = categories
.map {
when (it) {
in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it)
}
}
.toImmutableList()
setDialog(Dialog.ChangeMangasCategory(mangaList, preselected))
}
}
}
}
private suspend fun getNotDuplicateLibraryMangas(): List<Manga> {
return state.value.selection.filterNot { manga ->
getDuplicateLibraryManga.await(manga).isNotEmpty()
}
}
private suspend fun getDuplicateLibraryManga(startIdx: Int = 0): Pair<Int, Manga>? {
val mangas = state.value.selection
mangas.fastForEachIndexed { index, manga ->
if (index < startIdx) return@fastForEachIndexed
val dup = getDuplicateLibraryManga.await(manga)
if (dup.isEmpty()) return@fastForEachIndexed
return Pair(index, dup.first())
}
return null
}
fun removeDuplicateSelectedManga(index: Int) {
mutableState.update { state ->
val newSelection = state.selection.mutate { list ->
list.removeAt(index)
}
state.copy(selection = newSelection)
}
}
/**
* Bulk update categories of manga using old and new common categories.
*
* @param mangaList the list of manga to move.
* @param addCategories the categories to add for all mangas.
* @param removeCategories the categories to remove in all mangas.
*/
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
screenModelScope.launchNonCancellable {
mangaList.fastForEach { manga ->
val categoryIds = getCategories.await(manga.id)
.map { it.id }
.subtract(removeCategories.toSet())
.plus(addCategories)
.toList()
moveMangaToCategoriesAndAddToLibrary(manga, categoryIds)
}
}
toggleSelectionMode()
}
private fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List<Long>) {
moveMangaToCategory(manga.id, categories)
if (manga.favorite) return
screenModelScope.launchIO {
updateManga.awaitUpdateFavorite(manga.id, true)
}
}
private fun moveMangaToCategory(mangaId: Long, categoryIds: List<Long>) {
screenModelScope.launchIO {
setMangaCategories.await(mangaId, categoryIds)
}
}
/**
* Returns the common categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList()
return mangas
.map { getCategories.await(it.id).toSet() }
.reduce { set1, set2 -> set1.intersect(set2) }
}
/**
* Returns the mix (non-common) categories for the given list of manga.
*
* @param mangas the list of manga.
*/
private suspend fun getMixCategories(mangas: List<Manga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList()
val mangaCategories = mangas.map { getCategories.await(it.id).toSet() }
val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) }
return mangaCategories.flatten().distinct().subtract(common)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
suspend fun getCategories(): List<Category> {
return getCategories.subscribe()
.firstOrNull()
?.filterNot { it.isSystemCategory }
.orEmpty()
}
fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) }
}
sealed interface Dialog {
data class ChangeMangasCategory(
val mangas: List<Manga>,
val initialSelection: ImmutableList<CheckboxState<Category>>,
) : Dialog
data class AllowDuplicate(val duplicatedManga: Pair<Int, Manga>) : Dialog
}
// KMK <--
@Immutable
data class State(
val fromSourceId: Long? = null,
@ -200,6 +410,11 @@ abstract class SearchScreenModel(
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
val onlyShowHasResults: Boolean = false,
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
// KMK -->
val dialog: Dialog? = null,
val selection: PersistentList<Manga> = persistentListOf(),
val selectionMode: Boolean = false,
// KMK <--
) {
val progress: Int = items.count { it.value !is SearchItemResult.Loading }
val total: Int = items.size