feat(update-errors): Allow manually delete update errors (#916)

* Allow manually delete error(s)

* remember swipe action & correct Modifier

* Simplify LibraryUpdateError data structure and separate SourceManager from compose UI

* Add badge count errors

* using SwipeToDismiss

* correct Modifier & StringResource

* remove confirmValueChange when Settle
This commit is contained in:
Cuong-Tran 2025-05-17 19:07:42 +07:00 committed by GitHub
parent bc33a75b6b
commit 2024cdf168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 225 additions and 84 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.FindReplace
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.SelectAll
@ -63,6 +64,8 @@ fun LibraryUpdateErrorScreen(
onMultiMigrateClicked: (() -> Unit),
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onErrorsDelete: () -> Unit,
onErrorDelete: (Long) -> Unit,
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
navigateUp: () -> Unit,
) {
@ -112,6 +115,7 @@ fun LibraryUpdateErrorScreen(
onClickUnselectAll = { onSelectAll(false) },
onClickSelectAll = { onSelectAll(true) },
onClickInvertSelection = onInvertSelection,
onClickDeleteErrors = onErrorsDelete,
scrollBehavior = scrollBehavior,
)
},
@ -173,6 +177,7 @@ fun LibraryUpdateErrorScreen(
onErrorSelected = onErrorSelected,
onClick = onClick,
onClickCover = onClickCover,
onDelete = onErrorDelete,
)
}
}
@ -303,6 +308,7 @@ private fun LibraryUpdateErrorAppBar(
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
onClickDeleteErrors: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
AppBar(
@ -326,6 +332,11 @@ private fun LibraryUpdateErrorAppBar(
actionModeActions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_delete),
icon = Icons.Outlined.DeleteOutline,
onClick = onClickDeleteErrors,
),
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,

View file

@ -1,14 +1,28 @@
package eu.kanade.presentation.libraryUpdateError.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -19,14 +33,13 @@ import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.libraryUpdateError.LibraryUpdateErrorItem
import tachiyomi.domain.libraryUpdateError.model.LibraryUpdateErrorWithRelations
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ListGroupHeader
import tachiyomi.presentation.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.secondaryItemAlpha
import tachiyomi.presentation.core.util.selectedBackground
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
internal fun LazyListScope.libraryUpdateErrorUiItems(
uiModels: List<LibraryUpdateErrorUiModel>,
@ -34,6 +47,7 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
onClick: (LibraryUpdateErrorItem) -> Unit,
onClickCover: (LibraryUpdateErrorItem) -> Unit,
onDelete: (Long) -> Unit,
) {
uiModels.forEach { uiModel ->
when (uiModel) {
@ -46,6 +60,7 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
modifier = Modifier.animateItemFastScroll(),
text = uiModel.errorMessage,
tonalElevation = 1.dp,
count = uiModel.count,
)
}
}
@ -58,6 +73,8 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
LibraryUpdateErrorUiItem(
modifier = Modifier.animateItemFastScroll(),
error = libraryUpdateErrorItem.error,
mangaCover = libraryUpdateErrorItem.mangaCover,
sourceName = libraryUpdateErrorItem.sourceName,
selected = libraryUpdateErrorItem.selected,
onClick = {
when {
@ -80,6 +97,7 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
)
},
onClickCover = { onClickCover(libraryUpdateErrorItem) }.takeIf { !selectionMode },
onSwipe = { onDelete(libraryUpdateErrorItem.error.errorId) },
)
}
}
@ -91,15 +109,37 @@ internal fun LazyListScope.libraryUpdateErrorUiItems(
private fun LibraryUpdateErrorUiItem(
modifier: Modifier,
error: LibraryUpdateErrorWithRelations,
mangaCover: tachiyomi.domain.manga.model.MangaCover,
sourceName: String,
selected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onClickCover: (() -> Unit)?,
onSwipe: () -> Unit,
) {
val haptic = LocalHapticFeedback.current
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when (it) {
StartToEnd -> onSwipe()
EndToStart -> onSwipe()
else -> {}
}
return@rememberSwipeToDismissBoxState true
},
// Set threshold to 25% of the width
positionalThreshold = { totalDistance -> totalDistance * 0.25f },
)
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
backgroundContent = { DismissBackground(dismissState) },
) {
Row(
modifier = modifier
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
@ -115,7 +155,7 @@ private fun LibraryUpdateErrorUiItem(
modifier = Modifier
.padding(vertical = 6.dp)
.height(48.dp),
data = error.mangaCover,
data = mangaCover,
onClick = onClickCover,
)
@ -132,7 +172,7 @@ private fun LibraryUpdateErrorUiItem(
Row(modifier = Modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
text = Injekt.get<SourceManager>().getOrStub(error.mangaSource).name,
text = sourceName,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Visible,
maxLines = 1,
@ -144,10 +184,49 @@ private fun LibraryUpdateErrorUiItem(
}
}
}
}
@Composable
fun DismissBackground(dismissState: SwipeToDismissBoxState) {
val direction = dismissState.dismissDirection
val targetState = dismissState.targetValue
val backgroundColor by animateColorAsState(
when (direction) {
Settled ->
MaterialTheme.colorScheme.surface
StartToEnd ->
MaterialTheme.colorScheme.errorContainer
.copy(alpha = if (targetState == Settled) 0.45f else 1f)
EndToStart ->
MaterialTheme.colorScheme.errorContainer
.copy(alpha = if (targetState == Settled) 0.45f else 1f)
},
)
val alignment = when (direction) {
StartToEnd -> Alignment.CenterStart
EndToStart -> Alignment.CenterEnd
else -> Alignment.Center
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 20.dp),
contentAlignment = alignment,
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(MR.strings.action_delete),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
sealed class LibraryUpdateErrorUiModel {
data class Header(val errorMessage: String) : LibraryUpdateErrorUiModel()
data class Header(val errorMessage: String, val count: Int) : LibraryUpdateErrorUiModel()
data class Item(val item: LibraryUpdateErrorItem) : LibraryUpdateErrorUiModel()
}

View file

@ -96,14 +96,14 @@ fun MangaChapterListItem(
}
SwipeableActionsBox(
modifier = Modifier.clipToBounds(),
modifier = modifier.clipToBounds(),
startActions = listOfNotNull(swipeStart),
endActions = listOfNotNull(swipeEnd),
swipeThreshold = swipeActionThreshold,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceContainerLowest,
) {
Row(
modifier = modifier
modifier = Modifier
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
@ -244,7 +244,7 @@ internal fun getSwipeAction(
}
}
private fun swipeAction(
internal fun swipeAction(
onSwipe: () -> Unit,
icon: ImageVector,
background: Color,

View file

@ -655,7 +655,7 @@ class LibraryUpdateJob(private val context: Context, private val workerParams: W
// KMK -->
private suspend fun clearErrorFromDB(mangaId: Long) {
deleteLibraryUpdateErrors.deleteMangaError(mangaId = mangaId)
deleteLibraryUpdateErrors.deleteMangaError(mangaIds = listOf(mangaId))
}
private suspend fun writeErrorToDB(error: Pair<Manga, String?>) {

View file

@ -41,6 +41,8 @@ class LibraryUpdateErrorScreen : Screen() {
},
onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection,
onErrorsDelete = screenModel::deleteSelected,
onErrorDelete = screenModel::delete,
onErrorSelected = screenModel::toggleSelection,
navigateUp = navigator::pop,
)

View file

@ -5,19 +5,26 @@ import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.addOrRemove
import eu.kanade.presentation.libraryUpdateError.components.LibraryUpdateErrorUiModel
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.libraryUpdateError.interactor.DeleteLibraryUpdateErrors
import tachiyomi.domain.libraryUpdateError.interactor.GetLibraryUpdateErrorWithRelations
import tachiyomi.domain.libraryUpdateError.model.LibraryUpdateErrorWithRelations
import tachiyomi.domain.libraryUpdateErrorMessage.interactor.GetLibraryUpdateErrorMessages
import tachiyomi.domain.libraryUpdateErrorMessage.model.LibraryUpdateErrorMessage
import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class LibraryUpdateErrorScreenModel(
private val getLibraryUpdateErrorWithRelations: GetLibraryUpdateErrorWithRelations = Injekt.get(),
private val getLibraryUpdateErrorMessages: GetLibraryUpdateErrorMessages = Injekt.get(),
private val deleteLibraryUpdateErrors: DeleteLibraryUpdateErrors = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
) : StateScreenModel<LibraryUpdateErrorScreenState>(LibraryUpdateErrorScreenState()) {
// First and last selected index in list
@ -44,6 +51,7 @@ class LibraryUpdateErrorScreenModel(
return map { error ->
LibraryUpdateErrorItem(
error = error,
sourceName = sourceManager.getOrStub(error.mangaSource).name,
selected = error.errorId in selectedErrorIds,
)
}
@ -139,6 +147,26 @@ class LibraryUpdateErrorScreenModel(
selectedPositions[0] = -1
selectedPositions[1] = -1
}
@OptIn(DelicateCoroutinesApi::class)
fun deleteSelected() {
launchIO {
deleteLibraryUpdateErrors.delete(selectedErrorIds.toList())
withUIContext {
selectedErrorIds.clear()
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun delete(errorId: Long) {
launchIO {
deleteLibraryUpdateErrors.delete(listOf(errorId))
withUIContext {
selectedErrorIds.remove(errorId)
}
}
}
}
@Immutable
@ -156,7 +184,7 @@ data class LibraryUpdateErrorScreenState(
val errorMap = items.groupBy { it.error.messageId }
errorMap.forEach { (messageId, errors) ->
val message = messages.find { it.id == messageId }
uiModels.add(LibraryUpdateErrorUiModel.Header(message!!.message))
uiModels.add(LibraryUpdateErrorUiModel.Header(message!!.message, errors.size))
uiModels.addAll(errors.map { LibraryUpdateErrorUiModel.Item(it) })
}
return uiModels
@ -171,5 +199,14 @@ data class LibraryUpdateErrorScreenState(
@Immutable
data class LibraryUpdateErrorItem(
val error: LibraryUpdateErrorWithRelations,
val sourceName: String,
val selected: Boolean,
) {
val mangaCover = MangaCover(
mangaId = error.mangaId,
sourceId = error.mangaSource,
isMangaFavorite = error.favorite,
ogUrl = error.mangaThumbnail,
lastModified = error.coverLastModified,
)
}

View file

@ -25,17 +25,15 @@ class LibraryUpdateErrorRepositoryImpl(
return handler.await { libraryUpdateErrorQueries.deleteAllErrors() }
}
override suspend fun delete(errorId: Long) {
override suspend fun delete(errorIds: List<Long>) {
return handler.await {
libraryUpdateErrorQueries.deleteError(
_id = errorId,
)
libraryUpdateErrorQueries.deleteErrors(_ids = errorIds)
}
}
override suspend fun deleteMangaError(mangaId: Long) {
override suspend fun deleteMangaError(mangaIds: List<Long>) {
return handler.await {
libraryUpdateErrorQueries.deleteMangaError(mangaId = mangaId)
libraryUpdateErrorQueries.deleteMangaErrors(mangaIds = mangaIds)
}
}

View file

@ -1,7 +1,6 @@
package tachiyomi.data.libraryUpdateError
import tachiyomi.domain.libraryUpdateError.model.LibraryUpdateErrorWithRelations
import tachiyomi.domain.manga.model.MangaCover
val libraryUpdateErrorWithRelationsMapper:
(Long, String, Long, Boolean, String?, Long, Long, Long, Long) -> LibraryUpdateErrorWithRelations =
@ -10,13 +9,9 @@ val libraryUpdateErrorWithRelationsMapper:
mangaId = mangaId,
mangaTitle = mangaTitle,
mangaSource = mangaSource,
mangaCover = MangaCover(
mangaId = mangaId,
sourceId = mangaSource,
isMangaFavorite = favorite,
ogUrl = mangaThumbnail,
lastModified = coverLastModified,
),
favorite = favorite,
mangaThumbnail = mangaThumbnail,
coverLastModified = coverLastModified,
errorId = errorId,
messageId = messageId,
lastUpdate = lastUpdate,

View file

@ -25,13 +25,13 @@ WHERE manga_id = :mangaId;
deleteAllErrors:
DELETE FROM libraryUpdateError;
deleteError:
deleteErrors:
DELETE FROM libraryUpdateError
WHERE _id = :_id;
WHERE _id IN :_ids;
deleteMangaError:
deleteMangaErrors:
DELETE FROM libraryUpdateError
WHERE manga_id = :mangaId;
WHERE manga_id IN :mangaIds;
cleanUnrelevantMangaErrors:
DELETE FROM libraryUpdateError

View file

@ -9,7 +9,7 @@ class DeleteLibraryUpdateErrors(
private val libraryUpdateErrorRepository: LibraryUpdateErrorRepository,
) {
suspend fun await() = withNonCancellableContext {
suspend fun deleteAll() = withNonCancellableContext {
try {
libraryUpdateErrorRepository.deleteAll()
Result.Success
@ -19,9 +19,9 @@ class DeleteLibraryUpdateErrors(
}
}
suspend fun await(errorId: Long) = withNonCancellableContext {
suspend fun delete(errorIds: List<Long>) = withNonCancellableContext {
try {
libraryUpdateErrorRepository.delete(errorId)
libraryUpdateErrorRepository.delete(errorIds)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
@ -29,9 +29,9 @@ class DeleteLibraryUpdateErrors(
}
}
suspend fun deleteMangaError(mangaId: Long) = withNonCancellableContext {
suspend fun deleteMangaError(mangaIds: List<Long>) = withNonCancellableContext {
try {
libraryUpdateErrorRepository.deleteMangaError(mangaId)
libraryUpdateErrorRepository.deleteMangaError(mangaIds)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)

View file

@ -1,12 +1,12 @@
package tachiyomi.domain.libraryUpdateError.model
import tachiyomi.domain.manga.model.MangaCover
data class LibraryUpdateErrorWithRelations(
val mangaId: Long,
val mangaTitle: String,
val mangaSource: Long,
val mangaCover: MangaCover,
val favorite: Boolean,
val mangaThumbnail: String?,
val coverLastModified: Long,
val errorId: Long,
val messageId: Long,
val lastUpdate: Long,

View file

@ -11,9 +11,9 @@ interface LibraryUpdateErrorRepository {
suspend fun deleteAll()
suspend fun delete(errorId: Long)
suspend fun delete(errorIds: List<Long>)
suspend fun deleteMangaError(mangaId: Long)
suspend fun deleteMangaError(mangaIds: List<Long>)
suspend fun cleanUnrelevantMangaErrors()

View file

@ -1,11 +1,14 @@
package tachiyomi.presentation.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
@ -18,6 +21,7 @@ fun ListGroupHeader(
modifier: Modifier = Modifier,
// KMK -->
tonalElevation: Dp = 0.dp,
count: Int? = null,
// KMK <--
) {
// KMK -->
@ -25,6 +29,10 @@ fun ListGroupHeader(
modifier = modifier
.fillMaxWidth(),
tonalElevation = tonalElevation,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// KMK <--
Text(
@ -38,5 +46,16 @@ fun ListGroupHeader(
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
)
// KMK -->
if (count != null) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Text("$count")
}
}
// KMK <--
}
}
}