Add category filters for Updates tab (#3589)

* Add category filters for Updates tab

Works just like Library Updates.

Provides a tab in the Updates tab filter dialog to allow for including
& excluding of updates belonging to a title from a given category.

Excludes overpower Includes, as with Library Updates.

A little helpful text informs users about this.

* Changelog

* [skip ci] Remove unused string

Leftover from before I realized the Default category will always be
available for in-/exclusion.

* Make :includeEmpty/:excludeEmpty true Boolean params

* "include" -> "included"

Consistency with #3607.

* Remove DISTINCT from getRecentUpdatesWithFilters

Apparently not needed anymore (though it was required at some point).

* [skip ci] Fix Changelog
This commit is contained in:
MajorTanya
2026-08-02 12:59:58 +02:00
committed by GitHub
parent 6d69903a56
commit 1d8a2b05dd
10 changed files with 152 additions and 8 deletions
+2
View File
@@ -11,6 +11,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff.
## [Unreleased]
### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
## [v0.20.2] - 2026-08-01
### Added
@@ -14,12 +14,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsViewModel
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences
import tachiyomi.i18n.MR
@@ -27,6 +34,7 @@ import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable
@@ -38,14 +46,18 @@ fun UpdatesFilterDialog(
onDismissRequest = onDismissRequest,
tabTitles = listOf(
stringResource(MR.strings.action_filter),
stringResource(MR.strings.categories),
),
) {
) { page ->
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
FilterSheet(viewModel = viewModel)
when (page) {
0 -> FilterSheet(viewModel = viewModel)
1 -> CategoryFilterSheet(viewModel = viewModel)
}
}
}
}
@@ -108,3 +120,55 @@ private fun ColumnScope.FilterSheet(
)
}
}
@Composable
private fun ColumnScope.CategoryFilterSheet(
viewModel: UpdatesSettingsViewModel,
) {
Text(
stringResource(MR.strings.pref_filter_update_categories_details),
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.extraSmall))
val allCategories by viewModel.getCategories.subscribe().collectAsState(initial = emptyList())
if (allCategories.isEmpty()) {
// since it includes the system category, this should only happen when loading is required
LoadingScreen(modifier = Modifier.padding(16.dp))
return
}
val excluded by viewModel.updatesPreferences.filterExcludedCategories.collectAsState()
val included by viewModel.updatesPreferences.filterIncludedCategories.collectAsState()
val selected = remember {
allCategories.map { category ->
when (category.id) {
in included -> TriState.ENABLED_IS
in excluded -> TriState.ENABLED_NOT
else -> TriState.DISABLED
}
}.toMutableStateList()
}
Column {
allCategories.fastForEachIndexed { idx, category ->
val state = selected[idx]
TriStateItem(
label = category.visualName,
state = state,
onClick = {
selected[idx] = state.next()
viewModel.cycleCategory(category)
},
)
}
}
}
@@ -4,14 +4,32 @@ import androidx.lifecycle.ViewModel
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class UpdatesSettingsViewModel(
val updatesPreferences: UpdatesPreferences = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
) : ViewModel() {
val includedCategories = updatesPreferences.filterIncludedCategories
val excludedCategories = updatesPreferences.filterExcludedCategories
fun cycleCategory(category: Category) {
when (category.id) {
in includedCategories.get() -> {
includedCategories.getAndSet { it - category.id }
excludedCategories.getAndSet { it + category.id }
}
in excludedCategories.get() -> excludedCategories.getAndSet { it - category.id }
else -> includedCategories.getAndSet { it + category.id }
}
}
fun toggleFilter(preference: (UpdatesPreferences) -> Preference<TriState>) {
preference(updatesPreferences).getAndSet {
it.next()
@@ -93,6 +93,8 @@ class UpdatesViewModel(
started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators,
includedCategories = it.filterIncludedCategories,
excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged()
},
downloadCache.changes,
@@ -423,13 +425,18 @@ class UpdatesViewModel(
updatesPreferences.filterStarted.changes(),
updatesPreferences.filterBookmarked.changes(),
updatesPreferences.filterExcludedScanlators.changes(),
) { downloaded, unread, started, bookmarked, excludedScanlators ->
updatesPreferences.filterIncludedCategories.changes(),
updatesPreferences.filterExcludedCategories.changes(),
) {
@Suppress("UNCHECKED_CAST")
ItemPreferences(
filterDownloaded = downloaded,
filterUnread = unread,
filterStarted = started,
filterBookmarked = bookmarked,
filterExcludedScanlators = excludedScanlators,
filterDownloaded = it[0] as TriState,
filterUnread = it[1] as TriState,
filterStarted = it[2] as TriState,
filterBookmarked = it[3] as TriState,
filterExcludedScanlators = it[4] as Boolean,
filterIncludedCategories = it[5] as List<Long>,
filterExcludedCategories = it[6] as List<Long>,
)
}
}
@@ -445,6 +452,8 @@ class UpdatesViewModel(
val filterStarted: TriState,
val filterBookmarked: TriState,
val filterExcludedScanlators: Boolean,
val filterIncludedCategories: List<Long>,
val filterExcludedCategories: List<Long>,
)
@Immutable
@@ -35,6 +35,8 @@ class UpdatesRepositoryImpl(
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> {
return database.updatesViewQueries
.getRecentUpdatesWithFilters(
@@ -44,6 +46,10 @@ class UpdatesRepositoryImpl(
started = started?.toLong(),
bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators.toLong(),
includedEmpty = includedCategories.isEmpty(),
excludedEmpty = excludedCategories.isEmpty(),
includedCategories = includedCategories,
excludedCategories = excludedCategories,
mapper = ::mapUpdatesWithRelations,
)
.subscribeToList()
@@ -46,6 +46,33 @@ AND (:bookmarked IS NULL OR bookmark = :bookmarked)
AND (
(excludedScanlator IS NULL OR :hideExcludedScanlators = 0)
)
AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories mc WHERE mc.manga_id = mangaId)
)
)
AND (
-- excludedEmpty being true expresses "don't care" state and bypasses the membership filter
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangaId)
)
)
)
LIMIT :limit;
getUpdatesByReadStatus:
@@ -19,6 +19,8 @@ class GetUpdates(
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> {
return repository.subscribeAll(
instant.toEpochMilliseconds(),
@@ -27,6 +29,8 @@ class GetUpdates(
started = started,
bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators,
includedCategories = includedCategories,
excludedCategories = excludedCategories,
)
}
@@ -14,6 +14,8 @@ interface UpdatesRepository {
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>>
fun subscribeWithRead(read: Boolean, after: Long, limit: Long): Flow<List<UpdatesWithRelations>>
@@ -4,6 +4,7 @@ import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getLongArray
class UpdatesPreferences(
preferenceStore: PreferenceStore,
@@ -33,4 +34,14 @@ class UpdatesPreferences(
"pref_filter_updates_hide_excluded_scanlators",
false,
)
val filterIncludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_updates_included_categories",
emptyList(),
)
val filterExcludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_updates_excluded_categories",
emptyList(),
)
}
@@ -861,6 +861,7 @@
<string name="relative_time_span_never">Never</string>
<string name="action_view_upcoming">View Upcoming Updates</string>
<string name="action_filter_excluded_scanlators">Filter excluded scanlators</string>
<string name="pref_filter_update_categories_details">Updates in excluded categories will not be shown even if they are also in any included categories.</string>
<!-- Upcoming -->
<string name="upcoming_guide">Upcoming Guide</string>