diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 08787e1f83..97a9c4ff87 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -36,6 +36,7 @@ import mihon.domain.extensionrepo.service.ExtensionRepoService import mihon.domain.upcoming.interactor.GetUpcomingManga import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl +import tachiyomi.data.failed.FailedUpdatesRepositoryImpl import tachiyomi.data.history.HistoryRepositoryImpl import tachiyomi.data.manga.MangaRepositoryImpl import tachiyomi.data.release.ReleaseServiceImpl @@ -61,6 +62,7 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.repository.ChapterRepository +import tachiyomi.domain.failed.repository.FailedUpdatesRepository import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetTotalReadDuration @@ -170,6 +172,8 @@ class DomainModule : InjektModule { addSingletonFactory { UpdatesRepositoryImpl(get()) } addFactory { GetUpdates(get()) } + addSingletonFactory { FailedUpdatesRepositoryImpl(get()) } + addSingletonFactory { SourceRepositoryImpl(get(), get()) } addSingletonFactory { StubSourceRepositoryImpl(get()) } addFactory { GetEnabledSources(get(), get()) } diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index 93b8c1843a..36e575424a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -28,7 +28,9 @@ import androidx.compose.material.icons.outlined.BookmarkRemove import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.RemoveDone +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -51,6 +53,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.kanade.presentation.components.DownloadDropdownMenu import eu.kanade.presentation.manga.DownloadAction +import eu.kanade.presentation.updates.failed.FailedUpdatesManga +import eu.kanade.presentation.updates.failed.GroupByMode import eu.kanade.tachiyomi.R import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -60,6 +64,7 @@ import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalStdlibApi::class) @Composable fun MangaBottomActionMenu( visible: Boolean, @@ -218,6 +223,7 @@ private fun RowScope.Button( } } +@OptIn(ExperimentalStdlibApi::class) @Composable fun LibraryBottomActionMenu( visible: Boolean, @@ -308,3 +314,73 @@ fun LibraryBottomActionMenu( } } } + +@OptIn(ExperimentalStdlibApi::class) +@Composable +fun FailedUpdatesBottomActionMenu( + visible: Boolean, + modifier: Modifier = Modifier, + onDeleteClicked: () -> Unit, + onDismissClicked: () -> Unit, + onInfoClicked: (String) -> Unit, + selected: List, + groupingMode: GroupByMode, +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(animationSpec = tween(delayMillis = 300)), + exit = shrinkVertically(animationSpec = tween()), + ) { + val scope = rememberCoroutineScope() + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), + tonalElevation = 3.dp, + ) { + val haptic = LocalHapticFeedback.current + val confirm = remember { mutableStateListOf(false, false, false) } + var resetJob: Job? = remember { null } + val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + (0..<3).forEach { i -> confirm[i] = i == toConfirmIndex } + resetJob?.cancel() + resetJob = scope.launch { + delay(1.seconds) + if (isActive) confirm[toConfirmIndex] = false + } + } + Row( + modifier = Modifier + .windowInsetsPadding( + WindowInsets.navigationBars + .only(WindowInsetsSides.Bottom), + ) + .padding(horizontal = 8.dp, vertical = 12.dp), + ) { + Button( + title = stringResource(MR.strings.action_delete), + icon = Icons.Outlined.Delete, + toConfirm = confirm[0], + onLongClick = { onLongClickItem(0) }, + onClick = onDeleteClicked, + ) + Button( + title = stringResource(MR.strings.action_dismiss), + icon = Icons.Outlined.VisibilityOff, + toConfirm = confirm[1], + onLongClick = { onLongClickItem(1) }, + onClick = onDismissClicked, + ) + if (groupingMode == GroupByMode.NONE && selected.size <= 1) { + Button( + title = stringResource(MR.strings.action_info), + icon = Icons.Outlined.Info, + toConfirm = confirm[2], + onLongClick = { onLongClickItem(2) }, + onClick = { onInfoClicked(selected[0].errorMessage) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index 5693185c0c..1c9a814de3 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.FlipToBack import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarScrollBehavior @@ -28,6 +30,7 @@ import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.ui.updates.UpdatesItem import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch import tachiyomi.i18n.MR @@ -50,12 +53,14 @@ fun UpdateScreen( onInvertSelection: () -> Unit, onCalendarClicked: () -> Unit, onUpdateLibrary: () -> Boolean, + onUpdateWarning: () -> Unit, onDownloadChapter: (List, ChapterDownloadAction) -> Unit, onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, onMultiDeleteClicked: (List) -> Unit, onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onOpenChapter: (UpdatesItem) -> Unit, + hasFailedUpdates: Boolean, ) { BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) }) @@ -64,11 +69,13 @@ fun UpdateScreen( UpdatesAppBar( onCalendarClicked = { onCalendarClicked() }, onUpdateLibrary = { onUpdateLibrary() }, + onUpdateWarning = onUpdateWarning, actionModeCounter = state.selected.size, onSelectAll = { onSelectAll(true) }, onInvertSelection = { onInvertSelection() }, onCancelActionMode = { onSelectAll(false) }, scrollBehavior = scrollBehavior, + hasFailedUpdates = hasFailedUpdates, ) }, bottomBar = { @@ -131,6 +138,7 @@ fun UpdateScreen( private fun UpdatesAppBar( onCalendarClicked: () -> Unit, onUpdateLibrary: () -> Unit, + onUpdateWarning: () -> Unit, // For action mode actionModeCounter: Int, onSelectAll: () -> Unit, @@ -138,25 +146,40 @@ private fun UpdatesAppBar( onCancelActionMode: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, modifier: Modifier = Modifier, + hasFailedUpdates: Boolean, ) { + val warningIconTint = MaterialTheme.colorScheme.error AppBar( modifier = modifier, title = stringResource(MR.strings.label_recent_updates), actions = { - AppBarActions( - persistentListOf( + val actions = mutableListOf().apply { + if (hasFailedUpdates) { + add( + AppBar.Action( + title = stringResource(MR.strings.action_update_warning), + icon = Icons.Rounded.Warning, + onClick = onUpdateWarning, + iconTint = warningIconTint, + ), + ) + } + add( AppBar.Action( title = stringResource(MR.strings.action_view_upcoming), icon = Icons.Outlined.CalendarMonth, onClick = onCalendarClicked, ), + ) + add( AppBar.Action( title = stringResource(MR.strings.action_update_library), icon = Icons.Outlined.Refresh, onClick = onUpdateLibrary, ), - ), - ) + ) + } + AppBarActions(actions.toImmutableList()) }, actionModeCounter = actionModeCounter, onCancelActionMode = onCancelActionMode, diff --git a/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesContent.kt b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesContent.kt new file mode 100644 index 0000000000..8536b7e9f9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesContent.kt @@ -0,0 +1,512 @@ +package eu.kanade.presentation.updates.failed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +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.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastAny +import androidx.core.graphics.drawable.toBitmap +import eu.kanade.presentation.manga.components.MangaCover +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.util.system.LocaleHelper +import tachiyomi.domain.source.model.Source +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.Pill +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.util.secondaryItemAlpha +import tachiyomi.presentation.core.util.selectedBackground +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +fun LazyListScope.failedUpdatesUiItems( + items: List, + selectionMode: Boolean, + onSelected: (FailedUpdatesManga, Boolean, Boolean, Boolean) -> Unit, + onClick: (FailedUpdatesManga) -> Unit, + groupingMode: GroupByMode, +) { + items( + items = items, + key = { it.libraryManga.manga.id }, + ) { item -> + Box(modifier = Modifier.animateItemPlacement(animationSpec = tween(300))) { + FailedUpdatesUiItem( + modifier = Modifier, + selected = item.selected, + onLongClick = { + onSelected(item, !item.selected, true, true) + }, + onClick = { + when { + selectionMode -> onSelected(item, !item.selected, true, false) + else -> onClick(item) + } + }, + manga = item, + groupingMode = groupingMode, + ) + } + } +} + +@Composable +private fun FailedUpdatesUiItem( + modifier: Modifier, + manga: FailedUpdatesManga, + selected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + groupingMode: GroupByMode = GroupByMode.BY_SOURCE, +) { + val haptic = LocalHapticFeedback.current + val textAlpha = 1f + Row( + modifier = modifier + .selectedBackground(selected) + .combinedClickable( + onClick = onClick, + onLongClick = { + onLongClick() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + .height(56.dp) + .padding(start = MaterialTheme.padding.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + MangaCover.Square( + modifier = Modifier + .padding(vertical = 6.dp) + .fillMaxHeight(), + data = manga.libraryManga.manga, + ) + + Column( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .weight(1f) + .animateContentSize(), + ) { + Text( + text = manga.libraryManga.manga.title, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = textAlpha), + overflow = TextOverflow.Ellipsis, + ) + if (groupingMode == GroupByMode.NONE) { + Row(verticalAlignment = Alignment.CenterVertically) { + var textHeight by remember { mutableIntStateOf(0) } + Text( + text = manga.simplifiedErrorMessage, + maxLines = if (selected) Int.MAX_VALUE else 1, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + onTextLayout = { textHeight = it.size.height }, + modifier = Modifier + .weight(weight = 1f, fill = false), + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +fun returnSourceIcon(id: Long): ImageBitmap? { + return Injekt.get().getAppIconForSource(id) + ?.toBitmap() + ?.asImageBitmap() +} + +fun LazyListScope.failedUpdatesGroupUiItem( + errorMessageMap: Map, List>, + selectionMode: Boolean, + onSelected: (FailedUpdatesManga, Boolean, Boolean, Boolean) -> Unit, + onMangaClick: (FailedUpdatesManga) -> Unit, + id: String, + onGroupSelected: (List) -> Unit, + onExpandedMapChange: (GroupKey, Boolean) -> Unit, + expanded: Map, + showLanguageInContent: Boolean = true, + sourcesCount: List>, + onClickIcon: (String) -> Unit = {}, + onLongClickIcon: (String) -> Unit = {}, +) { + item( + key = errorMessageMap.values.flatten().find { it.source.name == id }!!.source.id, + ) { + ElevatedCard( + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + shape = RoundedCornerShape(corner = CornerSize(15.dp)), + modifier = Modifier + .padding(vertical = 9.dp) + .animateItemPlacement( + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + .fillMaxWidth(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .selectedBackground( + !errorMessageMap.values + .flatten() + .fastAny { !it.selected }, + ) + .combinedClickable( + onClick = { + val categoryKey = GroupKey(id, Pair("", "")) + if (!expanded.containsKey(categoryKey)) { + onExpandedMapChange(categoryKey, true) + } + onExpandedMapChange(categoryKey, !expanded[categoryKey]!!) + }, + onLongClick = { onGroupSelected(errorMessageMap.values.flatten()) }, + ) + .padding( + horizontal = 12.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + val item = errorMessageMap.values.flatten().find { it.source.name == id }!!.source + val sourceLangString = + LocaleHelper.getSourceDisplayName(item.lang, LocalContext.current) + .takeIf { showLanguageInContent } + val icon = returnSourceIcon(item.id) + if (icon != null) { + Image( + bitmap = icon, + contentDescription = null, + modifier = Modifier + .height(50.dp) + .aspectRatio(1f), + ) + } else { + Image( + imageVector = Icons.Filled.Dangerous, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), + modifier = Modifier + .height(50.dp) + .aspectRatio(1f), + ) + } + Column( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .weight(1f), + ) { + Text( + text = item.name.ifBlank { item.id.toString() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + verticalAlignment = Alignment.CenterVertically, + ) { + if (sourceLangString != null) { + Text( + modifier = Modifier.secondaryItemAlpha(), + text = sourceLangString, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + val mangaCount = errorMessageMap.values.flatten().size + val sourceCount = sourcesCount.find { it.first.id == item.id }!!.second + Pill( + text = "$mangaCount/$sourceCount", + modifier = Modifier.padding(start = 4.dp), + color = MaterialTheme.colorScheme.error, + isCustomText = true, + ) + val rotation by animateFloatAsState( + targetValue = if (expanded[GroupKey(id, Pair("", ""))] == true) 0f else -180f, + animationSpec = tween(500), + label = "", + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + modifier = Modifier + .rotate(rotation) + .padding(vertical = 8.dp, horizontal = 14.dp), + contentDescription = null, + ) + } + Column { + errorMessageMap.forEach { (errorMessagePair, items) -> + val errorMessageHeaderId = GroupKey(id, errorMessagePair) + AnimatedVisibility( + modifier = Modifier, + visible = expanded[GroupKey(id, Pair("", ""))] == true, + ) { + HorizontalDivider(thickness = 0.5.dp, color = Color.Gray) + Row( + modifier = Modifier + .fillMaxWidth() + .selectedBackground(!items.fastAny { !it.selected }) + .combinedClickable( + onClick = + { + if (expanded[errorMessageHeaderId] == null) { + onExpandedMapChange(errorMessageHeaderId, true) + } else { + onExpandedMapChange( + errorMessageHeaderId, + !expanded[errorMessageHeaderId]!!, + ) + } + }, + onLongClick = { onGroupSelected(items) }, + ) + .padding( + horizontal = 12.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomIconButton( + onClick = { + onClickIcon( + errorMessagePair.first, + ) + }, + onLongClick = { + onLongClickIcon( + errorMessagePair.first, + ) + }, + modifier = Modifier, + content = { + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = "", + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + Column( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .weight(1f), + ) { + Text( + errorMessagePair.second.ifEmpty { + errorMessagePair.first.substringAfter(":").substring(1) + }, + maxLines = 2, + color = MaterialTheme.colorScheme.error, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ) + } + val rotation by animateFloatAsState( + targetValue = if (expanded[errorMessageHeaderId] == true) 0f else -180f, + animationSpec = tween(500), + label = "", + ) + Box( + modifier = Modifier + .padding( + top = 8.dp, + bottom = 8.dp, + start = 10.dp, + end = 14.1.dp, + ) + .rotate(rotation), + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + contentDescription = null, + ) + } + } + } + Column { + items.forEachIndexed { index, item -> + val isLastItem = index == items.lastIndex + AnimatedVisibility( + modifier = Modifier, + visible = + expanded[errorMessageHeaderId] == true && + expanded[GroupKey(id, Pair("", ""))] == true, + ) { + FailedUpdatesUiItem( + modifier = Modifier + .padding(bottom = if (isLastItem) 5.dp else 0.dp), + selected = item.selected, + onLongClick = { + onSelected(item, !item.selected, true, true) + }, + onClick = { + when { + selectionMode -> onSelected( + item, + !item.selected, + true, + false, + ) + + else -> onMangaClick(item) + } + }, + manga = item, + ) + } + } + } + } + } + } + } + } +} + +@Composable +fun CustomIconButton( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(40.dp) + .clip(CircleShape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = remember { + ripple( + bounded = false, + radius = 20.dp, + ) + }, + ), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(content = content) + } +} + +@Composable +fun CategoryList( + contentPadding: PaddingValues, + selectionMode: Boolean, + onMangaClick: (FailedUpdatesManga) -> Unit, + onGroupSelected: (List) -> Unit, + onSelected: (FailedUpdatesManga, Boolean, Boolean, Boolean) -> Unit, + categoryMap: Map, List>>, + onExpandedMapChange: (GroupKey, Boolean) -> Unit, + expanded: Map, + sourcesCount: List>, + onClickIcon: (String) -> Unit = {}, + onLongClickIcon: (String) -> Unit = {}, + lazyListState: LazyListState, +) { + FastScrollLazyColumn( + contentPadding = contentPadding, + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 10.dp), + state = lazyListState, + ) { + categoryMap.forEach { (category, errorMessageMap) -> + failedUpdatesGroupUiItem( + id = category, + errorMessageMap = errorMessageMap, + selectionMode = selectionMode, + onMangaClick = onMangaClick, + onSelected = onSelected, + onGroupSelected = onGroupSelected, + onExpandedMapChange = onExpandedMapChange, + expanded = expanded, + sourcesCount = sourcesCount, + onClickIcon = onClickIcon, + onLongClickIcon = onLongClickIcon, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesDialog.kt b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesDialog.kt new file mode 100644 index 0000000000..5a74d9f9b5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesDialog.kt @@ -0,0 +1,45 @@ +package eu.kanade.presentation.updates.failed + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import eu.kanade.tachiyomi.R + +@Composable +fun ErrorMessageDialog( + onDismissRequest: () -> Unit, + onCopyClick: () -> Unit, + errorMessage: String, +) { + AlertDialog( + text = { + Column { + Text( + text = "${stringResource(R.string.label_error_message)}:\n", + fontSize = 20.sp, + textAlign = TextAlign.Justify, + ) + Text(text = errorMessage) + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = { + onCopyClick() + onDismissRequest() + }) { + Text(text = stringResource(R.string.copy)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreen.kt new file mode 100644 index 0000000000..0bad7d75ad --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreen.kt @@ -0,0 +1,429 @@ +package eu.kanade.presentation.updates.failed + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowLeft +import androidx.compose.material.icons.outlined.ArrowRight +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material.icons.outlined.Sort +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.TopAppBarScrollBehavior +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.presentation.components.NestedMenuItem +import eu.kanade.presentation.library.DeleteLibraryMangaDialog +import eu.kanade.presentation.manga.components.FailedUpdatesBottomActionMenu +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.manga.MangaScreen +import eu.kanade.tachiyomi.util.system.copyToClipboard +import kotlinx.collections.immutable.toImmutableList +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.Pill +import tachiyomi.presentation.core.components.SortItem +import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.screens.LoadingScreen +import tachiyomi.source.local.isLocal + +class FailedUpdatesScreen : Screen() { + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + val screenModel = rememberScreenModel { FailedUpdatesScreenModel(context) } + val state by screenModel.state.collectAsState() + val failedUpdatesListState = rememberLazyListState() + + val previousGroupByMode = remember { mutableStateOf(state.groupByMode) } + + Scaffold( + topBar = { scrollBehavior -> + FailedUpdatesAppBar( + groupByMode = state.groupByMode, + items = state.items, + selected = state.selected, + onSelectAll = { screenModel.toggleAllSelection(true) }, + onDismissAll = { screenModel.dismissManga(state.items) }, + isAllExpanded = state.expanded.values.all { it }, + onExpandAll = { screenModel.expandAll() }, + onContractAll = { screenModel.contractAll() }, + onInvertSelection = { screenModel.invertSelection() }, + onCancelActionMode = { screenModel.toggleAllSelection(false) }, + scrollBehavior = scrollBehavior, + onClickGroup = screenModel::runGroupBy, + onClickSort = screenModel::runSortAction, + sortState = state.sortMode, + descendingOrder = state.descendingOrder, + navigateUp = navigator::pop, + errorCount = state.items.size, + ) + }, + bottomBar = { + FailedUpdatesBottomActionMenu( + visible = state.selectionMode, + onDeleteClicked = { screenModel.openDeleteMangaDialog(state.selected) }, + onDismissClicked = { screenModel.dismissManga(state.selected) }, + onInfoClicked = { errorMessage -> + screenModel.openErrorMessageDialog(errorMessage) + }, + selected = state.selected, + groupingMode = state.groupByMode, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = state.items.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + ExtendedFloatingActionButton( + text = { Text(text = stringResource(R.string.label_help)) }, + icon = { Icon(imageVector = Icons.Outlined.HelpOutline, contentDescription = null) }, + onClick = { uriHandler.openUri("https://tachiyomi.org/help/guides/troubleshooting") }, + // Revisit + // expanded = failedUpdatesListState.isScrollingUp() || failedUpdatesListState.isScrolledToEnd(), + ) + } + }, + ) { contentPadding -> + when { + state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + + state.items.isEmpty() -> EmptyScreen( + stringRes = MR.strings.information_no_update_errors, + modifier = Modifier.padding(contentPadding), + happyFace = true, + ) + + else -> { + when (state.groupByMode) { + GroupByMode.NONE -> { + FastScrollLazyColumn( + contentPadding = contentPadding, + state = failedUpdatesListState, + ) { + failedUpdatesUiItems( + items = state.items, + selectionMode = state.selectionMode, + onClick = { item -> + navigator.push( + MangaScreen(item.libraryManga.manga.id), + ) + }, + onSelected = screenModel::toggleSelection, + groupingMode = state.groupByMode, + ) + } + } + + GroupByMode.BY_SOURCE -> { + val categoryMap = screenModel.categoryMap( + state.items, + GroupByMode.BY_SOURCE, + state.sortMode, + state.descendingOrder, + ) + + LaunchedEffect(state.groupByMode) { + val currentGroupByMode = state.groupByMode + + if (previousGroupByMode.value != currentGroupByMode) { + screenModel.initializeExpandedMap(categoryMap) + } + + previousGroupByMode.value = currentGroupByMode + } + + CategoryList( + contentPadding = contentPadding, + selectionMode = state.selectionMode, + onMangaClick = { item -> + navigator.push( + MangaScreen(item.libraryManga.manga.id), + ) + }, + onGroupSelected = screenModel::groupSelection, + onSelected = { item, selected, userSelected, fromLongPress -> + screenModel.toggleSelection(item, selected, userSelected, fromLongPress, true) + }, + categoryMap = categoryMap, + onExpandedMapChange = screenModel::updateExpandedMap, + expanded = state.expanded, + sourcesCount = state.sourcesCount, + onClickIcon = { errorMessage -> + screenModel.openErrorMessageDialog(errorMessage) + }, + onLongClickIcon = { errorMessage -> + context.copyToClipboard(errorMessage, errorMessage) + }, + lazyListState = failedUpdatesListState, + ) + } + } + } + } + } + val onDismissRequest = screenModel::closeDialog + when (val dialog = state.dialog) { + is Dialog.DeleteManga -> { + DeleteLibraryMangaDialog( + containsLocalManga = dialog.manga.any(Manga::isLocal), + onDismissRequest = onDismissRequest, + onConfirm = { deleteManga, deleteChapter -> + screenModel.removeMangas(dialog.manga, deleteManga, deleteChapter) + screenModel.toggleAllSelection(false) + }, + ) + } + is Dialog.ShowErrorMessage -> { + ErrorMessageDialog( + onDismissRequest = onDismissRequest, + onCopyClick = { + context.copyToClipboard(dialog.errorMessage, dialog.errorMessage) + screenModel.toggleAllSelection(false) + }, + errorMessage = dialog.errorMessage, + ) + } + null -> {} + } + } +} + +@Composable +private fun FailedUpdatesAppBar( + groupByMode: GroupByMode, + items: List, + modifier: Modifier = Modifier, + selected: List, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + onCancelActionMode: () -> Unit, + onClickSort: (SortingMode) -> Unit, + onClickGroup: (GroupByMode) -> Unit, + onDismissAll: () -> Unit, + isAllExpanded: Boolean, + onExpandAll: () -> Unit, + onContractAll: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + sortState: SortingMode, + descendingOrder: Boolean? = null, + navigateUp: (() -> Unit)?, + errorCount: Int, +) { + if (selected.isNotEmpty()) { + FailedUpdatesActionAppBar( + modifier = modifier, + onSelectAll = onSelectAll, + onInvertSelection = onInvertSelection, + onCancelActionMode = onCancelActionMode, + scrollBehavior = scrollBehavior, + navigateUp = navigateUp, + actionModeCounter = selected.size, + ) + BackHandler( + onBack = onCancelActionMode, + ) + } else { + AppBar( + navigateUp = navigateUp, + titleContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.label_failed_updates), + maxLines = 1, + modifier = Modifier.weight(1f, false), + overflow = TextOverflow.Ellipsis, + ) + if (errorCount > 0) { + val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f + Pill( + text = "$errorCount", + modifier = Modifier.padding(start = 4.dp), + color = MaterialTheme.colorScheme.onBackground + .copy(alpha = pillAlpha), + fontSize = 14.sp, + ) + } + } + }, + actions = { + if (items.isNotEmpty()) { + val filterTint = LocalContentColor.current + var sortExpanded by remember { mutableStateOf(false) } + val onSortDismissRequest = { sortExpanded = false } + var mainExpanded by remember { mutableStateOf(false) } + val onDismissRequest = { mainExpanded = false } + SortDropdownMenu( + expanded = sortExpanded, + onDismissRequest = onSortDismissRequest, + onSortClicked = onClickSort, + sortState = sortState, + descendingOrder = descendingOrder, + ) + DropdownMenu(expanded = mainExpanded, onDismissRequest = onDismissRequest) { + NestedMenuItem( + text = { Text(text = stringResource(R.string.action_groupBy)) }, + children = { closeMenu -> + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_group_by_source)) }, + onClick = { + onClickGroup(GroupByMode.BY_SOURCE) + closeMenu() + onDismissRequest() + }, + ) + }, + ) + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_sortBy)) }, + onClick = { + onDismissRequest() + sortExpanded = !sortExpanded + }, + trailingIcon = { + Icon( + imageVector = if (isLtr) Icons.Outlined.ArrowRight else Icons.Outlined.ArrowLeft, + contentDescription = null, + ) + }, + ) + } + val actions = mutableListOf() + actions += AppBar.Action( + title = stringResource(R.string.action_sort), + icon = Icons.Outlined.Sort, + iconTint = filterTint, + onClick = { mainExpanded = !mainExpanded }, + ) + actions += AppBar.OverflowAction( + title = stringResource(R.string.action_dismiss_all), + onClick = onDismissAll, + ) + if (groupByMode != GroupByMode.NONE) { + actions += if (isAllExpanded) { + AppBar.OverflowAction( + title = stringResource(R.string.action_contract_all), + onClick = { + onContractAll() + }, + ) + } else { + AppBar.OverflowAction( + title = stringResource(R.string.action_expand_all), + onClick = { + onExpandAll() + }, + ) + } + } + if (groupByMode != GroupByMode.NONE) { + actions += AppBar.OverflowAction( + title = stringResource(R.string.action_ungroup), + onClick = { onClickGroup(GroupByMode.NONE) }, + ) + } + AppBarActions(actions.toImmutableList()) + } + }, + scrollBehavior = scrollBehavior, + ) + } +} + +@Composable +private fun FailedUpdatesActionAppBar( + modifier: Modifier = Modifier, + actionModeCounter: Int, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + onCancelActionMode: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + navigateUp: (() -> Unit)?, +) { + AppBar( + modifier = modifier, + title = stringResource(R.string.label_failed_updates), + actionModeCounter = actionModeCounter, + onCancelActionMode = onCancelActionMode, + actionModeActions = { + AppBarActions( + listOf( + AppBar.Action( + title = stringResource(R.string.action_select_all), + icon = Icons.Outlined.SelectAll, + onClick = onSelectAll, + ), + AppBar.Action( + title = stringResource(R.string.action_select_inverse), + icon = Icons.Outlined.FlipToBack, + onClick = onInvertSelection, + ), + ).toImmutableList(), + ) + }, + scrollBehavior = scrollBehavior, + navigateUp = navigateUp, + ) +} + +@Composable +fun SortDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + onSortClicked: (SortingMode) -> Unit, + sortState: SortingMode, + descendingOrder: Boolean? = null, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + SortItem( + label = stringResource(R.string.action_sort_A_Z), + sortDescending = descendingOrder.takeIf { sortState == SortingMode.BY_ALPHABET }, + onClick = { onSortClicked(SortingMode.BY_ALPHABET) }, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreenModel.kt b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreenModel.kt new file mode 100644 index 0000000000..d61dd1d976 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/failed/FailedUpdatesScreenModel.kt @@ -0,0 +1,497 @@ +package eu.kanade.presentation.updates.failed + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import eu.kanade.core.util.addOrRemove +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import logcat.LogPriority +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.preference.getEnum +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.launchNonCancellable +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.model.Category +import tachiyomi.domain.failed.repository.FailedUpdatesRepository +import tachiyomi.domain.library.model.LibraryManga +import tachiyomi.domain.manga.interactor.GetLibraryManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.MangaUpdate +import tachiyomi.domain.source.model.Source +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.TreeMap + +class FailedUpdatesScreenModel( + private val context: Context, + private val getLibraryManga: GetLibraryManga = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), + private val downloadManager: DownloadManager = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(), + private val preferenceStore: PreferenceStore = Injekt.get(), + private val failedUpdatesManager: FailedUpdatesRepository = Injekt.get(), +) : StateScreenModel(FailedUpdatesScreenState()) { + private val selectedPositions: Array = arrayOf(-1, -1) + private val selectedMangaIds: HashSet = HashSet() + private val _channel = Channel(Int.MAX_VALUE) + val channel = _channel.receiveAsFlow() + + init { + screenModelScope.launchIO { + val sortMode = preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).get() + combine( + getSourcesWithFavoriteCount.subscribe(), + getLibraryManga.subscribe(), + failedUpdatesManager.getFailedUpdates(), + getCategories.subscribe(), + ) { sources, libraryManga, failedUpdates, categories -> + Triple(sources, libraryManga, failedUpdates) to categories + } + .catch { + logcat(LogPriority.ERROR, it) + _channel.send(Event.FailedFetchingSourcesWithCount) + } + .collectLatest { (triple, categories) -> + val (sources, libraryManga, failedUpdates) = triple + mutableState.update { state -> + val categoriesMap = categories.associateBy { group -> group.id } + state.copy( + sourcesCount = sources, + items = libraryManga.filter { libraryManga -> + failedUpdates.any { it.mangaId == libraryManga.manga.id } + }.map { libraryManga -> + // Untrusted Extensions cause null crash + val source = sourceManager.getOrStub(libraryManga.manga.source) + val failedUpdate = failedUpdates.find { it.mangaId == libraryManga.manga.id }!! + val errorMessage = failedUpdate.errorMessage + val simplifiedErrorMessage = + simplifyErrorMessage(errorMessage.substringBefore(":"), failedUpdate.isOnline) + FailedUpdatesManga( + libraryManga = libraryManga, + errorMessage = errorMessage, + simplifiedErrorMessage = simplifiedErrorMessage, + selected = libraryManga.id in selectedMangaIds, + source = source, + category = categoriesMap[libraryManga.category]!!, + ) + }, + groupByMode = preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).get(), + sortMode = sortMode, + descendingOrder = preferenceStore.getBoolean("descending_order", false).get(), + isLoading = false, + ) + } + } + runSortAction(sortMode) + } + } + + private fun simplifyErrorMessage(exception: String, isOnline: Long): String { + return when (exception) { + // General networking exceptions + // Hold your arses this is temporary and for testing purposes only + "SocketException" -> "Socket Exception" + "BindException" -> "Bind Exception" + "InterruptedIOException" -> "Interrupted IO Exception" + "HttpRetryException" -> "HTTP Retry Exception" + "PortUnreachableException" -> "Port Unreachable Exception" + // General IO-related exceptions + "IOException" -> if (isOnline == + 1L + ) { + "IO Exception" + } else { + "IOException: No Internet" + } + "TimeoutException" -> "Timeout Exception" + // SSL & Security-related + "SSLException" -> "SSL Exception" + "CertificateExpiredException" -> "Certificate Expired Exception" + "CertificateNotYetValidException" -> "Certificate Not Yet Valid Exception" + "CertificateParsingException" -> "Certificate Parsing Exception" + "CertificateEncodingException" -> "Certificate Encoding Exception" + "UnrecoverableKeyException" -> "Unrecoverable Key Exception" + "KeyManagementException" -> "Key Management Exception" + "NoSuchAlgorithmException" -> "No Such Algorithm Exception" + "KeyStoreException" -> "Key Store Exception" + "NoSuchProviderException" -> "No Such Provider Exception" + "SignatureException" -> "Signature Exception" + "InvalidKeySpecException" -> "Invalid Key Spec Exception" + // Host & DNS-related + "UnknownHostException" -> if (isOnline == + 1L + ) { + "Unknown Host Exception" + } else { + "Unknown Host Exception: No Internet" + } + "ConnectException" -> "Connect Exception" + "NoRouteToHostException" -> "No Route To Host Exception" + // URL & URI related + "URISyntaxException" -> "URI Syntax Exception" + "MalformedURLException" -> "Malformed URL Exception" + // Authentication & Proxy + "ProtocolException" -> "Protocol Exception" + // Concurrency & Operation-related + "CancellationException" -> "Cancellation Exception" + "InterruptedException" -> "Interrupted Exception" + "IllegalStateException" -> "Illegal State Exception" + "UnsupportedOperationException" -> "Unsupported Operation Exception" + "IllegalArgumentException" -> "Illegal Argument Exception" + else -> "Unknown: $exception" + } + } + + fun runSortAction(mode: SortingMode) { + when (mode) { + SortingMode.BY_ALPHABET -> sortByAlphabet() + } + } + + fun runGroupBy(mode: GroupByMode) { + when (mode) { + GroupByMode.NONE -> unGroup() + GroupByMode.BY_SOURCE -> groupBySource() + } + } + + private fun sortByAlphabet() { + mutableState.update { state -> + val descendingOrder = if (state.sortMode == SortingMode.BY_ALPHABET) !state.descendingOrder else false + preferenceStore.getBoolean("descending_order", false).set(descendingOrder) + state.copy( + items = if (descendingOrder) { + state.items.sortedByDescending { + it.libraryManga.manga.title + } + } else { + state.items.sortedBy { it.libraryManga.manga.title } + }, + descendingOrder = descendingOrder, + sortMode = SortingMode.BY_ALPHABET, + ) + } + preferenceStore.getEnum("sort_mode", SortingMode.BY_ALPHABET).set(SortingMode.BY_ALPHABET) + } + + @Composable + fun categoryMap( + items: List, + groupMode: GroupByMode, + sortMode: SortingMode, + descendingOrder: Boolean, + ): Map, List>> { + val unsortedMap = when (groupMode) { + GroupByMode.BY_SOURCE -> items.groupBy { it.source.name } + .mapValues { entry -> entry.value.groupBy { Pair(it.errorMessage, it.simplifiedErrorMessage) } } + GroupByMode.NONE -> emptyMap() + } + return when (sortMode) { + SortingMode.BY_ALPHABET -> { + val sortedMap = + TreeMap, List>>( + if (descendingOrder) { + compareByDescending { it } + } else { + compareBy { it } + }, + ) + sortedMap.putAll(unsortedMap) + sortedMap + } + } + } + + fun updateExpandedMap(key: GroupKey, value: Boolean) { + mutableState.update { it.copy(expanded = it.expanded + (key to value)) } + } + + fun initializeExpandedMap(categoryMap: Map, List>>) { + mutableState.update { currentState -> + val newMap = mutableMapOf() + newMap.putAll( + categoryMap.keys.flatMap { source -> + listOf(GroupKey(source, Pair("", "")) to false) + } + categoryMap.flatMap { entry -> + entry.value.keys.map { errorMessage -> + GroupKey(entry.key, errorMessage) to false + } + }, + ) + currentState.copy(expanded = newMap) + } + } + + fun expandAll() { + val newExpanded = mutableState.value.expanded.mapValues { true } + mutableState.update { it.copy(expanded = newExpanded) } + } + + fun contractAll() { + val newExpanded = mutableState.value.expanded.mapValues { false } + mutableState.update { it.copy(expanded = newExpanded) } + } + + private fun groupBySource() { + mutableState.update { + it.copy( + groupByMode = GroupByMode.BY_SOURCE, + ) + } + preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.BY_SOURCE) + } + + private fun unGroup() { + mutableState.update { + it.copy( + groupByMode = GroupByMode.NONE, + ) + } + preferenceStore.getEnum("group_by_mode", GroupByMode.NONE).set(GroupByMode.NONE) + } + + fun toggleSelection( + item: FailedUpdatesManga, + selected: Boolean, + userSelected: Boolean = false, + fromLongPress: Boolean = false, + groupByErrorMessage: Boolean = false, + ) { + mutableState.update { state -> + val newItems = state.items.toMutableList().apply { + val selectedIndex = indexOfFirst { it.libraryManga.manga.id == item.libraryManga.manga.id } + if (selectedIndex < 0) return@apply + + val selectedItem = get(selectedIndex) + if (selectedItem.selected == selected) return@apply + + val firstSelection = none { it.selected } + set(selectedIndex, selectedItem.copy(selected = selected)) + selectedMangaIds.addOrRemove(item.libraryManga.manga.id, selected) + + if (selected && userSelected && fromLongPress) { + if (firstSelection) { + selectedPositions[0] = selectedIndex + selectedPositions[1] = selectedIndex + } else { + val range: IntRange + if (selectedIndex < selectedPositions[0]) { + range = selectedIndex + 1 until selectedPositions[0] + selectedPositions[0] = selectedIndex + } else if (selectedIndex > selectedPositions[1]) { + range = (selectedPositions[1] + 1) until selectedIndex + selectedPositions[1] = selectedIndex + } else { + range = IntRange.EMPTY + } + + if (groupByErrorMessage) { + val firstErrorMessage = getOrNull(selectedPositions[0])?.errorMessage + val lastErrorMessage = getOrNull(selectedPositions[1])?.errorMessage + + range.forEach { + val inBetweenItem = getOrNull(it) + if (inBetweenItem != null && + !inBetweenItem.selected && + inBetweenItem.errorMessage == firstErrorMessage && + inBetweenItem.errorMessage == lastErrorMessage + ) { + selectedMangaIds.add(inBetweenItem.libraryManga.manga.id) + set(it, inBetweenItem.copy(selected = true)) + } + } + } else { + range.forEach { + val inBetweenItem = get(it) + if (!inBetweenItem.selected) { + selectedMangaIds.add(inBetweenItem.libraryManga.manga.id) + set(it, inBetweenItem.copy(selected = true)) + } + } + } + } + } else if (userSelected && !fromLongPress) { + if (!selected) { + if (selectedIndex == selectedPositions[0]) { + selectedPositions[0] = indexOfFirst { it.selected } + } else if (selectedIndex == selectedPositions[1]) { + selectedPositions[1] = indexOfLast { it.selected } + } + } else { + if (selectedIndex < selectedPositions[0]) { + selectedPositions[0] = selectedIndex + } else if (selectedIndex > selectedPositions[1]) { + selectedPositions[1] = selectedIndex + } + } + } + } + state.copy(items = newItems) + } + } + + fun toggleAllSelection(selected: Boolean) { + mutableState.update { state -> + val newItems = state.items.map { + selectedMangaIds.addOrRemove(it.libraryManga.manga.id, selected) + it.copy(selected = selected) + } + state.copy(items = newItems) + } + + selectedPositions[0] = -1 + selectedPositions[1] = -1 + } + + fun removeMangas(mangaList: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { + screenModelScope.launchNonCancellable { + val mangaToDelete = mangaList.distinctBy { it.id } + + if (deleteFromLibrary) { + val toDelete = mangaToDelete.map { + it.removeCovers(coverCache) + MangaUpdate( + favorite = false, + id = it.id, + ) + } + updateManga.awaitAll(toDelete) + } + + if (deleteChapters) { + mangaToDelete.forEach { manga -> + val source = sourceManager.get(manga.source) as? HttpSource + if (source != null) { + downloadManager.deleteManga(manga, source) + } + } + } + } + if (deleteFromLibrary) { + val set = mangaList.map { it.id }.toHashSet() + mutableState.update { state -> + state.copy( + items = state.items.filterNot { it.libraryManga.manga.id in set }, + ) + } + } + } + + fun dismissManga(selected: List) { + val set = selected.map { it.libraryManga.manga.id }.toHashSet() + val listOfMangaIds = selected.map { it.libraryManga.manga.id } + toggleAllSelection(false) + mutableState.update { state -> + state.copy( + items = state.items.filterNot { it.libraryManga.manga.id in set }, + ) + } + + screenModelScope.launchNonCancellable { failedUpdatesManager.removeFailedUpdatesByMangaIds(listOfMangaIds) } + } + + fun openDeleteMangaDialog(selected: List) { + val mangaList = selected.map { it.libraryManga.manga } + mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) } + } + + fun openErrorMessageDialog(errorMessage: String) { + mutableState.update { it.copy(dialog = Dialog.ShowErrorMessage(errorMessage)) } + } + + fun closeDialog() { + mutableState.update { it.copy(dialog = null) } + } + + fun invertSelection() { + mutableState.update { state -> + val newItems = state.items.map { + selectedMangaIds.addOrRemove(it.libraryManga.manga.id, !it.selected) + it.copy(selected = !it.selected) + } + state.copy(items = newItems) + } + selectedPositions[0] = -1 + selectedPositions[1] = -1 + } + + fun groupSelection(items: List) { + val newSelected = items.map { manga -> manga.libraryManga.id }.toHashSet() + selectedMangaIds.addAll(newSelected) + mutableState.update { state -> + val newItems = state.items.map { + it.copy(selected = if (it.libraryManga.id in newSelected) !it.selected else it.selected) + } + state.copy(items = newItems) + } + selectedPositions[0] = -1 + selectedPositions[1] = -1 + } +} + +enum class GroupByMode { + NONE, + BY_SOURCE, +} + +enum class SortingMode { + BY_ALPHABET, +} + +sealed class Dialog { + data class DeleteManga(val manga: List) : Dialog() + + data class ShowErrorMessage(val errorMessage: String) : Dialog() +} + +sealed class Event { + data object FailedFetchingSourcesWithCount : Event() +} + +@Immutable +data class FailedUpdatesManga( + val libraryManga: LibraryManga, + val errorMessage: String, + val simplifiedErrorMessage: String, + val selected: Boolean = false, + val source: eu.kanade.tachiyomi.source.Source, + val category: Category, +) + +@Immutable +data class FailedUpdatesScreenState( + val isLoading: Boolean = true, + val items: List = emptyList(), + val groupByMode: GroupByMode = GroupByMode.NONE, + val sortMode: SortingMode = SortingMode.BY_ALPHABET, + val descendingOrder: Boolean = false, + val dialog: Dialog? = null, + val sourcesCount: List> = emptyList(), + val expanded: Map = emptyMap(), +) { + val selected = items.filter { it.selected } + val selectionMode = selected.isNotEmpty() +} + +data class GroupKey( + val categoryOrSource: String, + val errorMessagePair: Pair, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 0982f41575..f189b7a204 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -24,9 +24,8 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.UpdateStrategy -import eu.kanade.tachiyomi.util.storage.getUriCompat -import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.isConnectedToWifi +import eu.kanade.tachiyomi.util.system.isOnline import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.workManager @@ -46,6 +45,7 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.category.model.Category import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.NoChaptersException +import tachiyomi.domain.failed.repository.FailedUpdatesRepository import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING @@ -64,7 +64,6 @@ import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.io.File import java.time.Instant import java.time.ZonedDateTime import java.util.concurrent.CopyOnWriteArrayList @@ -84,6 +83,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet private val updateManga: UpdateManga = Injekt.get() private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get() private val fetchInterval: FetchInterval = Injekt.get() + private val failedUpdatesManager: FailedUpdatesRepository = Injekt.get() private val filterChaptersForDownload: FilterChaptersForDownload = Injekt.get() private val notifier = LibraryUpdateNotifier(context) @@ -239,11 +239,12 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet val progressCount = AtomicInteger(0) val currentlyUpdatingManga = CopyOnWriteArrayList() val newUpdates = CopyOnWriteArrayList>>() - val failedUpdates = CopyOnWriteArrayList>() val hasDownloads = AtomicBoolean(false) + val failedUpdatesCount = AtomicInteger(0) val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now()) coroutineScope { + failedUpdatesManager.removeAllFailedUpdates() mangaToUpdate.groupBy { it.manga.source }.values .map { mangaInSource -> async { @@ -284,13 +285,20 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet is NoChaptersException -> context.stringResource( MR.strings.no_chapters_error, ) - // failedUpdates will already have the source, don't need to copy it into the message + is SourceNotInstalledException -> context.stringResource( MR.strings.loader_not_implemented_error, ) - else -> e.message + else -> e.message ?: "context.getString(MR.strings.exception_unknown)" + } + try { + failedUpdatesCount.getAndIncrement() + val fullErrorMessage = "${e::class.java.simpleName}: $errorMessage" + val isOnline = if (context.isOnline()) 1L else 0L + failedUpdatesManager.insert(manga.id, fullErrorMessage, isOnline) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) } - failedUpdates.add(manga to errorMessage) } } } @@ -309,11 +317,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet } } - if (failedUpdates.isNotEmpty()) { - val errorFile = writeErrorFile(failedUpdates) + if (failedUpdatesCount.get() > 0) { notifier.showUpdateErrorNotification( - failedUpdates.size, - errorFile.getUriCompat(context), + failedUpdatesCount.get(), ) } } @@ -376,43 +382,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet ) } - /** - * Writes basic file of update errors to cache dir. - */ - private fun writeErrorFile(errors: List>): File { - try { - if (errors.isNotEmpty()) { - val file = context.createFileInCacheDir("mihon_update_errors.txt") - file.bufferedWriter().use { out -> - out.write(context.stringResource(MR.strings.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n") - // Error file format: - // ! Error - // # Source - // - Manga - errors.groupBy({ it.second }, { it.first }).forEach { (error, mangas) -> - out.write("\n! ${error}\n") - mangas.groupBy { it.source }.forEach { (srcId, mangas) -> - val source = sourceManager.getOrStub(srcId) - out.write(" # $source\n") - mangas.forEach { - out.write(" - ${it.title}\n") - } - } - } - } - return file - } - } catch (_: Exception) {} - return File("") - } - companion object { private const val TAG = "LibraryUpdate" private const val WORK_NAME_AUTO = "LibraryUpdate-auto" private const val WORK_NAME_MANUAL = "LibraryUpdate-manual" - private const val ERROR_LOG_HELP_URL = "https://mihon.app/docs/guides/troubleshooting/" - private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index f7d6c5dbc0..364350d540 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import coil3.asDrawable @@ -139,12 +138,11 @@ class LibraryUpdateNotifier( } /** - * Shows notification containing update entries that failed with action to open full log. + * Shows notification containing update entries that failed with action to open failed updates screen. * * @param failed Number of entries that failed to update. - * @param uri Uri for error log file containing all titles that failed. */ - fun showUpdateErrorNotification(failed: Int, uri: Uri) { + fun showUpdateErrorNotification(failed: Int) { if (failed == 0) { return } @@ -157,7 +155,7 @@ class LibraryUpdateNotifier( setContentText(context.stringResource(MR.strings.action_show_errors)) setSmallIcon(R.drawable.ic_mihon) - setContentIntent(NotificationReceiver.openErrorLogPendingActivity(context, uri)) + setContentIntent(NotificationHandler.openFailedUpdatesPendingActivity(context)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt index f8435daecc..2f4be06322 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationHandler.kt @@ -31,11 +31,28 @@ object NotificationHandler { ) } + /** + * Returns [PendingIntent] that opens failed updates screen. + * + * @param context context of application + */ + internal fun openFailedUpdatesPendingActivity(context: Context): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = Constants.SHORTCUT_FAILED + } + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + /** * Returns [PendingIntent] that starts a gallery activity * * @param context context of application - * @param file file containing image */ internal fun openImagePendingActivity(context: Context, uri: Uri): PendingIntent { val intent = Intent(Intent.ACTION_VIEW).apply { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index f07015eb1c..d68d4e124c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -35,6 +35,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.TabNavigator import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.updates.failed.FailedUpdatesScreen import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.isTabletUi import eu.kanade.tachiyomi.ui.browse.BrowseTab @@ -157,7 +158,7 @@ object HomeScreen : Screen() { openTabEvent.receiveAsFlow().collectLatest { tabNavigator.current = when (it) { is Tab.Library -> LibraryTab - Tab.Updates -> UpdatesTab + is Tab.Updates -> UpdatesTab Tab.History -> HistoryTab is Tab.Browse -> { if (it.toExtensions) { @@ -174,6 +175,9 @@ object HomeScreen : Screen() { if (it is Tab.More && it.toDownloads) { navigator.push(DownloadQueueScreen) } + if (it is Tab.Updates && it.toFailedUpdates) { + navigator.push(FailedUpdatesScreen()) + } } } } @@ -309,7 +313,7 @@ object HomeScreen : Screen() { sealed interface Tab { data class Library(val mangaIdToOpen: Long? = null) : Tab - data object Updates : Tab + data class Updates(val toFailedUpdates: Boolean) : Tab data object History : Tab data class Browse(val toExtensions: Boolean = false) : Tab data class More(val toDownloads: Boolean) : Tab diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index aefe0fe77a..5bdc61ae94 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -395,7 +395,11 @@ class MainActivity : BaseActivity() { navigator.popUntilRoot() HomeScreen.Tab.Library(idToOpen) } - Constants.SHORTCUT_UPDATES -> HomeScreen.Tab.Updates + Constants.SHORTCUT_UPDATES -> HomeScreen.Tab.Updates(false) + Constants.SHORTCUT_FAILED -> { + navigator.popUntilRoot() + HomeScreen.Tab.Updates(true) + } Constants.SHORTCUT_HISTORY -> HomeScreen.Tab.History Constants.SHORTCUT_SOURCES -> HomeScreen.Tab.Browse(false) Constants.SHORTCUT_EXTENSIONS -> HomeScreen.Tab.Browse(true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt index c5385d1f04..4e0f4b5e90 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt @@ -38,6 +38,7 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.chapter.interactor.GetChapter import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.model.ChapterUpdate +import tachiyomi.domain.failed.repository.FailedUpdatesRepository import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.source.service.SourceManager @@ -58,6 +59,7 @@ class UpdatesScreenModel( private val getChapter: GetChapter = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), + private val failedUpdatesManager: FailedUpdatesRepository = Injekt.get(), ) : StateScreenModel(State()) { private val _events: Channel = Channel(Int.MAX_VALUE) @@ -78,16 +80,18 @@ class UpdatesScreenModel( getUpdates.subscribe(limit).distinctUntilChanged(), downloadCache.changes, downloadManager.queueState, - ) { updates, _, _ -> updates } + failedUpdatesManager.hasFailedUpdates(), + ) { updates, _, _, iconState -> updates to iconState } .catch { logcat(LogPriority.ERROR, it) _events.send(Event.InternalError) } - .collectLatest { updates -> - mutableState.update { - it.copy( + .collectLatest { (updates, iconState) -> + mutableState.update { state -> + state.copy( isLoading = false, items = updates.toUpdateItems(), + hasFailedUpdates = iconState, ) } } @@ -365,6 +369,7 @@ class UpdatesScreenModel( val isLoading: Boolean = true, val items: PersistentList = persistentListOf(), val dialog: Dialog? = null, + val hasFailedUpdates: Boolean = false, ) { val selected = items.filter { it.selected } val selectionMode = selected.isNotEmpty() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt index 7233ca818a..7b069f6268 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt @@ -17,6 +17,7 @@ import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions import eu.kanade.presentation.updates.UpdateScreen import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog +import eu.kanade.presentation.updates.failed.FailedUpdatesScreen import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen @@ -64,6 +65,7 @@ data object UpdatesTab : Tab { onSelectAll = screenModel::toggleAllSelection, onInvertSelection = screenModel::invertSelection, onUpdateLibrary = screenModel::updateLibrary, + onUpdateWarning = { navigator.push(FailedUpdatesScreen()) }, onDownloadChapter = screenModel::downloadChapters, onMultiBookmarkClicked = screenModel::bookmarkUpdates, onMultiMarkAsReadClicked = screenModel::markUpdatesRead, @@ -74,6 +76,7 @@ data object UpdatesTab : Tab { context.startActivity(intent) }, onCalendarClicked = { navigator.push(UpcomingScreen()) }, + hasFailedUpdates = state.hasFailedUpdates, ) val onDismissDialog = { screenModel.setDialog(null) } diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt b/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt index 38caeb3dcb..440cd2fb5b 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/Constants.kt @@ -12,6 +12,7 @@ object Constants { const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" const val SHORTCUT_UPDATES = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" + const val SHORTCUT_FAILED = "eu.kanade.tachiyomi.SHOW_FAILED_UPDATES" const val SHORTCUT_HISTORY = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" const val SHORTCUT_SOURCES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS" diff --git a/data/src/main/java/tachiyomi/data/failed/FailedUpdatesMapper.kt b/data/src/main/java/tachiyomi/data/failed/FailedUpdatesMapper.kt new file mode 100644 index 0000000000..3f696a3c55 --- /dev/null +++ b/data/src/main/java/tachiyomi/data/failed/FailedUpdatesMapper.kt @@ -0,0 +1,11 @@ +package tachiyomi.data.failed + +import tachiyomi.domain.failed.model.FailedUpdate + +val failedUpdatesMapper: (Long, String, Long) -> FailedUpdate = { mangaId, errorMessage, isOnline -> + FailedUpdate( + mangaId = mangaId, + errorMessage = errorMessage, + isOnline = isOnline, + ) +} diff --git a/data/src/main/java/tachiyomi/data/failed/FailedUpdatesRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/failed/FailedUpdatesRepositoryImpl.kt new file mode 100644 index 0000000000..fd5dea1dde --- /dev/null +++ b/data/src/main/java/tachiyomi/data/failed/FailedUpdatesRepositoryImpl.kt @@ -0,0 +1,51 @@ +package tachiyomi.data.failed + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.DatabaseHandler +import tachiyomi.domain.failed.model.FailedUpdate +import tachiyomi.domain.failed.repository.FailedUpdatesRepository + +class FailedUpdatesRepositoryImpl( + private val handler: DatabaseHandler, +) : FailedUpdatesRepository { + override fun getFailedUpdates(): Flow> { + return handler.subscribeToList { failed_updatesQueries.getFailedUpdates(failedUpdatesMapper) } + } + + override fun hasFailedUpdates(): Flow { + return handler + .subscribeToOne { failed_updatesQueries.getFailedUpdatesCount() } + .map { it > 0 } + .distinctUntilChanged() + } + + override suspend fun removeFailedUpdatesByMangaIds(mangaIds: List) { + try { + handler.await { failed_updatesQueries.removeFailedUpdatesByMangaIds(mangaIds) } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + } + + override suspend fun removeAllFailedUpdates() { + try { + handler.await { failed_updatesQueries.removeAllFailedUpdates() } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + } + } + + override suspend fun insert(mangaId: Long, errorMessage: String, isOnline: Long) { + handler.await(inTransaction = true) { + failed_updatesQueries.insert( + mangaId = mangaId, + errorMessage = errorMessage, + isOnline = isOnline, + ) + } + } +} diff --git a/data/src/main/sqldelight/tachiyomi/data/failed_updates.sq b/data/src/main/sqldelight/tachiyomi/data/failed_updates.sq new file mode 100644 index 0000000000..2486e16f7b --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/failed_updates.sq @@ -0,0 +1,26 @@ +CREATE TABLE failed_updates ( + manga_id INTEGER NOT NULL, + error_message TEXT NOT NULL, + is_online INTEGER NOT NULL CHECK(is_online IN (0, 1)), + UNIQUE (manga_id) ON CONFLICT REPLACE, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); + +insert: +INSERT INTO failed_updates(manga_id,error_message,is_online) +VALUES (:mangaId,:errorMessage,:isOnline); + +getFailedUpdates: +SELECT * +FROM failed_updates; + +removeFailedUpdatesByMangaIds: +DELETE FROM failed_updates +WHERE manga_id IN :mangaIds; + +removeAllFailedUpdates: +DELETE FROM failed_updates; + +getFailedUpdatesCount: +SELECT COUNT(*) AS row_count FROM failed_updates; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/4.sqm b/data/src/main/sqldelight/tachiyomi/migrations/4.sqm new file mode 100644 index 0000000000..9b0f77e00e --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/4.sqm @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS failed_updates ( + manga_id INTEGER NOT NULL, + error_message TEXT NOT NULL, + is_online INTEGER NOT NULL CHECK(is_online IN (0, 1)), + UNIQUE (manga_id) ON CONFLICT REPLACE, + FOREIGN KEY(manga_id) REFERENCES mangas (_id) + ON DELETE CASCADE +); diff --git a/domain/src/main/java/tachiyomi/domain/failed/model/FailedUpdate.kt b/domain/src/main/java/tachiyomi/domain/failed/model/FailedUpdate.kt new file mode 100644 index 0000000000..ee075df24d --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/failed/model/FailedUpdate.kt @@ -0,0 +1,7 @@ +package tachiyomi.domain.failed.model + +data class FailedUpdate( + val mangaId: Long, + val errorMessage: String, + val isOnline: Long, +) diff --git a/domain/src/main/java/tachiyomi/domain/failed/repository/FailedUpdatesRepository.kt b/domain/src/main/java/tachiyomi/domain/failed/repository/FailedUpdatesRepository.kt new file mode 100644 index 0000000000..de40d8b435 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/failed/repository/FailedUpdatesRepository.kt @@ -0,0 +1,16 @@ +package tachiyomi.domain.failed.repository + +import kotlinx.coroutines.flow.Flow +import tachiyomi.domain.failed.model.FailedUpdate + +interface FailedUpdatesRepository { + fun getFailedUpdates(): Flow> + + fun hasFailedUpdates(): Flow + + suspend fun removeFailedUpdatesByMangaIds(mangaIds: List) + + suspend fun removeAllFailedUpdates() + + suspend fun insert(mangaId: Long, errorMessage: String, isOnline: Long) +} diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 7d1f3bf7d0..bb06e4706a 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -26,6 +26,8 @@ Download queue Library Updates + Failed updates + Error message Upcoming History Sources @@ -59,6 +61,7 @@ Remove filter Alphabetically + A-Z Total entries Total chapters Last read @@ -83,7 +86,10 @@ Bookmark chapter Unbookmark chapter Delete + Dismiss + Info Update library + Update warning Enable all Disable all Edit @@ -112,6 +118,12 @@ Show entry Copy to clipboard Copy link + Category + Source + Ungroup + Dismiss all + Expand all + Contract all Open in WebView WebView @@ -136,6 +148,8 @@ OK Cancel all Cancel all for this series + Sort by + Group by Sort By upload date By chapter number @@ -917,6 +931,7 @@ No downloads + All entries updated successfully No recent updates Nothing read recently Your library is empty diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pill.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pill.kt index 1d8cf2d5f5..798e688ad7 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pill.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/Pill.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -20,6 +22,10 @@ fun Pill( color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor: Color = MaterialTheme.colorScheme.onSurface, fontSize: TextUnit = LocalTextStyle.current.fontSize, + fontWeight: FontWeight = FontWeight.Medium, + style: TextStyle = MaterialTheme.typography.bodySmall, + textColor: Color = MaterialTheme.colorScheme.onError, + isCustomText: Boolean = false, ) { Surface( modifier = modifier @@ -33,11 +39,22 @@ fun Pill( .padding(6.dp, 1.dp), contentAlignment = Alignment.Center, ) { - Text( - text = text, - fontSize = fontSize, - maxLines = 1, - ) + if (isCustomText) { + Text( + text = text, + fontSize = fontSize, + style = style, + fontWeight = fontWeight, + color = textColor, + maxLines = 1, + ) + } else { + Text( + text = text, + fontSize = fontSize, + maxLines = 1, + ) + } } } } diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/screens/EmptyScreen.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/screens/EmptyScreen.kt index 0922f925c8..9722eacfda 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/screens/EmptyScreen.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/screens/EmptyScreen.kt @@ -40,11 +40,13 @@ fun EmptyScreen( stringRes: StringResource, modifier: Modifier = Modifier, actions: ImmutableList? = null, + happyFace: Boolean = false, ) { EmptyScreen( message = stringResource(stringRes), modifier = modifier, actions = actions, + happyFace = happyFace, ) } @@ -53,8 +55,9 @@ fun EmptyScreen( message: String, modifier: Modifier = Modifier, actions: ImmutableList? = null, + happyFace: Boolean = false, ) { - val face = remember { getRandomErrorFace() } + val face = remember { getRandomFace(happyFace) } Column( modifier = modifier .fillMaxSize() @@ -108,6 +111,16 @@ private val ErrorFaces = listOf( "(・Д・。", ) -private fun getRandomErrorFace(): String { - return ErrorFaces[Random.nextInt(ErrorFaces.size)] +private val HappyFaces = listOf( + "ヽ(^Д^)ノ", + "≧◡≦", + "^ω^", + "^▽^", + "(◕‿◕)", + "◠‿◠", +) +private fun getRandomFace(happyFace: Boolean): String { + val faces = if (happyFace) HappyFaces else ErrorFaces + + return faces[Random.nextInt(faces.size)] }