feat(history): Allow multi/range-selection and batch reset for history items (#1437)

* feat(history): Allow multi-selection and batch reset for history items

* Use chapterId instead of history.id

* use remember getUiModel

* Toolbar selection mode

* cleanup History

* Replace selectedChapterIds with selection for chapter selection state

* Remove userSelected parameter from selection logic and related function signatures

* remove parentheses

* Fix range selection

* longclick on cover too

* disable clear-history button when none-selected
This commit is contained in:
Cuong-Tran 2026-02-02 11:21:32 +07:00 committed by GitHub
parent f373818d93
commit 8f57f4a5a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 450 additions and 130 deletions

View file

@ -1,16 +1,22 @@
package eu.kanade.presentation.history package eu.kanade.presentation.history
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Checklist
import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -23,6 +29,7 @@ import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.history.HistoryScreenModel import eu.kanade.tachiyomi.ui.history.HistoryScreenModel
import eu.kanade.tachiyomi.ui.history.HistoryScreenModel.HistorySelectionOptions
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -45,14 +52,32 @@ fun HistoryScreen(
onClickFavorite: (mangaId: Long) -> Unit, onClickFavorite: (mangaId: Long) -> Unit,
onDialogChange: (HistoryScreenModel.Dialog?) -> Unit, onDialogChange: (HistoryScreenModel.Dialog?) -> Unit,
// KMK --> // KMK -->
toggleSelectionMode: () -> Unit,
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onHistorySelected: (HistoryWithRelations, HistorySelectionOptions) -> Unit,
onFilterClicked: () -> Unit, onFilterClicked: () -> Unit,
hasActiveFilters: Boolean, hasActiveFilters: Boolean,
usePanoramaCover: Boolean, usePanoramaCover: Boolean,
// KMK <-- // KMK <--
) { ) {
// KMK -->
BackHandler(enabled = state.selectionMode, onBack = toggleSelectionMode)
// KMK <--
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
SearchToolbar( // KMK -->
when {
state.selectionMode -> HistorySelectionToolbar(
selectedCount = state.selection.size,
onCancelActionMode = toggleSelectionMode,
onClickSelectAll = { onSelectAll(true) },
onClickInvertSelection = onInvertSelection,
onClickClearHistory = { onDialogChange(HistoryScreenModel.Dialog.Delete(state.selected)) },
)
// KMK <--
else -> SearchToolbar(
titleContent = { AppBarTitle(stringResource(MR.strings.history)) }, titleContent = { AppBarTitle(stringResource(MR.strings.history)) },
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
onChangeSearchQuery = onSearchQueryChange, onChangeSearchQuery = onSearchQueryChange,
@ -69,21 +94,24 @@ fun HistoryScreen(
// KMK <-- // KMK <--
AppBar.Action( AppBar.Action(
title = stringResource(MR.strings.pref_clear_history), title = stringResource(MR.strings.pref_clear_history),
icon = Icons.Outlined.DeleteSweep, // KMK -->
onClick = { icon = Icons.Outlined.Checklist,
onDialogChange(HistoryScreenModel.Dialog.DeleteAll) onClick = toggleSelectionMode,
}, // KMK <--
), ),
), ),
) )
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding -> ) { contentPadding ->
state.list.let { state.list.let {
if (it == null) { // KMK -->
if (state.isLoading) {
// KMK <--
LoadingScreen(Modifier.padding(contentPadding)) LoadingScreen(Modifier.padding(contentPadding))
} else if (it.isEmpty()) { } else if (it.isEmpty()) {
val msg = if (!state.searchQuery.isNullOrEmpty()) { val msg = if (!state.searchQuery.isNullOrEmpty()) {
@ -96,14 +124,22 @@ fun HistoryScreen(
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
) )
} else { } else {
// KMK -->
val uiModels = remember(state.list) { state.getUiModel() }
// KMK <--
HistoryScreenContent( HistoryScreenContent(
history = it, // KMK -->
state = state,
history = uiModels,
// KMK <--
contentPadding = contentPadding, contentPadding = contentPadding,
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) }, onClickFavorite = { history -> onClickFavorite(history.mangaId) },
// KMK --> // KMK -->
selectionMode = state.selectionMode,
onHistorySelected = onHistorySelected,
usePanoramaCover = usePanoramaCover, usePanoramaCover = usePanoramaCover,
// KMK <-- // KMK <--
) )
@ -114,6 +150,9 @@ fun HistoryScreen(
@Composable @Composable
private fun HistoryScreenContent( private fun HistoryScreenContent(
// KMK -->
state: HistoryScreenModel.State,
history: List<HistoryUiModel>, history: List<HistoryUiModel>,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onClickCover: (HistoryWithRelations) -> Unit, onClickCover: (HistoryWithRelations) -> Unit,
@ -121,6 +160,8 @@ private fun HistoryScreenContent(
onClickDelete: (HistoryWithRelations) -> Unit, onClickDelete: (HistoryWithRelations) -> Unit,
onClickFavorite: (HistoryWithRelations) -> Unit, onClickFavorite: (HistoryWithRelations) -> Unit,
// KMK --> // KMK -->
selectionMode: Boolean,
onHistorySelected: (HistoryWithRelations, HistorySelectionOptions) -> Unit,
usePanoramaCover: Boolean, usePanoramaCover: Boolean,
// KMK <-- // KMK <--
) { ) {
@ -146,14 +187,40 @@ private fun HistoryScreenContent(
} }
is HistoryUiModel.Item -> { is HistoryUiModel.Item -> {
val value = item.item val value = item.item
// KMK -->
val isSelected = remember(state.selection) { value.chapterId in state.selection }
// KMK <--
HistoryItem( HistoryItem(
modifier = Modifier.animateItemFastScroll(), modifier = Modifier.animateItemFastScroll(),
history = value, history = value,
onClickCover = { onClickCover(value) }, onClickCover = { onClickCover(value) },
onClickResume = { onClickResume(value) }, // KMK -->
onClick = {
when {
selectionMode -> onHistorySelected(
item.item,
HistorySelectionOptions(
selected = !isSelected,
fromLongPress = false,
),
)
else -> onClickResume(value)
}
},
onLongClick = {
onHistorySelected(
item.item,
HistorySelectionOptions(
selected = !isSelected,
fromLongPress = true,
),
)
},
// KMK <--
onClickDelete = { onClickDelete(value) }, onClickDelete = { onClickDelete(value) },
onClickFavorite = { onClickFavorite(value) }, onClickFavorite = { onClickFavorite(value) },
// KMK --> // KMK -->
selected = isSelected,
readProgress = value.lastPageRead readProgress = value.lastPageRead
.takeIf { !value.read && it > 0L } .takeIf { !value.read && it > 0L }
?.let { ?.let {
@ -174,9 +241,50 @@ private fun HistoryScreenContent(
sealed interface HistoryUiModel { sealed interface HistoryUiModel {
data class Header(val date: LocalDate) : HistoryUiModel data class Header(val date: LocalDate) : HistoryUiModel
// KMK -->
data class Item(val item: HistoryWithRelations) : HistoryUiModel data class Item(val item: HistoryWithRelations) : HistoryUiModel
// KMK <--
} }
// KMK -->
@Composable
private fun HistorySelectionToolbar(
selectedCount: Int,
onCancelActionMode: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
onClickClearHistory: () -> Unit,
) {
AppBar(
titleContent = { Text(text = "$selectedCount") },
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onClickSelectAll,
),
AppBar.Action(
title = stringResource(MR.strings.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onClickInvertSelection,
),
AppBar.Action(
title = stringResource(MR.strings.pref_clear_history),
icon = Icons.Outlined.DeleteSweep,
onClick = onClickClearHistory,
enabled = selectedCount > 0,
),
),
)
},
isActionMode = true,
onCancelActionMode = onCancelActionMode,
)
}
// KMK <--
@PreviewLightDark @PreviewLightDark
@Composable @Composable
internal fun HistoryScreenPreviews( internal fun HistoryScreenPreviews(
@ -193,6 +301,10 @@ internal fun HistoryScreenPreviews(
onDialogChange = {}, onDialogChange = {},
onClickFavorite = {}, onClickFavorite = {},
// KMK --> // KMK -->
toggleSelectionMode = {},
onSelectAll = {},
onInvertSelection = {},
onHistorySelected = { _, _ -> },
onFilterClicked = {}, onFilterClicked = {},
hasActiveFilters = true, hasActiveFilters = true,
usePanoramaCover = true, usePanoramaCover = true,

View file

@ -8,6 +8,7 @@ import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Date import java.util.Date
import kotlin.random.Random import kotlin.random.Random
@ -17,13 +18,15 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private val multiPage = HistoryScreenModel.State( private val multiPage = HistoryScreenModel.State(
searchQuery = null, searchQuery = null,
list = list =
persistentListOf(HistoryUiModelExamples.headerToday) // KMK -->
persistentListOf(HistoryWithRelationExamples.headerToday)
.asSequence() .asSequence()
.plus(HistoryUiModelExamples.items().take(3)) .plus(HistoryWithRelationExamples.items().take(3))
.plus(HistoryUiModelExamples.header { it.minus(1, ChronoUnit.DAYS) }) .plus(HistoryWithRelationExamples.header { it.minus(1, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.items().take(1)) .plus(HistoryWithRelationExamples.items().take(1))
.plus(HistoryUiModelExamples.header { it.minus(2, ChronoUnit.DAYS) }) .plus(HistoryWithRelationExamples.header { it.minus(2, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.items().take(7)) .plus(HistoryWithRelationExamples.items().take(7))
// KMK <--
.toImmutableList(), .toImmutableList(),
dialog = null, dialog = null,
) )
@ -31,8 +34,10 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private val shortRecent = HistoryScreenModel.State( private val shortRecent = HistoryScreenModel.State(
searchQuery = null, searchQuery = null,
list = persistentListOf( list = persistentListOf(
HistoryUiModelExamples.headerToday, // KMK -->
HistoryUiModelExamples.items().first(), HistoryWithRelationExamples.headerToday,
HistoryWithRelationExamples.items().first(),
// KMK <--
), ),
dialog = null, dialog = null,
) )
@ -40,8 +45,10 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private val shortFuture = HistoryScreenModel.State( private val shortFuture = HistoryScreenModel.State(
searchQuery = null, searchQuery = null,
list = persistentListOf( list = persistentListOf(
HistoryUiModelExamples.headerTomorrow, // KMK -->
HistoryUiModelExamples.items().first(), HistoryWithRelationExamples.headerTomorrow,
HistoryWithRelationExamples.items().first(),
// KMK <--
), ),
dialog = null, dialog = null,
) )
@ -58,7 +65,9 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
private val loading = HistoryScreenModel.State( private val loading = HistoryScreenModel.State(
searchQuery = null, searchQuery = null,
list = null, // KMK -->
isLoading = true,
// KMK <--
dialog = null, dialog = null,
) )
@ -71,13 +80,17 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
loading, loading,
) )
private object HistoryUiModelExamples { // KMK -->
val headerToday = header() private object HistoryWithRelationExamples {
val headerTomorrow = val headerToday = randItem()
HistoryUiModel.Header(LocalDate.now().plusDays(1)) val headerTomorrow = randItem(LocalDate.now().plusDays(1).toDate())
fun header(instantBuilder: (Instant) -> Instant = { it }) = fun header(instantBuilder: (Instant) -> Instant = { it }) =
HistoryUiModel.Header(LocalDate.from(instantBuilder(Instant.now()))) randItem(LocalDate.from(instantBuilder(Instant.now())).toDate())
fun LocalDate.toDate(zone: ZoneId = ZoneId.systemDefault()): Date =
Date.from(atStartOfDay(zone).toInstant())
// KMK <--
fun items() = sequence { fun items() = sequence {
var count = 1 var count = 1
@ -87,8 +100,12 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
} }
} }
fun randItem(historyBuilder: (HistoryWithRelations) -> HistoryWithRelations = { it }) = fun randItem(
HistoryUiModel.Item( // KMK -->
readAt: Date = Date.from(Instant.now()),
// KMK <--
historyBuilder: (HistoryWithRelations) -> HistoryWithRelations = { it },
) =
historyBuilder( historyBuilder(
HistoryWithRelations( HistoryWithRelations(
id = Random.nextLong(), id = Random.nextLong(),
@ -103,8 +120,8 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
lastPageRead = Random.nextLong(1, 10), lastPageRead = Random.nextLong(1, 10),
totalCountCalculated = Random.nextLong(1, 100), totalCountCalculated = Random.nextLong(1, 100),
readCountCalculated = 1, readCountCalculated = 1,
readAt = readAt,
// KMK <-- // KMK <--
readAt = Date.from(Instant.now()),
readDuration = Random.nextLong(), readDuration = Random.nextLong(),
coverData = MangaCover( coverData = MangaCover(
mangaId = Random.nextLong(), mangaId = Random.nextLong(),
@ -114,7 +131,6 @@ class HistoryScreenModelStateProvider : PreviewParameterProvider<HistoryScreenMo
lastModified = Random.nextLong(), lastModified = Random.nextLong(),
), ),
), ),
),
) )
} }
} }

View file

@ -1,6 +1,6 @@
package eu.kanade.presentation.history.components package eu.kanade.presentation.history.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@ -23,6 +23,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
@ -42,6 +44,7 @@ import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.components.material.DISABLED_ALPHA import tachiyomi.presentation.core.components.material.DISABLED_ALPHA
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.selectedBackground
private val HistoryItemHeight = 96.dp private val HistoryItemHeight = 96.dp
@ -49,11 +52,15 @@ private val HistoryItemHeight = 96.dp
fun HistoryItem( fun HistoryItem(
history: HistoryWithRelations, history: HistoryWithRelations,
onClickCover: () -> Unit, onClickCover: () -> Unit,
onClickResume: () -> Unit, // KMK -->
onClick: () -> Unit,
onLongClick: () -> Unit,
// KMK <--
onClickDelete: () -> Unit, onClickDelete: () -> Unit,
onClickFavorite: () -> Unit, onClickFavorite: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// KMK --> // KMK -->
selected: Boolean,
readProgress: String?, readProgress: String?,
hasUnread: Boolean, hasUnread: Boolean,
usePanoramaCover: Boolean, usePanoramaCover: Boolean,
@ -61,11 +68,21 @@ fun HistoryItem(
// KMK <-- // KMK <--
) { ) {
// KMK --> // KMK -->
val haptic = LocalHapticFeedback.current
val textAlpha = if (history.read) DISABLED_ALPHA else 1f val textAlpha = if (history.read) DISABLED_ALPHA else 1f
// KMK <-- // KMK <--
Row( Row(
modifier = modifier modifier = modifier
.clickable(onClick = onClickResume) // KMK -->
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
)
// KMK <--
.height(HistoryItemHeight) .height(HistoryItemHeight)
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small), .padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -78,16 +95,24 @@ fun HistoryItem(
if (DebugToggles.HIDE_COVER_IMAGE_ONLY_SHOW_COLOR.enabled) { if (DebugToggles.HIDE_COVER_IMAGE_ONLY_SHOW_COLOR.enabled) {
MangaCoverHide.Book( MangaCoverHide.Book(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
bgColor = bgColor, bgColor = bgColor ?: MaterialTheme.colorScheme.surface.takeIf { selected },
tint = onBgColor, tint = onBgColor,
size = MangaCover.Size.Medium, size = MangaCover.Size.Medium,
) )
} else { } else {
if (usePanoramaCover && coverIsWide) { if (usePanoramaCover && coverIsWide) {
MangaCover.Panorama( MangaCover.Panorama(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight()
data = mangaCover, // KMK -->
.combinedClickable(
onClick = onClickCover, onClick = onClickCover,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
),
// KMK <--
data = mangaCover,
// KMK --> // KMK -->
bgColor = bgColor, bgColor = bgColor,
tint = onBgColor, tint = onBgColor,
@ -101,9 +126,17 @@ fun HistoryItem(
} else { } else {
// KMK <-- // KMK <--
MangaCover.Book( MangaCover.Book(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight()
data = mangaCover, // KMK -->
.combinedClickable(
onClick = onClickCover, onClick = onClickCover,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
),
// KMK <--
data = mangaCover,
// KMK --> // KMK -->
bgColor = bgColor, bgColor = bgColor,
tint = onBgColor, tint = onBgColor,
@ -209,12 +242,18 @@ private fun HistoryItemPreviews(
HistoryItem( HistoryItem(
history = historyWithRelations, history = historyWithRelations,
onClickCover = {}, onClickCover = {},
onClickResume = {}, // KMK -->
onClick = {},
onLongClick = {},
// KMK <--
onClickDelete = {}, onClickDelete = {},
onClickFavorite = {}, onClickFavorite = {},
readProgress = "Page 5", readProgress = "Page 5",
// KMK -->
hasUnread = true, hasUnread = true,
selected = true,
usePanoramaCover = false, usePanoramaCover = false,
// KMK <--
) )
} }
} }

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.history
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastFilter
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
@ -10,6 +11,7 @@ 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -26,6 +28,7 @@ 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 mihon.core.common.utils.mutate
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.mapAsCheckboxState import tachiyomi.core.common.preference.mapAsCheckboxState
@ -71,6 +74,11 @@ class HistoryScreenModel(
private val _events: Channel<Event> = Channel(Channel.UNLIMITED) private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
val events: Flow<Event> = _events.receiveAsFlow() val events: Flow<Event> = _events.receiveAsFlow()
// KMK -->
// First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
// KMK <--
init { init {
screenModelScope.launch { screenModelScope.launch {
// KMK --> // KMK -->
@ -97,10 +105,18 @@ class HistoryScreenModel(
logcat(LogPriority.ERROR, error) logcat(LogPriority.ERROR, error)
_events.send(Event.InternalError) _events.send(Event.InternalError)
} }
.map { it.toHistoryUiModels() }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
} }
.collect { newList -> mutableState.update { it.copy(list = newList) } } .collect { newList ->
mutableState.update {
it.copy(
// KMK -->
isLoading = false,
list = newList.toImmutableList(),
// KMK <--
)
}
}
} }
// KMK --> // KMK -->
@ -123,19 +139,6 @@ class HistoryScreenModel(
// KMK <-- // KMK <--
} }
private fun List<HistoryWithRelations>.toHistoryUiModels(): List<HistoryUiModel> {
return map { HistoryUiModel.Item(it) }
.insertSeparators { before, after ->
val beforeDate = before?.item?.readAt?.time?.toLocalDate()
val afterDate = after?.item?.readAt?.time?.toLocalDate()
when {
beforeDate != afterDate && afterDate != null -> HistoryUiModel.Header(afterDate)
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
suspend fun getNextChapter(): Chapter? { suspend fun getNextChapter(): Chapter? {
return withIOContext { getNextChapters.await(onlyUnread = false).firstOrNull() } return withIOContext { getNextChapters.await(onlyUnread = false).firstOrNull() }
} }
@ -151,17 +154,21 @@ class HistoryScreenModel(
_events.send(Event.OpenChapter(chapter)) _events.send(Event.OpenChapter(chapter))
} }
fun removeFromHistory(history: HistoryWithRelations) { // KMK -->
fun removeFromHistory(toDelete: List<HistoryWithRelations>) {
screenModelScope.launchIO { screenModelScope.launchIO {
removeHistory.await(history) removeHistory.await(toDelete.map { it.id })
} }
toggleSelectionMode(false)
} }
fun removeAllFromHistory(mangaId: Long) { fun removeAllFromHistory(toDelete: List<HistoryWithRelations>) {
screenModelScope.launchIO { screenModelScope.launchIO {
removeHistory.await(mangaId) removeHistory.awaitManga(toDelete.map { it.mangaId })
} }
toggleSelectionMode(false)
} }
// KMK <--
fun removeAllHistory() { fun removeAllHistory() {
screenModelScope.launchIO { screenModelScope.launchIO {
@ -169,6 +176,7 @@ class HistoryScreenModel(
if (!result) return@launchIO if (!result) return@launchIO
_events.send(Event.HistoryCleared) _events.send(Event.HistoryCleared)
} }
toggleSelectionMode(false)
} }
fun updateSearchQuery(query: String?) { fun updateSearchQuery(query: String?) {
@ -280,6 +288,113 @@ class HistoryScreenModel(
} }
// KMK --> // KMK -->
data class HistorySelectionOptions(
val selected: Boolean,
val fromLongPress: Boolean = false,
)
fun toggleSelection(
item: HistoryWithRelations,
selectionOptions: HistorySelectionOptions,
) {
val (selected, fromLongPress) = selectionOptions
if (item.chapterId in state.value.selection == selected) return
mutableState.update { state ->
val selection = state.selection.mutate { list ->
state.list.run {
val selectedIndex = indexOfFirst { it.chapterId == item.chapterId }
if (selectedIndex < 0) return@run
val firstSelection = list.isEmpty()
if (selected) list.add(item.chapterId) else list.remove(item.chapterId)
if (firstSelection) {
// Since it can go into selectionMode from toolbar (without long press), we need to set both positions here
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
} else if (selected && fromLongPress) {
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex + 1..<selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..<selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
range.forEach {
val inBetweenItem = get(it)
if (inBetweenItem.chapterId !in list) {
list.add(inBetweenItem.chapterId)
}
}
} else if (!fromLongPress) {
if (!selected) {
if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.chapterId in list }
} else if (selectedIndex == selectedPositions[1]) {
selectedPositions[1] = indexOfLast { it.chapterId in list }
}
} else {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
}
}
}
}
state.copy(
selection = selection,
selectionMode = selected || selection.isNotEmpty(),
)
}
}
fun toggleAllSelection(selected: Boolean) {
mutableState.update { state ->
val selection = if (selected) {
state.list.mapTo(mutableSetOf()) { it.chapterId }
} else {
emptySet()
}
selectedPositions[0] = -1
selectedPositions[1] = -1
state.copy(
selection = selection,
selectionMode = selected,
)
}
}
fun invertSelection() {
mutableState.update { state ->
val selection = state.selection.mutate { list ->
state.list.forEach { item ->
if (!list.remove(item.chapterId)) list.add(item.chapterId)
}
}
selectedPositions[0] = -1
selectedPositions[1] = -1
state.copy(selection = selection)
}
}
fun toggleSelectionMode(newMode: Boolean? = null) {
if (newMode == false || state.value.selectionMode) {
toggleAllSelection(false)
} else {
mutableState.update { it.copy(selectionMode = newMode ?: !it.selectionMode) }
}
}
// KMK <--
private fun getHistoryItemPreferenceFlow(): Flow<ItemPreferences> { private fun getHistoryItemPreferenceFlow(): Flow<ItemPreferences> {
return combine( return combine(
historyPreferences.filterUnfinishedManga().changes(), historyPreferences.filterUnfinishedManga().changes(),
@ -309,16 +424,39 @@ class HistoryScreenModel(
@Immutable @Immutable
data class State( data class State(
val searchQuery: String? = null, val searchQuery: String? = null,
val list: List<HistoryUiModel>? = null, // KMK -->
val list: ImmutableList<HistoryWithRelations> = persistentListOf(),
val isLoading: Boolean = true,
// KMK <--
val dialog: Dialog? = null, val dialog: Dialog? = null,
// KMk --> // KMk -->
val selection: Set<Long> = emptySet(),
val hasActiveFilters: Boolean = false, val hasActiveFilters: Boolean = false,
val selectionMode: Boolean = false,
) {
val selected
get() = list.fastFilter { it.chapterId in selection }
fun getUiModel() = list.map { HistoryUiModel.Item(it) }
.insertSeparators { before, after ->
val beforeDate = before?.item?.readAt?.time?.toLocalDate()
val afterDate = after?.item?.readAt?.time?.toLocalDate()
when {
beforeDate != afterDate && afterDate != null -> HistoryUiModel.Header(afterDate)
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
// KMK <-- // KMK <--
)
sealed interface Dialog { sealed interface Dialog {
data object DeleteAll : Dialog data object DeleteAll : Dialog
data class Delete(val history: HistoryWithRelations) : Dialog // KMK -->
data class Delete(val histories: List<HistoryWithRelations>) : Dialog {
constructor(history: HistoryWithRelations) : this(listOf(history))
}
// KMK <--
data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
data class ChangeCategory( data class ChangeCategory(
val manga: Manga, val manga: Manga,

View file

@ -101,6 +101,10 @@ data object HistoryTab : Tab {
onDialogChange = screenModel::setDialog, onDialogChange = screenModel::setDialog,
onClickFavorite = screenModel::addFavorite, onClickFavorite = screenModel::addFavorite,
// KMK --> // KMK -->
toggleSelectionMode = screenModel::toggleSelectionMode,
onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection,
onHistorySelected = screenModel::toggleSelection,
onFilterClicked = screenModel::showFilterDialog, onFilterClicked = screenModel::showFilterDialog,
hasActiveFilters = state.hasActiveFilters, hasActiveFilters = state.hasActiveFilters,
usePanoramaCover = usePanoramaCover, usePanoramaCover = usePanoramaCover,
@ -113,11 +117,13 @@ data object HistoryTab : Tab {
HistoryDeleteDialog( HistoryDeleteDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onDelete = { all -> onDelete = { all ->
// KMK -->
if (all) { if (all) {
screenModel.removeAllFromHistory(dialog.history.mangaId) screenModel.removeAllFromHistory(dialog.histories)
} else { } else {
screenModel.removeFromHistory(dialog.history) screenModel.removeFromHistory(dialog.histories)
} }
// KMK <--
}, },
) )
} }
@ -169,8 +175,10 @@ data object HistoryTab : Tab {
null -> {} null -> {}
} }
LaunchedEffect(state.list) { // KMK -->
if (state.list != null) { LaunchedEffect(state.isLoading) {
if (!state.isLoading) {
// KMK <--
(context as? MainActivity)?.ready = true (context as? MainActivity)?.ready = true
// AM (DISCORD) --> // AM (DISCORD) -->

View file

@ -125,9 +125,9 @@ class EHentaiUpdateHelper(context: Context) {
) )
// Delete the duplicate history first // Delete the duplicate history first
deleteHistory.forEach { // KMK -->
removeHistory.awaitById(it) removeHistory.await(deleteHistory)
} // KMK <--
// Insert new history // Insert new history
newHistory.forEach { newHistory.forEach {

View file

@ -56,17 +56,21 @@ class HistoryRepositoryImpl(
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) } return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
} }
override suspend fun resetHistory(historyId: Long) { // KMK -->
override suspend fun resetHistory(historyIds: List<Long>) {
try { try {
handler.await { historyQueries.resetHistoryById(historyId) } handler.await { historyQueries.resetHistoryByIds(historyIds) }
// KMK <--
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) logcat(LogPriority.ERROR, throwable = e)
} }
} }
override suspend fun resetHistoryByMangaId(mangaId: Long) { // KMK -->
override suspend fun resetHistoryByMangaIds(mangaIds: List<Long>) {
try { try {
handler.await { historyQueries.resetHistoryByMangaId(mangaId) } handler.await { historyQueries.resetHistoryByMangaIds(mangaIds) }
// KMK <--
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, throwable = e) logcat(LogPriority.ERROR, throwable = e)
} }

View file

@ -33,12 +33,16 @@ JOIN chapters C
ON H.chapter_id = C._id ON H.chapter_id = C._id
WHERE C.manga_id = :mangaId AND C.url = :chapterUrl AND C._id = H.chapter_id; WHERE C.manga_id = :mangaId AND C.url = :chapterUrl AND C._id = H.chapter_id;
resetHistoryById: -- KMK -->
resetHistoryByIds:
-- KMK <--
UPDATE history UPDATE history
SET last_read = 0 SET last_read = 0
WHERE _id = :historyId; -- KMK -->
WHERE _id IN :historyIds;
resetHistoryByMangaId: resetHistoryByMangaIds:
-- KMK <--
UPDATE history UPDATE history
SET last_read = 0 SET last_read = 0
WHERE _id IN ( WHERE _id IN (
@ -48,7 +52,9 @@ WHERE _id IN (
ON M._id = C.manga_id ON M._id = C.manga_id
INNER JOIN history H INNER JOIN history H
ON C._id = H.chapter_id ON C._id = H.chapter_id
WHERE M._id = :mangaId -- KMK -->
WHERE M._id IN :mangaIds
-- KMK <--
); );
removeAllHistory: removeAllHistory:

View file

@ -1,6 +1,5 @@
package tachiyomi.domain.history.interactor package tachiyomi.domain.history.interactor
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.history.repository.HistoryRepository
class RemoveHistory( class RemoveHistory(
@ -11,17 +10,13 @@ class RemoveHistory(
return repository.deleteAllHistory() return repository.deleteAllHistory()
} }
suspend fun await(history: HistoryWithRelations) { // KMK -->
repository.resetHistory(history.id) suspend fun await(historyIds: List<Long>) {
repository.resetHistory(historyIds)
} }
suspend fun await(mangaId: Long) { suspend fun awaitManga(mangaIds: List<Long>) {
repository.resetHistoryByMangaId(mangaId) repository.resetHistoryByMangaIds(mangaIds)
} }
// KMK <--
// SY -->
suspend fun awaitById(historyId: Long) {
repository.resetHistory(historyId)
}
// SY <--
} }

View file

@ -22,9 +22,11 @@ interface HistoryRepository {
suspend fun getHistoryByMangaId(mangaId: Long): List<History> suspend fun getHistoryByMangaId(mangaId: Long): List<History>
suspend fun resetHistory(historyId: Long) // KMK -->
suspend fun resetHistory(historyIds: List<Long>)
suspend fun resetHistoryByMangaId(mangaId: Long) suspend fun resetHistoryByMangaIds(mangaIds: List<Long>)
// KMK <--
suspend fun deleteAllHistory(): Boolean suspend fun deleteAllHistory(): Boolean