Add category filters to Upcoming calendar (#3607)

This commit is contained in:
MajorTanya
2026-08-04 12:30:23 +02:00
committed by GitHub
parent f7a1ecd251
commit a73271aefa
11 changed files with 299 additions and 25 deletions
+1
View File
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589)) - Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
- Add Category filtering for the Upcoming calendar ([@MajorTanya](https://github.com/MajorTanya)) ([#3607](https://github.com/mihonapp/mihon/pull/3607))
### Changed ### Changed
- Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678)) - Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
@@ -17,6 +17,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.upcoming.service.UpcomingPreferences
import tachiyomi.domain.updates.service.UpdatesPreferences import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
@@ -47,6 +48,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory { addSingletonFactory {
LibraryPreferences(get()) LibraryPreferences(get())
} }
addSingletonFactory {
UpcomingPreferences(get())
}
addSingletonFactory { addSingletonFactory {
UpdatesPreferences(get()) UpdatesPreferences(get())
} }
@@ -1,13 +1,38 @@
package mihon.feature.upcoming package mihon.feature.upcoming
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState 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.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import tachiyomi.core.common.preference.TriState
import tachiyomi.i18n.MR
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
class UpcomingScreen : Screen() { class UpcomingScreen : Screen() {
@@ -18,10 +43,93 @@ class UpcomingScreen : Screen() {
val viewModel = viewModel<UpcomingViewModel>() val viewModel = viewModel<UpcomingViewModel>()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
when (state.dialog) {
is UpcomingViewModel.Dialog.FilterSheet -> {
UpcomingFilterDialog(
viewModel = viewModel,
)
}
null -> {}
}
UpcomingScreenContent( UpcomingScreenContent(
state = state, state = state,
setSelectedYearMonth = viewModel::setSelectedYearMonth, setSelectedYearMonth = viewModel::setSelectedYearMonth,
onClickUpcoming = { navigator.push(MangaScreen(it.id)) }, onClickUpcoming = { navigator.push(MangaScreen(it.id)) },
hasActiveFilters = state.hasActiveFilters,
onClickFilter = viewModel::showFilterDialog,
) )
} }
} }
@Composable
private fun ColumnScope.CategoryFilterSheet(
viewModel: UpcomingViewModel,
) {
Text(
stringResource(MR.strings.pref_filter_upcoming_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()) {
LoadingScreen(modifier = Modifier.padding(16.dp))
return
}
val excluded by viewModel.upcomingPreferences.filterExcludedCategories.collectAsState()
val included by viewModel.upcomingPreferences.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)
},
)
}
}
}
@Composable
fun UpcomingFilterDialog(
viewModel: UpcomingViewModel,
) {
TabbedDialog(
onDismissRequest = viewModel::resetDialog,
tabTitles = listOf(
stringResource(MR.strings.categories),
),
) {
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
CategoryFilterSheet(viewModel = viewModel)
}
}
}
@@ -9,9 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.util.isTabletUi import eu.kanade.presentation.util.isTabletUi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,12 +39,15 @@ import tachiyomi.presentation.core.components.TwoPanelBox
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
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.theme.active
@Composable @Composable
fun UpcomingScreenContent( fun UpcomingScreenContent(
state: UpcomingViewModel.State, state: UpcomingViewModel.State,
setSelectedYearMonth: (YearMonth) -> Unit, setSelectedYearMonth: (YearMonth) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit, onClickUpcoming: (manga: Manga) -> Unit,
onClickFilter: () -> Unit,
hasActiveFilters: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -56,7 +60,12 @@ fun UpcomingScreenContent(
} }
} }
Scaffold( Scaffold(
topBar = { UpcomingToolbar() }, topBar = {
UpcomingToolbar(
hasFilters = hasActiveFilters,
onClickFilter = onClickFilter,
)
},
modifier = modifier, modifier = modifier,
) { paddingValues -> ) { paddingValues ->
if (isTabletUi()) { if (isTabletUi()) {
@@ -86,7 +95,10 @@ fun UpcomingScreenContent(
} }
@Composable @Composable
private fun UpcomingToolbar() { private fun UpcomingToolbar(
hasFilters: Boolean,
onClickFilter: () -> Unit,
) {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@@ -94,12 +106,21 @@ private fun UpcomingToolbar() {
title = stringResource(MR.strings.label_upcoming), title = stringResource(MR.strings.label_upcoming),
navigateUp = navigator::pop, navigateUp = navigator::pop,
actions = { actions = {
IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) { AppBarActions(
Icon( listOf(
imageVector = Icons.AutoMirrored.Outlined.HelpOutline, AppBar.Action(
contentDescription = stringResource(MR.strings.upcoming_guide), title = stringResource(MR.strings.action_filter),
) icon = Icons.Outlined.FilterList,
} iconTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current,
onClick = onClickFilter,
),
AppBar.Action(
title = stringResource(MR.strings.upcoming_guide),
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) },
),
),
)
}, },
) )
} }
@@ -171,6 +192,7 @@ private fun UpcomingScreenSmallImpl(
onClick = { onClickUpcoming(item.manga) }, onClick = { onClickUpcoming(item.manga) },
) )
} }
is UpcomingUIModel.Header -> { is UpcomingUIModel.Header -> {
DateHeading( DateHeading(
date = item.date, date = item.date,
@@ -222,6 +244,7 @@ private fun UpcomingScreenLargeImpl(
onClick = { onClickUpcoming(item.manga) }, onClick = { onClickUpcoming(item.manga) },
) )
} }
is UpcomingUIModel.Header -> { is UpcomingUIModel.Header -> {
DateHeading( DateHeading(
date = item.date, date = item.date,
@@ -1,12 +1,16 @@
package mihon.feature.upcoming package mihon.feature.upcoming
import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMapIndexedNotNull import androidx.compose.ui.util.fastMapIndexedNotNull
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import eu.kanade.core.util.insertSeparatorsReversed import eu.kanade.core.util.insertSeparatorsReversed
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
@@ -14,27 +18,46 @@ import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth import kotlinx.datetime.yearMonth
import mihon.core.viewmodel.StateViewModel import mihon.core.viewmodel.StateViewModel
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.upcoming.service.UpcomingPreferences
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.time.Clock import kotlin.time.Clock
class UpcomingViewModel( class UpcomingViewModel(
private val getUpcomingManga: GetUpcomingManga = Injekt.get(), private val getUpcomingManga: GetUpcomingManga = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
val upcomingPreferences: UpcomingPreferences = Injekt.get(),
) : StateViewModel<UpcomingViewModel.State>(State()) { ) : StateViewModel<UpcomingViewModel.State>(State()) {
val excludedCategories = upcomingPreferences.filterExcludedCategories
val includedCategories = upcomingPreferences.filterIncludedCategories
init { init {
viewModelScope.launch { viewModelScope.launchIO {
getUpcomingManga.subscribe().collectLatest { getUpcomingItemPreferenceFlow()
mutableState.update { state -> .distinctUntilChanged()
val upcomingItems = it.toUpcomingUIModels() .flatMapLatest {
state.copy( getUpcomingManga.subscribe(
items = upcomingItems, excludedCategories = it.filterExcludedCategories,
events = upcomingItems.toEvents(), includedCategories = it.filterIncludedCategories,
headerIndexes = upcomingItems.getHeaderIndexes(),
) )
.distinctUntilChanged()
}
.collectLatest {
mutableState.update { state ->
val upcomingItems = it.toUpcomingUIModels()
state.copy(
items = upcomingItems,
events = upcomingItems.toEvents(),
headerIndexes = upcomingItems.getHeaderIndexes(),
)
}
} }
}
} }
} }
@@ -81,6 +104,44 @@ class UpcomingViewModel(
mutableState.update { it.copy(selectedYearMonth = yearMonth) } mutableState.update { it.copy(selectedYearMonth = yearMonth) }
} }
private fun getUpcomingItemPreferenceFlow(): Flow<ItemPreferences> {
return combine(
upcomingPreferences.filterExcludedCategories.changes(),
upcomingPreferences.filterIncludedCategories.changes(),
) { excluded, included ->
ItemPreferences(
filterExcludedCategories = excluded,
filterIncludedCategories = included,
)
}
}
fun resetDialog() {
mutableState.update { it.copy(dialog = null) }
}
fun showFilterDialog() {
mutableState.update { it.copy(dialog = Dialog.FilterSheet) }
}
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 }
}
}
@Immutable
private data class ItemPreferences(
val filterExcludedCategories: List<Long>,
val filterIncludedCategories: List<Long>,
)
data class State( data class State(
val selectedYearMonth: YearMonth = Clock.System.now() val selectedYearMonth: YearMonth = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()) .toLocalDateTime(TimeZone.currentSystemDefault())
@@ -89,5 +150,11 @@ class UpcomingViewModel(
val items: List<UpcomingUIModel> = listOf(), val items: List<UpcomingUIModel> = listOf(),
val events: Map<LocalDate, Int> = mapOf(), val events: Map<LocalDate, Int> = mapOf(),
val headerIndexes: Map<LocalDate, Int> = mapOf(), val headerIndexes: Map<LocalDate, Int> = mapOf(),
val hasActiveFilters: Boolean = false,
val dialog: Dialog? = null,
) )
sealed interface Dialog {
data object FilterSheet : Dialog
}
} }
@@ -87,12 +87,24 @@ class MangaRepositoryImpl(
.awaitAsList() .awaitAsList()
} }
override suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>> { override suspend fun getUpcomingManga(
statuses: Set<Long>,
excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>> {
val timeZone = TimeZone.currentSystemDefault() val timeZone = TimeZone.currentSystemDefault()
val epochMillis = val epochMillis =
Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds() Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds()
return database.mangasQueries return database.mangasQueries
.getUpcomingManga(epochMillis, statuses, MangaMapper::mapManga) .getUpcomingManga(
startOfDay = epochMillis,
statuses = statuses,
includedEmpty = includedCategories.isEmpty(),
includedCategories = includedCategories,
excludedEmpty = excludedCategories.isEmpty(),
excludedCategories = excludedCategories,
mapper = MangaMapper::mapManga,
)
.subscribeToList() .subscribeToList()
} }
@@ -164,6 +164,33 @@ FROM mangas
WHERE next_update >= :startOfDay WHERE next_update >= :startOfDay
AND favorite = 1 AND favorite = 1
AND status IN :statuses AND status IN :statuses
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 = mangas._id
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id)
)
)
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 = mangas._id
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 = mangas._id)
)
)
)
ORDER BY next_update ASC; ORDER BY next_update ASC;
resetViewerFlags: resetViewerFlags:
@@ -14,7 +14,14 @@ class GetUpcomingManga(
SManga.PUBLISHING_FINISHED.toLong(), SManga.PUBLISHING_FINISHED.toLong(),
) )
suspend fun subscribe(): Flow<List<Manga>> { suspend fun subscribe(
return mangaRepository.getUpcomingManga(includedStatuses) excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>> {
return mangaRepository.getUpcomingManga(
includedStatuses,
excludedCategories = excludedCategories,
includedCategories = includedCategories,
)
} }
} }
@@ -28,7 +28,11 @@ interface MangaRepository {
suspend fun getDuplicateLibraryManga(id: Long, title: String): List<MangaWithChapterCount> suspend fun getDuplicateLibraryManga(id: Long, title: String): List<MangaWithChapterCount>
suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>> suspend fun getUpcomingManga(
statuses: Set<Long>,
excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>>
suspend fun resetViewerFlags(): Boolean suspend fun resetViewerFlags(): Boolean
@@ -0,0 +1,20 @@
package tachiyomi.domain.upcoming.service
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getLongArray
class UpcomingPreferences(
preferenceStore: PreferenceStore,
) {
val filterIncludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_upcoming_included_categories",
emptyList(),
)
val filterExcludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_upcoming_excluded_categories",
emptyList(),
)
}
@@ -867,6 +867,7 @@
<string name="upcoming_guide">Upcoming Guide</string> <string name="upcoming_guide">Upcoming Guide</string>
<string name="upcoming_calendar_next">Next Month</string> <string name="upcoming_calendar_next">Next Month</string>
<string name="upcoming_calendar_prev">Previous Month</string> <string name="upcoming_calendar_prev">Previous Month</string>
<string name="pref_filter_upcoming_categories_details">Titles in excluded categories are not shown, even if they are also in any of the included categories.</string>
<!-- History --> <!-- History -->
<string name="recent_manga_time">Ch. %1$s - %2$s</string> <string name="recent_manga_time">Ch. %1$s - %2$s</string>