diff --git a/CHANGELOG.md b/CHANGELOG.md index 485f41688..296e34f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index da1e7b451..e8e3e9864 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -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()) } diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt index 66df7908e..e65a02d54 100644 --- a/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreen.kt @@ -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() 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) + } + } +} diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt index e20202901..33dc1ad17 100644 --- a/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingScreenContent.kt @@ -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, diff --git a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt index e07caca27..bb26301c9 100644 --- a/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt +++ b/app/src/main/java/mihon/feature/upcoming/UpcomingViewModel.kt @@ -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(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 { + 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, + val filterIncludedCategories: List, + ) + data class State( val selectedYearMonth: YearMonth = Clock.System.now() .toLocalDateTime(TimeZone.currentSystemDefault()) @@ -89,5 +150,11 @@ class UpcomingViewModel( val items: List = listOf(), val events: Map = mapOf(), val headerIndexes: Map = mapOf(), + val hasActiveFilters: Boolean = false, + val dialog: Dialog? = null, ) + + sealed interface Dialog { + data object FilterSheet : Dialog + } } diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index 6ad2104dd..74820f142 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -87,12 +87,24 @@ class MangaRepositoryImpl( .awaitAsList() } - override suspend fun getUpcomingManga(statuses: Set): Flow> { + override suspend fun getUpcomingManga( + statuses: Set, + excludedCategories: List, + includedCategories: List, + ): Flow> { 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() } diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas.sq b/data/src/main/sqldelight/tachiyomi/data/mangas.sq index 65f647a9d..b5d1909f7 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas.sq @@ -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: diff --git a/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt b/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt index dd618b955..cf63415a1 100644 --- a/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt +++ b/domain/src/main/java/mihon/domain/upcoming/interactor/GetUpcomingManga.kt @@ -14,7 +14,14 @@ class GetUpcomingManga( SManga.PUBLISHING_FINISHED.toLong(), ) - suspend fun subscribe(): Flow> { - return mangaRepository.getUpcomingManga(includedStatuses) + suspend fun subscribe( + excludedCategories: List, + includedCategories: List, + ): Flow> { + return mangaRepository.getUpcomingManga( + includedStatuses, + excludedCategories = excludedCategories, + includedCategories = includedCategories, + ) } } diff --git a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt index f3b817479..e1111ec57 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt @@ -28,7 +28,11 @@ interface MangaRepository { suspend fun getDuplicateLibraryManga(id: Long, title: String): List - suspend fun getUpcomingManga(statuses: Set): Flow> + suspend fun getUpcomingManga( + statuses: Set, + excludedCategories: List, + includedCategories: List, + ): Flow> suspend fun resetViewerFlags(): Boolean diff --git a/domain/src/main/java/tachiyomi/domain/upcoming/service/UpcomingPreferences.kt b/domain/src/main/java/tachiyomi/domain/upcoming/service/UpcomingPreferences.kt new file mode 100644 index 000000000..7c3c6acf9 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/upcoming/service/UpcomingPreferences.kt @@ -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> = preferenceStore.getLongArray( + "pref_filter_upcoming_included_categories", + emptyList(), + ) + + val filterExcludedCategories: Preference> = preferenceStore.getLongArray( + "pref_filter_upcoming_excluded_categories", + emptyList(), + ) +} diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 05f54924a..ccba4a401 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -867,6 +867,7 @@ Upcoming Guide Next Month Previous Month + Titles in excluded categories are not shown, even if they are also in any of the included categories. Ch. %1$s - %2$s