Bladeren bron

feat: 书架按钮提示同名同作者副本

详情页书架按钮(「加入书架」/「已在书架」)在有同作品副本时改变外观,让用户在点击前就知道会发生什么;判定复用入架查重,不新增口径、不新增 Sheet、不改变点击行为:

- 未入架且有副本:Shuffle 图标 + secondaryContainer 底色 + 文案「疑似重复」,点击仍走原有入架流程(弹冲突 Sheet 问共存/迁移);- 已入架且有 N 个副本:同样标出,文案「N 个副本」(点击仍是删除本书)。

判定复用 FindBookshelfConflictUseCase(宽松「疑似」口径,按 bookUrl 排除自身),因此「按钮是冲突态」⟺「点击真的会弹冲突 Sheet」;统计按归一化书名+作者去重,一次打开最多一次全表扫描,入架/下架/换源后作废重算。

顺带把 bookshelf_edit_subtitle 移入 values/non_translat.xml:它只有格式占位符、没有实词,本来就属于那里(http_ip、diy_edit_source_group_title 同理),这也是 main 上 lintAppDebug 唯一的报错。
HapeLee 1 maand geleden
bovenliggende
commit
38af86b817

+ 12 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -58,6 +58,18 @@ data class BookInfoUiState(
     /** 加入书架时发现的疑似重复;非空时由冲突 Sheet 决定共存还是迁移。 */
     /** 加入书架时发现的疑似重复;非空时由冲突 Sheet 决定共存还是迁移。 */
     val shelfConflict: BookshelfConflict? = null,
     val shelfConflict: BookshelfConflict? = null,
     val isResolvingShelfConflict: Boolean = false,
     val isResolvingShelfConflict: Boolean = false,
+
+    /**
+     * 书架里同一部作品的**其他**副本数(不含本书自身)。
+     *
+     * 两种用途,都由书架按钮呈现:
+     * - 本书**未入架**且大于 0:入架会弹冲突 Sheet,按钮提前标成冲突态;
+     * - 本书**已入架**且大于 0:让用户看出「这本还有 N 个同名同作者副本」。
+     *
+     * 判定口径与 [shelfConflict] 完全一致(同一个用例),因此「按钮是冲突态」⟺「点击真的会
+     * 弹冲突 Sheet」;具体是哪几本仍由点击后的 Sheet 给出。
+     */
+    val shelfDuplicateCount: Int = 0,
     /** 本书是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
     /** 本书是否私密(单本标记 ∪ 所属私密分组);菜单里"标记/取消私密"读它 */
     val bookPrivate: Boolean = false,
     val bookPrivate: Boolean = false,
     /** 进入本页时这本书是否需要验证(页面内刚标记私密不会立刻脱敏) */
     /** 进入本页时这本书是否需要验证(页面内刚标记私密不会立刻脱敏) */

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

@@ -55,6 +55,7 @@ import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Shuffle
 import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material.icons.outlined.Book
 import androidx.compose.material.icons.outlined.Book
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -425,6 +426,7 @@ private fun BookInfoScreenContent(
                                     ) {
                                     ) {
                                         BookInfoActions(
                                         BookInfoActions(
                                             inBookshelf = state.inBookshelf,
                                             inBookshelf = state.inBookshelf,
+                                            shelfDuplicateCount = state.shelfDuplicateCount,
                                             onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
                                             onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
                                             onTocClick = { onIntent(BookInfoIntent.TocClick) },
                                             onTocClick = { onIntent(BookInfoIntent.TocClick) },
                                             onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
                                             onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
@@ -1344,6 +1346,7 @@ private fun BookInfoHeader(
 @Composable
 @Composable
 private fun BookInfoActions(
 private fun BookInfoActions(
     inBookshelf: Boolean,
     inBookshelf: Boolean,
+    shelfDuplicateCount: Int,
     onShelfClick: () -> Unit,
     onShelfClick: () -> Unit,
     onTocClick: () -> Unit,
     onTocClick: () -> Unit,
     onGroupClick: () -> Unit,
     onGroupClick: () -> Unit,
@@ -1370,12 +1373,29 @@ private fun BookInfoActions(
         }
         }
     }
     }
 
 
+    // 书架里存在同作品副本时把书架按钮标出来,两种情况都用「换图标 + 换底色 + 换文案」表示:
+    // - 未入架:点击会弹冲突 Sheet,提前告诉用户这一步要问「共存还是迁移」;
+    // - 已入架:让用户看出「这本还有 N 个同名同作者副本」(点击仍是删除本书,行为不变)。
+    val hasShelfDuplicate = shelfDuplicateCount > 0
+    val conflictHighlight = hasShelfDuplicate && !inBookshelf
+    val duplicateHighlight = hasShelfDuplicate && inBookshelf
     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)
         else -> stringResource(R.string.add_to_bookshelf)
         else -> stringResource(R.string.add_to_bookshelf)
     }
     }
+    val shelfDescription = when {
+        conflictHighlight -> stringResource(R.string.bookshelf_conflict_hint_desc)
+        duplicateHighlight -> stringResource(
+            R.string.shelf_duplicate_count_desc,
+            shelfDuplicateCount,
+        )
+
+        else -> shelfLabel
+    }
 
 
     Row(
     Row(
         modifier = Modifier
         modifier = Modifier
@@ -1386,8 +1406,23 @@ private fun BookInfoActions(
     ) {
     ) {
         BookInfoActionCard(
         BookInfoActionCard(
             modifier = Modifier.weight(1f),
             modifier = Modifier.weight(1f),
-            icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd,
+            icon = when {
+                hasShelfDuplicate -> Icons.Default.Shuffle
+                inBookshelf -> Icons.Outlined.Book
+                else -> Icons.Default.BookmarkAdd
+            },
             label = shelfLabel,
             label = shelfLabel,
+            contentDescription = shelfDescription,
+            containerColor = if (hasShelfDuplicate) {
+                LegadoTheme.colorScheme.secondaryContainer
+            } else {
+                LegadoTheme.colorScheme.surfaceContainerLow
+            },
+            contentColor = if (hasShelfDuplicate) {
+                LegadoTheme.colorScheme.onSecondaryContainer
+            } else {
+                LegadoTheme.colorScheme.onSurface
+            },
             onLongClick = onGroupClick,
             onLongClick = onGroupClick,
             onClick = {
             onClick = {
                 if (!inBookshelf) {
                 if (!inBookshelf) {
@@ -1426,18 +1461,21 @@ private fun BookInfoActionCard(
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
     icon: ImageVector,
     icon: ImageVector,
     label: String,
     label: String,
+    contentDescription: String = label,
+    containerColor: Color = LegadoTheme.colorScheme.surfaceContainerLow,
+    contentColor: Color = LegadoTheme.colorScheme.onSurface,
     onLongClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     onClick: () -> Unit
     onClick: () -> Unit
 ) {
 ) {
     GlassCard(
     GlassCard(
         modifier = modifier.semantics(mergeDescendants = true) {
         modifier = modifier.semantics(mergeDescendants = true) {
             role = Role.Button
             role = Role.Button
-            contentDescription = label
+            this.contentDescription = contentDescription
         },
         },
         onLongClick = onLongClick,
         onLongClick = onLongClick,
         onClick = onClick,
         onClick = onClick,
-        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
-        contentColor = LegadoTheme.colorScheme.onSurface,
+        containerColor = containerColor,
+        contentColor = contentColor,
     ) {
     ) {
         Column(
         Column(
             modifier = Modifier
             modifier = Modifier

+ 60 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -33,6 +33,7 @@ import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.PrivateAccessGateway
 import io.legado.app.domain.gateway.PrivateAccessGateway
 import io.legado.app.domain.gateway.PrivateContentGateway
 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.BookshelfConflict
 import io.legado.app.domain.model.BookshelfConflict
 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
@@ -290,6 +291,21 @@ class BookInfoViewModel(
     /** 命中重复、等待用户在冲突 Sheet 上选择共存还是迁移的那本书。 */
     /** 命中重复、等待用户在冲突 Sheet 上选择共存还是迁移的那本书。 */
     private var pendingShelfBook: Book? = null
     private var pendingShelfBook: Book? = null
 
 
+    /**
+     * 书架里同一部作品的其他副本数(宽松「疑似」口径,见 [refreshShelfDuplicateCount])。
+     *
+     * 只驱动书架按钮的外观,不影响任何数据。
+     */
+    private var shelfDuplicateCount = 0
+
+    /**
+     * 上一次统计用的归一化身份键(书名 + 作者)。
+     *
+     * upBook 与爬取后各统计一次,而候选集来自一次全表扫描;身份没变就不必重查
+     * (`canReName` 真改写了身份时键会变,仍会重算)。书架的增删由本页动作显式作废。
+     */
+    private var shelfDuplicateIdentity: Pair<String, String>? = null
+
     var inBookshelf = false
     var inBookshelf = false
         private set
         private set
     var bookSource: BookSource? = null
     var bookSource: BookSource? = null
@@ -937,9 +953,45 @@ class BookInfoViewModel(
         }
         }
         currentBook = book
         currentBook = book
         inBookshelf = true
         inBookshelf = true
+        // 入架后按钮语义变了(点击=删除),副本数要按新状态重算:共存会多一本、迁移会少一本
+        invalidateShelfDuplicateCount()
+        syncUiState()
+        viewModelScope.launch { refreshShelfDuplicateCount(book) }
+    }
+
+    /**
+     * 统计书架里同一部作品的**其他**副本数,驱动书架按钮的外观。
+     *
+     * 判定复用入架查重的同一个用例([FindBookshelfConflictUseCase],它按 bookUrl 排除自身),
+     * 因此本书未入架时「按钮是冲突态」⟺「点击真的会弹冲突 Sheet」,入口承诺与实际行为一致;
+     * 已入架时同一个数字就是「还有几本同名同作者副本」。
+     *
+     * 提示不改变任何数据,用户在 Sheet 里仍会看到具体是哪几本,所以用宽松「疑似」口径是安全的:
+     * 没有「静默把人带去另一本书」这种代价,不需要另立严格口径。
+     */
+    private suspend fun refreshShelfDuplicateCount(book: Book) {
+        val identity = BookMatchKey.of(book.name) to BookMatchKey.of(book.author)
+        // 身份没变就不重查(见 [shelfDuplicateIdentity])
+        if (identity == shelfDuplicateIdentity) return
+        shelfDuplicateIdentity = identity
+        val count = findBookshelfConflictUseCase.execute(book)?.candidates?.size ?: 0
+        // 期间身份可能已被更可信的一次(爬取后)覆盖,旧结论不能反过来盖掉新的
+        if (shelfDuplicateIdentity == identity) {
+            setShelfDuplicateCount(count)
+        }
+    }
+
+    private fun setShelfDuplicateCount(value: Int) {
+        if (shelfDuplicateCount == value) return
+        shelfDuplicateCount = value
         syncUiState()
         syncUiState()
     }
     }
 
 
+    /** 书架内容被本页动作改动后,之前的统计结论作废。 */
+    private fun invalidateShelfDuplicateCount() {
+        shelfDuplicateIdentity = null
+    }
+
     /**
     /**
      * 共存 / 迁移的落地执行。
      * 共存 / 迁移的落地执行。
      *
      *
@@ -1032,6 +1084,7 @@ class BookInfoViewModel(
         val book = currentBook ?: return
         val book = currentBook ?: return
         execute {
         execute {
             inBookshelf = false
             inBookshelf = false
+            invalidateShelfDuplicateCount()
             if (book.isLocal) {
             if (book.isLocal) {
                 LocalBook.deleteBook(book, deleteOriginal)
                 LocalBook.deleteBook(book, deleteOriginal)
             }
             }
@@ -1100,6 +1153,8 @@ class BookInfoViewModel(
                     if (inBookshelf) {
                     if (inBookshelf) {
                         loadedBook.save()
                         loadedBook.save()
                     }
                     }
+                    // 爬取后的身份最可信(canReName 可能改写书名/作者),重算一次
+                    refreshShelfDuplicateCount(loadedBook)
                     syncUiState(isTocLoading = showLoading)
                     syncUiState(isTocLoading = showLoading)
                     refreshMeta(loadedBook)
                     refreshMeta(loadedBook)
                     if (loadedBook.isWebFile) {
                     if (loadedBook.isWebFile) {
@@ -1148,6 +1203,8 @@ class BookInfoViewModel(
             currentKindLabels = emptyList()
             currentKindLabels = emptyList()
             syncUiState(isTocLoading = false)
             syncUiState(isTocLoading = false)
             refreshMeta(it)
             refreshMeta(it)
+            // 换源会改写书名/作者,旧的副本统计已不对应这本书
+            refreshShelfDuplicateCount(it)
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
             postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
         }
         }
     }
     }
@@ -1169,6 +1226,8 @@ class BookInfoViewModel(
         loadBookEvents(book.bookUrl)
         loadBookEvents(book.bookUrl)
         refreshMeta(book)
         refreshMeta(book)
         upCoverByRule(book)
         upCoverByRule(book)
+        // 副本统计与书籍加载无关,异步跑即可;爬取路径会在拿到更可信的身份后再重算一次
+        viewModelScope.launch { refreshShelfDuplicateCount(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 {
@@ -1720,6 +1779,7 @@ class BookInfoViewModel(
                 bookSource = bookSource,
                 bookSource = bookSource,
                 bookSourceUi = bookSource?.toBookInfoSourceUi(),
                 bookSourceUi = bookSource?.toBookInfoSourceUi(),
                 isTocLoading = isTocLoading,
                 isTocLoading = isTocLoading,
+                shelfDuplicateCount = shelfDuplicateCount,
                 deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
                 deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
                 deleteOriginal = LocalConfig.deleteBookOriginal,
                 deleteOriginal = LocalConfig.deleteBookOriginal,
             )
             )

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

@@ -3392,6 +3392,12 @@
     <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
     <string name="feature_bookmarks_chapter_fallback">第 %1$d 章</string>
 
 
     <string name="bookshelf_conflict_title">检测到可能重复的作品</string>
     <string name="bookshelf_conflict_title">检测到可能重复的作品</string>
+    <!-- 详情页「加入书架」按钮的冲突态文案(按钮上的文字 / 读屏描述) -->
+    <string name="bookshelf_conflict_hint">疑似重复</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>
     <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>

+ 2 - 0
app/src/main/res/values/non_translat.xml

@@ -10,6 +10,8 @@
     <string name="http_ip" translatable="false">http://%1$s:%2$d</string>
     <string name="http_ip" translatable="false">http://%1$s:%2$d</string>
     <string name="git_hub" translatable="false">GitHub</string>
     <string name="git_hub" translatable="false">GitHub</string>
     <string name="diy_edit_source_group_title" translatable="false">【%s】</string>
     <string name="diy_edit_source_group_title" translatable="false">【%s】</string>
+    <!-- 只有格式占位符、没有实词,各语言共用同一写法 -->
+    <string name="bookshelf_edit_subtitle" translatable="false">%1$s · %2$s</string>
     <string name="vip_title" translatable="false">🔒%s</string>
     <string name="vip_title" translatable="false">🔒%s</string>
     <string name="payed_title" translatable="false">🔓%s</string>
     <string name="payed_title" translatable="false">🔓%s</string>
 
 

+ 6 - 1
app/src/main/res/values/strings.xml

@@ -2066,7 +2066,6 @@
     <string name="tool_bar_style">Tool bar style</string>
     <string name="tool_bar_style">Tool bar style</string>
     <string name="bookshelf_total_count">%1$d books</string>
     <string name="bookshelf_total_count">%1$d books</string>
     <string name="bookshelf_selected_count">%1$d selected</string>
     <string name="bookshelf_selected_count">%1$d selected</string>
-    <string name="bookshelf_edit_subtitle">%1$s · %2$s</string>
     <string name="bookshelf_empty_global_search">No books here. Try global search.</string>
     <string name="bookshelf_empty_global_search">No books here. Try global search.</string>
     <string name="global_search">Global search</string>
     <string name="global_search">Global search</string>
     <string name="cache_management">Cache management</string>
     <string name="cache_management">Cache management</string>
@@ -3398,6 +3397,12 @@
     <string name="feature_bookmarks_chapter_fallback">Chapter %1$d</string>
     <string name="feature_bookmarks_chapter_fallback">Chapter %1$d</string>
 
 
     <string name="bookshelf_conflict_title">Possible Duplicate</string>
     <string name="bookshelf_conflict_title">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>
+    <!-- 本书已在架、且还有 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>
     <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>