Просмотр исходного кода

feat: 已入架书籍改为「书架操作」Sheet(副本 / 分组 / 删除)

原来点一下书架按钮就直接问「是否删除」:用户没有别的去处,也没法切到同名同作者的其他副本。现在已入架时点按钮弹出「书架操作」Sheet:有副本先列出其他副本(点卡片跳到那一本的详情),下面是分组与删除;没有副本时只剩分组与删除。

删除仍复用原来的确认框(本地书带「删除原文件」选项,且照旧受「删除前提醒」设置影响),因此不丢任何既有能力;长按卡片直接进「分组」的老路保持不变。

有副本时不再给书架按钮高亮(可做的事情已由 Sheet 表达),未入架时的「疑似重复」高亮保持不变——那是在提示「入架会弹冲突窗」。

副本列表复用 ConflictBookCard(与冲突 Sheet 同一张卡片),因此该卡片由 private 放宽为 internal。
HapeLee 1 месяц назад
Родитель
Сommit
45a9425c87

+ 16 - 5
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -60,16 +60,16 @@ data class BookInfoUiState(
     val isResolvingShelfConflict: Boolean = false,
     val isResolvingShelfConflict: Boolean = false,
 
 
     /**
     /**
-     * 书架里同一部作品的**其他**副本数(不含本书自身)。
+     * 书架里同一部作品的**其他**副本(不含本书自身)。
      *
      *
-     * 两种用途,都由书架按钮呈现:
-     * - 本书**未入架**且大于 0:入架会弹冲突 Sheet,按钮提前标成冲突态;
-     * - 本书**已入架**且大于 0:让用户看出「这本还有 N 个同名同作者副本」。
+     * 两种用途:
+     * - 本书**未入架**且非空:入架会弹冲突 Sheet,书架按钮提前标成冲突态;
+     * - 本书**已入架**且非空:由「书架操作」Sheet 列出,供用户切到另一个副本。
      *
      *
      * 判定口径与 [shelfConflict] 完全一致(同一个用例),因此「按钮是冲突态」⟺「点击真的会
      * 判定口径与 [shelfConflict] 完全一致(同一个用例),因此「按钮是冲突态」⟺「点击真的会
      * 弹冲突 Sheet」;具体是哪几本仍由点击后的 Sheet 给出。
      * 弹冲突 Sheet」;具体是哪几本仍由点击后的 Sheet 给出。
      */
      */
-    val shelfDuplicateCount: Int = 0,
+    val shelfDuplicates: ImmutableList<ConflictBookSummary> = persistentListOf(),
     /** 本书是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
     /** 本书是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
     val bookPrivate: Boolean = false,
     val bookPrivate: Boolean = false,
     /** 进入本页时这本书是否需要验证(页面内刚标记私密不会立刻脱敏) */
     /** 进入本页时这本书是否需要验证(页面内刚标记私密不会立刻脱敏) */
@@ -142,6 +142,8 @@ sealed interface BookInfoSheet {
     data object None : BookInfoSheet
     data object None : BookInfoSheet
     data object CoverPicker : BookInfoSheet
     data object CoverPicker : BookInfoSheet
     data object GroupPicker : BookInfoSheet
     data object GroupPicker : BookInfoSheet
+    /** 已入架书籍的操作面板:其他副本 / 分组 / 删除。 */
+    data object ShelfActions : BookInfoSheet
     data class SourcePicker(val oldBook: Book) : BookInfoSheet
     data class SourcePicker(val oldBook: Book) : BookInfoSheet
     data object ReadRecord : BookInfoSheet
     data object ReadRecord : BookInfoSheet
     data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
     data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
@@ -215,6 +217,15 @@ sealed interface BookInfoIntent {
 
 
     data object DismissShelfConflict : BookInfoIntent
     data object DismissShelfConflict : BookInfoIntent
     data class OpenShelfConflictBook(val summary: ConflictBookSummary) : BookInfoIntent
     data class OpenShelfConflictBook(val summary: ConflictBookSummary) : BookInfoIntent
+
+    /** 「书架操作」Sheet:切到同名同作者的其他副本。 */
+    data class OpenShelfDuplicate(val summary: ConflictBookSummary) : BookInfoIntent
+
+    /** 「书架操作」Sheet:改分组(进入原有的分组选择 Sheet)。 */
+    data object ShelfActionsGroup : BookInfoIntent
+
+    /** 「书架操作」Sheet:删除本书(仍走原有的确认框 / 删除前提醒设置)。 */
+    data object ShelfActionsDelete : BookInfoIntent
     data class CoexistWithShelfConflict(
     data class CoexistWithShelfConflict(
         val existingBookUrl: String,
         val existingBookUrl: String,
         val options: ChangeSourceMigrationOptions,
         val options: ChangeSourceMigrationOptions,

+ 17 - 17
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -426,7 +426,7 @@ private fun BookInfoScreenContent(
                                     ) {
                                     ) {
                                         BookInfoActions(
                                         BookInfoActions(
                                             inBookshelf = state.inBookshelf,
                                             inBookshelf = state.inBookshelf,
-                                            shelfDuplicateCount = state.shelfDuplicateCount,
+                                            hasShelfDuplicates = state.shelfDuplicates.isNotEmpty(),
                                             onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
                                             onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
                                             onTocClick = { onIntent(BookInfoIntent.TocClick) },
                                             onTocClick = { onIntent(BookInfoIntent.TocClick) },
                                             onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
                                             onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
@@ -535,6 +535,14 @@ private fun BookInfoScreenContent(
                 onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
                 onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) },
             )
             )
         }
         }
+        BookInfoSheet.ShelfActions -> ShelfActionsSheet(
+            show = currentSheet == BookInfoSheet.ShelfActions,
+            copies = state.shelfDuplicates,
+            onOpenCopy = { onIntent(BookInfoIntent.OpenShelfDuplicate(it)) },
+            onGroup = { onIntent(BookInfoIntent.ShelfActionsGroup) },
+            onDelete = { onIntent(BookInfoIntent.ShelfActionsDelete) },
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+        )
         is BookInfoSheet.SourcePicker -> {
         is BookInfoSheet.SourcePicker -> {
             ChangeSourceSheet(
             ChangeSourceSheet(
                 show = currentSheet is BookInfoSheet.SourcePicker,
                 show = currentSheet is BookInfoSheet.SourcePicker,
@@ -1346,7 +1354,7 @@ private fun BookInfoHeader(
 @Composable
 @Composable
 private fun BookInfoActions(
 private fun BookInfoActions(
     inBookshelf: Boolean,
     inBookshelf: Boolean,
-    shelfDuplicateCount: Int,
+    hasShelfDuplicates: Boolean,
     onShelfClick: () -> Unit,
     onShelfClick: () -> Unit,
     onTocClick: () -> Unit,
     onTocClick: () -> Unit,
     onGroupClick: () -> Unit,
     onGroupClick: () -> Unit,
@@ -1373,27 +1381,19 @@ private fun BookInfoActions(
         }
         }
     }
     }
 
 
-    // 书架里存在同作品副本时把书架按钮标出来,两种情况都用「换图标 + 换底色 + 换文案」表示:
-    // - 未入架:点击会弹冲突 Sheet,提前告诉用户这一步要问「共存还是迁移」;
-    // - 已入架:让用户看出「这本还有 N 个同名同作者副本」(点击仍是删除本书,行为不变)。
-    val hasShelfDuplicate = shelfDuplicateCount > 0
-    val conflictHighlight = hasShelfDuplicate && !inBookshelf
-    val duplicateHighlight = hasShelfDuplicate && inBookshelf
+    // 未入架但书架里已有同作品副本时,把书架按钮标成冲突态:点击会弹冲突 Sheet,
+    // 提前告诉用户这一步要问「共存还是迁移」。
+    // 已入架时按钮不高亮:可做的事情(副本 / 分组 / 删除)改由点击后的「书架操作」Sheet 给出。
+    val conflictHighlight = !inBookshelf && hasShelfDuplicates
     val shelfLabel = when {
     val shelfLabel = when {
         showShelfRemoveHint -> stringResource(R.string.click_to_remove)
         showShelfRemoveHint -> stringResource(R.string.click_to_remove)
         showLongPressGroupHint -> stringResource(R.string.long_press_group)
         showLongPressGroupHint -> stringResource(R.string.long_press_group)
-        duplicateHighlight -> stringResource(R.string.shelf_duplicate_count, shelfDuplicateCount)
         inBookshelf -> stringResource(R.string.already_in_bookshelf)
         inBookshelf -> stringResource(R.string.already_in_bookshelf)
         conflictHighlight -> stringResource(R.string.bookshelf_conflict_hint)
         conflictHighlight -> stringResource(R.string.bookshelf_conflict_hint)
         else -> stringResource(R.string.add_to_bookshelf)
         else -> stringResource(R.string.add_to_bookshelf)
     }
     }
     val shelfDescription = when {
     val shelfDescription = when {
         conflictHighlight -> stringResource(R.string.bookshelf_conflict_hint_desc)
         conflictHighlight -> stringResource(R.string.bookshelf_conflict_hint_desc)
-        duplicateHighlight -> stringResource(
-            R.string.shelf_duplicate_count_desc,
-            shelfDuplicateCount,
-        )
-
         else -> shelfLabel
         else -> shelfLabel
     }
     }
 
 
@@ -1407,18 +1407,18 @@ private fun BookInfoActions(
         BookInfoActionCard(
         BookInfoActionCard(
             modifier = Modifier.weight(1f),
             modifier = Modifier.weight(1f),
             icon = when {
             icon = when {
-                hasShelfDuplicate -> Icons.Default.Shuffle
+                conflictHighlight -> Icons.Default.Shuffle
                 inBookshelf -> Icons.Outlined.Book
                 inBookshelf -> Icons.Outlined.Book
                 else -> Icons.Default.BookmarkAdd
                 else -> Icons.Default.BookmarkAdd
             },
             },
             label = shelfLabel,
             label = shelfLabel,
             contentDescription = shelfDescription,
             contentDescription = shelfDescription,
-            containerColor = if (hasShelfDuplicate) {
+            containerColor = if (conflictHighlight) {
                 LegadoTheme.colorScheme.secondaryContainer
                 LegadoTheme.colorScheme.secondaryContainer
             } else {
             } else {
                 LegadoTheme.colorScheme.surfaceContainerLow
                 LegadoTheme.colorScheme.surfaceContainerLow
             },
             },
-            contentColor = if (hasShelfDuplicate) {
+            contentColor = if (conflictHighlight) {
                 LegadoTheme.colorScheme.onSecondaryContainer
                 LegadoTheme.colorScheme.onSecondaryContainer
             } else {
             } else {
                 LegadoTheme.colorScheme.onSurface
                 LegadoTheme.colorScheme.onSurface

+ 56 - 26
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -35,6 +35,7 @@ import io.legado.app.domain.gateway.PrivateContentGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.BookMatchKey
 import io.legado.app.domain.model.BookMatchKey
 import io.legado.app.domain.model.BookshelfConflict
 import io.legado.app.domain.model.BookshelfConflict
+import io.legado.app.domain.model.ConflictBookSummary
 import io.legado.app.domain.model.PrivateAccessState
 import io.legado.app.domain.model.PrivateAccessState
 import io.legado.app.domain.model.PrivateUnlockTarget
 import io.legado.app.domain.model.PrivateUnlockTarget
 import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.CoverSettings
@@ -292,11 +293,12 @@ class BookInfoViewModel(
     private var pendingShelfBook: Book? = null
     private var pendingShelfBook: Book? = null
 
 
     /**
     /**
-     * 书架里同一部作品的其他副本数(宽松「疑似」口径,见 [refreshShelfDuplicateCount])。
+     * 书架里同一部作品的其他副本(宽松「疑似」口径,见 [refreshShelfDuplicates])。
      *
      *
-     * 只驱动书架按钮的外观,不影响任何数据。
+     * 未入架时驱动书架按钮的冲突态外观;已入架时给「书架操作」Sheet 列出来。
+     * 只影响展示,不改动任何数据。
      */
      */
-    private var shelfDuplicateCount = 0
+    private var shelfDuplicates: List<ConflictBookSummary> = emptyList()
 
 
     /**
     /**
      * 上一次统计用的归一化身份键(书名 + 作者)。
      * 上一次统计用的归一化身份键(书名 + 作者)。
@@ -484,6 +486,12 @@ class BookInfoViewModel(
                 _screenState.update { it.copy(shelfConflict = null) }
                 _screenState.update { it.copy(shelfConflict = null) }
             }
             }
 
 
+            is BookInfoIntent.OpenShelfDuplicate -> openShelfDuplicate(intent.summary)
+
+            BookInfoIntent.ShelfActionsGroup -> setSheet(BookInfoSheet.GroupPicker)
+
+            BookInfoIntent.ShelfActionsDelete -> onShelfActionsDelete()
+
             is BookInfoIntent.OpenShelfConflictBook -> {
             is BookInfoIntent.OpenShelfConflictBook -> {
                 // 先收起冲突 Sheet 再导航:详情页之间跳转会复用同一份冲突状态,
                 // 先收起冲突 Sheet 再导航:详情页之间跳转会复用同一份冲突状态,
                 // 不收起来的话新页面一进来就显示 Sheet,并把第一次返回键吃掉。
                 // 不收起来的话新页面一进来就显示 Sheet,并把第一次返回键吃掉。
@@ -953,42 +961,42 @@ class BookInfoViewModel(
         }
         }
         currentBook = book
         currentBook = book
         inBookshelf = true
         inBookshelf = true
-        // 入架后按钮语义变了(点击=删除),副本数要按新状态重算:共存会多一本、迁移会少一本
-        invalidateShelfDuplicateCount()
+        // 入架后按钮语义变了(点击=「书架操作」Sheet),副本要按新状态重算:共存会多一本、迁移会少一本
+        invalidateShelfDuplicates()
         syncUiState()
         syncUiState()
-        viewModelScope.launch { refreshShelfDuplicateCount(book) }
+        viewModelScope.launch { refreshShelfDuplicates(book) }
     }
     }
 
 
     /**
     /**
-     * 统计书架里同一部作品的**其他**副本数,驱动书架按钮的外观。
+     * 统计书架里同一部作品的**其他**副本,驱动书架按钮的冲突态与「书架操作」Sheet 的副本列表。
      *
      *
      * 判定复用入架查重的同一个用例([FindBookshelfConflictUseCase],它按 bookUrl 排除自身),
      * 判定复用入架查重的同一个用例([FindBookshelfConflictUseCase],它按 bookUrl 排除自身),
      * 因此本书未入架时「按钮是冲突态」⟺「点击真的会弹冲突 Sheet」,入口承诺与实际行为一致;
      * 因此本书未入架时「按钮是冲突态」⟺「点击真的会弹冲突 Sheet」,入口承诺与实际行为一致;
-     * 已入架时同一个数字就是「还有几本同名同作者副本」。
+     * 已入架时同一份结果就是「还有哪几本是同一部作品」。
      *
      *
-     * 提示不改变任何数据,用户在 Sheet 里仍会看到具体是哪几本,所以用宽松「疑似」口径是安全的:
+     * 只影响展示,用户在 Sheet 里仍会看到具体是哪几本,所以用宽松「疑似」口径是安全的:
      * 没有「静默把人带去另一本书」这种代价,不需要另立严格口径。
      * 没有「静默把人带去另一本书」这种代价,不需要另立严格口径。
      */
      */
-    private suspend fun refreshShelfDuplicateCount(book: Book) {
+    private suspend fun refreshShelfDuplicates(book: Book) {
         val identity = BookMatchKey.of(book.name) to BookMatchKey.of(book.author)
         val identity = BookMatchKey.of(book.name) to BookMatchKey.of(book.author)
         // 身份没变就不重查(见 [shelfDuplicateIdentity])
         // 身份没变就不重查(见 [shelfDuplicateIdentity])
         if (identity == shelfDuplicateIdentity) return
         if (identity == shelfDuplicateIdentity) return
         shelfDuplicateIdentity = identity
         shelfDuplicateIdentity = identity
-        val count = findBookshelfConflictUseCase.execute(book)?.candidates?.size ?: 0
+        val copies = findBookshelfConflictUseCase.execute(book)?.candidates.orEmpty()
         // 期间身份可能已被更可信的一次(爬取后)覆盖,旧结论不能反过来盖掉新的
         // 期间身份可能已被更可信的一次(爬取后)覆盖,旧结论不能反过来盖掉新的
         if (shelfDuplicateIdentity == identity) {
         if (shelfDuplicateIdentity == identity) {
-            setShelfDuplicateCount(count)
+            setShelfDuplicates(copies)
         }
         }
     }
     }
 
 
-    private fun setShelfDuplicateCount(value: Int) {
-        if (shelfDuplicateCount == value) return
-        shelfDuplicateCount = value
+    private fun setShelfDuplicates(value: List<ConflictBookSummary>) {
+        if (shelfDuplicates == value) return
+        shelfDuplicates = value
         syncUiState()
         syncUiState()
     }
     }
 
 
     /** 书架内容被本页动作改动后,之前的统计结论作废。 */
     /** 书架内容被本页动作改动后,之前的统计结论作废。 */
-    private fun invalidateShelfDuplicateCount() {
+    private fun invalidateShelfDuplicates() {
         shelfDuplicateIdentity = null
         shelfDuplicateIdentity = null
     }
     }
 
 
@@ -1084,7 +1092,7 @@ class BookInfoViewModel(
         val book = currentBook ?: return
         val book = currentBook ?: return
         execute {
         execute {
             inBookshelf = false
             inBookshelf = false
-            invalidateShelfDuplicateCount()
+            invalidateShelfDuplicates()
             if (book.isLocal) {
             if (book.isLocal) {
                 LocalBook.deleteBook(book, deleteOriginal)
                 LocalBook.deleteBook(book, deleteOriginal)
             }
             }
@@ -1154,7 +1162,7 @@ class BookInfoViewModel(
                         loadedBook.save()
                         loadedBook.save()
                     }
                     }
                     // 爬取后的身份最可信(canReName 可能改写书名/作者),重算一次
                     // 爬取后的身份最可信(canReName 可能改写书名/作者),重算一次
-                    refreshShelfDuplicateCount(loadedBook)
+                    refreshShelfDuplicates(loadedBook)
                     syncUiState(isTocLoading = showLoading)
                     syncUiState(isTocLoading = showLoading)
                     refreshMeta(loadedBook)
                     refreshMeta(loadedBook)
                     if (loadedBook.isWebFile) {
                     if (loadedBook.isWebFile) {
@@ -1204,7 +1212,7 @@ class BookInfoViewModel(
             syncUiState(isTocLoading = false)
             syncUiState(isTocLoading = false)
             refreshMeta(it)
             refreshMeta(it)
             // 换源会改写书名/作者,旧的副本统计已不对应这本书
             // 换源会改写书名/作者,旧的副本统计已不对应这本书
-            refreshShelfDuplicateCount(it)
+            refreshShelfDuplicates(it)
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }
         }
     }
     }
@@ -1227,7 +1235,7 @@ class BookInfoViewModel(
         refreshMeta(book)
         refreshMeta(book)
         upCoverByRule(book)
         upCoverByRule(book)
         // 副本统计与书籍加载无关,异步跑即可;爬取路径会在拿到更可信的身份后再重算一次
         // 副本统计与书籍加载无关,异步跑即可;爬取路径会在拿到更可信的身份后再重算一次
-        viewModelScope.launch { refreshShelfDuplicateCount(book) }
+        viewModelScope.launch { refreshShelfDuplicates(book) }
         if (book.tocUrl.isEmpty() && !book.isLocal) {
         if (book.tocUrl.isEmpty() && !book.isLocal) {
             loadBookInfo(book, runPreUpdateJs = inBookshelf, showLoading = false)
             loadBookInfo(book, runPreUpdateJs = inBookshelf, showLoading = false)
         } else {
         } else {
@@ -1387,11 +1395,9 @@ class BookInfoViewModel(
     private fun onShelfClick() {
     private fun onShelfClick() {
         val book = currentBook ?: return
         val book = currentBook ?: return
         if (inBookshelf) {
         if (inBookshelf) {
-            if (LocalConfig.bookInfoDeleteAlert) {
-                showDialog(BookInfoDialog.DeleteBook(book.isLocal))
-            } else {
-                deleteBook(LocalConfig.deleteBookOriginal)
-            }
+            // 已入架:先摊开「其他副本 / 分组 / 删除」。副本要切过去,删除也仍会再确认一次,
+            // 因此这里不再像以前那样点一下就直接问删不删。
+            setSheet(BookInfoSheet.ShelfActions)
         } else if (book.isWebFile) {
         } else if (book.isWebFile) {
             setSheet(BookInfoSheet.WebFiles(openAfterImport = false))
             setSheet(BookInfoSheet.WebFiles(openAfterImport = false))
         } else {
         } else {
@@ -1399,6 +1405,30 @@ class BookInfoViewModel(
         }
         }
     }
     }
 
 
+    private fun onShelfActionsDelete() {
+        dismissSheet()
+        if (LocalConfig.bookInfoDeleteAlert) {
+            showDialog(BookInfoDialog.DeleteBook(currentBook?.isLocal == true))
+        } else {
+            deleteBook(LocalConfig.deleteBookOriginal)
+        }
+    }
+
+    private fun openShelfDuplicate(summary: ConflictBookSummary) {
+        // 先收起 Sheet 再导航:详情页之间跳转会复用同一份 Sheet 状态,
+        // 不收起来的话新页面一进来就显示 Sheet,并把第一次返回键吃掉。
+        dismissSheet()
+        emitEffect(
+            BookInfoEffect.NavigateToBookInfo(
+                name = summary.name,
+                author = summary.author,
+                bookUrl = summary.bookUrl,
+                origin = summary.origin,
+                coverPath = summary.displayCover,
+            )
+        )
+    }
+
     private fun onTocClick() {
     private fun onTocClick() {
         val book = currentBook ?: return
         val book = currentBook ?: return
         if (currentChapterList.isEmpty()) {
         if (currentChapterList.isEmpty()) {
@@ -1779,7 +1809,7 @@ class BookInfoViewModel(
                 bookSource = bookSource,
                 bookSource = bookSource,
                 bookSourceUi = bookSource?.toBookInfoSourceUi(),
                 bookSourceUi = bookSource?.toBookInfoSourceUi(),
                 isTocLoading = isTocLoading,
                 isTocLoading = isTocLoading,
-                shelfDuplicateCount = shelfDuplicateCount,
+                shelfDuplicates = shelfDuplicates.toImmutableList(),
                 deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
                 deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
                 deleteOriginal = LocalConfig.deleteBookOriginal,
                 deleteOriginal = LocalConfig.deleteBookOriginal,
             )
             )

+ 90 - 0
app/src/main/java/io/legado/app/ui/book/info/ShelfActionsSheet.kt

@@ -0,0 +1,90 @@
+package io.legado.app.ui.book.info
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.ConflictBookSummary
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.conflict.ConflictBookCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.TinySettingItem
+import io.legado.app.ui.widget.components.text.AppText
+
+/**
+ * 已入架书籍的「书架操作」Sheet:其他副本 / 分组 / 删除。
+ *
+ * 为什么不再直接弹删除确认框:已入架的书可能还有同名同作者的其他副本(共存),
+ * 而原来的交互只有「点一下 → 问是否删除」,用户没有别的去处,也没法切到另一个副本。
+ * 这里把三种意图平铺出来,删除仍复用原有的确认框(本地书带「删除原文件」选项,
+ * 且照旧受「删除前提醒」设置影响)。
+ *
+ * 长按卡片直接进「分组」的老路保持不变(见 `BookInfoActions`)。
+ */
+@Composable
+fun ShelfActionsSheet(
+    show: Boolean,
+    copies: List<ConflictBookSummary>,
+    onOpenCopy: (ConflictBookSummary) -> Unit,
+    onGroup: () -> Unit,
+    onDelete: () -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.shelf_actions_title),
+    ) {
+        if (copies.isNotEmpty()) {
+            AppText(
+                text = stringResource(R.string.shelf_duplicate_count, copies.size),
+                style = LegadoTheme.typography.titleSmallEmphasized,
+            )
+            AppText(
+                text = stringResource(R.string.bookshelf_conflict_open_hint),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+            Spacer(modifier = Modifier.height(8.dp))
+            LazyColumn(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 320.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                items(copies, key = { it.bookUrl }) { copy ->
+                    ConflictBookCard(
+                        summary = copy,
+                        onClick = { onOpenCopy(copy) },
+                        modifier = Modifier.fillMaxWidth(),
+                    )
+                }
+            }
+            Spacer(modifier = Modifier.height(12.dp))
+        }
+        TinySettingItem(
+            title = stringResource(R.string.group),
+            imageVector = Icons.Default.Group,
+            onClick = onGroup,
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+        TinySettingItem(
+            title = stringResource(R.string.delete),
+            imageVector = Icons.Default.Delete,
+            color = LegadoTheme.colorScheme.errorContainer,
+            onClick = onDelete,
+        )
+        Spacer(modifier = Modifier.height(12.dp))
+    }
+}

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/conflict/BookshelfConflictSheet.kt

@@ -191,8 +191,9 @@ fun BookshelfConflictSheet(
     )
     )
 }
 }
 
 
+/** 「书架里已有的同作品」卡片:冲突 Sheet 与详情页的「书架操作」Sheet 共用。 */
 @Composable
 @Composable
-private fun ConflictBookCard(
+internal fun ConflictBookCard(
     summary: ConflictBookSummary,
     summary: ConflictBookSummary,
     onClick: () -> Unit,
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,

+ 3 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -3396,9 +3396,9 @@
     <!-- 详情页「加入书架」按钮的冲突态文案(按钮上的文字 / 读屏描述) -->
     <!-- 详情页「加入书架」按钮的冲突态文案(按钮上的文字 / 读屏描述) -->
     <string name="bookshelf_conflict_hint">疑似重复</string>
     <string name="bookshelf_conflict_hint">疑似重复</string>
     <string name="bookshelf_conflict_hint_desc">疑似重复,点击加入书架</string>
     <string name="bookshelf_conflict_hint_desc">疑似重复,点击加入书架</string>
-    <!-- 本书已在架、且还有 N 个同名同作者副本时,书架按钮上的计数与读屏描述 -->
-    <string name="shelf_duplicate_count">%1$d 个副本</string>
-    <string name="shelf_duplicate_count_desc">已在书架,另有 %1$d 个同名同作者副本,点击删除本书</string>
+    <!-- 已入架书籍的「书架操作」Sheet:标题与副本列表的分组标题 -->
+    <string name="shelf_actions_title">书架操作</string>
+    <string name="shelf_duplicate_count">其他 %1$d 个副本</string>
     <string name="bookshelf_conflict_existing_title">书架中已有的作品</string>
     <string name="bookshelf_conflict_existing_title">书架中已有的作品</string>
     <string name="bookshelf_conflict_open_hint">点击卡片查看该作品详情</string>
     <string name="bookshelf_conflict_open_hint">点击卡片查看该作品详情</string>
     <string name="bookshelf_conflict_coexist">共存</string>
     <string name="bookshelf_conflict_coexist">共存</string>

+ 3 - 3
app/src/main/res/values/strings.xml

@@ -3400,9 +3400,9 @@
     <!-- 详情页「加入书架」按钮的冲突态文案(按钮上的文字 / 读屏描述) -->
     <!-- 详情页「加入书架」按钮的冲突态文案(按钮上的文字 / 读屏描述) -->
     <string name="bookshelf_conflict_hint">Possible Duplicate</string>
     <string name="bookshelf_conflict_hint">Possible Duplicate</string>
     <string name="bookshelf_conflict_hint_desc">Possible duplicate, tap to add to bookshelf</string>
     <string name="bookshelf_conflict_hint_desc">Possible duplicate, tap to add to bookshelf</string>
-    <!-- 本书已在架、且还有 N 个同名同作者副本时,书架按钮上的计数与读屏描述 -->
-    <string name="shelf_duplicate_count">%1$d copies</string>
-    <string name="shelf_duplicate_count_desc">On bookshelf with %1$d other copies of the same work, tap to remove this one</string>
+    <!-- 已入架书籍的「书架操作」Sheet:标题与副本列表的分组标题 -->
+    <string name="shelf_actions_title">On-shelf Actions</string>
+    <string name="shelf_duplicate_count">Other copies: %1$d</string>
     <string name="bookshelf_conflict_existing_title">Already on bookshelf</string>
     <string name="bookshelf_conflict_existing_title">Already on bookshelf</string>
     <string name="bookshelf_conflict_open_hint">Tap a book to open its details</string>
     <string name="bookshelf_conflict_open_hint">Tap a book to open its details</string>
     <string name="bookshelf_conflict_coexist">Keep Both</string>
     <string name="bookshelf_conflict_coexist">Keep Both</string>