Range-selection for Migration (#466)

- Select a range of entries for migration
- Better bottom bar
- Show obsolete sources

---------

Co-authored-by: ImaginaryDesignation <108343184+ImaginaryDesignation@users.noreply.github.com>
This commit is contained in:
Cuong-Tran 2024-10-28 22:20:52 +07:00
parent 6b6e6a7fba
commit 467c39b899
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
10 changed files with 668 additions and 187 deletions

View file

@ -1,35 +1,124 @@
package eu.kanade.presentation.browse
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FindReplace
import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material.icons.outlined.VerticalAlignBottom
import androidx.compose.material.icons.outlined.VerticalAlignTop
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.manga.components.BaseMangaListItem
import eu.kanade.presentation.manga.components.Button
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaItem
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreenModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import kotlin.time.Duration.Companion.seconds
@Composable
fun MigrateMangaScreen(
navigateUp: () -> Unit,
title: String?,
title: String,
state: MigrateMangaScreenModel.State,
onClickItem: (Manga) -> Unit,
onClickItem: (MigrateMangaItem) -> Unit,
onClickCover: (Manga) -> Unit,
// KMK -->
onMultiMigrateClicked: (() -> Unit),
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit,
) {
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val enableScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
val enableScrollToBottom by remember {
derivedStateOf {
listState.canScrollForward
}
}
// KMK <--
Scaffold(
topBar = { scrollBehavior ->
AppBar(
// KMK -->
MigrateMangaAppBar(
title = title,
itemCnt = state.titles.size,
navigateUp = navigateUp,
selectedCount = state.selected.size,
onClickUnselectAll = { onSelectAll(false) },
onClickSelectAll = { onSelectAll(true) },
onClickInvertSelection = onInvertSelection,
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
MigrateMangaBottomBar(
selected = state.selected,
onMultiMigrateClicked = onMultiMigrateClicked,
enableScrollToTop = enableScrollToTop,
enableScrollToBottom = enableScrollToBottom,
scrollToTop = {
scope.launch {
listState.scrollToItem(0)
}
},
scrollToBottom = {
scope.launch {
listState.scrollToItem(state.titles.size - 1)
}
},
// KMK <--
)
},
) { contentPadding ->
if (state.isEmpty) {
EmptyScreen(
@ -44,6 +133,10 @@ fun MigrateMangaScreen(
state = state,
onClickItem = onClickItem,
onClickCover = onClickCover,
// KMK -->
onMangaSelected = onMangaSelected,
listState = listState,
// KMK <--
)
}
}
@ -52,17 +145,36 @@ fun MigrateMangaScreen(
private fun MigrateMangaContent(
contentPadding: PaddingValues,
state: MigrateMangaScreenModel.State,
onClickItem: (Manga) -> Unit,
onClickItem: (MigrateMangaItem) -> Unit,
onClickCover: (Manga) -> Unit,
// KMK -->
onMangaSelected: (MigrateMangaItem, Boolean, Boolean, Boolean) -> Unit,
listState: LazyListState,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
state = listState,
) {
items(state.titles) { manga ->
// KMK <--
items(items = state.titles) {
MigrateMangaItem(
manga = manga,
onClickItem = onClickItem,
onClickCover = onClickCover,
manga = it.manga,
onClickItem = {
// KMK -->
when {
state.selectionMode -> onMangaSelected(it, !it.selected, true, false)
// KMK <--
else -> onClickItem(it)
}
},
onClickCover = {
onClickCover(it.manga)
// KMK -->
}.takeIf { !state.selectionMode },
onLongClick = { onMangaSelected(it, !it.selected, true, true) },
selected = it.selected,
modifier = Modifier.animateItemFastScroll(),
// KMK <--
)
}
}
@ -71,14 +183,159 @@ private fun MigrateMangaContent(
@Composable
private fun MigrateMangaItem(
manga: Manga,
onClickItem: (Manga) -> Unit,
onClickCover: (Manga) -> Unit,
onClickItem: () -> Unit,
onClickCover: (() -> Unit)?,
// KMK -->
onLongClick: () -> Unit,
selected: Boolean,
// KMK <--
modifier: Modifier = Modifier,
) {
BaseMangaListItem(
modifier = modifier,
manga = manga,
onClickItem = { onClickItem(manga) },
onClickCover = { onClickCover(manga) },
onClickItem = onClickItem,
onClickCover = { onClickCover?.invoke() },
// KMK -->
onLongClick = onLongClick,
selected = selected,
// KMK <--
)
}
// KMK -->
@Composable
private fun MigrateMangaAppBar(
title: String,
itemCnt: Int,
navigateUp: () -> Unit,
selectedCount: Int,
onClickUnselectAll: () -> Unit,
onClickSelectAll: () -> Unit,
onClickInvertSelection: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
AppBar(
title = title,
navigateUp = navigateUp,
actions = {
if (itemCnt > 0) {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onClickSelectAll,
),
),
)
}
},
actionModeCounter = selectedCount,
onCancelActionMode = onClickUnselectAll,
actionModeActions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onClickSelectAll,
),
AppBar.Action(
title = stringResource(MR.strings.action_select_inverse),
icon = Icons.Outlined.FlipToBack,
onClick = onClickInvertSelection,
),
),
)
},
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun MigrateMangaBottomBar(
modifier: Modifier = Modifier,
selected: List<MigrateMangaItem>,
onMultiMigrateClicked: (() -> Unit),
enableScrollToTop: Boolean,
enableScrollToBottom: Boolean,
scrollToTop: () -> Unit,
scrollToBottom: () -> Unit,
) {
val scope = rememberCoroutineScope()
val animatedElevation by animateDpAsState(
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp,
label = "elevation",
)
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large.copy(
bottomEnd = ZeroCornerSize,
bottomStart = ZeroCornerSize,
),
color = MaterialTheme.colorScheme.surfaceColorAtElevation(
elevation = animatedElevation,
),
) {
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 until 3).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.padding(
WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom)
.asPaddingValues(),
)
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
Button(
title = stringResource(KMR.strings.action_scroll_to_top),
icon = Icons.Outlined.VerticalAlignTop,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = if (enableScrollToTop) {
scrollToTop
} else {
{}
},
enabled = enableScrollToTop,
)
Button(
title = stringResource(MR.strings.migrate),
icon = Icons.Outlined.FindReplace,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = if (selected.isNotEmpty()) {
onMultiMigrateClicked
} else {
{}
},
enabled = selected.isNotEmpty(),
)
Button(
title = stringResource(KMR.strings.action_scroll_to_bottom),
icon = Icons.Outlined.VerticalAlignBottom,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = if (enableScrollToBottom) {
scrollToBottom
} else {
{}
},
enabled = enableScrollToBottom,
)
}
}
}
// KMK <--

