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>
This commit is contained in:
AntsyLich 2025-08-03 01:03:45 +06:00 committed by Cuong-Tran
parent 9a3934eadf
commit 9cbd46947b
8 changed files with 158 additions and 185 deletions

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.DownloadAction
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@ -18,9 +17,26 @@ fun DownloadDropdownMenu(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDownloadClicked: (DownloadAction) -> Unit, onDownloadClicked: (DownloadAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// KMK --> offset: DpOffset = /* KMK --> */ DefaultDropdownMenuOffset, /* KMK <-- */
offset: DpOffset = DpOffset(0.dp, 0.dp), ) {
// 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( val options = persistentListOf(
DownloadAction.NEXT_1_CHAPTER to pluralStringResource(MR.plurals.download_amount, 1, 1), 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), DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
) )
DropdownMenu( options.forEach { (downloadAction, string) ->
expanded = expanded, DropdownMenuItem(
onDismissRequest = onDismissRequest, text = { Text(text = string) },
modifier = modifier, onClick = {
// KMK --> onDownloadClicked(downloadAction)
offset = offset, onDismissRequest()
// KMK <-- },
) { )
options.map { (downloadAction, string) ->
DropdownMenuItem(
text = { Text(text = string) },
onClick = {
onDownloadClicked(downloadAction)
onDismissRequest()
},
)
}
} }
} }

View file

@ -25,6 +25,10 @@ import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu 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 * DropdownMenu but overlaps anchor and has width constraints to better
* match non-Compose implementation. * match non-Compose implementation.
@ -34,7 +38,7 @@ fun DropdownMenu(
expanded: Boolean, expanded: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(8.dp, (-56).dp), offset: DpOffset = /* KMK --> */ DefaultDropdownMenuOffset, /* KMK <-- */
scrollState: ScrollState = rememberScrollState(), scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true), properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,

View file

