Quellcode durchsuchen

feat: 下滑加书签支持拖动跟随、顶部提示与设置开关

- 阅读设置新增「下滑添加书签」开关(默认关闭):手势受开关门控,
  也规避了整页手势抢占斜向翻页的问题
- 手势期间当前页随手指下移(封顶 60%),达到 80dp 后顶部显示
  「松手添加书签」,松手执行切换;未到位/取消则弹回,不再误触发
- ReadView 补上与页面一致的背景,下拉露出的区域颜色统一
- 同步 ReadSettings 映射、MoreConfigSheet 开关、strings 与相关测试
  (ReadBookDomainSplitBoundaryTest 基线 2523→2533 为上游合入所致)
HapeLee vor 3 Monaten
Ursprung
Commit
86f1a9686f

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

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

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

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

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

@@ -1521,6 +1521,9 @@ sealed interface ConfigUpdate {
     data class KeyPageOnLongPress(val value: Boolean) : ConfigUpdate {
     data class KeyPageOnLongPress(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
+    data class SwipeToAddBookmark(val value: Boolean) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class SliderVibrator(val value: Boolean) : ConfigUpdate {
     data class SliderVibrator(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }

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

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

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

@@ -2,19 +2,25 @@ package io.legado.app.ui.book.read.page
 
 
 import android.content.Context
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.Canvas
+import android.graphics.Color
 import android.graphics.RectF
 import android.graphics.RectF
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
 import android.os.Build
 import android.os.Build
 import android.os.Bundle
 import android.os.Bundle
 import android.util.AttributeSet
 import android.util.AttributeSet
 import android.util.Log
 import android.util.Log
+import android.view.Gravity
 import android.view.MotionEvent
 import android.view.MotionEvent
 import android.view.View
 import android.view.View
 import android.view.ViewConfiguration
 import android.view.ViewConfiguration
 import android.view.WindowInsets
 import android.view.WindowInsets
 import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityEvent
 import android.view.accessibility.AccessibilityNodeInfo
 import android.view.accessibility.AccessibilityNodeInfo
+import android.view.animation.DecelerateInterpolator
 import android.widget.FrameLayout
 import android.widget.FrameLayout
 import android.widget.TextView
 import android.widget.TextView
+import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.ViewCompat
 import androidx.core.view.ViewCompat
 import io.legado.app.BuildConfig
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.R
@@ -123,6 +129,37 @@ class ReadView(
     private var bookmarkSwipeReleased = false
     private var bookmarkSwipeReleased = false
     private val bookmarkSwipeMinDistance by lazy { BOOKMARK_SWIPE_MIN_DISTANCE_DP.dpToPx() }
     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 val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
     private var pageSlopSquare: Int = slopSquare
     private var pageSlopSquare: Int = slopSquare
     var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
     var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
@@ -152,6 +189,7 @@ class ReadView(
         addView(nextPage)
         addView(nextPage)
         addView(curPage)
         addView(curPage)
         addView(prevPage)
         addView(prevPage)
+        addView(bookmarkSwipeHint)
         // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
         // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
         // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
         // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
         listOf(prevPage, curPage, nextPage).forEach {
         listOf(prevPage, curPage, nextPage).forEach {
@@ -251,6 +289,7 @@ class ReadView(
                 postDelayed(longPressRunnable, longPressTimeout)
                 postDelayed(longPressRunnable, longPressTimeout)
                 pressDown = true
                 pressDown = true
                 isMove = false
                 isMove = false
+                bookmarkSwipeEnabled = ReadConfig.swipeToAddBookmark
                 resetBookmarkSwipe()
                 resetBookmarkSwipe()
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onTouch(event)
                 pageDelegate?.onDown()
                 pageDelegate?.onDown()
@@ -270,7 +309,14 @@ class ReadView(
                     // 每帧按当前累计位移重新判定,方向翻转时才能交还 pageDelegate。
                     // 每帧按当前累计位移重新判定,方向翻转时才能交还 pageDelegate。
                     if (!bookmarkSwipeReleased) {
                     if (!bookmarkSwipeReleased) {
                         isBookmarkSwipe = isBookmarkSwipeCandidate(event.y - startY, absX, absY)
                         isBookmarkSwipe = isBookmarkSwipeCandidate(event.y - startY, absX, absY)
-                        if (!isBookmarkSwipe) bookmarkSwipeReleased = true
+                        if (!isBookmarkSwipe) {
+                            bookmarkSwipeReleased = true
+                            // 交还 pageDelegate 前把页面拉回原位,避免委托拿到被移位的 curPage。
+                            endBookmarkSwipeDrag()
+                        } else {
+                            // 页面随手指下移;到位后顶部亮起「松手加入书签」。
+                            updateBookmarkSwipeDrag(event.y - startY)
+                        }
                     }
                     }
                     if (isTextSelected) {
                     if (isTextSelected) {
                         selectText(event.x, event.y)
                         selectText(event.x, event.y)
@@ -335,11 +381,58 @@ class ReadView(
      * @param dy 相对按下点的竖直位移,向下为正
      * @param dy 相对按下点的竖直位移,向下为正
      */
      */
     private fun isBookmarkSwipeCandidate(dy: Float, absX: Float, absY: Float): Boolean =
     private fun isBookmarkSwipeCandidate(dy: Float, absX: Float, absY: Float): Boolean =
-        !isScroll && !isTextSelected && dy > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
+        bookmarkSwipeEnabled && !isScroll && !isTextSelected &&
+            dy > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
 
 
+    /**
+     * 手势结束(抬手/取消):页面弹回原位、收起提示条。翻页委托不受影响。
+     */
     private fun resetBookmarkSwipe() {
     private fun resetBookmarkSwipe() {
         isBookmarkSwipe = false
         isBookmarkSwipe = false
         bookmarkSwipeReleased = 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) {
+            bookmarkSwipeHint.visibility = View.VISIBLE
+        }
+    }
+
+    private fun hideBookmarkSwipeHint() {
+        if (bookmarkSwipeHint.visibility != View.GONE) {
+            bookmarkSwipeHint.visibility = View.GONE
+        }
     }
     }
 
 
     override fun performClick(): Boolean {
     override fun performClick(): Boolean {
@@ -795,6 +888,13 @@ class ReadView(
         curPage.upBg()
         curPage.upBg()
         prevPage.upBg()
         prevPage.upBg()
         nextPage.upBg()
         nextPage.upBg()
+        // 与页面同底:下拉书签手势把 curPage 移开后,露出的区域仍是页面背景而非主题色。
+        background = LayerDrawable(
+            arrayOf(
+                ReadSessionState.backgroundMeanColor.toDrawable(),
+                ReadSessionState.background
+            )
+        )
     }
     }
 
 
     /**
     /**
@@ -955,5 +1055,17 @@ class ReadView(
 
 
         /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
         /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
         const val VERTICAL_DOMINANCE_RATIO = 1.5f
         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
     }
     }
 }
 }

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

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

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

@@ -334,6 +334,8 @@
     <string name="bookmark_add">添加书签</string>
     <string name="bookmark_add">添加书签</string>
     <string name="bookmark_added">已添加书签</string>
     <string name="bookmark_added">已添加书签</string>
     <string name="bookmark_removed">已取消书签</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="a11y_page_bookmarked">本页已添加书签</string>
     <string name="action_del">删除</string>
     <string name="action_del">删除</string>
     <string name="load_over_time">加载超时</string>
     <string name="load_over_time">加载超时</string>

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

@@ -280,6 +280,8 @@
     <string name="bookmark_add">添加書籤</string>
     <string name="bookmark_add">添加書籤</string>
     <string name="bookmark_added">已添加書籤</string>
     <string name="bookmark_added">已添加書籤</string>
     <string name="bookmark_removed">已取消書籤</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="a11y_page_bookmarked">本頁已添加書籤</string>
     <string name="action_del">刪除</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">加載超時</string>
     <string name="load_over_time">加載超時</string>

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

@@ -282,6 +282,8 @@
     <string name="bookmark_add">新增書籤</string>
     <string name="bookmark_add">新增書籤</string>
     <string name="bookmark_added">已新增書籤</string>
     <string name="bookmark_added">已新增書籤</string>
     <string name="bookmark_removed">已取消書籤</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="a11y_page_bookmarked">本頁已新增書籤</string>
     <string name="action_del">刪除</string>
     <string name="action_del">刪除</string>
     <string name="load_over_time">載入超時</string>
     <string name="load_over_time">載入超時</string>

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

@@ -364,6 +364,8 @@
     <string name="bookmark_add">Add to Bookmarks</string>
     <string name="bookmark_add">Add to Bookmarks</string>
     <string name="bookmark_added">Bookmark added</string>
     <string name="bookmark_added">Bookmark added</string>
     <string name="bookmark_removed">Bookmark removed</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="a11y_page_bookmarked">This page is bookmarked</string>
     <string name="action_del">Delete</string>
     <string name="action_del">Delete</string>
     <string name="load_over_time">Loading timeout</string>
     <string name="load_over_time">Loading timeout</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 {
 class ReadSettingsMappingTest {
 
 
     @Test
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 104 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 105 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
 
-        assertEquals(104, actualKeys.size)
+        assertEquals(105, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
         assertEquals(expectedKeys, actualKeys)
     }
     }
 
 
@@ -202,6 +202,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(volumeKeyPage = false),
         base.copy(volumeKeyPage = false),
         base.copy(volumeKeyPageOnPlay = false),
         base.copy(volumeKeyPageOnPlay = false),
         base.copy(keyPageOnLongPress = true),
         base.copy(keyPageOnLongPress = true),
+        base.copy(swipeToAddBookmark = true),
         base.copy(sliderVibrator = true),
         base.copy(sliderVibrator = true),
         base.copy(selectVibrator = true),
         base.copy(selectVibrator = true),
         base.copy(autoChangeSource = false),
         base.copy(autoChangeSource = false),
@@ -244,6 +245,7 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.volumeKeyPage to volumeKeyPage,
     PreferKey.volumeKeyPage to volumeKeyPage,
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
     PreferKey.keyPageOnLongPress to keyPageOnLongPress,
+    PreferKey.swipeToAddBookmark to swipeToAddBookmark,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.pageTouchSlop to pageTouchSlop,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.sliderVibrator to sliderVibrator,
     PreferKey.selectVibrator to selectVibrator,
     PreferKey.selectVibrator to selectVibrator,

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

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

+ 7 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -85,16 +85,20 @@ class ReadBookDomainSplitBoundaryTest {
      * `bookKey` 的投影(`bookmarks` 表以书名+作者为关联键,只有 VM 持有 `_uiState`)、
      * `bookKey` 的投影(`bookmarks` 表以书名+作者为关联键,只有 VM 持有 `_uiState`)、
      * `Host.emitEffect` 的实现(`_effects` 只有 VM 能碰)、`start()` 与
      * `Host.emitEffect` 的实现(`_effects` 只有 VM 能碰)、`start()` 与
      * `ToggleBookmark` 的转发各一行,以及一个 `map` import。
      * `ToggleBookmark` 的转发各一行,以及一个 `map` import。
+     *
+     * 2523 → 2533:分支基准移动了 10 行。2523 是 PR 基于更早的 main(VM 2515 行)定
+     * 的线;合入当前 main 后 VM 已 2525 行(上游页眉页脚对齐、书籍信息/目录改 Sheet
+     * 等),本 PR 的 8 行接线叠加为 2533。溢出全部来自上游合并,不是新长出来的域。
      */
      */
     @Test
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2523 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2533 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
             .readLines().size
         assertTrue(
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2523。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2533。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2523,
+            lineCount <= 2533,
         )
         )
     }
     }