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
@@ -17,6 +17,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.upcoming.service.UpcomingPreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
@@ -47,6 +48,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
LibraryPreferences(get())
}
addSingletonFactory {
UpcomingPreferences(get())
}
addSingletonFactory {
UpdatesPreferences(get())
}
@@ -1,13 +1,38 @@
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.collectAsState
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 cafe.adriel.voyager.navigator.LocalNavigator
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.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() {
@@ -18,10 +43,93 @@ class UpcomingScreen : Screen() {
val viewModel = viewModel<UpcomingViewModel>()
val state by viewModel.state.collectAsState()
when (state.dialog) {
is UpcomingViewModel.Dialog.FilterSheet -> {
UpcomingFilterDialog(
viewModel = viewModel,
)
}
null -> {}
}
UpcomingScreenContent(
state = state,
setSelectedYearMonth = viewModel::setSelectedYearMonth,
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.util.isTabletUi
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.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.theme.active
@Composable
fun UpcomingScreenContent(
state: UpcomingViewModel.State,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit,
onClickFilter: () -> Unit,
hasActiveFilters: Boolean,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -56,7 +60,12 @@ fun UpcomingScreenContent(
}
}
Scaffold(
topBar = { UpcomingToolbar() },
topBar = {
UpcomingToolbar(
hasFilters = hasActiveFilters,
onClickFilter = onClickFilter,
)
},
modifier = modifier,
) { paddingValues ->
if (isTabletUi()) {
@@ -86,7 +95,10 @@ fun UpcomingScreenContent(
}
@Composable
private fun UpcomingToolbar() {
private fun UpcomingToolbar(
hasFilters: Boolean,
onClickFilter: () -> Unit,
) {
val navigator = LocalNavigator.currentOrThrow
val uriHandler = LocalUriHandler.current
@@ -94,12 +106,21 @@ private fun UpcomingToolbar() {
title = stringResource(MR.strings.label_upcoming),
navigateUp = navigator::pop,
actions = {
IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
contentDescription = stringResource(MR.strings.upcoming_guide),
)
}
AppBarActions(
listOf(
AppBar.Action(
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) },
)
}
is UpcomingUIModel.Header -> {
DateHeading(
date = item.date,
@@ -222,6 +244,7 @@ private fun UpcomingScreenLargeImpl(
onClick = { onClickUpcoming(item.manga) },
)
}
is UpcomingUIModel.Header -> {
DateHeading(
date = item.date,
@@ -1,12 +1,16 @@
package mihon.feature.upcoming
import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMapIndexedNotNull
import androidx.lifecycle.viewModelScope
import eu.kanade.core.util.insertSeparatorsReversed
import kotlinx.coroutines.flow.Flow
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.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
@@ -14,27 +18,46 @@ import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth
import mihon.core.viewmodel.StateViewModel
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.upcoming.service.UpcomingPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.time.Clock
class UpcomingViewModel(
private val getUpcomingManga: GetUpcomingManga = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
val upcomingPreferences: UpcomingPreferences = Injekt.get(),
) : StateViewModel<UpcomingViewModel.State>(State()) {
val excludedCategories = upcomingPreferences.filterExcludedCategories
val includedCategories = upcomingPreferences.filterIncludedCategories
init {
viewModelScope.launch {
getUpcomingManga.subscribe().collectLatest {
mutableState.update { state ->
val upcomingItems = it.toUpcomingUIModels()
state.copy(
items = upcomingItems,
events = upcomingItems.toEvents(),
headerIndexes = upcomingItems.getHeaderIndexes(),
viewModelScope.launchIO {
getUpcomingItemPreferenceFlow()
.distinctUntilChanged()
.flatMapLatest {
getUpcomingManga.subscribe(
excludedCategories = it.filterExcludedCategories,
includedCategories = it.filterIncludedCategories,
)
.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) }
}
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(
val selectedYearMonth: YearMonth = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
@@ -89,5 +150,11 @@ class UpcomingViewModel(
val items: List<UpcomingUIModel> = listOf(),
val events: 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
}
}