@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope 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.Delete
import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Download 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.MoreVert
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.RemoveDone
import androidx.compose.material.icons.outlined.SwapCalls import androidx.compose.material.icons.outlined.SwapCalls
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -211,7 +212,7 @@ internal fun RowScope.Button(
label = "color", label = "color",
) )
// KMK <-- // KMK <--
Column( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.weight(animatedWeight) .weight(animatedWeight)
@ -221,30 +222,34 @@ internal fun RowScope.Button(
onLongClick = onLongClick, onLongClick = onLongClick,
onClick = onClick, onClick = onClick,
), ),
verticalArrangement = Arrangement.Center, contentAlignment = Alignment.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Icon( Column(
imageVector = icon, verticalArrangement = Arrangement.Center,
contentDescription = title, horizontalAlignment = Alignment.CenterHorizontally,
// KMK -->
tint = animatedColor,
// KMK <--
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) { ) {
Text( Icon(
text = title, imageVector = icon,
overflow = TextOverflow.Visible, contentDescription = title,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
// KMK --> // KMK -->
color = animatedColor, tint = animatedColor,
// KMK <-- // 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() content?.invoke()
} }
@ -258,17 +263,17 @@ fun LibraryBottomActionMenu(
onMarkAsUnreadClicked: () -> Unit, onMarkAsUnreadClicked: () -> Unit,
onDownloadClicked: ((DownloadAction) -> Unit)?, onDownloadClicked: ((DownloadAction) -> Unit)?,
onDeleteClicked: () -> Unit, onDeleteClicked: () -> Unit,
onMigrateClicked: () -> Unit,
// KMK -->
onMergeClicked: () -> Unit,
onSelectionUpdateClicked: () -> Unit,
// KMK <--
// SY --> // SY -->
onClickCleanTitles: (() -> Unit)?, onClickCleanTitles: (() -> Unit)?,
onClickMigrate: (() -> Unit)?,
onClickCollectRecommendations: (() -> Unit)?, onClickCollectRecommendations: (() -> Unit)?,
onClickAddToMangaDex: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?,
onClickResetInfo: (() -> Unit)?, onClickResetInfo: (() -> Unit)?,
// SY <-- // SY <--
// KMK -->
onClickMerge: (() -> Unit)?,
onClickRefreshSelected: (() -> Unit)?,
// KMK <--
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@ -283,34 +288,17 @@ fun LibraryBottomActionMenu(
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val confirm = val confirm = remember { mutableStateListOf(false, false, false, false, false, false /* SY --> */, false /* SY <-- */) }
remember {
mutableStateListOf(false, false, false, false, false /* SY --> */, false, false, false /* SY <-- */)
}
var resetJob: Job? = remember { null } var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<8).forEach { i -> confirm[i] = i == toConfirmIndex } (0..<confirm.size).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel() resetJob?.cancel()
resetJob = scope.launch { resetJob = scope.launch {
delay(1.seconds) delay(1.seconds)
if (isActive) confirm[toConfirmIndex] = false 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( Row(
modifier = Modifier modifier = Modifier
.windowInsetsPadding( .windowInsetsPadding(
@ -326,30 +314,6 @@ fun LibraryBottomActionMenu(
onLongClick = { onLongClickItem(0) }, onLongClick = { onLongClickItem(0) },
onClick = onChangeCategoryClicked, 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( Button(
title = stringResource(MR.strings.action_mark_as_read), title = stringResource(MR.strings.action_mark_as_read),
icon = Icons.Outlined.DoneAll, icon = Icons.Outlined.DoneAll,
@ -364,71 +328,83 @@ fun LibraryBottomActionMenu(
onLongClick = { onLongClickItem(2) }, onLongClick = { onLongClickItem(2) },
onClick = onMarkAsUnreadClicked, onClick = onMarkAsUnreadClicked,
) )
// SY --> if (onDownloadClicked != null) {
if (showOverflow) { var downloadExpanded by remember { mutableStateOf(false) }
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 <--
}
Button( Button(
title = stringResource(MR.strings.label_more), title = stringResource(MR.strings.action_download),
icon = Icons.Outlined.MoreVert, 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], toConfirm = confirm[5],
onLongClick = { onLongClickItem(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( DropdownMenu(
expanded = overFlowOpen, expanded = overflowMenuOpen,
onDismissRequest = { overFlowOpen = false }, onDismissRequest = { overflowMenuOpen = false },
// KMK --> offset = BottomBarMenuDpOffset,
offset = DpOffset((-10).dp, 0.dp),
// KMK <--
) { ) {
// KMK --> // KMK -->
if (onClickRefreshSelected != null) {
DropdownMenuItem(
text = { Text(stringResource(KMR.strings.action_update)) },
onClick = {
overFlowOpen = false
onClickRefreshSelected()
},
)
}
// KMK <--
if (!isTabletUi) { if (!isTabletUi) {
if (onClickMigrate != null) { if (onDownloadClicked != null) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(MR.strings.migrate)) }, text = { Text(stringResource(KMR.strings.action_update)) },
onClick = onClickMigrate, onClick = onSelectionUpdateClicked,
)
}
// KMK -->
if (onClickMerge != null) {
DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.merge)) },
onClick = onClickMerge,
) )
} }
// KMK <-- // 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) { if (onClickCleanTitles != null) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(SYMR.strings.action_clean_titles)) }, text = { Text(stringResource(SYMR.strings.action_clean_titles)) },
@ -453,31 +429,12 @@ fun LibraryBottomActionMenu(
onClick = onClickResetInfo, 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)

View file

@ -1012,9 +1012,9 @@ class LibraryScreenModel(
} }
fun resetInfo() { fun resetInfo() {
state.value.selectedManga.fastForEach { manga -> state.value.selection.forEach { id ->
val mangaInfo = CustomMangaInfo( val mangaInfo = CustomMangaInfo(
id = manga.id, id = id,
title = null, title = null,
author = null, author = null,
artist = null, artist = null,
@ -1034,7 +1034,7 @@ class LibraryScreenModel(
/** /**
* Update Selected Mangas * Update Selected Mangas
*/ */
fun refreshSelectedManga(): Boolean { fun updateSelectedManga(): Boolean {
val mangaIds = state.value.selection.toList() val mangaIds = state.value.selection.toList()
return LibraryUpdateJob.startNow( return LibraryUpdateJob.startNow(
context = preferences.context, context = preferences.context,

View file

@ -202,25 +202,24 @@ data object LibraryTab : Tab {
onDownloadClicked = screenModel::performDownloadAction onDownloadClicked = screenModel::performDownloadAction
.takeIf { state.selectedManga.fastAll { !it.isLocal() } }, .takeIf { state.selectedManga.fastAll { !it.isLocal() } },
onDeleteClicked = screenModel::openDeleteMangaDialog, onDeleteClicked = screenModel::openDeleteMangaDialog,
// SY --> onMigrateClicked = {
onClickCleanTitles = screenModel::cleanTitles.takeIf { state.showCleanTitles }, val selection = state
onClickMigrate = { // KMK -->
val selectedMangaIds = state.selectedManga .selectedManga
.filterNot { it.source == MERGED_SOURCE_ID } .filterNot { it.source == MERGED_SOURCE_ID }
.map { it.id } .map { it.id }
// KMK <--
screenModel.clearSelection() screenModel.clearSelection()
if (selectedMangaIds.isNotEmpty()) { // KMK -->
navigator.push(MigrationConfigScreen(selectedMangaIds)) if (selection.isEmpty()) {
} else {
context.toast(SYMR.strings.no_valid_entry) 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 --> // KMK -->
onClickMerge = { onMergeClicked = {
if (state.selection.size == 1) { if (state.selection.size == 1) {
val manga = state.selectedManga.first() val manga = state.selectedManga.first()
// Invoke merging for this manga // Invoke merging for this manga
@ -258,8 +257,8 @@ data object LibraryTab : Tab {
context.toast(SYMR.strings.no_valid_entry) context.toast(SYMR.strings.no_valid_entry)
} }
}, },
onClickRefreshSelected = { onSelectionUpdateClicked = {
val started = screenModel.refreshSelectedManga() val started = screenModel.updateSelectedManga()
scope.launch { scope.launch {
val msgRes = if (started) { val msgRes = if (started) {
KMR.strings.updating KMR.strings.updating
@ -273,6 +272,12 @@ data object LibraryTab : Tab {
} }
}, },
// KMK <-- // 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) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },

View file

@ -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. * @param mangaIds IDs of the manga that will be migrated using the sources configured on this screen.
*/ */
class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() { class MigrationConfigScreen(private val mangaIds: Collection<Long>) : Screen() {
constructor(mangaId: Long) : this(listOf(mangaId)) constructor(mangaId: Long) : this(listOf(mangaId))

View file

@ -25,7 +25,7 @@ import tachiyomi.i18n.MR
/** /**
* Screen showing a list of pair of current-target manga entries being migrated. * Screen showing a list of pair of current-target manga entries being migrated.
*/ */
class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() { class MigrationListScreen(private val mangaIds: Collection<Long>, private val extraSearchQuery: String?) : Screen() {
private var matchOverride: Pair<Long, Long>? = null private var matchOverride: Pair<Long, Long>? = null

View file

@ -47,7 +47,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class MigrationListScreenModel( class MigrationListScreenModel(
mangaIds: List<Long>, mangaIds: Collection<Long>,
extraSearchQuery: String?, extraSearchQuery: String?,
val preferences: SourcePreferences = Injekt.get(), val preferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),