Library Error Screen PR from Tachi

Co-Authored-By: Programmer-0-0 <67505416+programmer-0-0@users.noreply.github.com>
This commit is contained in:
Roshan Varughese 2024-10-26 21:01:19 +13:00
parent 2f4bb7cadb
commit 298a1134e6
24 changed files with 1766 additions and 77 deletions

View file

@ -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<UpdatesRepository> { UpdatesRepositoryImpl(get()) }
addFactory { GetUpdates(get()) }
addSingletonFactory<FailedUpdatesRepository> { FailedUpdatesRepositoryImpl(get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
addSingletonFactory<StubSourceRepository> { StubSourceRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), get()) }

View file

@ -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<FailedUpdatesManga>,
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(R.string.action_delete),
icon = Icons.Outlined.Delete,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = onDeleteClicked,
)
Button(
title = stringResource(R.string.action_dismiss),
icon = Icons.Outlined.VisibilityOff,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = onDismissClicked,
)
if (groupingMode == GroupByMode.NONE && selected.size <= 1) {
Button(
title = stringResource(R.string.action_info),
icon = Icons.Outlined.Info,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = { onInfoClicked(selected[0].errorMessage) },
)
}
}
}
}
}

View file

@ -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
@ -50,12 +52,14 @@ fun UpdateScreen(
onInvertSelection: () -> Unit,
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Boolean,
onUpdateWarning: () -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
onOpenChapter: (UpdatesItem) -> Unit,
hasFailedUpdates: Boolean,
) {
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
@ -64,11 +68,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 +137,7 @@ fun UpdateScreen(
private fun UpdatesAppBar(
onCalendarClicked: () -> Unit,
onUpdateLibrary: () -> Unit,
onUpdateWarning: () -> Unit,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
@ -138,25 +145,33 @@ 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(
AppBar.Action(
val actions = mutableListOf<AppBar.Action>()
if (hasFailedUpdates) { // only add the warning icon if it is enabled
actions += AppBar.Action(
title = stringResource(R.string.action_update_warning),
icon = Icons.Rounded.Warning,
onClick = onUpdateWarning,
iconTint = warningIconTint,
)
}
actions += AppBar.Action(
title = stringResource(MR.strings.action_view_upcoming),
icon = Icons.Outlined.CalendarMonth,
onClick = onCalendarClicked,
),
AppBar.Action(
title = stringResource(MR.strings.action_update_library),
)
actions += AppBar.Action(
title = stringResource(R.string.action_update_library),
icon = Icons.Outlined.Refresh,
onClick = onUpdateLibrary,
),
),
)
AppBarActions(actions)
},
actionModeCounter = actionModeCounter,
onCancelActionMode = onCancelActionMode,

View file

@ -0,0 +1,495 @@
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.KeyboardArrowUp
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material.ripple.rememberRipple
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.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.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<FailedUpdatesManga>,
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<ExtensionManager>().getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
}
fun LazyListScope.failedUpdatesGroupUiItem(
errorMessageMap: Map<Pair<String, String>, List<FailedUpdatesManga>>,
selectionMode: Boolean,
onSelected: (FailedUpdatesManga, Boolean, Boolean, Boolean) -> Unit,
onMangaClick: (FailedUpdatesManga) -> Unit,
id: String,
onGroupSelected: (List<FailedUpdatesManga>) -> Unit,
onExpandedMapChange: (GroupKey, Boolean) -> Unit,
expanded: Map<GroupKey, Boolean>,
showLanguageInContent: Boolean = true,
sourcesCount: List<Pair<Source, Long>>,
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),
)
}
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 = rememberRipple(
bounded = false,
radius = 20.dp,
),
),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(content = content)
}
}
@Composable
fun CategoryList(
contentPadding: PaddingValues,
selectionMode: Boolean,
onMangaClick: (FailedUpdatesManga) -> Unit,
onGroupSelected: (List<FailedUpdatesManga>) -> Unit,
onSelected: (FailedUpdatesManga, Boolean, Boolean, Boolean) -> Unit,
categoryMap: Map<String, Map<Pair<String, String>, List<FailedUpdatesManga>>>,
onExpandedMapChange: (GroupKey, Boolean) -> Unit,
expanded: Map<GroupKey, Boolean>,
sourcesCount: List<Pair<Source, Long>>,
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,
)
}
}
}

View file

@ -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))
}
},
)
}

View file

@ -0,0 +1,428 @@
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 tachiyomi.domain.manga.model.Manga
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.presentation.core.util.isScrolledToEnd
import tachiyomi.presentation.core.util.isScrollingUp
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") },
expanded = failedUpdatesListState.isScrollingUp() || failedUpdatesListState.isScrolledToEnd(),
)
}
},
) { contentPadding ->
when {
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
state.items.isEmpty() -> EmptyScreen(
textResource = R.string.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<FailedUpdatesManga>,
modifier: Modifier = Modifier,
selected: List<FailedUpdatesManga>,
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<AppBar.AppBarAction>()
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)
}
},
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,
),
),
)
},
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) },
)
}
}