View file

@ -16,16 +16,15 @@ import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.model.installedExtension
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.presentation.components.AnimatedFloatingSearchBox
@ -57,9 +56,6 @@ fun MigrateSourceScreen(
onClickItem: (Source) -> Unit,
onToggleSortingDirection: () -> Unit,
onToggleSortingMode: () -> Unit,
// SY -->
onClickAll: (Source) -> Unit,
// SY <--
// KMK -->
onChangeSearchQuery: (String?) -> Unit,
// KMK <--
@ -87,9 +83,6 @@ fun MigrateSourceScreen(
onToggleSortingMode = onToggleSortingMode,
sortingDirection = state.sortingDirection,
onToggleSortingDirection = onToggleSortingDirection,
// SY -->
onClickAll = onClickAll,
// SY <--
// KMK -->
state = state,
onChangeSearchQuery = onChangeSearchQuery,
@ -108,9 +101,6 @@ private fun MigrateSourceList(
onToggleSortingMode: () -> Unit,
sortingDirection: SetMigrateSorting.Direction,
onToggleSortingDirection: () -> Unit,
// SY -->
onClickAll: (Source) -> Unit,
// SY <--
// KMK -->
state: MigrateSourceScreenModel.State,
onChangeSearchQuery: (String?) -> Unit,
@ -199,9 +189,6 @@ private fun MigrateSourceList(
count = count,
onClickItem = { onClickItem(source) },
onLongClickItem = { onLongClickItem(source) },
// SY -->
onClickAll = { onClickAll(source) },
// SY <--
)
}
}
@ -214,9 +201,6 @@ private fun MigrateSourceItem(
count: Long,
onClickItem: () -> Unit,
onLongClickItem: () -> Unit,
// SY -->
onClickAll: () -> Unit,
// SY <--
modifier: Modifier = Modifier,
) {
BaseSourceItem(
@ -230,16 +214,6 @@ private fun MigrateSourceItem(
BadgeGroup {
Badge(text = "$count")
}
// SY -->
TextButton(onClick = onClickAll) {
Text(
text = stringResource(MR.strings.all),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.primary,
),
)
}
// SY <--
},
content = { _, sourceLangString, /* KMK --> */ lang /* KMK <-- */ ->
Column(
@ -276,6 +250,17 @@ private fun MigrateSourceItem(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// KMK -->
} else if (source.installedExtension?.isObsolete == true) {
Text(
modifier = Modifier.secondaryItemAlpha(),
text = stringResource(MR.strings.ext_obsolete),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
// KMK <--
}
}
}

View file

@ -1,26 +1,14 @@
package eu.kanade.presentation.libraryUpdateError
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.material.icons.Icons
@ -31,12 +19,10 @@ import androidx.compose.material.icons.outlined.FlipToBack
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material.icons.outlined.VerticalAlignBottom
import androidx.compose.material.icons.outlined.VerticalAlignTop
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.ripple
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@ -45,16 +31,14 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.libraryUpdateError.components.libraryUpdateErrorUiItems
import eu.kanade.presentation.manga.components.Button
import eu.kanade.tachiyomi.ui.libraryUpdateError.LibraryUpdateErrorItem
import eu.kanade.tachiyomi.ui.libraryUpdateError.LibraryUpdateErrorScreenState
import kotlinx.collections.immutable.persistentListOf
@ -74,7 +58,6 @@ import kotlin.time.Duration.Companion.seconds
@Composable
fun LibraryUpdateErrorScreen(
state: LibraryUpdateErrorScreenState,
modifier: Modifier = Modifier,
onClick: (LibraryUpdateErrorItem) -> Unit,
onClickCover: (LibraryUpdateErrorItem) -> Unit,
onMultiMigrateClicked: (() -> Unit),
@ -83,8 +66,15 @@ fun LibraryUpdateErrorScreen(
onErrorSelected: (LibraryUpdateErrorItem, Boolean, Boolean, Boolean) -> Unit,
navigateUp: () -> Unit,
) {
val listState = rememberLazyListState()
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val headerIndexes = remember { mutableStateOf<List<Int>>(emptyList()) }
LaunchedEffect(state) {
headerIndexes.value = state.getHeaderIndexes()
}
val enableScrollToTop by remember {
derivedStateOf {
@ -98,10 +88,6 @@ fun LibraryUpdateErrorScreen(
}
}
val headerIndexes = remember { mutableStateOf<List<Int>>(emptyList()) }
LaunchedEffect(state) {
headerIndexes.value = state.getHeaderIndexes()
}
val enableScrollToPrevious by remember {
derivedStateOf {
headerIndexes.value.any { it < listState.firstVisibleItemIndex }
@ -113,11 +99,9 @@ fun LibraryUpdateErrorScreen(
}
}
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
Scaffold(
topBar = { scrollBehavior ->
LibraryUpdateErrorsAppBar(
LibraryUpdateErrorAppBar(
title = stringResource(
KMR.strings.label_library_update_errors,
state.items.size,
@ -132,8 +116,7 @@ fun LibraryUpdateErrorScreen(
)
},
bottomBar = {
LibraryUpdateErrorsBottomBar(
modifier = modifier,
LibraryUpdateErrorBottomBar(
selected = state.selected,
onMultiMigrateClicked = onMultiMigrateClicked,
enableScrollToTop = enableScrollToTop,
@ -198,7 +181,7 @@ fun LibraryUpdateErrorScreen(
}
@Composable
private fun LibraryUpdateErrorsBottomBar(
private fun LibraryUpdateErrorBottomBar(
modifier: Modifier = Modifier,
selected: List<LibraryUpdateErrorItem>,
onMultiMigrateClicked: (() -> Unit),
@ -212,13 +195,19 @@ private fun LibraryUpdateErrorsBottomBar(
scrollToNext: () -> Unit,
) {
val scope = rememberCoroutineScope()
val animatedElevation by animateDpAsState(
targetValue = if (selected.isNotEmpty()) 3.dp else 0.dp,
label = "elevation",
)
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large.copy(
bottomEnd = ZeroCornerSize,
bottomStart = ZeroCornerSize,
),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
color = MaterialTheme.colorScheme.surfaceColorAtElevation(
elevation = animatedElevation,
),
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
@ -306,62 +295,7 @@ private fun LibraryUpdateErrorsBottomBar(
}
@Composable
private fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
enabled: Boolean,
onLongClick: () -> Unit,
onClick: (() -> Unit),
content: (@Composable () -> Unit)? = null,
) {
val animatedWeight by animateFloatAsState(if (toConfirm) 2f else 1f)
val animatedColor by animateColorAsState(
if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.38f,
)
},
)
Column(
modifier = Modifier
.size(48.dp)
.weight(animatedWeight)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onLongClick = onLongClick,
onClick = onClick,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = title,
tint = animatedColor,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
Text(
text = title,
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
color = animatedColor,
)
}
content?.invoke()
}
}
@Composable
private fun LibraryUpdateErrorsAppBar(
private fun LibraryUpdateErrorAppBar(
title: String,
itemCnt: Int,
navigateUp: () -> Unit,

View file

@ -1,6 +1,6 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@ -12,10 +12,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.util.selectedBackground
@Composable
fun BaseMangaListItem(
@ -23,13 +26,31 @@ fun BaseMangaListItem(
modifier: Modifier = Modifier,
onClickItem: () -> Unit = {},
onClickCover: () -> Unit = onClickItem,
// KMK -->
onLongClick: () -> Unit = onClickItem,
selected: Boolean,
// KMK <--
cover: @Composable RowScope.() -> Unit = { defaultCover(manga, onClickCover) },
actions: @Composable RowScope.() -> Unit = {},
content: @Composable RowScope.() -> Unit = { defaultContent(manga) },
) {
// KMK -->
val haptic = LocalHapticFeedback.current
// KMK <--
Row(
modifier = modifier
.clickable(onClick = onClickItem)
// KMK -->
.selectedBackground(selected)
.combinedClickable(
// KMK <--
onClick = onClickItem,
// KMK -->
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
// KMK <--
)
.height(56.dp)
.padding(horizontal = MaterialTheme.padding.medium),
verticalAlignment = Alignment.CenterVertically,

View file

@ -1,6 +1,7 @@
package eu.kanade.presentation.manga.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
@ -182,18 +183,33 @@ fun MangaBottomActionMenu(
}
@Composable
private fun RowScope.Button(
internal fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
// KMK -->
enabled: Boolean = true,
// KMK <--
content: (@Composable () -> Unit)? = null,
) {
val animatedWeight by animateFloatAsState(
targetValue = if (toConfirm) 2f else 1f,
label = "weight",
)
// KMK -->
val animatedColor by animateColorAsState(
if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.38f,
)
},
label = "color",
)
// KMK <--
Column(
modifier = Modifier
.size(48.dp)
@ -210,6 +226,9 @@ private fun RowScope.Button(
Icon(
imageVector = icon,
contentDescription = title,
// KMK -->
tint = animatedColor,
// KMK <--
)
AnimatedVisibility(
visible = toConfirm,
@ -221,6 +240,9 @@ private fun RowScope.Button(
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
// KMK -->
color = animatedColor,
// KMK <--
)
}
content?.invoke()

View file

@ -2,36 +2,69 @@ package eu.kanade.tachiyomi.ui.browse.migration.advanced.design
import android.view.LayoutInflater
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Deselect
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material.icons.outlined.ToggleOn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.ViewCompat
import androidx.core.view.updatePadding
@ -41,13 +74,15 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.SourcesSearchBox
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.databinding.PreMigrationListBinding
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationListScreen
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigrationProcedureConfig
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
@ -57,6 +92,7 @@ import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import java.io.Serializable
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
sealed class MigrationType : Serializable {
data class MangaList(val mangaIds: List<Long>) : MigrationType()
@ -125,32 +161,19 @@ class PreMigrationScreen(val migration: MigrationType) : Screen() {
title = stringResource(SYMR.strings.select_sources),
navigateUp = navigator::pop,
scrollBehavior = scrollBehavior,
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(SYMR.strings.select_none),
icon = Icons.Outlined.Deselect,
onClick = { screenModel.massSelect(false) },
),
AppBar.Action(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = { screenModel.massSelect(true) },
),
AppBar.OverflowAction(
title = stringResource(SYMR.strings.match_enabled_sources),
onClick = { screenModel.matchSelection(true) },
),
AppBar.OverflowAction(
title = stringResource(SYMR.strings.match_pinned_sources),
onClick = { screenModel.matchSelection(false) },
),
),
)
},
)
},
// KMK -->
bottomBar = {
PreMigrationScreenBottomBar(
modifier = Modifier,
onSelectAll = { screenModel.massSelect(true) },
onSelectNone = { screenModel.massSelect(false) },
onSelectPinned = { screenModel.matchSelection(false) },
onSelectEnabled = { screenModel.matchSelection(true) },
)
},
// KMK <--
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(MR.strings.action_migrate)) },
@ -234,6 +257,124 @@ class PreMigrationScreen(val migration: MigrationType) : Screen() {
}
}
// KMK -->
@Composable
fun PreMigrationScreenBottomBar(
modifier: Modifier,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit,
onSelectPinned: () -> Unit,
onSelectEnabled: () -> Unit,
) {
val scope = rememberCoroutineScope()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large.copy(
bottomEnd = ZeroCornerSize,
bottomStart = ZeroCornerSize,
),
color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation = 0.dp),
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false) }
var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0 until 4).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.padding(
WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom)
.asPaddingValues(),
)
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
Button(
title = stringResource(MR.strings.action_select_all),
icon = Icons.Outlined.SelectAll,
onClick = onSelectAll,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
)
Button(
title = stringResource(SYMR.strings.select_none),
icon = Icons.Outlined.Deselect,
onClick = onSelectNone,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
)
Button(
title = stringResource(SYMR.strings.match_enabled_sources),
icon = Icons.Outlined.ToggleOn,
onClick = onSelectEnabled,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
)
Button(
title = stringResource(SYMR.strings.match_pinned_sources),
icon = Icons.Outlined.PushPin,
onClick = onSelectPinned,
toConfirm = confirm[3],
onLongClick = { onLongClickItem(3) },
)
}
}
}
@Composable
private fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
onLongClick: () -> Unit,
onClick: (() -> Unit),
content: (@Composable () -> Unit)? = null,
) {
val animatedWeight by animateFloatAsState(
targetValue = if (toConfirm) 2f else 1f,
label = "weight",
)
Column(
modifier = Modifier
.size(48.dp)
.weight(animatedWeight)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onLongClick = onLongClick,
onClick = onClick,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = title,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
Text(
text = title,
overflow = TextOverflow.Visible,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
)
}
content?.invoke()
}
}
// KMK <--
companion object {
fun navigateToMigration(skipPre: Boolean, navigator: Navigator, mangaIds: List<Long>) {
navigator.push(

View file

@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.screens.LoadingScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -39,18 +40,39 @@ data class MigrateMangaScreen(
MigrateMangaScreen(
navigateUp = navigator::pop,
title = state.source!!.name,
title = state.source?.name ?: "???",
state = state,
onClickItem = {
// SY -->
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
listOf(it.id),
listOf(it.manga.id),
)
// SY <--
},
onClickCover = { navigator.push(MangaScreen(it.id)) },
// KMK -->
onMultiMigrateClicked = {
if (state.selectionMode) {
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
state.selected.map { it.manga.id },
)
} else {
context.toast(KMR.strings.migrating_all_entries)
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
state.titles.map { it.manga.id },
)
}
},
onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection,
onMangaSelected = screenModel::toggleSelection,
// KMK <--
)
LaunchedEffect(Unit) {

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.browse.migration.manga
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.addOrRemove
import eu.kanade.tachiyomi.source.Source
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -32,6 +33,12 @@ class MigrateMangaScreenModel(
private val _events: Channel<MigrationMangaEvent> = Channel()
val events: Flow<MigrationMangaEvent> = _events.receiveAsFlow()
// KMK -->
// First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
private val selectedMangaIds: HashSet<Long> = HashSet()
// KMK <--
init {
screenModelScope.launch {
mutableState.update { state ->
@ -46,9 +53,14 @@ class MigrateMangaScreenModel(
state.copy(titleList = persistentListOf())
}
}
// KMK -->
.map { manga ->
toMigrationMangaScreenItems(manga)
}
// KMK <--
.map { manga ->
manga
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.manga.title })
.toImmutableList()
}
.collectLatest { list ->
@ -57,13 +69,117 @@ class MigrateMangaScreenModel(
}
}
// KMK -->
private fun toMigrationMangaScreenItems(mangas: List<Manga>): List<MigrateMangaItem> {
return mangas.map { manga ->
MigrateMangaItem(
manga = manga,
selected = manga.id in selectedMangaIds,
)
}
}
fun toggleSelection(
item: MigrateMangaItem,
selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false,
) {
mutableState.update { state ->
val newItems = state.titles.toMutableList().apply {
val selectedIndex = indexOfFirst { it.manga.id == item.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.manga.id, selected)
if (selected && userSelected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Try to select the items in-between when possible
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 {
// Just select itself
range = IntRange.EMPTY
}
range.forEach {
val inBetweenItem = get(it)
if (!inBetweenItem.selected) {
selectedMangaIds.add(inBetweenItem.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(titleList = newItems.toImmutableList())
}
}
fun toggleAllSelection(selected: Boolean) {
mutableState.update { state ->
val newItems = state.titles.map {
selectedMangaIds.addOrRemove(it.manga.id, selected)
it.copy(selected = selected)
}
state.copy(titleList = newItems.toImmutableList())
}
selectedPositions[0] = -1
selectedPositions[1] = -1
}
fun invertSelection() {
mutableState.update { state ->
val newItems = state.titles.map {
selectedMangaIds.addOrRemove(it.manga.id, !it.selected)
it.copy(selected = !it.selected)
}
state.copy(titleList = newItems.toImmutableList())
}
selectedPositions[0] = -1
selectedPositions[1] = -1
}
// KMK <--
@Immutable
data class State(
val source: Source? = null,
private val titleList: ImmutableList<Manga>? = null,
private val titleList: ImmutableList<MigrateMangaItem>? = null,
) {
// KMK -->
val selected = titles.filter { it.selected }
val selectionMode = selected.isNotEmpty()
// KMK <--
val titles: ImmutableList<Manga>
val titles: ImmutableList<MigrateMangaItem>
get() = titleList ?: persistentListOf()
val isLoading: Boolean
@ -77,3 +193,11 @@ class MigrateMangaScreenModel(
sealed interface MigrationMangaEvent {
data object FailedFetchingFavorites : MigrationMangaEvent
}
// KMK -->
@Immutable
data class MigrateMangaItem(
val manga: Manga,
val selected: Boolean,
)
// KMK <--

View file

@ -13,18 +13,10 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.MigrateSourceScreen
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreen
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.DelicateCoroutinesApi
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.UnsortedPreferences
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@Composable
fun Screen.migrateSourceTab(): TabContent {
@ -53,24 +45,6 @@ fun Screen.migrateSourceTab(): TabContent {
},
onToggleSortingDirection = screenModel::toggleSortingDirection,
onToggleSortingMode = screenModel::toggleSortingMode,
// SY -->
onClickAll = { source ->
// TODO: Jay wtf, need to clean this up sometime
@OptIn(DelicateCoroutinesApi::class)
launchIO {
val manga = Injekt.get<GetFavorites>().await()
val sourceMangas =
manga.asSequence().filter { it.source == source.id }.map { it.id }.toList()
withUIContext {
PreMigrationScreen.navigateToMigration(
Injekt.get<UnsortedPreferences>().skipPreMigration().get(),
navigator,
sourceMangas,
)
}
}
},
// SY <--
// KMK -->
onChangeSearchQuery = screenModel::search,
// KMK <--

View file

@ -151,6 +151,7 @@
<!-- Migration -->
<string name="current_">Current: %1$s</string>
<string name="migrating_all_entries">Migrating all entries from source</string>
<!-- Misc -->
<string name="label_to_be_updated">To be updated</string>