From 9cbd46947bf49723790344c38d0c4d6e128907f3 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+antsylich@users.noreply.github.com> Date: Sun, 3 Aug 2025 01:03:45 +0600 Subject: [PATCH] Support mass migration for selected library items (mihonapp/mihon#2336) (cherry picked from commit 982ebcf777215c90584ad28fae79e9ca8a22a951) Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com> --- .../components/DownloadDropdownMenu.kt | 49 ++-- .../presentation/components/DropdownMenu.kt | 6 +- .../manga/components/MangaBottomActionMenu.kt | 243 +++++++----------- .../ui/library/LibraryScreenModel.kt | 6 +- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 33 ++- .../migration/config/MigrationConfigScreen.kt | 2 +- .../migration/list/MigrationListScreen.kt | 2 +- .../list/MigrationListScreenModel.kt | 2 +- 8 files changed, 158 insertions(+), 185 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/components/DownloadDropdownMenu.kt b/app/src/main/java/eu/kanade/presentation/components/DownloadDropdownMenu.kt index d3a88cdb8..7bdf97d71 100644 --- a/app/src/main/java/eu/kanade/presentation/components/DownloadDropdownMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/DownloadDropdownMenu.kt @@ -5,7 +5,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp import eu.kanade.presentation.manga.DownloadAction import kotlinx.collections.immutable.persistentListOf import tachiyomi.i18n.MR @@ -18,9 +17,26 @@ fun DownloadDropdownMenu( onDismissRequest: () -> Unit, onDownloadClicked: (DownloadAction) -> Unit, modifier: Modifier = Modifier, - // KMK --> - offset: DpOffset = DpOffset(0.dp, 0.dp), - // KMK <-- + offset: DpOffset = /* KMK --> */ DefaultDropdownMenuOffset, /* KMK <-- */ +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier, + offset = offset, + content = { + DownloadDropdownMenuItems( + onDismissRequest = onDismissRequest, + onDownloadClicked = onDownloadClicked, + ) + }, + ) +} + +@Composable +private fun DownloadDropdownMenuItems( + onDismissRequest: () -> Unit, + onDownloadClicked: (DownloadAction) -> Unit, ) { val options = persistentListOf( DownloadAction.NEXT_1_CHAPTER to pluralStringResource(MR.plurals.download_amount, 1, 1), @@ -30,22 +46,13 @@ fun DownloadDropdownMenu( DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread), ) - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = modifier, - // KMK --> - offset = offset, - // KMK <-- - ) { - options.map { (downloadAction, string) -> - DropdownMenuItem( - text = { Text(text = string) }, - onClick = { - onDownloadClicked(downloadAction) - onDismissRequest() - }, - ) - } + options.forEach { (downloadAction, string) -> + DropdownMenuItem( + text = { Text(text = string) }, + onClick = { + onDownloadClicked(downloadAction) + onDismissRequest() + }, + ) } } diff --git a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt index c1fcbf524..32b28c58a 100644 --- a/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/DropdownMenu.kt @@ -25,6 +25,10 @@ import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu +// KMK --> +val DefaultDropdownMenuOffset = DpOffset(8.dp, (-56).dp) +// KMK <-- + /** * DropdownMenu but overlaps anchor and has width constraints to better * match non-Compose implementation. @@ -34,7 +38,7 @@ fun DropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(8.dp, (-56).dp), + offset: DpOffset = /* KMK --> */ DefaultDropdownMenuOffset, /* KMK <-- */ scrollState: ScrollState = rememberScrollState(), properties: PopupProperties = PopupProperties(focusable = true), content: @Composable ColumnScope.() -> Unit, 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 2ab530f75..a52ff2f14 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 @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.combinedClickable 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 @@ -29,8 +30,8 @@ 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.Merge import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.SwapCalls import androidx.compose.material3.DropdownMenuItem @@ -211,7 +212,7 @@ internal fun RowScope.Button( label = "color", ) // KMK <-- - Column( + Box( modifier = Modifier .size(48.dp) .weight(animatedWeight) @@ -221,30 +222,34 @@ internal fun RowScope.Button( onLongClick = onLongClick, onClick = onClick, ), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = title, - // KMK --> - tint = animatedColor, - // KMK <-- - ) - AnimatedVisibility( - visible = toConfirm, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = title, - overflow = TextOverflow.Visible, - maxLines = 1, - style = MaterialTheme.typography.labelSmall, + Icon( + imageVector = icon, + contentDescription = title, // KMK --> - color = animatedColor, + tint = animatedColor, // KMK <-- ) + 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, + // KMK --> + color = animatedColor, + // KMK <-- + ) + } } content?.invoke() } @@ -258,17 +263,17 @@ fun LibraryBottomActionMenu( onMarkAsUnreadClicked: () -> Unit, onDownloadClicked: ((DownloadAction) -> Unit)?, onDeleteClicked: () -> Unit, + onMigrateClicked: () -> Unit, + // KMK --> + onMergeClicked: () -> Unit, + onSelectionUpdateClicked: () -> Unit, + // KMK <-- // SY --> onClickCleanTitles: (() -> Unit)?, - onClickMigrate: (() -> Unit)?, onClickCollectRecommendations: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?, onClickResetInfo: (() -> Unit)?, // SY <-- - // KMK --> - onClickMerge: (() -> Unit)?, - onClickRefreshSelected: (() -> Unit)?, - // KMK <-- modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -283,34 +288,17 @@ fun LibraryBottomActionMenu( color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { val haptic = LocalHapticFeedback.current - val confirm = - remember { - mutableStateListOf(false, false, false, false, false /* SY --> */, false, false, false /* SY <-- */) - } + val confirm = remember { mutableStateListOf(false, false, false, false, false, false /* SY --> */, false /* SY <-- */) } var resetJob: Job? = remember { null } val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> haptic.performHapticFeedback(HapticFeedbackType.LongPress) - (0..<8).forEach { i -> confirm[i] = i == toConfirmIndex } + (0.. confirm[i] = i == toConfirmIndex } resetJob?.cancel() resetJob = scope.launch { delay(1.seconds) if (isActive) confirm[toConfirmIndex] = false } } - // SY --> - val showOverflow = onClickCleanTitles != null || - onClickAddToMangaDex != null || - onClickResetInfo != null || - // KMK --> - onClickMigrate != null || - onClickMerge != null || - onClickRefreshSelected != null || - // KMK <-- - onClickCollectRecommendations != null - val configuration = LocalConfiguration.current - val isTabletUi = remember { configuration.isTabletUi() } - var overFlowOpen by remember { mutableStateOf(false) } - // SY <-- Row( modifier = Modifier .windowInsetsPadding( @@ -326,30 +314,6 @@ fun LibraryBottomActionMenu( onLongClick = { onLongClickItem(0) }, onClick = onChangeCategoryClicked, ) - if (onDownloadClicked != null) { - var downloadExpanded by remember { mutableStateOf(false) } - Button( - title = stringResource(MR.strings.action_download), - icon = Icons.Outlined.Download, - toConfirm = confirm[3], - onLongClick = { onLongClickItem(3) }, - onClick = { downloadExpanded = !downloadExpanded }, - ) { - val onDismissRequest = { downloadExpanded = false } - DownloadDropdownMenu( - expanded = downloadExpanded, - onDismissRequest = onDismissRequest, - onDownloadClicked = onDownloadClicked, - ) - } - } - Button( - title = stringResource(MR.strings.action_delete), - icon = Icons.Outlined.Delete, - toConfirm = confirm[4], - onLongClick = { onLongClickItem(4) }, - onClick = onDeleteClicked, - ) Button( title = stringResource(MR.strings.action_mark_as_read), icon = Icons.Outlined.DoneAll, @@ -364,71 +328,83 @@ fun LibraryBottomActionMenu( onLongClick = { onLongClickItem(2) }, onClick = onMarkAsUnreadClicked, ) - // SY --> - if (showOverflow) { - if (isTabletUi) { - if (onClickMigrate != null) { - Button( - title = stringResource(MR.strings.migrate), - icon = Icons.Outlined.SwapCalls, - toConfirm = confirm[6], - onLongClick = { onLongClickItem(6) }, - onClick = onClickMigrate, - ) - } - // KMK --> - if (onClickMerge != null) { - Button( - title = stringResource(SYMR.strings.merge), - icon = Icons.Outlined.Merge, - toConfirm = confirm[7], - onLongClick = { onLongClickItem(7) }, - onClick = onClickMerge, - ) - } - // KMK <-- - } + if (onDownloadClicked != null) { + var downloadExpanded by remember { mutableStateOf(false) } Button( - title = stringResource(MR.strings.label_more), - icon = Icons.Outlined.MoreVert, + title = stringResource(MR.strings.action_download), + icon = Icons.Outlined.Download, + toConfirm = confirm[3], + onLongClick = { onLongClickItem(3) }, + onClick = { downloadExpanded = !downloadExpanded }, + ) { + DownloadDropdownMenu( + expanded = downloadExpanded, + onDismissRequest = { downloadExpanded = false }, + onDownloadClicked = onDownloadClicked, + offset = BottomBarMenuDpOffset, + ) + } + } + // SY --> + val configuration = LocalConfiguration.current + val isTabletUi = remember { configuration.isTabletUi() } + // SY <-- + // KMK --> + if (onDownloadClicked == null || isTabletUi) { + Button( + title = stringResource(KMR.strings.action_update), + icon = Icons.Outlined.Refresh, + toConfirm = confirm[4], + onLongClick = { onLongClickItem(4) }, + onClick = onSelectionUpdateClicked, + ) + } + if (isTabletUi) { + // KMK <-- + Button( + title = stringResource(MR.strings.migrate), + icon = Icons.Outlined.SwapCalls, toConfirm = confirm[5], onLongClick = { onLongClickItem(5) }, - onClick = { overFlowOpen = true }, + onClick = onMigrateClicked, ) + } + var overflowMenuOpen by remember { mutableStateOf(false) } + Button( + title = stringResource(MR.strings.label_more), + icon = Icons.Outlined.MoreVert, + toConfirm = confirm[6], + onLongClick = { onLongClickItem(6) }, + onClick = { overflowMenuOpen = true }, + ) { DropdownMenu( - expanded = overFlowOpen, - onDismissRequest = { overFlowOpen = false }, - // KMK --> - offset = DpOffset((-10).dp, 0.dp), - // KMK <-- + expanded = overflowMenuOpen, + onDismissRequest = { overflowMenuOpen = false }, + offset = BottomBarMenuDpOffset, ) { // KMK --> - if (onClickRefreshSelected != null) { - DropdownMenuItem( - text = { Text(stringResource(KMR.strings.action_update)) }, - onClick = { - overFlowOpen = false - onClickRefreshSelected() - }, - ) - } - // KMK <-- if (!isTabletUi) { - if (onClickMigrate != null) { + if (onDownloadClicked != null) { DropdownMenuItem( - text = { Text(stringResource(MR.strings.migrate)) }, - onClick = onClickMigrate, - ) - } - // KMK --> - if (onClickMerge != null) { - DropdownMenuItem( - text = { Text(stringResource(SYMR.strings.merge)) }, - onClick = onClickMerge, + text = { Text(stringResource(KMR.strings.action_update)) }, + onClick = onSelectionUpdateClicked, ) } // KMK <-- + DropdownMenuItem( + text = { Text(stringResource(MR.strings.migrate)) }, + onClick = onMigrateClicked, + ) } + DropdownMenuItem( + text = { Text(stringResource(MR.strings.action_delete)) }, + onClick = onDeleteClicked, + ) + // KMK --> + DropdownMenuItem( + text = { Text(stringResource(SYMR.strings.merge)) }, + onClick = onMergeClicked, + ) if (onClickCleanTitles != null) { DropdownMenuItem( text = { Text(stringResource(SYMR.strings.action_clean_titles)) }, @@ -453,31 +429,12 @@ fun LibraryBottomActionMenu( onClick = onClickResetInfo, ) } + // KMK <-- } - /* KMK --> - } else { - if (onClickMigrate != null) { - Button( - title = stringResource(MR.strings.migrate), - icon = Icons.Outlined.SwapCalls, - toConfirm = confirm[6], - onLongClick = { onLongClickItem(6) }, - onClick = onClickMigrate, - ) - } - if (onClickMerge != null) { - Button( - title = stringResource(SYMR.strings.merge), - icon = Icons.Outlined.Merge, - toConfirm = confirm[7], - onLongClick = { onLongClickItem(7) }, - onClick = onClickMerge, - ) - } - // KMK <-- */ } - // SY <-- } } } } + +private val BottomBarMenuDpOffset = DpOffset(0.dp, 0.dp) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index b43e537ff..26329a379 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -1012,9 +1012,9 @@ class LibraryScreenModel( } fun resetInfo() { - state.value.selectedManga.fastForEach { manga -> + state.value.selection.forEach { id -> val mangaInfo = CustomMangaInfo( - id = manga.id, + id = id, title = null, author = null, artist = null, @@ -1034,7 +1034,7 @@ class LibraryScreenModel( /** * Update Selected Mangas */ - fun refreshSelectedManga(): Boolean { + fun updateSelectedManga(): Boolean { val mangaIds = state.value.selection.toList() return LibraryUpdateJob.startNow( context = preferences.context, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index e49a8cea3..5eda7ac7e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -202,25 +202,24 @@ data object LibraryTab : Tab { onDownloadClicked = screenModel::performDownloadAction .takeIf { state.selectedManga.fastAll { !it.isLocal() } }, onDeleteClicked = screenModel::openDeleteMangaDialog, - // SY --> - onClickCleanTitles = screenModel::cleanTitles.takeIf { state.showCleanTitles }, - onClickMigrate = { - val selectedMangaIds = state.selectedManga + onMigrateClicked = { + val selection = state + // KMK --> + .selectedManga .filterNot { it.source == MERGED_SOURCE_ID } .map { it.id } + // KMK <-- screenModel.clearSelection() - if (selectedMangaIds.isNotEmpty()) { - navigator.push(MigrationConfigScreen(selectedMangaIds)) - } else { + // KMK --> + if (selection.isEmpty()) { context.toast(SYMR.strings.no_valid_entry) + } else { + // KMK <-- + navigator.push(MigrationConfigScreen(selection)) } }, - onClickCollectRecommendations = screenModel::showRecommendationSearchDialog.takeIf { state.selection.size > 1 }, - onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex }, - onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo }, - // SY <-- // KMK --> - onClickMerge = { + onMergeClicked = { if (state.selection.size == 1) { val manga = state.selectedManga.first() // Invoke merging for this manga @@ -258,8 +257,8 @@ data object LibraryTab : Tab { context.toast(SYMR.strings.no_valid_entry) } }, - onClickRefreshSelected = { - val started = screenModel.refreshSelectedManga() + onSelectionUpdateClicked = { + val started = screenModel.updateSelectedManga() scope.launch { val msgRes = if (started) { KMR.strings.updating @@ -273,6 +272,12 @@ data object LibraryTab : Tab { } }, // KMK <-- + // SY --> + onClickCleanTitles = screenModel::cleanTitles.takeIf { state.showCleanTitles }, + onClickCollectRecommendations = screenModel::showRecommendationSearchDialog.takeIf { state.selection.size > 1 }, + onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex }, + onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo }, + // SY <-- ) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, diff --git a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt index 495ca8bec..5dda41206 100644 --- a/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt +++ b/app/src/main/java/mihon/feature/migration/config/MigrationConfigScreen.kt @@ -91,7 +91,7 @@ import uy.kohesive.injekt.api.get * * @param mangaIds IDs of the manga that will be migrated using the sources configured on this screen. */ -class MigrationConfigScreen(private val mangaIds: List) : Screen() { +class MigrationConfigScreen(private val mangaIds: Collection) : Screen() { constructor(mangaId: Long) : this(listOf(mangaId)) diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt index 85053f0a8..8cd8affe4 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreen.kt @@ -25,7 +25,7 @@ import tachiyomi.i18n.MR /** * Screen showing a list of pair of current-target manga entries being migrated. */ -class MigrationListScreen(private val mangaIds: List, private val extraSearchQuery: String?) : Screen() { +class MigrationListScreen(private val mangaIds: Collection, private val extraSearchQuery: String?) : Screen() { private var matchOverride: Pair? = null diff --git a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt index eed80229e..ea8b3ba5e 100644 --- a/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt +++ b/app/src/main/java/mihon/feature/migration/list/MigrationListScreenModel.kt @@ -47,7 +47,7 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class MigrationListScreenModel( - mangaIds: List, + mangaIds: Collection, extraSearchQuery: String?, val preferences: SourcePreferences = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),