View file

@ -0,0 +1,460 @@
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.coroutineScope
import eu.kanade.core.util.addOrRemove
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
import eu.kanade.tachiyomi.R
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.preference.PreferenceStore
import tachiyomi.core.preference.getEnum
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.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>(FailedUpdatesScreenState()) {
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedMangaIds: HashSet<Long> = HashSet()
private val _channel = Channel<Event>(Int.MAX_VALUE)
val channel = _channel.receiveAsFlow()
init {
coroutineScope.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 ->
val source = sourceManager.get(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
"SocketException" -> context.getString(R.string.exception_socket_error)
"BindException" -> context.getString(R.string.exception_bind_port)
"InterruptedIOException" -> context.getString(R.string.exception_io_interrupted)
"HttpRetryException" -> context.getString(R.string.exception_http_retry)
"PortUnreachableException" -> context.getString(R.string.exception_port_unreachable)
// General IO-related exceptions
"IOException" -> if (isOnline == 1L) context.getString(R.string.exception_io_error) else context.getString(R.string.exception_internet_connection)
"TimeoutException" -> context.getString(R.string.exception_timed_out)
// SSL & Security-related
"SSLException" -> context.getString(R.string.exception_ssl_connection)
"CertificateExpiredException" -> context.getString(R.string.exception_ssl_certificate)
"CertificateNotYetValidException" -> context.getString(R.string.exception_ssl_not_valid)
"CertificateParsingException" -> context.getString(R.string.exception_ssl_parsing)
"CertificateEncodingException" -> context.getString(R.string.exception_ssl_encoding)
"UnrecoverableKeyException" -> context.getString(R.string.exception_unrecoverable_key)
"KeyManagementException" -> context.getString(R.string.exception_key_management)
"NoSuchAlgorithmException" -> context.getString(R.string.exception_algorithm)
"KeyStoreException" -> context.getString(R.string.exception_keystore)
"NoSuchProviderException" -> context.getString(R.string.exception_security_provider)
"SignatureException" -> context.getString(R.string.exception_signature_validation)
"InvalidKeySpecException" -> context.getString(R.string.exception_key_specification)
// Host & DNS-related
"UnknownHostException" -> if (isOnline == 1L) context.getString(R.string.exception_domain) else context.getString(R.string.exception_internet_connection)
"NoRouteToHostException" -> context.getString(R.string.exception_route_to_host)
// URL & URI related
"URISyntaxException" -> context.getString(R.string.exception_uri_syntax)
"MalformedURLException" -> context.getString(R.string.exception_malformed_url)
// Authentication & Proxy
"ProtocolException" -> context.getString(R.string.exception_protocol_proxy_type)
// Concurrency & Operation-related
"CancellationException" -> context.getString(R.string.exception_cancelled)
"InterruptedException" -> context.getString(R.string.exception_interrupted)
"IllegalStateException" -> context.getString(R.string.exception_unexpected_state)
"UnsupportedOperationException" -> context.getString(R.string.exception_not_supported)
"IllegalArgumentException" -> context.getString(R.string.exception_invalid_argument)
else -> ""
}
}
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<FailedUpdatesManga>, groupMode: GroupByMode, sortMode: SortingMode, descendingOrder: Boolean): Map<String, Map<Pair<String, String>, List<FailedUpdatesManga>>> {
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<String, Map<Pair<String, String>, List<FailedUpdatesManga>>>(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<String, Map<Pair<String, String>, List<FailedUpdatesManga>>>) {
mutableState.update { currentState ->
val newMap = mutableMapOf<GroupKey, Boolean>()
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<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
coroutineScope.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<FailedUpdatesManga>) {
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 },
)
}
coroutineScope.launchNonCancellable { failedUpdatesManager.removeFailedUpdatesByMangaIds(listOfMangaIds) }
}
fun openDeleteMangaDialog(selected: List<FailedUpdatesManga>) {
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<FailedUpdatesManga>) {
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<Manga>) : 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<FailedUpdatesManga> = emptyList(),
val groupByMode: GroupByMode = GroupByMode.NONE,
val sortMode: SortingMode = SortingMode.BY_ALPHABET,
val descendingOrder: Boolean = false,
val dialog: Dialog? = null,
val sourcesCount: List<Pair<Source, Long>> = emptyList(),
val expanded: Map<GroupKey, Boolean> = emptyMap(),
) {
val selected = items.filter { it.selected }
val selectionMode = selected.isNotEmpty()
}
data class GroupKey(
val categoryOrSource: String,
val errorMessagePair: Pair<String, String>,
)

View file

