Add category filters to Upcoming calendar (#3607)
This commit is contained in:
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
## [Unreleased]
|
||||
### 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 Upcoming calendar ([@MajorTanya](https://github.com/MajorTanya)) ([#3607](https://github.com/mihonapp/mihon/pull/3607))
|
||||
|
||||
### Changed
|
||||
- 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.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,18 +18,37 @@ 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 {
|
||||
viewModelScope.launchIO {
|
||||
getUpcomingItemPreferenceFlow()
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest {
|
||||
getUpcomingManga.subscribe(
|
||||
excludedCategories = it.filterExcludedCategories,
|
||||
includedCategories = it.filterIncludedCategories,
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
.collectLatest {
|
||||
mutableState.update { state ->
|
||||
val upcomingItems = it.toUpcomingUIModels()
|
||||
state.copy(
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +87,24 @@ class MangaRepositoryImpl(
|
||||
.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 epochMillis =
|
||||
Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds()
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,33 @@ FROM mangas
|
||||
WHERE next_update >= :startOfDay
|
||||
AND favorite = 1
|
||||
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;
|
||||
|
||||
resetViewerFlags:
|
||||
|
||||
@@ -14,7 +14,14 @@ class GetUpcomingManga(
|
||||
SManga.PUBLISHING_FINISHED.toLong(),
|
||||
)
|
||||
|
||||
suspend fun subscribe(): Flow<List<Manga>> {
|
||||
return mangaRepository.getUpcomingManga(includedStatuses)
|
||||
suspend fun subscribe(
|
||||
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 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
|
||||
|
||||
|
||||
@@ -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_calendar_next">Next 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 -->
|
||||
<string name="recent_manga_time">Ch. %1$s - %2$s</string>
|
||||
|
||||
Reference in New Issue
Block a user