From 7e71a34256e79b03a8a8ea50334b1ccece4b7154 Mon Sep 17 00:00:00 2001 From: Roshan Varughese <40583749+Animeboynz@users.noreply.github.com> Date: Tue, 25 Feb 2025 05:20:20 +1300 Subject: [PATCH] Add button to favorite manga from history screen (#1733) --- CHANGELOG.md | 1 + .../presentation/history/HistoryScreen.kt | 5 + .../history/components/HistoryItem.kt | 13 ++ .../ui/history/HistoryScreenModel.kt | 131 ++++++++++++++++++ .../kanade/tachiyomi/ui/history/HistoryTab.kt | 38 +++++ 5 files changed, 188 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d81dad19a..8f75b00c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Added - Add option to always decode long strip images with SSIV - Added option to enable incognito per extension ([@sdaqo](https://github.com/sdaqo), [@AntsyLich](https://github.com/AntsyLich)) ([#157](https://github.com/mihonapp/mihon/pull/157)) +- Add button to favorite manga from history screen ([@Animeboynz](https://github.com/Animeboynz)) ([#1733](https://github.com/mihonapp/mihon/pull/1733)) ### Changed - Apply "Downloaded only" filter to all entries regardless of favourite status ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#1603](https://github.com/mihonapp/mihon/pull/1603)) diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index e1806237d..6b8b760d6 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -38,6 +38,7 @@ fun HistoryScreen( onSearchQueryChange: (String?) -> Unit, onClickCover: (mangaId: Long) -> Unit, onClickResume: (mangaId: Long, chapterId: Long) -> Unit, + onClickFavorite: (mangaId: Long) -> Unit, onDialogChange: (HistoryScreenModel.Dialog?) -> Unit, ) { Scaffold( @@ -84,6 +85,7 @@ fun HistoryScreen( onClickCover = { history -> onClickCover(history.mangaId) }, onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) }, onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) }, + onClickFavorite = { history -> onClickFavorite(history.mangaId) }, ) } } @@ -97,6 +99,7 @@ private fun HistoryScreenContent( onClickCover: (HistoryWithRelations) -> Unit, onClickResume: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations) -> Unit, + onClickFavorite: (HistoryWithRelations) -> Unit, ) { FastScrollLazyColumn( contentPadding = contentPadding, @@ -126,6 +129,7 @@ private fun HistoryScreenContent( onClickCover = { onClickCover(value) }, onClickResume = { onClickResume(value) }, onClickDelete = { onClickDelete(value) }, + onClickFavorite = { onClickFavorite(value) }, ) } } @@ -152,6 +156,7 @@ internal fun HistoryScreenPreviews( onClickCover = {}, onClickResume = { _, _ -> run {} }, onDialogChange = {}, + onClickFavorite = {}, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt index 4298ba43c..e8d391c03 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt +++ b/app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -39,6 +40,7 @@ fun HistoryItem( onClickCover: () -> Unit, onClickResume: () -> Unit, onClickDelete: () -> Unit, + onClickFavorite: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -82,6 +84,16 @@ fun HistoryItem( ) } + if (!history.coverData.isMangaFavorite) { + IconButton(onClick = onClickFavorite) { + Icon( + imageVector = Icons.Outlined.FavoriteBorder, + contentDescription = stringResource(MR.strings.add_to_library), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + IconButton(onClick = onClickDelete) { Icon( imageVector = Icons.Outlined.Delete, @@ -105,6 +117,7 @@ private fun HistoryItemPreviews( onClickCover = {}, onClickResume = {}, onClickDelete = {}, + onClickFavorite = {}, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt index c0c2b555f..ec9c0e6f6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryScreenModel.kt @@ -1,11 +1,16 @@ package eu.kanade.tachiyomi.ui.history +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Immutable import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.core.util.insertSeparators +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.presentation.history.HistoryUiModel import eu.kanade.tachiyomi.util.lang.toLocalDate +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -18,21 +23,41 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import logcat.LogPriority +import tachiyomi.core.common.preference.CheckboxState +import tachiyomi.core.common.preference.mapAsCheckboxState import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.interactor.SetMangaCategories +import tachiyomi.domain.category.model.Category import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.RemoveHistory import tachiyomi.domain.history.model.HistoryWithRelations +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.collections.map class HistoryScreenModel( + private val addTracks: AddTracks = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(), private val getHistory: GetHistory = Injekt.get(), + private val getManga: GetManga = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), private val removeHistory: RemoveHistory = Injekt.get(), + private val setMangaCategories: SetMangaCategories = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + val snackbarHostState: SnackbarHostState = SnackbarHostState(), + private val sourceManager: SourceManager = Injekt.get(), ) : StateScreenModel(State()) { private val _events: Channel = Channel(Channel.UNLIMITED) @@ -112,6 +137,106 @@ class HistoryScreenModel( mutableState.update { it.copy(dialog = dialog) } } + /** + * Get user categories. + * + * @return List of categories, not including the default category + */ + suspend fun getCategories(): List { + return getCategories.await().filterNot { it.isSystemCategory } + } + + private fun moveMangaToCategory(mangaId: Long, categories: Category?) { + val categoryIds = listOfNotNull(categories).map { it.id } + moveMangaToCategory(mangaId, categoryIds) + } + + private fun moveMangaToCategory(mangaId: Long, categoryIds: List) { + screenModelScope.launchIO { + setMangaCategories.await(mangaId, categoryIds) + } + } + + fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List) { + moveMangaToCategory(manga.id, categories) + if (manga.favorite) return + + screenModelScope.launchIO { + updateManga.awaitUpdateFavorite(manga.id, true) + } + } + + private suspend fun getMangaCategoryIds(manga: Manga): List { + return getCategories.await(manga.id) + .map { it.id } + } + + fun addFavorite(mangaId: Long) { + screenModelScope.launchIO { + val manga = getManga.await(mangaId) ?: return@launchIO + + val duplicate = getDuplicateLibraryManga.await(manga).getOrNull(0) + if (duplicate != null) { + mutableState.update { it.copy(dialog = Dialog.DuplicateManga(manga, duplicate)) } + return@launchIO + } + + addFavorite(manga) + } + } + + fun addFavorite(manga: Manga) { + screenModelScope.launchIO { + // Move to default category if applicable + val categories = getCategories() + val defaultCategoryId = libraryPreferences.defaultCategory().get().toLong() + val defaultCategory = categories.find { it.id == defaultCategoryId } + + when { + // Default category set + defaultCategory != null -> { + val result = updateManga.awaitUpdateFavorite(manga.id, true) + if (!result) return@launchIO + moveMangaToCategory(manga.id, defaultCategory) + } + + // Automatic 'Default' or no categories + defaultCategoryId == 0L || categories.isEmpty() -> { + val result = updateManga.awaitUpdateFavorite(manga.id, true) + if (!result) return@launchIO + moveMangaToCategory(manga.id, null) + } + + // Choose a category + else -> showChangeCategoryDialog(manga) + } + + // Sync with tracking services if applicable + addTracks.bindEnhancedTrackers(manga, sourceManager.getOrStub(manga.source)) + } + } + + fun showMigrateDialog(currentManga: Manga, duplicate: Manga) { + mutableState.update { currentState -> + currentState.copy(dialog = Dialog.Migrate(newManga = currentManga, oldManga = duplicate)) + } + } + + fun showChangeCategoryDialog(manga: Manga) { + screenModelScope.launch { + val categories = getCategories() + val selection = getMangaCategoryIds(manga) + mutableState.update { currentState -> + currentState.copy( + dialog = Dialog.ChangeCategory( + manga = manga, + initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(), + ), + ) + } + } + } + @Immutable data class State( val searchQuery: String? = null, @@ -122,6 +247,12 @@ class HistoryScreenModel( sealed interface Dialog { data object DeleteAll : Dialog data class Delete(val history: HistoryWithRelations) : Dialog + data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog + data class ChangeCategory( + val manga: Manga, + val initialSelection: ImmutableList>, + ) : Dialog + data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog } sealed interface Event { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt index 575f586f9..5c4b04477 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt @@ -16,11 +16,16 @@ import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions +import eu.kanade.presentation.category.components.ChangeCategoryDialog import eu.kanade.presentation.history.HistoryScreen import eu.kanade.presentation.history.components.HistoryDeleteAllDialog import eu.kanade.presentation.history.components.HistoryDeleteDialog +import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog +import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel +import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.reader.ReaderActivity @@ -68,6 +73,7 @@ data object HistoryTab : Tab { onClickCover = { navigator.push(MangaScreen(it)) }, onClickResume = screenModel::getNextChapterForManga, onDialogChange = screenModel::setDialog, + onClickFavorite = screenModel::addFavorite, ) val onDismissRequest = { screenModel.setDialog(null) } @@ -90,6 +96,38 @@ data object HistoryTab : Tab { onDelete = screenModel::removeAllHistory, ) } + is HistoryScreenModel.Dialog.DuplicateManga -> { + DuplicateMangaDialog( + onDismissRequest = onDismissRequest, + onConfirm = { + screenModel.addFavorite(dialog.manga) + }, + onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) }, + onMigrate = { + screenModel.showMigrateDialog(dialog.manga, dialog.duplicate) + }, + ) + } + is HistoryScreenModel.Dialog.ChangeCategory -> { + ChangeCategoryDialog( + initialSelection = dialog.initialSelection, + onDismissRequest = onDismissRequest, + onEditCategories = { navigator.push(CategoryScreen()) }, + onConfirm = { include, _ -> + screenModel.moveMangaToCategoriesAndAddToLibrary(dialog.manga, include) + }, + ) + } + is HistoryScreenModel.Dialog.Migrate -> { + MigrateDialog( + oldManga = dialog.oldManga, + newManga = dialog.newManga, + screenModel = MigrateDialogScreenModel(), + onDismissRequest = onDismissRequest, + onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) }, + onPopScreen = { navigator.replace(MangaScreen(dialog.newManga.id)) }, + ) + } null -> {} }