@ -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<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
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,13 +317,12 @@ 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(),
)
}
}
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
@ -376,43 +383,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
)
}
/**
* Writes basic file of update errors to cache dir.
*/
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): 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
/**

View file

@ -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))
}
}

View file

@ -31,11 +31,23 @@ 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 {

View file

@ -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

View file

@ -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)

View file

@ -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<UpdatesScreenModel.State>(State()) {
private val _events: Channel<Event> = 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<UpdatesItem> = persistentListOf(),
val dialog: Dialog? = null,
val hasFailedUpdates: Boolean = false,
) {
val selected = items.filter { it.selected }
val selectionMode = selected.isNotEmpty()

View file

@ -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) }

View file

@ -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"

View file

@ -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,
)
}

View file

@ -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.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<List<FailedUpdate>> {
return handler.subscribeToList { failed_updatesQueries.getFailedUpdates(failedUpdatesMapper) }
}
override fun hasFailedUpdates(): Flow<Boolean> {
return handler
.subscribeToOne { failed_updatesQueries.getFailedUpdatesCount() }
.map { it > 0 }
.distinctUntilChanged()
}
override suspend fun removeFailedUpdatesByMangaIds(mangaIds: List<Long>) {
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,
)
}
}
}

View file

@ -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;

View file

@ -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
);

View file

@ -0,0 +1,7 @@
package tachiyomi.domain.failed.model
data class FailedUpdate(
val mangaId: Long,
val errorMessage: String,
val isOnline: Long,
)

View file

@ -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<List<FailedUpdate>>
fun hasFailedUpdates(): Flow<Boolean>
suspend fun removeFailedUpdatesByMangaIds(mangaIds: List<Long>)
suspend fun removeAllFailedUpdates()
suspend fun insert(mangaId: Long, errorMessage: String, isOnline: Long)
}

View file

@ -26,6 +26,8 @@
<string name="label_download_queue">Download queue</string>
<string name="label_library">Library</string>
<string name="label_recent_updates">Updates</string>
<string name="label_failed_updates">Failed updates</string>
<string name="label_error_message">Error message</string>
<string name="label_upcoming">Upcoming</string>
<string name="label_recent_manga">History</string>
<string name="label_sources">Sources</string>
@ -59,6 +61,7 @@
<!-- reserved for #4048 -->
<string name="action_filter_empty">Remove filter</string>
<string name="action_sort_alpha">Alphabetically</string>
<string name="action_sort_A_Z">A-Z</string>
<string name="action_sort_count">Total entries</string>
<string name="action_sort_total">Total chapters</string>
<string name="action_sort_last_read">Last read</string>
@ -83,7 +86,10 @@
<string name="action_bookmark">Bookmark chapter</string>
<string name="action_remove_bookmark">Unbookmark chapter</string>
<string name="action_delete">Delete</string>
<string name="action_dismiss">Dismiss</string>
<string name="action_info">Info</string>
<string name="action_update_library">Update library</string>
<string name="action_update_warning">Update warning</string>
<string name="action_enable_all">Enable all</string>
<string name="action_disable_all">Disable all</string>
<string name="action_edit">Edit</string>
@ -112,6 +118,12 @@
<string name="action_show_manga">Show entry</string>
<string name="action_copy_to_clipboard">Copy to clipboard</string>
<string name="action_copy_link">Copy link</string>
<string name="action_group_by_category">Category</string>
<string name="action_group_by_source">Source</string>
<string name="action_ungroup">Ungroup</string>
<string name="action_dismiss_all">Dismiss all</string>
<string name="action_expand_all">Expand all</string>
<string name="action_contract_all">Contract all</string>
<!-- Do not translate "WebView" -->
<string name="action_open_in_web_view">Open in WebView</string>
<string name="action_web_view" translatable="false">WebView</string>
@ -136,6 +148,8 @@
<string name="action_ok">OK</string>
<string name="action_cancel_all">Cancel all</string>
<string name="cancel_all_for_series">Cancel all for this series</string>
<string name="action_sortBy">Sort by</string>
<string name="action_groupBy">Group by</string>
<string name="action_sort">Sort</string>
<string name="action_order_by_upload_date">By upload date</string>
<string name="action_order_by_chapter_number">By chapter number</string>
@ -917,6 +931,7 @@
<!-- Information Text -->
<string name="information_no_downloads">No downloads</string>
<string name="information_no_update_errors">All entries updated successfully</string>
<string name="information_no_recent">No recent updates</string>
<string name="information_no_recent_manga">Nothing read recently</string>
<string name="information_empty_library">Your library is empty</string>

View file

@ -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,6 +39,16 @@ fun Pill(
.padding(6.dp, 1.dp),
contentAlignment = Alignment.Center,
) {
if (isCustomText) {
Text(
text = text,
fontSize = fontSize,
style = style,
fontWeight = fontWeight,
color = textColor,
maxLines = 1,
)
} else {
Text(
text = text,
fontSize = fontSize,
@ -40,4 +56,5 @@ fun Pill(
)
}
}
}
}

View file

@ -40,11 +40,13 @@ fun EmptyScreen(
stringRes: StringResource,
modifier: Modifier = Modifier,
actions: ImmutableList<EmptyScreenAction>? = 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<EmptyScreenAction>? = 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)]
}