feat(library): add category library filter (#895)

* feat(library): add category library filter

* feat(library): pre-calculate filter categories map

* feat(library): ungroup library items if category filter is enabled

* refactor `combine` function and improve flow handling

* fix: Exclude system's `Default` category from the list (as no other category would match with it)

* refactor: categories conversion

* feat: correctly restore categories filter preferences

* feat: improve DB ops performance

* chore: add KMK comment

---------

Co-authored-by: Cuong-Tran <cuongtran.tm@gmail.com>
This commit is contained in:
Fermín Cirella 2025-05-20 05:32:41 -03:00 committed by GitHub
parent 70fcc3dff0
commit 7f820b590c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 252 additions and 11 deletions

View file

@ -48,6 +48,7 @@ import tachiyomi.data.updates.UpdatesRepositoryImpl
import tachiyomi.domain.category.interactor.CreateCategoryWithName
import tachiyomi.domain.category.interactor.DeleteCategory
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.GetCategoriesPerLibraryManga
import tachiyomi.domain.category.interactor.HideCategory
import tachiyomi.domain.category.interactor.RenameCategory
import tachiyomi.domain.category.interactor.ReorderCategory
@ -116,6 +117,7 @@ class DomainModule : InjektModule {
addFactory { DeleteCategory(get(), get(), get()) }
// KMK -->
addFactory { HideCategory(get()) }
addFactory { GetCategoriesPerLibraryManga(get()) }
// KMK <--
addSingletonFactory<MangaRepository> { MangaRepositoryImpl(get()) }

View file

@ -1,27 +1,40 @@
package eu.kanade.presentation.library
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.more.settings.widget.TriStateListDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.library.LibrarySettingsScreenModel
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
@ -29,6 +42,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.toggle
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryGroup
@ -43,9 +57,11 @@ import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.IconItem
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.SliderItem
import tachiyomi.presentation.core.components.SortItem
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.TextButton
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
@ -57,6 +73,9 @@ fun LibrarySettingsDialog(
// SY -->
hasCategories: Boolean,
// SY <--
// KMK -->
categories: List<Category>,
// KMK <--
) {
TabbedDialog(
onDismissRequest = onDismissRequest,
@ -77,6 +96,9 @@ fun LibrarySettingsDialog(
when (page) {
0 -> FilterPage(
screenModel = screenModel,
// KMK -->
categories = categories,
// KMK <--
)
1 -> SortPage(
category = category,
@ -100,6 +122,7 @@ fun LibrarySettingsDialog(
@Composable
private fun ColumnScope.FilterPage(
screenModel: LibrarySettingsScreenModel,
categories: List<Category>,
) {
val filterDownloaded by screenModel.libraryPreferences.filterDownloaded().collectAsState()
val downloadedOnly by screenModel.preferences.downloadedOnly().collectAsState()
@ -157,6 +180,13 @@ private fun ColumnScope.FilterPage(
)
// SY <--
// KMY -->
CategoriesFilter(
libraryPreferences = screenModel.libraryPreferences,
categories = categories,
)
// KMK <--
val trackers by screenModel.trackersFlow.collectAsState()
when (trackers.size) {
0 -> {
@ -434,3 +464,58 @@ private fun ColumnScope.GroupPage(
}
}
// SY <--
// KMK -->
@Composable
private fun CategoriesFilter(
libraryPreferences: LibraryPreferences,
categories: List<Category>,
) {
val filterCategories by libraryPreferences.filterCategories().collectAsState()
val filterCategoriesInclude = libraryPreferences.filterCategoriesInclude()
val filterCategoriesExclude = libraryPreferences.filterCategoriesExclude()
val included by filterCategoriesInclude.collectAsState()
val excluded by filterCategoriesExclude.collectAsState()
var showCategoriesDialog by rememberSaveable { mutableStateOf(false) }
if (showCategoriesDialog) {
TriStateListDialog(
title = stringResource(MR.strings.categories),
message = stringResource(KMR.strings.pref_library_filter_categories_details),
items = categories,
initialChecked = included.mapNotNull { id -> categories.find { it.id.toString() == id } },
initialInversed = excluded.mapNotNull { id -> categories.find { it.id.toString() == id } },
itemLabel = { it.visualName },
onDismissRequest = { showCategoriesDialog = false },
onValueChanged = { newIncluded, newExcluded ->
filterCategoriesInclude.set(newIncluded.map { it.id.toString() }.toSet())
filterCategoriesExclude.set(newExcluded.map { it.id.toString() }.toSet())
showCategoriesDialog = false
},
)
}
Row(
modifier = Modifier
.clickable(onClick = { libraryPreferences.filterCategories().toggle() })
.fillMaxWidth()
.padding(horizontal = SettingsItemsPaddings.Horizontal),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
Checkbox(
checked = filterCategories,
onCheckedChange = null,
)
Text(
text = stringResource(MR.strings.categories),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.weight(1f))
TextButton(onClick = { showCategoriesDialog = true }) {
Text(stringResource(MR.strings.action_edit))
}
}
}
// KMK <--

View file

@ -57,10 +57,12 @@ import exh.util.cancellable
import exh.util.isLewd
import exh.util.nullIfBlank
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
@ -87,6 +89,7 @@ import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.GetCategoriesPerLibraryManga
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
@ -159,6 +162,7 @@ class LibraryScreenModel(
// SY <--
// KMK -->
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
private val getCategoriesPerLibraryManga: GetCategoriesPerLibraryManga = Injekt.get(),
// KMK <--
) : StateScreenModel<LibraryScreenModel.State>(State()) {
@ -170,13 +174,16 @@ class LibraryScreenModel(
init {
screenModelScope.launchIO {
combine(
combine(
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS),
getLibraryFlow(),
getTracksPerManga.subscribe(),
combine(
getTrackingFilterFlow(),
downloadCache.changes,
::Triple,
),
combine(
getTracksPerManga.subscribe(),
getTrackingFilterFlow(),
::Pair,
),
// SY -->
@ -186,12 +193,25 @@ class LibraryScreenModel(
::Pair,
),
// SY <--
) { searchQuery, library, tracks, (trackingFilter, _), (groupType, sort) ->
// KMK -->
combine(
getCategoriesPerLibraryManga.subscribe(),
state.map { it.filterCategory }.distinctUntilChanged(),
::Pair,
),
// KMK <--
) { (searchQuery, library, _), (tracks, trackingFilter), (groupType, sort), (categoriesPerManga, filterCategory) ->
library
// SY -->
.applyGrouping(groupType)
.applyGrouping(/* KMK --> */ if (filterCategory) LibraryGroup.UNGROUPED else /* KMK <-- */ groupType)
// SY <--
.applyFilters(tracks, trackingFilter)
.applyFilters(
tracks,
trackingFilter,
// KMK -->
categoriesPerManga,
// KMK <--
)
.applySort(
tracks,
trackingFilter.keys,
@ -253,7 +273,10 @@ class LibraryScreenModel(
prefs.filterLewd,
// SY <--
) + trackFilter.values
).any { it != TriState.DISABLED }
).any { it != TriState.DISABLED } ||
// KMK -->
prefs.filterCategories
// KMK <--
}
.distinctUntilChanged()
.onEach {
@ -294,6 +317,25 @@ class LibraryScreenModel(
}
.launchIn(screenModelScope)
// SY <--
// KMK -->
libraryPreferences.filterCategories().changes()
.onEach {
mutableState.update { state ->
state.copy(filterCategory = it)
}
}.launchIn(screenModelScope)
screenModelScope.launchIO {
getCategories
.subscribe()
.collect { categories ->
mutableState.update { state ->
state.copy(libraryCategories = categories.filterNot(Category::isSystemCategory))
}
}
}
// KMK <--
}
/**
@ -302,6 +344,9 @@ class LibraryScreenModel(
private suspend fun LibraryMap.applyFilters(
trackMap: Map<Long, List<Track>>,
trackingFilter: Map<Long, TriState>,
// KMK -->
categoriesPerManga: Map<Long, Set<Long>>,
// KMK <--
): LibraryMap {
val prefs = getLibraryItemPreferencesFlow().first()
val downloadedOnly = prefs.globalFilterDownloaded
@ -323,6 +368,12 @@ class LibraryScreenModel(
val filterLewd = prefs.filterLewd
// SY <--
// KMK -->
val filterCategories = prefs.filterCategories
val includedCategories = prefs.filterCategoriesInclude
val excludedCategories = prefs.filterCategoriesExclude
// KMK <--
val filterFnDownloaded: (LibraryItem) -> Boolean = {
applyFilter(filterDownloaded) {
it.libraryManga.manga.isLocal() ||
@ -374,6 +425,19 @@ class LibraryScreenModel(
!isExcluded && isIncluded
}
// KMK -->
val filterFnCategories: (LibraryItem) -> Boolean = categories@{ item ->
if (!filterCategories) return@categories true
val mangaCategories = categoriesPerManga[item.libraryManga.id].orEmpty()
val isExcluded = excludedCategories.any { it in mangaCategories }
val isIncluded = includedCategories.isEmpty() || includedCategories.all { it in mangaCategories }
!isExcluded && isIncluded
}
// KMK <--
val filterFn: (LibraryItem) -> Boolean = {
filterFnDownloaded(it) &&
filterFnUnread(it) &&
@ -383,8 +447,11 @@ class LibraryScreenModel(
filterFnIntervalCustom(it) &&
filterFnTracking(it) &&
// SY -->
filterFnLewd(it)
filterFnLewd(it) &&
// SY <--
// KMK -->
filterFnCategories(it)
// KMK <---
}
return mapValues { (_, value) -> value.fastFilter(filterFn) }
@ -524,6 +591,9 @@ class LibraryScreenModel(
// KMK -->
libraryPreferences.sourceBadge().changes(),
libraryPreferences.useLangIcon().changes(),
libraryPreferences.filterCategories().changes(),
libraryPreferences.filterCategoriesInclude().changes(),
libraryPreferences.filterCategoriesExclude().changes(),
// KMK <--
) {
ItemPreferences(
@ -545,6 +615,9 @@ class LibraryScreenModel(
// KMK -->
sourceBadge = it[13] as Boolean,
useLangIcon = it[14] as Boolean,
filterCategories = it[15] as Boolean,
filterCategoriesInclude = (it[16] as Set<*>).filterIsInstance<String>().mapNotNull(String::toLongOrNull).toImmutableSet(),
filterCategoriesExclude = (it[17] as Set<*>).filterIsInstance<String>().mapNotNull(String::toLongOrNull).toImmutableSet(),
// KMK <--
)
}
@ -1435,6 +1508,11 @@ class LibraryScreenModel(
// SY -->
val filterLewd: TriState,
// SY <--
// KMK -->
val filterCategories: Boolean,
val filterCategoriesInclude: ImmutableSet<Long>,
val filterCategoriesExclude: ImmutableSet<Long>,
// KMK <--
)
@Immutable
@ -1454,6 +1532,10 @@ class LibraryScreenModel(
val ogCategories: List<Category> = emptyList(),
val groupType: Int = LibraryGroup.BY_DEFAULT,
// SY <--
// KMK -->
val libraryCategories: List<Category> = emptyList(),
val filterCategory: Boolean = false,
// KMK <--
) {
private val libraryCount by lazy {
library.values

View file

@ -328,6 +328,9 @@ data object LibraryTab : Tab {
// SY -->
hasCategories = state.categories.fastAny { !it.isSystemCategory },
// SY <--
// KMK -->
categories = state.libraryCategories,
// KMK <--
)
}
is LibraryScreenModel.Dialog.ChangeCategory -> {

View file

@ -28,6 +28,10 @@ class CategoryPreferencesCleanupMigration : Migration {
downloadPreferences.removeExcludeCategories(),
downloadPreferences.downloadNewChapterCategories(),
downloadPreferences.downloadNewChapterCategoriesExclude(),
// KMK -->
libraryPreferences.filterCategoriesInclude(),
libraryPreferences.filterCategoriesExclude(),
// KMK <--
)
categoryPreferences.forEach { preference ->
val ids = preference.get()

View file

@ -1,6 +1,7 @@
package tachiyomi.data.category
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import tachiyomi.data.Database
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.category.model.Category
@ -35,6 +36,18 @@ class CategoryRepositoryImpl(
}
}
// KMK -->
override fun getCategoriesPerLibraryMangaAsFlow(): Flow<Map<Long, Set<Long>>> {
return handler.subscribeToList {
mangas_categoriesQueries.getCategoriesPerLibraryManga { mangaId, categoryId ->
mangaId to categoryId
}
}.map { list ->
list.groupBy({ it.first }, { it.second }).mapValues { it.value.toSet() }
}
}
// KMK <--
// SY -->
override suspend fun insert(category: Category): Long {
return handler.awaitOneExecutable(true) {

View file

@ -22,3 +22,12 @@ VALUES (:mangaId, :categoryId);
deleteMangaCategoryByMangaId:
DELETE FROM mangas_categories
WHERE manga_id = :mangaId;
-- KMK -->
getCategoriesPerLibraryManga:
SELECT MC.manga_id, MC.category_id
FROM mangas_categories MC
JOIN mangas M
ON MC.manga_id = M._id
WHERE M.favorite = 1;
-- KMK <--

View file

@ -41,6 +41,10 @@ class DeleteCategory(
downloadPreferences.removeExcludeCategories(),
downloadPreferences.downloadNewChapterCategories(),
downloadPreferences.downloadNewChapterCategoriesExclude(),
// KMK -->
libraryPreferences.filterCategoriesInclude(),
libraryPreferences.filterCategoriesExclude(),
// KMK <--
)
val categoryIdString = categoryId.toString()
categoryPreferences.forEach { preference ->

View file

@ -0,0 +1,13 @@
package tachiyomi.domain.category.interactor
import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.category.repository.CategoryRepository
class GetCategoriesPerLibraryManga(
private val categoryRepository: CategoryRepository,
) {
fun subscribe(): Flow<Map<Long, Set<Long>>> {
return categoryRepository.getCategoriesPerLibraryMangaAsFlow()
}
}

View file

@ -16,6 +16,10 @@ interface CategoryRepository {
fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow<List<Category>>
// KMK -->
fun getCategoriesPerLibraryMangaAsFlow(): Flow<Map<Long, Set<Long>>>
// KMK <--
// SY -->
suspend fun insert(category: Category): Long
// SY <--

View file

@ -116,6 +116,17 @@ class LibraryPreferences(
)
// SY <--
// KMK -->
fun filterCategories() = preferenceStore.getBoolean(
"pref_filter_library_categories",
false,
)
fun filterCategoriesInclude() = preferenceStore.getStringSet(FILTER_LIBRARY_CATEGORIES_INCLUDE_PREF_KEY, emptySet())
fun filterCategoriesExclude() = preferenceStore.getStringSet(FILTER_LIBRARY_CATEGORIES_EXCLUDE_PREF_KEY, emptySet())
// KMK <--
fun filterTracking(id: Int) = preferenceStore.getEnum(
"pref_filter_library_tracked_${id}_v2",
TriState.DISABLED,
@ -263,10 +274,20 @@ class LibraryPreferences(
const val DEFAULT_CATEGORY_PREF_KEY = "default_category"
private const val LIBRARY_UPDATE_CATEGORIES_PREF_KEY = "library_update_categories"
private const val LIBRARY_UPDATE_CATEGORIES_EXCLUDE_PREF_KEY = "library_update_categories_exclude"
// KMK -->
private const val FILTER_LIBRARY_CATEGORIES_INCLUDE_PREF_KEY = "pref_filter_library_categories_include"
private const val FILTER_LIBRARY_CATEGORIES_EXCLUDE_PREF_KEY = "pref_filter_library_categories_exclude"
// KMK <--
val categoryPreferenceKeys = setOf(
DEFAULT_CATEGORY_PREF_KEY,
LIBRARY_UPDATE_CATEGORIES_PREF_KEY,
LIBRARY_UPDATE_CATEGORIES_EXCLUDE_PREF_KEY,
// KMK -->
FILTER_LIBRARY_CATEGORIES_INCLUDE_PREF_KEY,
FILTER_LIBRARY_CATEGORIES_EXCLUDE_PREF_KEY,
// KMK <--
)
}
}

View file

@ -75,6 +75,7 @@
<string name="pref_show_updating_progress_banner">Show updating progress banner</string>
<string name="action_display_source_badge">Source icon</string>
<string name="action_display_language_icon">Use language icon</string>
<string name="pref_library_filter_categories_details">Entries in excluded categories will not be shown even if they are also in included categories.</string>
<!-- Extension section -->
<string name="extensions_page_need_refresh">Refresh extension page to update list.</string>
<string name="extensions_page_more">More extensions...</string>