From b97aa235480e35b5514b7b1489b9d4413cea66d9 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 5 Nov 2023 21:34:35 +0600 Subject: [PATCH] Implement scanlator filter (#8803) * Implement scanlator filter * Visual improvement to scanlator filter dialog * Review changes + Bug fixes Backup not containing filtered chapters and similar issue fix * Review Changes + Fix SQL query * Lint mamma mia --- app/build.gradle.kts | 2 +- .../java/eu/kanade/domain/DomainModule.kt | 8 +- .../interactor/GetAvailableScanlators.kt | 24 ++++ .../interactor/SyncChaptersWithSource.kt | 8 +- .../manga/interactor/GetExcludedScanlators.kt | 24 ++++ .../manga/interactor/SetExcludedScanlators.kt | 22 +++ .../manga/ChapterSettingsDialog.kt | 49 +++++++ .../kanade/presentation/manga/MangaScreen.kt | 5 +- .../manga/components/ScanlatorFilterDialog.kt | 134 ++++++++++++++++++ .../tachiyomi/data/backup/BackupCreator.kt | 12 +- .../ui/library/LibraryScreenModel.kt | 2 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 16 +++ .../tachiyomi/ui/manga/MangaScreenModel.kt | 48 ++++++- .../tachiyomi/ui/reader/ReaderViewModel.kt | 2 +- .../data/chapter/ChapterRepositoryImpl.kt | 26 +++- .../sqldelight/tachiyomi/data/chapters.sq | 14 +- .../tachiyomi/data/excluded_scanlators.sq | 22 +++ .../sqldelight/tachiyomi/migrations/23.sqm | 2 +- .../sqldelight/tachiyomi/migrations/26.sqm | 44 ++++++ .../sqldelight/tachiyomi/view/libraryView.sq | 4 + .../interactor/GetChaptersByMangaId.kt | 4 +- .../chapter/repository/ChapterRepository.kt | 8 +- .../history/interactor/GetNextChapters.kt | 2 +- .../domain/manga/interactor/FetchInterval.kt | 2 +- .../manga/interactor/GetMangaWithChapters.kt | 8 +- i18n/src/main/res/values/strings.xml | 3 + 26 files changed, 462 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt create mode 100644 app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt create mode 100644 app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt create mode 100644 data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/26.sqm diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3c2aa74fa..943d88031c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 108 + versionCode = 109 versionName = "0.14.7" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 49c1308525..778b7645c7 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -1,11 +1,14 @@ package eu.kanade.domain +import eu.kanade.domain.chapter.interactor.GetAvailableScanlators import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.extension.interactor.GetExtensionSources import eu.kanade.domain.extension.interactor.GetExtensionsByType +import eu.kanade.domain.manga.interactor.GetExcludedScanlators +import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.source.interactor.GetEnabledSources @@ -112,6 +115,8 @@ class DomainModule : InjektModule { addFactory { NetworkToLocalManga(get()) } addFactory { UpdateManga(get(), get()) } addFactory { SetMangaCategories(get()) } + addFactory { GetExcludedScanlators(get()) } + addFactory { SetExcludedScanlators(get()) } addSingletonFactory { ReleaseServiceImpl(get(), get()) } addFactory { GetApplicationRelease(get(), get()) } @@ -133,7 +138,8 @@ class DomainModule : InjektModule { addFactory { UpdateChapter(get()) } addFactory { SetReadStatus(get(), get(), get(), get()) } addFactory { ShouldUpdateDbChapter() } - addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) } + addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get(), get()) } + addFactory { GetAvailableScanlators(get()) } addSingletonFactory { HistoryRepositoryImpl(get()) } addFactory { GetHistory(get()) } diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt new file mode 100644 index 0000000000..13bd35e1ff --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/GetAvailableScanlators.kt @@ -0,0 +1,24 @@ +package eu.kanade.domain.chapter.interactor + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import tachiyomi.domain.chapter.repository.ChapterRepository + +class GetAvailableScanlators( + private val repository: ChapterRepository, +) { + + private fun List.cleanupAvailableScanlators(): Set { + return mapNotNull { it.ifBlank { null } }.toSet() + } + + suspend fun await(mangaId: Long): Set { + return repository.getScanlatorsByMangaId(mangaId) + .cleanupAvailableScanlators() + } + + fun subscribe(mangaId: Long): Flow> { + return repository.getScanlatorsByMangaIdAsFlow(mangaId) + .map { it.cleanupAvailableScanlators() } + } +} diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index fd878d5f8c..1690180b2d 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -2,6 +2,7 @@ package eu.kanade.domain.chapter.interactor import eu.kanade.domain.chapter.model.copyFromSChapter import eu.kanade.domain.chapter.model.toSChapter +import eu.kanade.domain.manga.interactor.GetExcludedScanlators import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.model.toSManga import eu.kanade.tachiyomi.data.download.DownloadManager @@ -33,6 +34,7 @@ class SyncChaptersWithSource( private val updateManga: UpdateManga, private val updateChapter: UpdateChapter, private val getChaptersByMangaId: GetChaptersByMangaId, + private val getExcludedScanlators: GetExcludedScanlators, ) { /** @@ -208,6 +210,10 @@ class SyncChaptersWithSource( val reAddedUrls = reAdded.map { it.url }.toHashSet() - return updatedToAdd.filterNot { it.url in reAddedUrls } + val excludedScanlators = getExcludedScanlators.await(manga.id).toHashSet() + + return updatedToAdd.filterNot { + it.url in reAddedUrls || it.scanlator in excludedScanlators + } } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt new file mode 100644 index 0000000000..dc326f209b --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt @@ -0,0 +1,24 @@ +package eu.kanade.domain.manga.interactor + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import tachiyomi.data.DatabaseHandler + +class GetExcludedScanlators( + private val handler: DatabaseHandler, +) { + + suspend fun await(mangaId: Long): Set { + return handler.awaitList { + excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId) + } + .toSet() + } + + fun subscribe(mangaId: Long): Flow> { + return handler.subscribeToList { + excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId) + } + .map { it.toSet() } + } +} diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt new file mode 100644 index 0000000000..a52fb9afd1 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt @@ -0,0 +1,22 @@ +package eu.kanade.domain.manga.interactor + +import tachiyomi.data.DatabaseHandler + +class SetExcludedScanlators( + private val handler: DatabaseHandler, +) { + + suspend fun await(mangaId: Long, excludedScanlators: Set) { + handler.await(inTransaction = true) { + val currentExcluded = handler.awaitList { + excluded_scanlatorsQueries.getExcludedScanlatorsByMangaId(mangaId) + }.toSet() + val toAdd = excludedScanlators.minus(currentExcluded) + for (scanlator in toAdd) { + excluded_scanlatorsQueries.insert(mangaId, scanlator) + } + val toRemove = currentExcluded.minus(excludedScanlators) + excluded_scanlatorsQueries.remove(mangaId, toRemove) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt index 7370a14491..e296681770 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/ChapterSettingsDialog.kt @@ -1,13 +1,21 @@ package eu.kanade.presentation.manga +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.outlined.PeopleAlt import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -15,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -29,6 +38,7 @@ import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.RadioItem import tachiyomi.presentation.core.components.SortItem import tachiyomi.presentation.core.components.TriStateItem +import tachiyomi.presentation.core.theme.active @Composable fun ChapterSettingsDialog( @@ -37,6 +47,8 @@ fun ChapterSettingsDialog( onDownloadFilterChanged: (TriState) -> Unit, onUnreadFilterChanged: (TriState) -> Unit, onBookmarkedFilterChanged: (TriState) -> Unit, + scanlatorFilterActive: Boolean, + onScanlatorFilterClicked: (() -> Unit), onSortModeChanged: (Long) -> Unit, onDisplayModeChanged: (Long) -> Unit, onSetAsDefault: (applyToExistingManga: Boolean) -> Unit, @@ -89,6 +101,8 @@ fun ChapterSettingsDialog( onUnreadFilterChanged = onUnreadFilterChanged, bookmarkedFilter = manga?.bookmarkedFilter ?: TriState.DISABLED, onBookmarkedFilterChanged = onBookmarkedFilterChanged, + scanlatorFilterActive = scanlatorFilterActive, + onScanlatorFilterClicked = onScanlatorFilterClicked, ) } 1 -> { @@ -117,6 +131,8 @@ private fun ColumnScope.FilterPage( onUnreadFilterChanged: (TriState) -> Unit, bookmarkedFilter: TriState, onBookmarkedFilterChanged: (TriState) -> Unit, + scanlatorFilterActive: Boolean, + onScanlatorFilterClicked: (() -> Unit), ) { TriStateItem( label = stringResource(R.string.label_downloaded), @@ -133,6 +149,39 @@ private fun ColumnScope.FilterPage( state = bookmarkedFilter, onClick = onBookmarkedFilterChanged, ) + ScanlatorFilterItem( + active = scanlatorFilterActive, + onClick = onScanlatorFilterClicked, + ) +} + +@Composable +fun ScanlatorFilterItem( + active: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxWidth() + .padding(horizontal = TabbedDialogPaddings.Horizontal, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + Icon( + imageVector = Icons.Outlined.PeopleAlt, + contentDescription = null, + tint = if (active) { + MaterialTheme.colorScheme.active + } else { + LocalContentColor.current + }, + ) + Text( + text = stringResource(R.string.scanlator), + style = MaterialTheme.typography.bodyMedium, + ) + } } @Composable diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 698db525be..6d5ebc39eb 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap -import eu.kanade.domain.manga.model.chaptersFiltered import eu.kanade.presentation.manga.components.ChapterDownloadAction import eu.kanade.presentation.manga.components.ChapterHeader import eu.kanade.presentation.manga.components.ExpandableMangaDescription @@ -308,7 +307,7 @@ private fun MangaScreenSmallImpl( title = state.manga.title, titleAlphaProvider = { animatedTitleAlpha }, backgroundAlphaProvider = { animatedBgAlpha }, - hasFilters = state.manga.chaptersFiltered(), + hasFilters = state.filterActive, onBackClicked = internalOnBackPressed, onClickFilter = onFilterClicked, onClickShare = onShareClicked, @@ -561,7 +560,7 @@ fun MangaScreenLargeImpl( title = state.manga.title, titleAlphaProvider = { if (isAnySelected) 1f else 0f }, backgroundAlphaProvider = { 1f }, - hasFilters = state.manga.chaptersFiltered(), + hasFilters = state.filterActive, onBackClicked = internalOnBackPressed, onClickFilter = onFilterButtonClicked, onClickShare = onShareClicked, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt new file mode 100644 index 0000000000..07d3510d31 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/ScanlatorFilterDialog.kt @@ -0,0 +1,134 @@ +package eu.kanade.presentation.manga.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank +import androidx.compose.material.icons.rounded.DisabledByDefault +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import eu.kanade.tachiyomi.R +import tachiyomi.presentation.core.components.material.TextButton +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.isScrolledToEnd +import tachiyomi.presentation.core.util.isScrolledToStart + +@Composable +fun ScanlatorFilterDialog( + availableScanlators: Set, + excludedScanlators: Set, + onDismissRequest: () -> Unit, + onConfirm: (Set) -> Unit, +) { + val sortedAvailableScanlators = remember(availableScanlators) { + availableScanlators.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it }) + } + val mutableExcludedScanlators = remember(excludedScanlators) { excludedScanlators.toMutableStateList() } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.exclude_scanlators)) }, + text = textFunc@{ + if (sortedAvailableScanlators.isEmpty()) { + Text(text = stringResource(R.string.no_scanlators_found)) + return@textFunc + } + Box { + val state = rememberLazyListState() + LazyColumn(state = state) { + sortedAvailableScanlators.forEach { scanlator -> + item { + val isExcluded = mutableExcludedScanlators.contains(scanlator) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + if (isExcluded) { + mutableExcludedScanlators.remove(scanlator) + } else { + mutableExcludedScanlators.add(scanlator) + } + } + .minimumInteractiveComponentSize() + .clip(MaterialTheme.shapes.small) + .fillMaxWidth() + .padding(horizontal = MaterialTheme.padding.small), + ) { + Icon( + imageVector = if (isExcluded) { + Icons.Rounded.DisabledByDefault + } else { + Icons.Rounded.CheckBoxOutlineBlank + }, + tint = if (isExcluded) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + }, + contentDescription = null, + ) + Text( + text = scanlator, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 24.dp), + ) + } + } + } + } + if (!state.isScrolledToStart()) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + if (!state.isScrolledToEnd()) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + FlowRow { + if (sortedAvailableScanlators.isEmpty()) { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + return@FlowRow + } + TextButton(onClick = mutableExcludedScanlators::clear) { + Text(text = stringResource(R.string.action_reset)) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + TextButton( + onClick = { + onConfirm(mutableExcludedScanlators.toSet()) + onDismissRequest() + }, + ) { + Text(text = stringResource(R.string.action_ok)) + } + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt index 37c9e73f0f..402bd0d940 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreator.kt @@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.models.BackupChapter import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupPreference @@ -189,10 +190,15 @@ class BackupCreator( // Check if user wants chapter information in backup if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { // Backup all the chapters - val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } - if (chapters.isNotEmpty()) { - mangaObject.chapters = chapters + handler.awaitList { + chaptersQueries.getChaptersByMangaId( + mangaId = manga.id, + applyScanlatorFilter = 0, // false + mapper = backupChapterMapper, + ) } + .takeUnless(List::isEmpty) + ?.let { mangaObject.chapters = it } } // Check if user wants category information in backup diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 0bc2d8b5dc..c33b6cd121 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -414,7 +414,7 @@ class LibraryScreenModel( } suspend fun getNextUnreadChapter(manga: Manga): Chapter? { - return getChaptersByMangaId.await(manga.id).getNextUnread(manga, downloadManager) + return getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true).getNextUnread(manga, downloadManager) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index a4504e2375..0931ea1fe3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -9,8 +9,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -30,6 +32,7 @@ import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.components.DeleteChaptersDialog import eu.kanade.presentation.manga.components.MangaCoverDialog +import eu.kanade.presentation.manga.components.ScanlatorFilterDialog import eu.kanade.presentation.manga.components.SetIntervalDialog import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.Screen @@ -152,6 +155,8 @@ class MangaScreen( onInvertSelection = screenModel::invertSelection, ) + var showScanlatorsDialog by remember { mutableStateOf(false) } + val onDismissRequest = { screenModel.dismissDialog() } when (val dialog = successState.dialog) { null -> {} @@ -189,6 +194,8 @@ class MangaScreen( onDisplayModeChanged = screenModel::setDisplayMode, onSetAsDefault = screenModel::setCurrentSettingsAsDefault, onResetToDefault = screenModel::resetToDefaultSettings, + scanlatorFilterActive = successState.scanlatorFilterActive, + onScanlatorFilterClicked = { showScanlatorsDialog = true }, ) MangaScreenModel.Dialog.TrackSheet -> { NavigatorAdaptiveSheet( @@ -235,6 +242,15 @@ class MangaScreen( ) } } + + if (showScanlatorsDialog) { + ScanlatorFilterDialog( + availableScanlators = successState.availableScanlators, + excludedScanlators = successState.excludedScanlators, + onDismissRequest = { showScanlatorsDialog = false }, + onConfirm = screenModel::setExcludedScanlators, + ) + } } private fun continueReading(context: Context, unreadChapter: Chapter?) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index 004414bc5d..3f084c9169 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -11,9 +11,13 @@ import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.core.preference.asState import eu.kanade.core.util.addOrRemove import eu.kanade.core.util.insertSeparators +import eu.kanade.domain.chapter.interactor.GetAvailableScanlators import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.manga.interactor.GetExcludedScanlators +import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.chaptersFiltered import eu.kanade.domain.manga.model.downloadedFilter import eu.kanade.domain.manga.model.toSManga import eu.kanade.domain.track.interactor.AddTracks @@ -92,6 +96,9 @@ class MangaScreenModel( private val downloadCache: DownloadCache = Injekt.get(), private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(), private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(), + private val getAvailableScanlators: GetAvailableScanlators = Injekt.get(), + private val getExcludedScanlators: GetExcludedScanlators = Injekt.get(), + private val setExcludedScanlators: SetExcludedScanlators = Injekt.get(), private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(), private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(), private val setReadStatus: SetReadStatus = Injekt.get(), @@ -154,7 +161,7 @@ class MangaScreenModel( init { screenModelScope.launchIO { combine( - getMangaAndChapters.subscribe(mangaId).distinctUntilChanged(), + getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true).distinctUntilChanged(), downloadCache.changes, downloadManager.queueState, ) { mangaAndChapters, _, _ -> mangaAndChapters } @@ -168,11 +175,31 @@ class MangaScreenModel( } } + screenModelScope.launchIO { + getExcludedScanlators.subscribe(mangaId) + .distinctUntilChanged() + .collectLatest { excludedScanlators -> + updateSuccessState { + it.copy(excludedScanlators = excludedScanlators) + } + } + } + + screenModelScope.launchIO { + getAvailableScanlators.subscribe(mangaId) + .distinctUntilChanged() + .collectLatest { availableScanlators -> + updateSuccessState { + it.copy(availableScanlators = availableScanlators) + } + } + } + observeDownloads() screenModelScope.launchIO { val manga = getMangaAndChapters.awaitManga(mangaId) - val chapters = getMangaAndChapters.awaitChapters(mangaId) + val chapters = getMangaAndChapters.awaitChapters(mangaId, applyScanlatorFilter = true) .toChapterListItems(manga) if (!manga.favorite) { @@ -189,6 +216,8 @@ class MangaScreenModel( source = Injekt.get().getOrStub(manga.source), isFromSource = isFromSource, chapters = chapters, + availableScanlators = getAvailableScanlators.await(mangaId), + excludedScanlators = getExcludedScanlators.await(mangaId), isRefreshingData = needRefreshInfo || needRefreshChapter, dialog = null, ) @@ -995,6 +1024,12 @@ class MangaScreenModel( updateSuccessState { it.copy(dialog = Dialog.FullCover) } } + fun setExcludedScanlators(excludedScanlators: Set) { + screenModelScope.launchIO { + setExcludedScanlators.await(mangaId, excludedScanlators) + } + } + sealed interface State { @Immutable data object Loading : State @@ -1005,12 +1040,13 @@ class MangaScreenModel( val source: Source, val isFromSource: Boolean, val chapters: List, + val availableScanlators: Set, + val excludedScanlators: Set, val trackItems: List = emptyList(), val isRefreshingData: Boolean = false, val dialog: Dialog? = null, val hasPromptedToAddBefore: Boolean = false, ) : State { - val processedChapters by lazy { chapters.applyFilters(manga).toList() } @@ -1042,6 +1078,12 @@ class MangaScreenModel( } } + val scanlatorFilterActive: Boolean + get() = excludedScanlators.intersect(availableScanlators).isNotEmpty() + + val filterActive: Boolean + get() = scanlatorFilterActive || manga.chaptersFiltered() + val trackingAvailable: Boolean get() = trackItems.isNotEmpty() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 6718f69354..47cfa5ef14 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -147,7 +147,7 @@ class ReaderViewModel @JvmOverloads constructor( */ private val chapterList by lazy { val manga = manga!! - val chapters = runBlocking { getChaptersByMangaId.await(manga.id) } + val chapters = runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) } val selectedChapter = chapters.find { it.id == chapterId } ?: error("Requested chapter of id $chapterId not found in chapter list") diff --git a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt index 1249c8967a..e156726fa9 100644 --- a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt @@ -2,6 +2,7 @@ package tachiyomi.data.chapter import kotlinx.coroutines.flow.Flow import logcat.LogPriority +import tachiyomi.core.util.lang.toLong import tachiyomi.core.util.system.logcat import tachiyomi.data.DatabaseHandler import tachiyomi.domain.chapter.model.Chapter @@ -76,8 +77,22 @@ class ChapterRepositoryImpl( } } - override suspend fun getChapterByMangaId(mangaId: Long): List { - return handler.awaitList { chaptersQueries.getChaptersByMangaId(mangaId, ::mapChapter) } + override suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean): List { + return handler.awaitList { + chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter) + } + } + + override suspend fun getScanlatorsByMangaId(mangaId: Long): List { + return handler.awaitList { + chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } + } + } + + override fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow> { + return handler.subscribeToList { + chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } + } } override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List { @@ -93,12 +108,9 @@ class ChapterRepositoryImpl( return handler.awaitOneOrNull { chaptersQueries.getChapterById(id, ::mapChapter) } } - override suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow> { + override suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean): Flow> { return handler.subscribeToList { - chaptersQueries.getChaptersByMangaId( - mangaId, - ::mapChapter, - ) + chaptersQueries.getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter) } } diff --git a/data/src/main/sqldelight/tachiyomi/data/chapters.sq b/data/src/main/sqldelight/tachiyomi/data/chapters.sq index ef22228853..f51856c542 100644 --- a/data/src/main/sqldelight/tachiyomi/data/chapters.sq +++ b/data/src/main/sqldelight/tachiyomi/data/chapters.sq @@ -36,7 +36,19 @@ FROM chapters WHERE _id = :id; getChaptersByMangaId: -SELECT * +SELECT C.* +FROM chapters C +LEFT JOIN excluded_scanlators ES +ON C.manga_id = ES.manga_id +AND C.scanlator = ES.scanlator +WHERE C.manga_id = :mangaId +AND ( + :applyScanlatorFilter = 0 + OR ES.scanlator IS NULL +); + +getScanlatorsByMangaId: +SELECT scanlator FROM chapters WHERE manga_id = :mangaId; diff --git a/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq new file mode 100644 index 0000000000..2af2f4199b --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq @@ -0,0 +1,22 @@ +CREATE TABLE excluded_scanlators( + manga_id INTEGER NOT NULL, + scanlator TEXT NOT NULL, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); + +CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id); + +insert: +INSERT INTO excluded_scanlators(manga_id, scanlator) +VALUES (:mangaId, :scanlator); + +remove: +DELETE FROM excluded_scanlators +WHERE manga_id = :mangaId +AND scanlator IN :scanlators; + +getExcludedScanlatorsByMangaId: +SELECT scanlator +FROM excluded_scanlators +WHERE manga_id = :mangaId; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/23.sqm b/data/src/main/sqldelight/tachiyomi/migrations/23.sqm index 6b25113543..cf80a941e2 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/23.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/23.sqm @@ -20,4 +20,4 @@ FROM mangas JOIN chapters ON mangas._id = chapters.manga_id WHERE favorite = 1 AND date_fetch > date_added -ORDER BY date_fetch DESC; \ No newline at end of file +ORDER BY date_fetch DESC; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/26.sqm b/data/src/main/sqldelight/tachiyomi/migrations/26.sqm new file mode 100644 index 0000000000..b68ad43ba1 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/26.sqm @@ -0,0 +1,44 @@ +CREATE TABLE excluded_scanlators( + manga_id INTEGER NOT NULL, + scanlator TEXT NOT NULL, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); + +CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id); + +DROP VIEW IF EXISTS libraryView; + +CREATE VIEW libraryView AS +SELECT + M.*, + coalesce(C.total, 0) AS totalCount, + coalesce(C.readCount, 0) AS readCount, + coalesce(C.latestUpload, 0) AS latestUpload, + coalesce(C.fetchedAt, 0) AS chapterFetchedAt, + coalesce(C.lastRead, 0) AS lastRead, + coalesce(C.bookmarkCount, 0) AS bookmarkCount, + coalesce(MC.category_id, 0) AS category +FROM mangas M +LEFT JOIN( + SELECT + chapters.manga_id, + count(*) AS total, + sum(read) AS readCount, + coalesce(max(chapters.date_upload), 0) AS latestUpload, + coalesce(max(history.last_read), 0) AS lastRead, + coalesce(max(chapters.date_fetch), 0) AS fetchedAt, + sum(chapters.bookmark) AS bookmarkCount + FROM chapters + LEFT JOIN excluded_scanlators + ON chapters.manga_id = excluded_scanlators.manga_id + AND chapters.scanlator = excluded_scanlators.scanlator + LEFT JOIN history + ON chapters._id = history.chapter_id + WHERE excluded_scanlators.scanlator IS NULL + GROUP BY chapters.manga_id +) AS C +ON M._id = C.manga_id +LEFT JOIN mangas_categories AS MC +ON MC.manga_id = M._id +WHERE M.favorite = 1; diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index 4b1468872e..0a5d285433 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -19,8 +19,12 @@ LEFT JOIN( coalesce(max(chapters.date_fetch), 0) AS fetchedAt, sum(chapters.bookmark) AS bookmarkCount FROM chapters + LEFT JOIN excluded_scanlators + ON chapters.manga_id = excluded_scanlators.manga_id + AND chapters.scanlator = excluded_scanlators.scanlator LEFT JOIN history ON chapters._id = history.chapter_id + WHERE excluded_scanlators.scanlator IS NULL GROUP BY chapters.manga_id ) AS C ON M._id = C.manga_id diff --git a/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt b/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt index 6dcc0d32bb..66dab15c75 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/interactor/GetChaptersByMangaId.kt @@ -9,9 +9,9 @@ class GetChaptersByMangaId( private val chapterRepository: ChapterRepository, ) { - suspend fun await(mangaId: Long): List { + suspend fun await(mangaId: Long, applyScanlatorFilter: Boolean = false): List { return try { - chapterRepository.getChapterByMangaId(mangaId) + chapterRepository.getChapterByMangaId(mangaId, applyScanlatorFilter) } catch (e: Exception) { logcat(LogPriority.ERROR, e) emptyList() diff --git a/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt b/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt index 22952f9f93..ae4af5106f 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/repository/ChapterRepository.kt @@ -14,13 +14,17 @@ interface ChapterRepository { suspend fun removeChaptersWithIds(chapterIds: List) - suspend fun getChapterByMangaId(mangaId: Long): List + suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean = false): List + + suspend fun getScanlatorsByMangaId(mangaId: Long): List + + fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow> suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List suspend fun getChapterById(id: Long): Chapter? - suspend fun getChapterByMangaIdAsFlow(mangaId: Long): Flow> + suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean = false): Flow> suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter? } diff --git a/domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt b/domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt index 2cbda1bdf0..2e7fefc96d 100644 --- a/domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt +++ b/domain/src/main/java/tachiyomi/domain/history/interactor/GetNextChapters.kt @@ -20,7 +20,7 @@ class GetNextChapters( suspend fun await(mangaId: Long, onlyUnread: Boolean = true): List { val manga = getManga.await(mangaId) ?: return emptyList() - val chapters = getChaptersByMangaId.await(mangaId) + val chapters = getChaptersByMangaId.await(mangaId, applyScanlatorFilter = true) .sortedWith(getChapterSort(manga, sortDescending = false)) return if (onlyUnread) { diff --git a/domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt b/domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt index 308ed8bf6f..0a9124d16e 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/interactor/FetchInterval.kt @@ -24,7 +24,7 @@ class FetchInterval( } else { window } - val chapters = getChaptersByMangaId.await(manga.id) + val chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true) val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval( chapters, dateTime.zone, diff --git a/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt b/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt index 189fe5c1a5..4fddd81401 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/interactor/GetMangaWithChapters.kt @@ -12,10 +12,10 @@ class GetMangaWithChapters( private val chapterRepository: ChapterRepository, ) { - suspend fun subscribe(id: Long): Flow>> { + suspend fun subscribe(id: Long, applyScanlatorFilter: Boolean = false): Flow>> { return combine( mangaRepository.getMangaByIdAsFlow(id), - chapterRepository.getChapterByMangaIdAsFlow(id), + chapterRepository.getChapterByMangaIdAsFlow(id, applyScanlatorFilter), ) { manga, chapters -> Pair(manga, chapters) } @@ -25,7 +25,7 @@ class GetMangaWithChapters( return mangaRepository.getMangaById(id) } - suspend fun awaitChapters(id: Long): List { - return chapterRepository.getChapterByMangaId(id) + suspend fun awaitChapters(id: Long, applyScanlatorFilter: Boolean = false): List { + return chapterRepository.getChapterByMangaId(id, applyScanlatorFilter) } } diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index e7bf21e345..b15ee64e63 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Tracking Delete downloaded History + Scanlator More @@ -702,6 +703,8 @@ Set as default No chapters found Are you sure? + Exclude scanlators + No scanlators found Tracking