diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt index 0564eca21..716cb50b8 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateMangaScreen.kt @@ -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, + 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 <-- diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt index f81183f13..6e74f032b 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt @@ -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 <-- } } } diff --git a/app/src/main/java/eu/kanade/presentation/libraryUpdateError/LibraryUpdateErrorScreen.kt b/app/src/main/java/eu/kanade/presentation/libraryUpdateError/LibraryUpdateErrorScreen.kt index d2adc2c51..f4c02f1ef 100644 --- a/app/src/main/java/eu/kanade/presentation/libraryUpdateError/LibraryUpdateErrorScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/libraryUpdateError/LibraryUpdateErrorScreen.kt @@ -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>(emptyList()) } + LaunchedEffect(state) { + headerIndexes.value = state.getHeaderIndexes() + } val enableScrollToTop by remember { derivedStateOf { @@ -98,10 +88,6 @@ fun LibraryUpdateErrorScreen( } } - val headerIndexes = remember { mutableStateOf>(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, 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, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt index 652fb87d3..16281868a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/BaseMangaListItem.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index 60a845069..28301a5c2 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt index 2ead0a30a..0210bf564 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/advanced/design/PreMigrationScreen.kt @@ -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) : 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) { navigator.push( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt index d2b978354..b5da1d691 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreen.kt @@ -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().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().skipPreMigration().get(), + navigator, + state.selected.map { it.manga.id }, + ) + } else { + context.toast(KMR.strings.migrating_all_entries) + PreMigrationScreen.navigateToMigration( + Injekt.get().skipPreMigration().get(), + navigator, + state.titles.map { it.manga.id }, + ) + } + }, + onSelectAll = screenModel::toggleAllSelection, + onInvertSelection = screenModel::invertSelection, + onMangaSelected = screenModel::toggleSelection, + // KMK <-- ) LaunchedEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreenModel.kt index 8faf1c320..896581d9e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/manga/MigrateMangaScreenModel.kt @@ -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 = Channel() val events: Flow = _events.receiveAsFlow() + // KMK --> + // First and last selected index in list + private val selectedPositions: Array = arrayOf(-1, -1) + private val selectedMangaIds: HashSet = 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): List { + 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? = null, + private val titleList: ImmutableList? = null, ) { + // KMK --> + val selected = titles.filter { it.selected } + val selectionMode = selected.isNotEmpty() + // KMK <-- - val titles: ImmutableList + val titles: ImmutableList 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 <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt index 563dff586..e07d49c68 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/sources/MigrateSourceTab.kt @@ -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().await() - val sourceMangas = - manga.asSequence().filter { it.source == source.id }.map { it.id }.toList() - withUIContext { - PreMigrationScreen.navigateToMigration( - Injekt.get().skipPreMigration().get(), - navigator, - sourceMangas, - ) - } - } - }, - // SY <-- // KMK --> onChangeSearchQuery = screenModel::search, // KMK <-- diff --git a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml index 7d2cf9e07..e8c3d11c5 100644 --- a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml @@ -151,6 +151,7 @@ Current: %1$s + Migrating all entries from source To be updated