mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05:00
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:
parent
2f4bb7cadb
commit
298a1134e6
24 changed files with 1766 additions and 77 deletions
|
@ -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()) }
|
||||
|
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>,
|
||||
)
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
26
data/src/main/sqldelight/tachiyomi/data/failed_updates.sq
Normal file
26
data/src/main/sqldelight/tachiyomi/data/failed_updates.sq
Normal 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;
|
8
data/src/main/sqldelight/tachiyomi/migrations/4.sqm
Normal file
8
data/src/main/sqldelight/tachiyomi/migrations/4.sqm
Normal 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
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
package tachiyomi.domain.failed.model
|
||||
|
||||
data class FailedUpdate(
|
||||
val mangaId: Long,
|
||||
val errorMessage: String,
|
||||
val isOnline: Long,
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue