Add button to favorite manga from history screen (#1733)

This commit is contained in:
Roshan Varughese 2025-02-25 05:20:20 +13:00 committed by GitHub
parent 29ee53f461
commit 7e71a34256
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 0 deletions

View File

@ -14,6 +14,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### Added ### Added
- Add option to always decode long strip images with SSIV - 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)) - 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 ### 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)) - 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))

View File

@ -38,6 +38,7 @@ fun HistoryScreen(
onSearchQueryChange: (String?) -> Unit, onSearchQueryChange: (String?) -> Unit,
onClickCover: (mangaId: Long) -> Unit, onClickCover: (mangaId: Long) -> Unit,
onClickResume: (mangaId: Long, chapterId: Long) -> Unit, onClickResume: (mangaId: Long, chapterId: Long) -> Unit,
onClickFavorite: (mangaId: Long) -> Unit,
onDialogChange: (HistoryScreenModel.Dialog?) -> Unit, onDialogChange: (HistoryScreenModel.Dialog?) -> Unit,
) { ) {
Scaffold( Scaffold(
@ -84,6 +85,7 @@ fun HistoryScreen(
onClickCover = { history -> onClickCover(history.mangaId) }, onClickCover = { history -> onClickCover(history.mangaId) },
onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) }, onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) },
onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) }, onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) },
onClickFavorite = { history -> onClickFavorite(history.mangaId) },
) )
} }
} }
@ -97,6 +99,7 @@ private fun HistoryScreenContent(
onClickCover: (HistoryWithRelations) -> Unit, onClickCover: (HistoryWithRelations) -> Unit,
onClickResume: (HistoryWithRelations) -> Unit, onClickResume: (HistoryWithRelations) -> Unit,
onClickDelete: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations) -> Unit,
onClickFavorite: (HistoryWithRelations) -> Unit,
) { ) {
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@ -126,6 +129,7 @@ private fun HistoryScreenContent(
onClickCover = { onClickCover(value) }, onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) }, onClickResume = { onClickResume(value) },
onClickDelete = { onClickDelete(value) }, onClickDelete = { onClickDelete(value) },
onClickFavorite = { onClickFavorite(value) },
) )
} }
} }
@ -152,6 +156,7 @@ internal fun HistoryScreenPreviews(
onClickCover = {}, onClickCover = {},
onClickResume = { _, _ -> run {} }, onClickResume = { _, _ -> run {} },
onDialogChange = {}, onDialogChange = {},
onClickFavorite = {},
) )
} }
} }

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -39,6 +40,7 @@ fun HistoryItem(
onClickCover: () -> Unit, onClickCover: () -> Unit,
onClickResume: () -> Unit, onClickResume: () -> Unit,
onClickDelete: () -> Unit, onClickDelete: () -> Unit,
onClickFavorite: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( 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) { IconButton(onClick = onClickDelete) {
Icon( Icon(
imageVector = Icons.Outlined.Delete, imageVector = Icons.Outlined.Delete,
@ -105,6 +117,7 @@ private fun HistoryItemPreviews(
onClickCover = {}, onClickCover = {},
onClickResume = {}, onClickResume = {},
onClickDelete = {}, onClickDelete = {},
onClickFavorite = {},
) )
} }
} }

View File

@ -1,11 +1,16 @@
package eu.kanade.tachiyomi.ui.history package eu.kanade.tachiyomi.ui.history
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.insertSeparators 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.presentation.history.HistoryUiModel
import eu.kanade.tachiyomi.util.lang.toLocalDate import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -18,21 +23,41 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority 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.launchIO
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat 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.chapter.model.Chapter
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.RemoveHistory import tachiyomi.domain.history.interactor.RemoveHistory
import tachiyomi.domain.history.model.HistoryWithRelations 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.collections.map
class HistoryScreenModel( 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 getHistory: GetHistory = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val removeHistory: RemoveHistory = 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<HistoryScreenModel.State>(State()) { ) : StateScreenModel<HistoryScreenModel.State>(State()) {
private val _events: Channel<Event> = Channel(Channel.UNLIMITED) private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
@ -112,6 +137,106 @@ class HistoryScreenModel(
mutableState.update { it.copy(dialog = dialog) } mutableState.update { it.copy(dialog = dialog) }
} }
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
suspend fun getCategories(): List<Category> {
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<Long>) {
screenModelScope.launchIO {
setMangaCategories.await(mangaId, categoryIds)
}
}
fun moveMangaToCategoriesAndAddToLibrary(manga: Manga, categories: List<Long>) {
moveMangaToCategory(manga.id, categories)
if (manga.favorite) return
screenModelScope.launchIO {
updateManga.awaitUpdateFavorite(manga.id, true)
}
}
private suspend fun getMangaCategoryIds(manga: Manga): List<Long> {
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 @Immutable
data class State( data class State(
val searchQuery: String? = null, val searchQuery: String? = null,
@ -122,6 +247,12 @@ class HistoryScreenModel(
sealed interface Dialog { sealed interface Dialog {
data object DeleteAll : Dialog data object DeleteAll : Dialog
data class Delete(val history: HistoryWithRelations) : 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<CheckboxState<Category>>,
) : Dialog
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
} }
sealed interface Event { sealed interface Event {

View File

@ -16,11 +16,16 @@ import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions 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.HistoryScreen
import eu.kanade.presentation.history.components.HistoryDeleteAllDialog import eu.kanade.presentation.history.components.HistoryDeleteAllDialog
import eu.kanade.presentation.history.components.HistoryDeleteDialog import eu.kanade.presentation.history.components.HistoryDeleteDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.util.Tab import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R 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.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@ -68,6 +73,7 @@ data object HistoryTab : Tab {
onClickCover = { navigator.push(MangaScreen(it)) }, onClickCover = { navigator.push(MangaScreen(it)) },
onClickResume = screenModel::getNextChapterForManga, onClickResume = screenModel::getNextChapterForManga,
onDialogChange = screenModel::setDialog, onDialogChange = screenModel::setDialog,
onClickFavorite = screenModel::addFavorite,
) )
val onDismissRequest = { screenModel.setDialog(null) } val onDismissRequest = { screenModel.setDialog(null) }
@ -90,6 +96,38 @@ data object HistoryTab : Tab {
onDelete = screenModel::removeAllHistory, 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 -> {} null -> {}
} }