Răsfoiți Sursa

Merge pull request #1987 from maker255/pr/reader-bookmark-swipe

Pr/reader bookmark swipe
Kudomaga 3 luni în urmă
părinte
comite
0c4e53082d
26 a modificat fișierele cu 613 adăugiri și 8 ștergeri
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 15 0
      app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt
  3. 14 0
      app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt
  4. 3 0
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  5. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  6. 60 0
      app/src/main/java/io/legado/app/model/ReaderBookmarkState.kt
  7. 9 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  8. 5 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  9. 8 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  10. 117 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  11. 5 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  12. 25 0
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  13. 192 2
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  14. 3 0
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt
  15. 9 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt
  16. 1 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  17. 11 0
      app/src/main/res/drawable/ic_bookmark_badge.xml
  18. 12 0
      app/src/main/res/layout/view_book_page.xml
  19. 5 0
      app/src/main/res/values-zh-rCN/strings.xml
  20. 5 0
      app/src/main/res/values-zh-rHK/strings.xml
  21. 5 0
      app/src/main/res/values-zh-rTW/strings.xml
  22. 5 0
      app/src/main/res/values/strings.xml
  23. 4 2
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  24. 75 0
      app/src/test/java/io/legado/app/model/ReaderBookmarkStateTest.kt
  25. 1 0
      app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt
  26. 22 4
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -181,6 +181,7 @@ object PreferKey {
     const val previewImageByClick = "previewImageByClick"
     const val clickImgWay = "clickImgWay"
     const val keyPageOnLongPress = "keyPageOnLongPress"
+    const val swipeToAddBookmark = "swipeToAddBookmark"
     const val volumeKeyPage = "volumeKeyPage"
     const val volumeKeyPageOnPlay = "volumeKeyPageOnPlay"
     const val mouseWheelPage = "mouseWheelPage"

+ 15 - 0
app/src/main/java/io/legado/app/data/dao/BookmarkDao.kt

@@ -59,6 +59,21 @@ interface BookmarkDao {
     """)
     fun flowSearchAll(query: String): Flow<List<Bookmark>>
 
+    /** 落在某章某段位置区间内的书签,供「本页是否已有书签」判定使用。 */
+    @Query(
+        """select * from bookmarks
+        where bookName = :bookName and bookAuthor = :bookAuthor
+        and chapterIndex = :chapterIndex
+        and chapterPos >= :startPos and chapterPos < :endPos"""
+    )
+    fun getByChapterRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): List<Bookmark>
+
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     fun insert(vararg bookmark: Bookmark)
 

+ 14 - 0
app/src/main/java/io/legado/app/data/repository/BookmarkRepository.kt

@@ -25,10 +25,24 @@ class BookmarkRepository(
             dao.getByBook(bookName, bookAuthor)
         }
 
+    suspend fun getByChapterRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): List<Bookmark> = withContext(Dispatchers.IO) {
+        dao.getByChapterRange(bookName, bookAuthor, chapterIndex, startPos, endPos)
+    }
+
     suspend fun save(bookmark: Bookmark) = withContext(Dispatchers.IO) {
         dao.insert(bookmark)
     }
 
+    suspend fun deleteAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
+        dao.delete(*bookmarks.toTypedArray())
+    }
+
     suspend fun saveAll(bookmarks: List<Bookmark>) = withContext(Dispatchers.IO) {
         dao.insert(*bookmarks.toTypedArray())
     }

+ 3 - 0
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -359,6 +359,7 @@ class ReadSettingsRepository(
             volumeKeyPage = compatDsValue(Keys.VolumeKeyPage, true),
             volumeKeyPageOnPlay = compatDsValue(Keys.VolumeKeyPageOnPlay, true),
             keyPageOnLongPress = compatDsValue(Keys.KeyPageOnLongPress, false),
+            swipeToAddBookmark = compatDsValue(Keys.SwipeToAddBookmark, false),
             pageTouchSlop = compatDsValue(Keys.PageTouchSlop, 0),
             sliderVibrator = compatDsValue(Keys.SliderVibrator, false),
             useNewTocSheet = compatDsValue(Keys.UseNewTocSheet, true),
@@ -473,6 +474,7 @@ class ReadSettingsRepository(
         val VolumeKeyPage = booleanPreferencesKey(PreferKey.volumeKeyPage)
         val VolumeKeyPageOnPlay = booleanPreferencesKey(PreferKey.volumeKeyPageOnPlay)
         val KeyPageOnLongPress = booleanPreferencesKey(PreferKey.keyPageOnLongPress)
+        val SwipeToAddBookmark = booleanPreferencesKey(PreferKey.swipeToAddBookmark)
         val PageTouchSlop = intPreferencesKey(PreferKey.pageTouchSlop)
         val SliderVibrator = booleanPreferencesKey(PreferKey.sliderVibrator)
         val UseNewTocSheet = booleanPreferencesKey(PreferKey.useNewTocSheet)
@@ -596,6 +598,7 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.volumeKeyPage to volumeKeyPage,
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
+    PreferKey.swipeToAddBookmark to swipeToAddBookmark,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.useNewTocSheet to useNewTocSheet,

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -29,6 +29,7 @@ data class ReadSettings(
     val volumeKeyPage: Boolean = true,
     val volumeKeyPageOnPlay: Boolean = true,
     val keyPageOnLongPress: Boolean = false,
+    val swipeToAddBookmark: Boolean = false,
     val pageTouchSlop: Int = 0,
     val sliderVibrator: Boolean = false,
     val useNewTocSheet: Boolean = true,

+ 60 - 0
app/src/main/java/io/legado/app/model/ReaderBookmarkState.kt

@@ -0,0 +1,60 @@
+package io.legado.app.model
+
+import io.legado.app.data.entities.Bookmark
+
+/**
+ * 当前阅读会话的书签位置快照,供渲染层同步判定「本页是否已有书签」(右上角角标)。
+ *
+ * 渲染层(`PageView.setProgress`)在主线程热路径上按页查询,不能起协程查库;
+ * 由 `ReadBookmarkDelegate` 收集 `BookmarkRepository.flowByBook` 后写入。
+ * 只读缓存,不参与持久化——与 [ReadSessionState] 同层。
+ *
+ * 快照携带 `bookName to bookAuthor` 书键:查库/写快照都带键,键不一致视为无书签。
+ * 这样换书后、新书书签流第一条数据到达之前,上一本残留的旧快照不会误供给新书
+ * (旧键 vs 新查询键 → 失配 → 不显示角标)。
+ */
+object ReaderBookmarkState {
+
+    private data class Snapshot(
+        val bookName: String,
+        val bookAuthor: String,
+        /** chapterIndex → 该章内所有书签的 chapterPos。整体替换,读侧无需加锁。 */
+        val positionsByChapter: Map<Int, List<Int>>,
+    )
+
+    @Volatile
+    private var snapshot: Snapshot? = null
+
+    /**
+     * 判定 `[startPos, endPos)` 这段章节内位置区间是否落有书签。
+     *
+     * @param bookName 当前书书名(`ReadBook.book.name`),须与快照书键一致
+     * @param bookAuthor 当前书作者
+     * @param startPos 页首字符在章节内的位置(`TextPage.chapterPosition`)
+     * @param endPos 页尾之后一个字符的位置
+     */
+    fun hasBookmarkInRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): Boolean {
+        val snap = snapshot ?: return false
+        if (snap.bookName != bookName || snap.bookAuthor != bookAuthor) return false
+        val positions = snap.positionsByChapter[chapterIndex] ?: return false
+        return positions.any { it >= startPos && it < endPos }
+    }
+
+    fun update(bookName: String, bookAuthor: String, bookmarks: List<Bookmark>) {
+        snapshot = Snapshot(
+            bookName = bookName,
+            bookAuthor = bookAuthor,
+            positionsByChapter = bookmarks.groupBy({ it.chapterIndex }, { it.chapterPos }),
+        )
+    }
+
+    fun clear() {
+        snapshot = null
+    }
+}

+ 9 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -501,6 +501,9 @@ sealed interface ReadBookIntent {
 
     // Bookmark
     data object AddBookmark : ReadBookIntent
+
+    /** 下滑手势:本页无书签则直接存,有则取消。 */
+    data object ToggleBookmark : ReadBookIntent
     data class SaveBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
     data class DeleteBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent
 
@@ -804,6 +807,9 @@ sealed interface ReadBookEffect {
 
     // Menu / UI actions
     data object AddBookmark : ReadBookEffect
+
+    /** 书签集合变化后刷新三页的右上角书签角标。 */
+    data object UpBookmarkBadge : ReadBookEffect
     data object CancelSelect : ReadBookEffect
     data object UpSystemUiVisibility : ReadBookEffect
     data class SetBrightness(val value: Int) : ReadBookEffect
@@ -1515,6 +1521,9 @@ sealed interface ConfigUpdate {
     data class KeyPageOnLongPress(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
+    data class SwipeToAddBookmark(val value: Boolean) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class SliderVibrator(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -461,6 +461,7 @@ class ReadBookController(
             ReaderEvent.OpenContentEdit -> openContentEdit()
             ReaderEvent.OpenSearch -> openSearch(null)
             ReaderEvent.AddBookmark -> addBookmark()
+            ReaderEvent.ToggleBookmark -> viewModel.onIntent(ReadBookIntent.ToggleBookmark)
             ReaderEvent.ChangeReplaceRuleState -> changeReplaceRuleState()
             ReaderEvent.NextChapter -> viewModel.onIntent(ReadBookIntent.NextChapter)
             ReaderEvent.PrevChapter -> viewModel.onIntent(ReadBookIntent.PrevChapter)
@@ -1248,6 +1249,10 @@ class ReadBookController(
             is ReadBookEffect.AddBookmark -> {
                 // Handled by route/ViewModel — no-op here
             }
+
+            is ReadBookEffect.UpBookmarkBadge -> {
+                refs?.readView?.upBookmarkBadge()
+            }
         }
     }
 

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -95,6 +95,7 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
@@ -340,8 +341,13 @@ class ReadBookViewModel(
             override fun setActiveSheet(sheet: ReadBookSheet?) {
                 _uiState.update { it.copy(menuState = ReadBookMenuState(), activeSheet = sheet) }
             }
+
+            override fun emitEffect(effect: ReadBookEffect) {
+                _effects.tryEmit(effect)
+            }
         },
         bookmarkRepository = bookmarkRepository,
+        bookKey = _uiState.map { it.book?.let { book -> book.name to book.author } },
     )
 
     // --- 开书 / 目录 / 换源 / 进度同步域(无自持状态,isInitFinish 仍在 UiState)---
@@ -565,6 +571,7 @@ class ReadBookViewModel(
         collectEventBus()
         collectReaderSession()
         collectReadStyle()
+        bookmarkDelegate.start()
         replaceRuleDelegate.start()
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
     }
@@ -861,6 +868,7 @@ class ReadBookViewModel(
             is ReadBookIntent.SureNewProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.SureSyncProgress -> ReadBook.setProgress(intent.progress)
             is ReadBookIntent.AddBookmark -> bookmarkDelegate.addForCurrentPage()
+            is ReadBookIntent.ToggleBookmark -> bookmarkDelegate.toggleForCurrentPage()
             is ReadBookIntent.SaveBookmark -> bookmarkDelegate.save(intent.bookmark)
             is ReadBookIntent.DeleteBookmark -> bookmarkDelegate.delete(intent.bookmark)
             is ReadBookIntent.CancelSelect -> _effects.tryEmit(ReadBookEffect.CancelSelect)

+ 117 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -1,13 +1,25 @@
 package io.legado.app.ui.book.read
 
+import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.repository.BookmarkRepository
 import io.legado.app.model.ReadBook
+import io.legado.app.model.ReaderBookmarkState
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+import kotlin.math.abs
 
 /**
  * 书签域(R2.2 续批)。
@@ -19,11 +31,111 @@ class ReadBookmarkDelegate(
     private val scope: CoroutineScope,
     private val host: Host,
     private val bookmarkRepository: BookmarkRepository,
+    /**
+     * 当前书的 `书名 to 作者`(未开书时为 null)——`bookmarks` 表没有 bookUrl 列,
+     * 这两个字段就是关联键。[start] 用它当切换键重订书签流。
+     */
+    private val bookKey: Flow<Pair<String, String>?>,
 ) {
 
+    /**
+     * 串行化下滑手势的切换:先查再写不是原子的,快速连滑会双双看到「空」而重复插入。
+     * 锁住读-查-写整段后,两次滑动退化成正确的两次 toggle(加一条再删一条),不会出现重复。
+     */
+    private val toggleMutex = Mutex()
+
     interface Host {
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         fun setActiveSheet(sheet: ReadBookSheet?)
+
+        fun emitEffect(effect: ReadBookEffect)
+    }
+
+    /**
+     * 维护 [ReaderBookmarkState]:渲染层要在主线程热路径上同步判定「本页是否有书签」画角标,
+     * 不能起协程查库,所以把当前书的书签位置整份缓存进会话快照。VM 构造时调一次。
+     *
+     * 收集随 [scope] 结束,`finally` 里清掉快照——否则下一本书会读到上一本的书签位置。
+     */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun start() {
+        scope.launch {
+            try {
+                bookKey
+                    .distinctUntilChanged()
+                    .flatMapLatest { key ->
+                        if (key == null) {
+                            flowOf(key to emptyList())
+                        } else {
+                            bookmarkRepository.flowByBook(key.first, key.second).map { key to it }
+                        }
+                    }
+                    .collect { (key, bookmarks) ->
+                        if (key == null) {
+                            ReaderBookmarkState.clear()
+                        } else {
+                            ReaderBookmarkState.update(key.first, key.second, bookmarks)
+                        }
+                        host.emitEffect(ReadBookEffect.UpBookmarkBadge)
+                    }
+            } finally {
+                ReaderBookmarkState.clear()
+            }
+        }
+    }
+
+    /**
+     * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉离当前阅读位置最近的一条。
+     *
+     * 页范围取 `[页首位置, 下一页页首位置)`——末页取到章节已排版长度,
+     * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致。
+     * 消息页/空页与角标判定一致地跳过(页首位置取 `textLines.first()`,空页会抛异常)。
+     * 整体串行化:先查后写不是原子的,快速连滑会双双看到「空」而重复插入。
+     */
+    fun toggleForCurrentPage() {
+        scope.launch(IO) {
+            toggleMutex.withLock {
+                val book = ReadBook.book ?: return@withLock
+                val chapter = ReadBook.curTextChapter ?: return@withLock
+                val pageIndex = ReadBook.durPageIndex
+                val page = chapter.getPage(pageIndex) ?: return@withLock
+                if (page.isMsgPage || page.lineSize <= 0) return@withLock
+                val startPos = page.chapterPosition
+                val endPos = chapter.getPage(pageIndex + 1)?.chapterPosition
+                    ?: (startPos + page.charSize)
+                val existing = bookmarkRepository.getByChapterRange(
+                    bookName = book.name,
+                    bookAuthor = book.author,
+                    chapterIndex = chapter.chapter.index,
+                    startPos = startPos,
+                    endPos = endPos,
+                )
+                if (existing.isEmpty()) {
+                    bookmarkRepository.save(
+                        Bookmark(
+                            bookName = book.name,
+                            bookAuthor = book.author,
+                            chapterIndex = chapter.chapter.index,
+                            chapterName = chapter.title,
+                            chapterPos = ReadBook.durChapterPos,
+                            bookText = page.text.replace(BOOK_TEXT_MARKS, "").trim(),
+                            content = "",
+                        )
+                    )
+                    host.emitEffect(
+                        ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_added))
+                    )
+                } else {
+                    // 只删离当前阅读位置最近的一条:同一页可能有多条书签,不应整页误删。
+                    val nearest = existing.minByOrNull { abs(it.chapterPos - ReadBook.durChapterPos) }
+                        ?: return@withLock
+                    bookmarkRepository.delete(nearest)
+                    host.emitEffect(
+                        ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_removed))
+                    )
+                }
+            }
+        }
     }
 
     /** 从菜单「加书签」进入:以当前页正文预填草稿。 */
@@ -65,4 +177,9 @@ class ReadBookmarkDelegate(
             host.setActiveSheet(null)
         }
     }
+
+    private companion object {
+        /** 与 ReadBookController.addBookmark 一致:剔除正文里的排版占位符。 */
+        val BOOK_TEXT_MARKS = Regex("[袮꧁]")
+    }
 }

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt

@@ -623,6 +623,11 @@ class ReadConfigUpdateDelegate(
                     readSettingsRepository.setKeyPageOnLongPress(update.value)
                 }
             }
+            is ConfigUpdate.SwipeToAddBookmark -> {
+                scope.launch {
+                    readSettingsRepository.update { it.copy(swipeToAddBookmark = update.value) }
+                }
+            }
             is ConfigUpdate.SliderVibrator -> {
                 scope.launch {
                     readSettingsRepository.setSliderVibrator(update.value)

+ 25 - 0
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -24,6 +24,7 @@ import io.legado.app.databinding.ViewBookPageBinding
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
+import io.legado.app.model.ReaderBookmarkState
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -78,6 +79,27 @@ class PageView(
 
     private var currentTextPage: TextPage? = null
 
+    /**
+     * 右上角书签角标:本页范围内落有书签时显示。
+     *
+     * 滚动模式一屏可见多页,`currentTextPage` 不再唯一对应可见内容,故该模式下不显示——
+     * 与下滑手势在滚动模式下同样不启用保持一致。
+     */
+    fun upBookmarkBadge() {
+        val page = currentTextPage
+        val book = ReadBook.book
+        val visible = book != null && page != null && !isScroll && !page.isMsgPage && page.lineSize > 0 &&
+                ReaderBookmarkState.hasBookmarkInRange(
+                    bookName = book.name,
+                    bookAuthor = book.author,
+                    chapterIndex = page.chapterIndex,
+                    startPos = page.chapterPosition,
+                    endPos = page.chapterPosition + page.charSize,
+                )
+        // 角标是固定的黄色书签丝带(见 ic_bookmark_badge),不随主题变色。
+        binding.ivBookmarkBadge.isGone = !visible
+    }
+
     val headerHeight: Int
         get() {
             val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
@@ -174,6 +196,7 @@ class PageView(
     /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
     fun upThemeColors() = binding.run {
         applyTipColors(TipStyleProvider.style)
+        upBookmarkBadge()
         contentTextView.invalidate()
     }
 
@@ -644,6 +667,7 @@ class PageView(
             "$wholeBookPageDisplay  $readProgress"
         )
         upCustomTip(textPage)
+        upBookmarkBadge()
         this@PageView.layoutSync()
     }
 
@@ -682,6 +706,7 @@ class PageView(
     fun setIsScroll(value: Boolean) {
         isScroll = value
         binding.contentTextView.setIsScroll(value)
+        upBookmarkBadge()
     }
 
     /**

+ 192 - 2
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -2,20 +2,28 @@ package io.legado.app.ui.book.read.page
 
 import android.content.Context
 import android.graphics.Canvas
+import android.graphics.Color
 import android.graphics.RectF
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
 import android.os.Build
 import android.os.Bundle
 import android.util.AttributeSet
 import android.util.Log
+import android.view.Gravity
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
 import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
+import android.view.animation.DecelerateInterpolator
 import android.widget.FrameLayout
 import android.widget.TextView
+import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -42,7 +50,9 @@ import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.activity
+import io.legado.app.utils.dpToPx
 import io.legado.app.utils.invisible
+import io.legado.app.utils.statusBarHeight
 
 import io.legado.app.utils.throttle
 import java.text.BreakIterator
@@ -105,6 +115,54 @@ class ReadView(
     private var pressOnTextSelected = false
     private val initialTextPos = TextPos(0, 0, 0)
 
+    /**
+     * 本次手势**仍可能**是「下滑切换书签」。
+     *
+     * 只是候选而非认领:每帧 MOVE 都按当前位移重新判定,因此中途转为横向占优的斜划会
+     * 交还 [pageDelegate] 正常翻页,不会被吞掉。候选期间不喂 [pageDelegate],
+     * 否则横向委托会同时启动翻页动画。
+     */
+    private var isBookmarkSwipe = false
+
+    /**
+     * 手势已交还 [pageDelegate],本次触摸不再收回。
+     *
+     * 交还那一刻委托就可能起了翻页动画,中途再抢回来会留下一个停在半路的动画。
+     */
+    private var bookmarkSwipeReleased = false
+    private val bookmarkSwipeMinDistance by lazy { BOOKMARK_SWIPE_MIN_DISTANCE_DP.dpToPx() }
+
+    /**
+     * 本次触摸是否启用下滑书签手势。在 DOWN 时读一次开关(DataStore 快照重建较贵,
+     * 不宜放进逐帧的 [isBookmarkSwipeCandidate]),改设置在下次触摸生效。
+     */
+    private var bookmarkSwipeEnabled = false
+
+    /**
+     * 顶部「松手加入书签」提示条:下滑距离达标后出现,松手即执行切换。
+     */
+    private val bookmarkSwipeHint = TextView(context).apply {
+        text = context.getString(R.string.bookmark_swipe_release_to_add)
+        gravity = Gravity.CENTER
+        setTextColor(Color.WHITE)
+        textSize = 14f
+        includeFontPadding = false
+        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+        background = GradientDrawable().apply {
+            cornerRadius = BOOKMARK_SWIPE_HINT_CORNER_RADIUS_DP.dpToPx()
+            setColor(Color.argb(160, 0, 0, 0))
+        }
+        setPadding(16.dpToPx(), 8.dpToPx(), 16.dpToPx(), 8.dpToPx())
+        layoutParams = FrameLayout.LayoutParams(
+            FrameLayout.LayoutParams.WRAP_CONTENT,
+            FrameLayout.LayoutParams.WRAP_CONTENT,
+            Gravity.TOP or Gravity.CENTER_HORIZONTAL,
+        ).apply {
+            topMargin = BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP.dpToPx()
+        }
+        visibility = View.GONE
+    }
+
     private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
     private var pageSlopSquare: Int = slopSquare
     var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
@@ -134,6 +192,7 @@ class ReadView(
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
+        addView(bookmarkSwipeHint)
         // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
         // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
         listOf(prevPage, curPage, nextPage).forEach {
@@ -233,6 +292,8 @@ class ReadView(
                 postDelayed(longPressRunnable, longPressTimeout)
                 pressDown = true
                 isMove = false
+                bookmarkSwipeEnabled = ReadConfig.swipeToAddBookmark
+                resetBookmarkSwipe()
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onDown()
                 setStartPoint(event.x, event.y, false)
@@ -248,9 +309,21 @@ class ReadView(
                 if (isMove) {
                     longPressed = false
                     removeCallbacks(longPressRunnable)
+                    // 每帧按当前累计位移重新判定,方向翻转时才能交还 pageDelegate。
+                    if (!bookmarkSwipeReleased) {
+                        isBookmarkSwipe = isBookmarkSwipeCandidate(event.y - startY, absX, absY)
+                        if (!isBookmarkSwipe) {
+                            bookmarkSwipeReleased = true
+                            // 交还 pageDelegate 前把页面拉回原位,避免委托拿到被移位的 curPage。
+                            endBookmarkSwipeDrag()
+                        } else {
+                            // 页面随手指下移;到位后顶部亮起「松手加入书签」。
+                            updateBookmarkSwipeDrag(event.y - startY)
+                        }
+                    }
                     if (isTextSelected) {
                         selectText(event.x, event.y)
-                    } else {
+                    } else if (!isBookmarkSwipe) {
                         pageDelegate?.onTouch(event)
                     }
                 }
@@ -271,7 +344,16 @@ class ReadView(
                         return true
                     }
                 }
-                if (isTextSelected) {
+                if (isBookmarkSwipe) {
+                    // 以抬手时的最终位移复核:MOVE 之后手指还可能拐弯或回抽。
+                    val dy = event.y - startY
+                    if (dy >= bookmarkSwipeMinDistance &&
+                        isBookmarkSwipeCandidate(dy, abs(startX - event.x), abs(dy))
+                    ) {
+                        eventListener.onEvent(ReaderEvent.ToggleBookmark)
+                    }
+                    resetBookmarkSwipe()
+                } else if (isTextSelected) {
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
                     pageDelegate?.onTouch(event)
@@ -283,6 +365,7 @@ class ReadView(
                 removeCallbacks(longPressRunnable)
                 if (!pressDown) return true
                 pressDown = false
+                resetBookmarkSwipe()
                 if (isTextSelected) {
                     callBack.showTextActionMenu()
                 } else if (pageDelegate!!.isMoved) {
@@ -295,6 +378,77 @@ class ReadView(
         return true
     }
 
+    /**
+     * 当前位移是否仍算「下滑切换书签」:向下、且竖直位移压过水平位移。
+     *
+     * @param dy 相对按下点的竖直位移,向下为正
+     */
+    private fun isBookmarkSwipeCandidate(dy: Float, absX: Float, absY: Float): Boolean =
+        bookmarkSwipeEnabled && !isScroll && !isTextSelected &&
+            dy > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
+
+    /**
+     * 手势结束(抬手/取消):页面弹回原位、收起提示条。翻页委托不受影响。
+     */
+    private fun resetBookmarkSwipe() {
+        isBookmarkSwipe = false
+        bookmarkSwipeReleased = false
+        hideBookmarkSwipeHint()
+        curPage.animate().cancel()
+        curPage.animate()
+            .translationY(0f)
+            .setDuration(BOOKMARK_SWIPE_SPRING_BACK_MS)
+            .setInterpolator(DecelerateInterpolator())
+            .start()
+    }
+
+    /**
+     * 手势已交还 [pageDelegate](中途转为横向翻页):立即把页面拉回原位,
+     * 不起动画,避免与委托的翻页动画互相干扰。
+     */
+    private fun endBookmarkSwipeDrag() {
+        hideBookmarkSwipeHint()
+        curPage.animate().cancel()
+        curPage.translationY = 0f
+    }
+
+    /**
+     * 拖动跟随:页面随手指下移(封顶),达到最小距离后顶部亮起提示条。
+     *
+     * @param dy 相对按下点的竖直位移,向下为正
+     */
+    private fun updateBookmarkSwipeDrag(dy: Float) {
+        curPage.translationY = dy.coerceIn(0f, height.toFloat() * BOOKMARK_SWIPE_MAX_PULL_RATIO)
+        if (dy >= bookmarkSwipeMinDistance) {
+            showBookmarkSwipeHint()
+        } else {
+            hideBookmarkSwipeHint()
+        }
+    }
+
+    private fun showBookmarkSwipeHint() {
+        if (bookmarkSwipeHint.visibility != View.VISIBLE) {
+            // 提示条是 ReadView 的直接子 View,edge-to-edge 下会被状态栏盖住;
+            // 每次显示前按当前系统栏顶部 inset 垫高,状态栏隐藏(inset 为 0)时自然回落顶部。
+            bookmarkSwipeHint.updateLayoutParams<FrameLayout.LayoutParams> {
+                topMargin = bookmarkSwipeHintTopInset() + BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP.dpToPx()
+            }
+            bookmarkSwipeHint.visibility = View.VISIBLE
+        }
+    }
+
+    private fun bookmarkSwipeHintTopInset(): Int =
+        ViewCompat.getRootWindowInsets(this)
+            ?.getInsets(WindowInsetsCompat.Type.systemBars())
+            ?.top
+            ?: context.statusBarHeight
+
+    private fun hideBookmarkSwipeHint() {
+        if (bookmarkSwipeHint.visibility != View.GONE) {
+            bookmarkSwipeHint.visibility = View.GONE
+        }
+    }
+
     override fun performClick(): Boolean {
         super.performClick()
         return true
@@ -748,6 +902,13 @@ class ReadView(
         curPage.upBg()
         prevPage.upBg()
         nextPage.upBg()
+        // 与页面同底:下拉书签手势把 curPage 移开后,露出的区域仍是页面背景而非主题色。
+        background = LayerDrawable(
+            arrayOf(
+                ReadSessionState.backgroundMeanColor.toDrawable(),
+                ReadSessionState.background
+            )
+        )
     }
 
     /**
@@ -768,6 +929,15 @@ class ReadView(
         nextPage.upTime()
     }
 
+    /**
+     * 更新右上角书签角标
+     */
+    fun upBookmarkBadge() {
+        curPage.upBookmarkBadge()
+        prevPage.upBookmarkBadge()
+        nextPage.upBookmarkBadge()
+    }
+
     /**
      * 更新电量信息
      */
@@ -892,4 +1062,24 @@ class ReadView(
         fun showTextActionMenu()
         fun upSystemUiVisibility()
     }
+
+    private companion object {
+        /** 下滑切换书签所需的最小竖直位移。取得比翻页 slop 大,避免与轻微斜划抢手势。 */
+        const val BOOKMARK_SWIPE_MIN_DISTANCE_DP = 80f
+
+        /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
+        const val VERTICAL_DOMINANCE_RATIO = 1.5f
+
+        /** 顶部提示条相对页面顶部的距离。 */
+        const val BOOKMARK_SWIPE_HINT_TOP_MARGIN_DP = 12
+
+        /** 顶部提示条圆角半径。 */
+        const val BOOKMARK_SWIPE_HINT_CORNER_RADIUS_DP = 24f
+
+        /** 松手/取消后页面弹回原位的动画时长。 */
+        const val BOOKMARK_SWIPE_SPRING_BACK_MS = 180L
+
+        /** 下拉时页面最多跟随手指移动的比例,防止整页拖出屏幕。 */
+        const val BOOKMARK_SWIPE_MAX_PULL_RATIO = 0.6f
+    }
 }

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt

@@ -23,6 +23,9 @@ sealed interface ReaderEvent {
 
     data object AddBookmark : ReaderEvent
 
+    /** 下滑手势:切换当前页书签。 */
+    data object ToggleBookmark : ReaderEvent
+
     data object ChangeReplaceRuleState : ReaderEvent
 
     data object NextChapter : ReaderEvent

+ 9 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt

@@ -105,6 +105,9 @@ fun MoreConfigSheet(
                 onKeyPageOnLongPressChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.KeyPageOnLongPress(it)))
                 },
+                onSwipeToAddBookmarkChange = {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.SwipeToAddBookmark(it)))
+                },
             )
 
             // Other
@@ -235,6 +238,7 @@ private fun PageControlSettings(
     onVolumeKeyPageChange: (Boolean) -> Unit,
     onVolumeKeyPageOnPlayChange: (Boolean) -> Unit,
     onKeyPageOnLongPressChange: (Boolean) -> Unit,
+    onSwipeToAddBookmarkChange: (Boolean) -> Unit,
 ) {
     val doublePageEntries = stringArrayResource(R.array.double_page_title)
     val doublePageValues = stringArrayResource(R.array.double_page_value)
@@ -275,6 +279,11 @@ private fun PageControlSettings(
         checked = preferences.keyPageOnLongPress,
         onCheckedChange = onKeyPageOnLongPressChange,
     )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.swipe_to_add_bookmark),
+        checked = preferences.swipeToAddBookmark,
+        onCheckedChange = onSwipeToAddBookmarkChange,
+    )
 }
 
 @Composable

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -75,6 +75,7 @@ object ReadConfig {
     val volumeKeyPage get() = read.volumeKeyPage
     val volumeKeyPageOnPlay get() = read.volumeKeyPageOnPlay
     val keyPageOnLongPress get() = read.keyPageOnLongPress
+    val swipeToAddBookmark get() = read.swipeToAddBookmark
     val sliderVibrator get() = read.sliderVibrator
     val useNewTocSheet get() = read.useNewTocSheet
     val selectVibrator get() = read.selectVibrator

+ 11 - 0
app/src/main/res/drawable/ic_bookmark_badge.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 阅读页右上角书签角标:黄色书签丝带(底部 V 形缺口),模拟真实纸质书签。 -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="12dp"
+    android:height="24dp"
+    android:viewportWidth="12"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="#FFFFC107"
+        android:pathData="M2,2 L10,2 L10,22 L6,17 L2,22 Z" />
+</vector>

+ 12 - 0
app/src/main/res/layout/view_book_page.xml

@@ -73,6 +73,18 @@
         app:layout_constraintBottom_toTopOf="@id/vw_bottom_divider"
         app:layout_constraintTop_toBottomOf="@id/vw_top_divider" />
 
+    <ImageView
+        android:id="@+id/iv_bookmark_badge"
+        android:layout_width="10dp"
+        android:layout_height="20dp"
+        android:layout_marginTop="2dp"
+        android:layout_marginEnd="6dp"
+        android:contentDescription="@string/a11y_page_bookmarked"
+        android:src="@drawable/ic_bookmark_badge"
+        android:visibility="gone"
+        app:layout_constraintEnd_toEndOf="@id/content_text_view"
+        app:layout_constraintTop_toTopOf="@id/content_text_view" />
+
     <View
         android:id="@+id/vw_bottom_divider"
         android:layout_width="match_parent"

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

@@ -332,6 +332,11 @@
     <string name="group_yf">养肥区</string>
     <string name="bookmark">书签</string>
     <string name="bookmark_add">添加书签</string>
+    <string name="bookmark_added">已添加书签</string>
+    <string name="bookmark_removed">已取消书签</string>
+    <string name="swipe_to_add_bookmark">下滑添加书签</string>
+    <string name="bookmark_swipe_release_to_add">松手添加书签</string>
+    <string name="a11y_page_bookmarked">本页已添加书签</string>
     <string name="action_del">删除</string>
     <string name="load_over_time">加载超时</string>
     <string name="join_group">关注:%s</string>

+ 5 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -278,6 +278,11 @@
     <string name="group_yf">養肥區</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark_add">添加書籤</string>
+    <string name="bookmark_added">已添加書籤</string>
+    <string name="bookmark_removed">已取消書籤</string>
+    <string name="swipe_to_add_bookmark">下滑新增書籤</string>
+    <string name="bookmark_swipe_release_to_add">鬆手新增書籤</string>
+    <string name="a11y_page_bookmarked">本頁已添加書籤</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">加載超時</string>
     <string name="join_group">關注: %s</string>

+ 5 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -280,6 +280,11 @@
     <string name="group_yf">養肥區</string>
     <string name="bookmark">書籤</string>
     <string name="bookmark_add">新增書籤</string>
+    <string name="bookmark_added">已新增書籤</string>
+    <string name="bookmark_removed">已取消書籤</string>
+    <string name="swipe_to_add_bookmark">下滑新增書籤</string>
+    <string name="bookmark_swipe_release_to_add">鬆手新增書籤</string>
+    <string name="a11y_page_bookmarked">本頁已新增書籤</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">載入超時</string>
     <string name="join_group">關注:%s</string>

+ 5 - 0
app/src/main/res/values/strings.xml

@@ -362,6 +362,11 @@
     <string name="group_yf">Awaiting more</string>
     <string name="bookmark">Bookmarks</string>
     <string name="bookmark_add">Add to Bookmarks</string>
+    <string name="bookmark_added">Bookmark added</string>
+    <string name="bookmark_removed">Bookmark removed</string>
+    <string name="swipe_to_add_bookmark">Swipe down to add bookmark</string>
+    <string name="bookmark_swipe_release_to_add">Release to add bookmark</string>
+    <string name="a11y_page_bookmarked">This page is bookmarked</string>
     <string name="action_del">Delete</string>
     <string name="load_over_time">Loading timeout</string>
     <string name="join_group">Follow:%s</string>

+ 4 - 2
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -14,11 +14,11 @@ import kotlin.reflect.full.primaryConstructor
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 104 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 105 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(104, actualKeys.size)
+        assertEquals(105, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
@@ -202,6 +202,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(volumeKeyPage = false),
         base.copy(volumeKeyPageOnPlay = false),
         base.copy(keyPageOnLongPress = true),
+        base.copy(swipeToAddBookmark = true),
         base.copy(sliderVibrator = true),
         base.copy(selectVibrator = true),
         base.copy(autoChangeSource = false),
@@ -244,6 +245,7 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.volumeKeyPage to volumeKeyPage,
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
+    PreferKey.swipeToAddBookmark to swipeToAddBookmark,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.selectVibrator to selectVibrator,

+ 75 - 0
app/src/test/java/io/legado/app/model/ReaderBookmarkStateTest.kt

@@ -0,0 +1,75 @@
+package io.legado.app.model
+
+import io.legado.app.data.entities.Bookmark
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderBookmarkStateTest {
+
+    @After
+    fun tearDown() {
+        ReaderBookmarkState.clear()
+    }
+
+    private fun bookmark(
+        bookName: String = "book",
+        bookAuthor: String = "author",
+        chapterIndex: Int,
+        chapterPos: Int,
+    ) = Bookmark(
+        time = chapterIndex * 1000L + chapterPos,
+        bookName = bookName,
+        bookAuthor = bookAuthor,
+        chapterIndex = chapterIndex,
+        chapterPos = chapterPos,
+    )
+
+    @Test
+    fun `page start is inclusive and page end is exclusive`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        // 页首命中
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
+        // 上一页的尾界不应吞掉下一页的页首书签
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 0, endPos = 200))
+        // 下一页不应命中
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 400, endPos = 600))
+    }
+
+    @Test
+    fun `bookmarks are isolated per chapter`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 4, startPos = 200, endPos = 400))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 2, startPos = 200, endPos = 400))
+    }
+
+    @Test
+    fun `update replaces the previous snapshot`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 5, chapterPos = 10)))
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 5, startPos = 0, endPos = 100))
+    }
+
+    @Test
+    fun `book key mismatch does not serve bookmarks`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        // 另一本书在旧快照到达前查询:键不一致 → 视为无书签,不串台
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("otherBook", "author", 3, startPos = 200, endPos = 400))
+        // 同书同作者 → 命中
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
+    }
+
+    @Test
+    fun `clear drops every bookmark`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.clear()
+
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
+    }
+}

+ 1 - 0
app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt

@@ -127,6 +127,7 @@ class ConfigUpdateActionsInvariantTest {
             "VolumeKeyPage",
             "VolumeKeyPageOnPlay",
             "KeyPageOnLongPress",
+            "SwipeToAddBookmark",
             "SliderVibrator",
             "SelectVibrator",
             "ClickImgWay",

+ 22 - 4
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -78,16 +78,27 @@ class ReadBookDomainSplitBoundaryTest {
      * 再加两个对齐项,一个字段就是一行,纯派生、没有逻辑可摘。上游同批带来的
      * `useNewTocSheet` 分支(书籍信息/目录改开 Sheet)本来是两处复制粘贴,
      * 已合并成 `openBookNavigation()`,那部分没占额度。
+     *
+     * 2520 → 2523:下滑手势切换书签。新增的不是域,书签域早已是
+     * `ReadBookmarkDelegate`——切换判定、页范围计算、`ReaderBookmarkState` 快照的
+     * 订阅与清理全在该 delegate 里。留在 VM 的是 8 行纯接线,逐行都摘不掉:
+     * `bookKey` 的投影(`bookmarks` 表以书名+作者为关联键,只有 VM 持有 `_uiState`)、
+     * `Host.emitEffect` 的实现(`_effects` 只有 VM 能碰)、`start()` 与
+     * `ToggleBookmark` 的转发各一行,以及一个 `map` import。
+     *
+     * 2523 → 2533:分支基准移动了 10 行。2523 是 PR 基于更早的 main(VM 2515 行)定
+     * 的线;合入当前 main 后 VM 已 2525 行(上游页眉页脚对齐、书籍信息/目录改 Sheet
+     * 等),本 PR 的 8 行接线叠加为 2533。溢出全部来自上游合并,不是新长出来的域。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2520 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2533 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2520。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2533。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2520,
+            lineCount <= 2533,
         )
     }
 
@@ -208,7 +219,14 @@ class ReadBookDomainSplitBoundaryTest {
                 name = "书签",
                 delegateFile = "io/legado/app/ui/book/read/ReadBookmarkDelegate.kt",
                 stateFields = emptySet(),
-                stateTypes = listOf("bookmarkRepository.save", "bookmarkRepository.delete"),
+                // ReaderBookmarkState 是渲染层同步查角标用的快照:订阅 flowByBook 与
+                // 退出时清理都归本域,VM 只投影 bookKey。
+                stateTypes = listOf(
+                    "bookmarkRepository.save",
+                    "bookmarkRepository.delete",
+                    "bookmarkRepository.flowByBook",
+                    "ReaderBookmarkState",
+                ),
             ),
             // 样式域无自持状态:styleConfig 的重建由 VM 的 collectReadStyle() 统一驱动,
             // activeReminder / eyeProtection 被菜单栏直读;靠 stateTypes 守