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. - `Other` - for technical stuff.
## [Unreleased] ## [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 ## [v0.20.2] - 2026-08-01
### Added ### Added
@@ -14,12 +14,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsViewModel import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsViewModel
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences import tachiyomi.domain.updates.service.UpdatesPreferences
import tachiyomi.i18n.MR 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.TriStateItem
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
@Composable @Composable
@@ -38,14 +46,18 @@ fun UpdatesFilterDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
tabTitles = listOf( tabTitles = listOf(
stringResource(MR.strings.action_filter), stringResource(MR.strings.action_filter),
stringResource(MR.strings.categories),
), ),
) { ) { page ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical) .padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()), .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.Preference
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet 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 tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class UpdatesSettingsViewModel( class UpdatesSettingsViewModel(
val updatesPreferences: UpdatesPreferences = Injekt.get(), val updatesPreferences: UpdatesPreferences = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
) : ViewModel() { ) : 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>) { fun toggleFilter(preference: (UpdatesPreferences) -> Preference<TriState>) {
preference(updatesPreferences).getAndSet { preference(updatesPreferences).getAndSet {
it.next() it.next()
@@ -93,6 +93,8 @@ class UpdatesViewModel(
started = it.filterStarted.toBooleanOrNull(), started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(), bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators, hideExcludedScanlators = it.filterExcludedScanlators,
includedCategories = it.filterIncludedCategories,
excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged() ).distinctUntilChanged()
}, },
downloadCache.changes, downloadCache.changes,
@@ -423,13 +425,18 @@ class UpdatesViewModel(
updatesPreferences.filterStarted.changes(), updatesPreferences.filterStarted.changes(),
updatesPreferences.filterBookmarked.changes(), updatesPreferences.filterBookmarked.changes(),
updatesPreferences.filterExcludedScanlators.changes(), updatesPreferences.filterExcludedScanlators.changes(),
) { downloaded, unread, started, bookmarked, excludedScanlators -> updatesPreferences.filterIncludedCategories.changes(),
updatesPreferences.filterExcludedCategories.changes(),
) {
@Suppress("UNCHECKED_CAST")
ItemPreferences( ItemPreferences(
filterDownloaded = downloaded, filterDownloaded = it[0] as TriState,
filterUnread = unread, filterUnread = it[1] as TriState,
filterStarted = started, filterStarted = it[2] as TriState,
filterBookmarked = bookmarked, filterBookmarked = it[3] as TriState,
filterExcludedScanlators = excludedScanlators, 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 filterStarted: TriState,
val filterBookmarked: TriState, val filterBookmarked: TriState,
val filterExcludedScanlators: Boolean, val filterExcludedScanlators: Boolean,
val filterIncludedCategories: List<Long>,
val filterExcludedCategories: List<Long>,
) )
@Immutable @Immutable
@@ -35,6 +35,8 @@ class UpdatesRepositoryImpl(
started: Boolean?, started: Boolean?,
bookmarked: Boolean?, bookmarked: Boolean?,
hideExcludedScanlators: Boolean, hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> { ): Flow<List<UpdatesWithRelations>> {
return database.updatesViewQueries return database.updatesViewQueries
.getRecentUpdatesWithFilters( .getRecentUpdatesWithFilters(
@@ -44,6 +46,10 @@ class UpdatesRepositoryImpl(
started = started?.toLong(), started = started?.toLong(),
bookmarked = bookmarked, bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators.toLong(), hideExcludedScanlators = hideExcludedScanlators.toLong(),
includedEmpty = includedCategories.isEmpty(),
excludedEmpty = excludedCategories.isEmpty(),
includedCategories = includedCategories,
excludedCategories = excludedCategories,
mapper = ::mapUpdatesWithRelations, mapper = ::mapUpdatesWithRelations,
) )
.subscribeToList() .subscribeToList()
@@ -46,6 +46,33 @@ AND (:bookmarked IS NULL OR bookmark = :bookmarked)
AND ( AND (
(excludedScanlator IS NULL OR :hideExcludedScanlators = 0) (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; LIMIT :limit;
getUpdatesByReadStatus: getUpdatesByReadStatus:
@@ -19,6 +19,8 @@ class GetUpdates(
started: Boolean?, started: Boolean?,
bookmarked: Boolean?, bookmarked: Boolean?,
hideExcludedScanlators: Boolean, hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> { ): Flow<List<UpdatesWithRelations>> {
return repository.subscribeAll( return repository.subscribeAll(
instant.toEpochMilliseconds(), instant.toEpochMilliseconds(),
@@ -27,6 +29,8 @@ class GetUpdates(
started = started, started = started,
bookmarked = bookmarked, bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators, hideExcludedScanlators = hideExcludedScanlators,
includedCategories = includedCategories,
excludedCategories = excludedCategories,
) )
} }
@@ -14,6 +14,8 @@ interface UpdatesRepository {
started: Boolean?, started: Boolean?,
bookmarked: Boolean?, bookmarked: Boolean?,
hideExcludedScanlators: Boolean, hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> ): Flow<List<UpdatesWithRelations>>
fun subscribeWithRead(read: Boolean, after: Long, limit: 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.PreferenceStore
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getLongArray
class UpdatesPreferences( class UpdatesPreferences(
preferenceStore: PreferenceStore, preferenceStore: PreferenceStore,
@@ -33,4 +34,14 @@ class UpdatesPreferences(
"pref_filter_updates_hide_excluded_scanlators", "pref_filter_updates_hide_excluded_scanlators",
false, 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="relative_time_span_never">Never</string>
<string name="action_view_upcoming">View Upcoming Updates</string> <string name="action_view_upcoming">View Upcoming Updates</string>
<string name="action_filter_excluded_scanlators">Filter excluded scanlators</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 --> <!-- Upcoming -->
<string name="upcoming_guide">Upcoming Guide</string> <string name="upcoming_guide">Upcoming Guide</string>