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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user