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

feat(read): add custom header/footer template feature

实现自定义页眉页脚模板功能,支持6个位置的自定义模板,内置常用占位符并提供校验,可通过弹窗快速插入占位符来编辑模板内容
52fisher 3 месяцев назад
Родитель
Сommit
6e13fa8933

+ 67 - 0
app/src/main/java/io/legado/app/help/config/CustomTipPlaceholder.kt

@@ -0,0 +1,67 @@
+package io.legado.app.help.config
+
+import androidx.annotation.StringRes
+import io.legado.app.R
+
+/**
+ * 内置自定义页眉/页脚模板占位符。
+ *
+ * - [token] 是用户写在模板里的完整形式,例如 `{BookName}`,必须带花括号。
+ * - [key] 是 [token] 去括号后的内部字符串,例如 `BookName`,用于校验。
+ * - [labelRes] 是 chip / 提示中展示的本地化名,**不应**带花括号。
+ *
+ * 校验逻辑:先把模板中所有 `{Xxx}` 内部的 key 提取出来,
+ * 再与 [validKeys] 集合对比 —— 任何未在枚举中的 key 都会被拒绝。
+ */
+enum class CustomTipPlaceholder(
+    val token: String,
+    val key: String,
+    @StringRes val labelRes: Int,
+) {
+    BOOK_NAME("{BookName}", "BookName", R.string.placeholder_book_name),
+    CHAPTER_TITLE("{ChapterTitle}", "ChapterTitle", R.string.placeholder_chapter_title),
+    TIME("{Time}", "Time", R.string.placeholder_time),
+    BATTERY_PERCENT("{BatteryPercent}", "BatteryPercent", R.string.placeholder_battery_percent),
+    CHAPTER_INDEX("{ChapterIndex}", "ChapterIndex", R.string.placeholder_chapter_index),
+    CHAPTER_SIZE("{ChapterSize}", "ChapterSize", R.string.placeholder_chapter_size),
+    PAGE_INDEX("{PageIndex}", "PageIndex", R.string.placeholder_page_index),
+    PAGE_SIZE("{PageSize}", "PageSize", R.string.placeholder_page_size),
+    READ_PROGRESS("{ReadProgress}", "ReadProgress", R.string.placeholder_read_progress);
+
+    companion object {
+        /** 占位符 key(即花括号内的部分),用于校验。 */
+        private val validKeys: Set<String> = values().mapTo(hashSetOf()) { it.key }
+
+        /**
+         * 从模板中提取所有 `{Xxx}` 内部的 key。
+         * 解析规则:匹配 `{` 后面到下一个 `}` 之间的非空字符串。
+         * 未闭合的 `{` 会被忽略(不会作为 key 提取)。
+         */
+        fun extractPlaceholders(template: String): List<String> {
+            if (template.isEmpty()) return emptyList()
+            val result = mutableListOf<String>()
+            var i = 0
+            while (i < template.length) {
+                val open = template.indexOf('{', i)
+                if (open < 0) break
+                val close = template.indexOf('}', open + 1)
+                if (close < 0) break
+                val inside = template.substring(open + 1, close)
+                if (inside.isNotEmpty()) {
+                    result.add(inside)
+                }
+                i = close + 1
+            }
+            return result
+        }
+
+        /**
+         * 校验模板中所有 `{Xxx}` 是否都是预定义占位符。
+         *
+         * @return `true` 表示模板中无占位符或所有占位符都是预定义的;`false` 表示至少有一个未知占位符。
+         */
+        fun isValid(template: String): Boolean {
+            return extractPlaceholders(template).all { it in validKeys }
+        }
+    }
+}

+ 44 - 1
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -94,6 +94,7 @@ object ReadBookConfig {
     const val tipBatteryClassic = 15
     const val tipTimeBatteryClassic = 16
     const val tipChapterTitleArrowClassic = 17
+    const val tipCustom = 18
     // endregion
 
     const val configFileName = "readConfig.json"
@@ -752,6 +753,42 @@ object ReadBookConfig {
             config.tipFooterRight = value
         }
 
+    var customTipHeaderLeft: String
+        get() = config.customTipHeaderLeft
+        set(value) {
+            config.customTipHeaderLeft = value
+        }
+
+    var customTipHeaderMiddle: String
+        get() = config.customTipHeaderMiddle
+        set(value) {
+            config.customTipHeaderMiddle = value
+        }
+
+    var customTipHeaderRight: String
+        get() = config.customTipHeaderRight
+        set(value) {
+            config.customTipHeaderRight = value
+        }
+
+    var customTipFooterLeft: String
+        get() = config.customTipFooterLeft
+        set(value) {
+            config.customTipFooterLeft = value
+        }
+
+    var customTipFooterMiddle: String
+        get() = config.customTipFooterMiddle
+        set(value) {
+            config.customTipFooterMiddle = value
+        }
+
+    var customTipFooterRight: String
+        get() = config.customTipFooterRight
+        set(value) {
+            config.customTipFooterRight = value
+        }
+
     var headerMode: Int
         get() = config.headerMode
         set(value) {
@@ -804,7 +841,7 @@ object ReadBookConfig {
         tipNone, tipBookName, tipChapterTitle, tipChapterTitleArrow, tipChapterTitleArrowClassic,
         tipTime, tipBattery, tipBatteryClassic, tipBatteryInside, tipBatteryIcon, tipBatteryPercentage,
         tipPage, tipTotalProgress, tipTotalProgress1, tipPageAndTotal, tipTimeBattery,
-        tipTimeBatteryClassic, tipTimeBatteryPercentage
+        tipTimeBatteryClassic, tipTimeBatteryPercentage, tipCustom
     )
     val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList()
     val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList()
@@ -1000,6 +1037,12 @@ object ReadBookConfig {
         var tipFooterLeft: Int = tipChapterTitle,
         var tipFooterMiddle: Int = tipNone,
         var tipFooterRight: Int = tipPageAndTotal,
+        var customTipHeaderLeft: String = "",
+        var customTipHeaderMiddle: String = "",
+        var customTipHeaderRight: String = "",
+        var customTipFooterLeft: String = "",
+        var customTipFooterMiddle: String = "",
+        var customTipFooterRight: String = "",
         var tipHeaderColor: Int = 0,
         var tipHeaderColorNight: Int = 0,
         var tipFooterColor: Int = 0,

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

@@ -1028,6 +1028,24 @@ sealed interface ConfigUpdate {
     data class TipFooterRight(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
     }
+    data class CustomTipHeaderLeft(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipHeaderMiddle(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipHeaderRight(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterLeft(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterMiddle(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterRight(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
     data class HeaderFont(val path: String) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }

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

@@ -4489,6 +4489,12 @@ class ReadBookViewModel(
             is ConfigUpdate.TipFooterLeft -> ReadBookConfig.tipFooterLeft = update.value
             is ConfigUpdate.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = update.value
             is ConfigUpdate.TipFooterRight -> ReadBookConfig.tipFooterRight = update.value
+            is ConfigUpdate.CustomTipHeaderLeft -> ReadBookConfig.customTipHeaderLeft = update.value
+            is ConfigUpdate.CustomTipHeaderMiddle -> ReadBookConfig.customTipHeaderMiddle = update.value
+            is ConfigUpdate.CustomTipHeaderRight -> ReadBookConfig.customTipHeaderRight = update.value
+            is ConfigUpdate.CustomTipFooterLeft -> ReadBookConfig.customTipFooterLeft = update.value
+            is ConfigUpdate.CustomTipFooterMiddle -> ReadBookConfig.customTipFooterMiddle = update.value
+            is ConfigUpdate.CustomTipFooterRight -> ReadBookConfig.customTipFooterRight = update.value
             is ConfigUpdate.HeaderFont -> ReadBookConfig.headerFont = update.path
             is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value
             is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color

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

@@ -18,12 +18,14 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst.timeFormat
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.databinding.ViewBookPageBinding
+import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 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
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.sheet.CustomTipTarget
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.widget.BatteryView
 import androidx.core.view.updateLayoutParams
@@ -72,6 +74,8 @@ class PageView(
     private var isMainView = false
     var isScroll = false
 
+    private var currentTextPage: TextPage? = null
+
     val headerHeight: Int
         get() {
             val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
@@ -360,6 +364,31 @@ class PageView(
             typeface = tipTypeface
             textSize = tipTextSize
         }
+        // 自定义页眉/页脚模板:6 个位置共享同一段配置逻辑
+        val customTypeface = tipTypeface
+        val customTextSize = tipTextSize
+        for (target in CUSTOM_TIP_TARGETS) {
+            if (target.tipValue != ReadBookConfig.tipCustom) continue
+            val view = customTipViewFor(target)
+            view.tag = ReadBookConfig.tipCustom
+            view.typeface = customTypeface
+            view.textSize = customTextSize
+            view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
+        }
+    }
+
+    /**
+     * 根据 [target] 取得对应的页眉/页脚 [BatteryView] 实例。
+     */
+    private fun customTipViewFor(target: CustomTipTarget): BatteryView = binding.run {
+        when (target) {
+            CustomTipTarget.HEADER_LEFT -> tvHeaderLeft
+            CustomTipTarget.HEADER_MIDDLE -> tvHeaderMiddle
+            CustomTipTarget.HEADER_RIGHT -> tvHeaderRight
+            CustomTipTarget.FOOTER_LEFT -> tvFooterLeft
+            CustomTipTarget.FOOTER_MIDDLE -> tvFooterMiddle
+            CustomTipTarget.FOOTER_RIGHT -> tvFooterRight
+        }
     }
 
     /**
@@ -405,6 +434,7 @@ class PageView(
     fun upTime() {
         tvTime?.text = timeFormat.format(Date(System.currentTimeMillis()))
         upTimeBattery()
+        upCustomTip(currentTextPage)
     }
 
     /**
@@ -419,6 +449,7 @@ class PageView(
         tvBatteryIcon?.setBattery(battery)
         tvBatteryP?.text = "$battery%"
         upTimeBattery()
+        upCustomTip(currentTextPage)
     }
 
     /**
@@ -434,6 +465,66 @@ class PageView(
         tvTimeBatteryP?.text = "$time $battery%"
     }
 
+    /**
+     * 将所有启用自定义模板的页眉/页脚视图按当前 textPage 与最新时间/电池进行渲染。
+     * 当位置未启用 tipCustom 或模板为空时不做任何处理。
+     */
+    fun upCustomTip(textPage: TextPage?) {
+        val page = textPage ?: return
+        for (target in CUSTOM_TIP_TARGETS) {
+            if (target.tipValue != ReadBookConfig.tipCustom) continue
+            val template = target.customTemplate
+            val view = customTipViewFor(target)
+            if (template.isEmpty()) {
+                view.setTextIfNotEqual("")
+            } else {
+                view.setTextIfNotEqual(resolveCustomTemplate(template, page))
+            }
+        }
+    }
+
+    /**
+     * 将 [template] 中的预定义占位符替换为当前页面、时间、电池等实际值。
+     *
+     * 仅会替换 [CustomTipPlaceholder] 中枚举的合法 token;未识别的 `{Xxx}` 段将被原样保留
+     * (校验由 [CustomTipDialog] / [CustomTipPlaceholder.isValid] 在保存前完成)。
+     */
+    @SuppressLint("SetTextI18n")
+    private fun resolveCustomTemplate(template: String, textPage: TextPage): String {
+        if (template.isEmpty()) return ""
+        val time = timeFormat.format(Date(System.currentTimeMillis()))
+        val bookName = ReadBook.book?.name.orEmpty()
+        val chapterTitle = textPage.title
+        val chapterIndexDisplay = textPage.chapterIndex.plus(1).toString()
+        val chapterSizeDisplay = textPage.chapterSize.toString()
+        val pageIndexDisplay = textPage.index.plus(1).toString()
+        val pageSizeDisplay = if (textPage.textChapter.isCompleted) {
+            textPage.pageSize.toString()
+        } else {
+            val pageSizeInt = textPage.pageSize
+            if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
+        }
+        val readProgressDisplay = textPage.readProgress
+        // 使用本 PageView 实例持有的电池字段,值由 [upBattery] 在系统电量变化时持续刷新。
+        val batteryPercentDisplay = "$battery%"
+        var result = template
+        for (placeholder in CUSTOM_TIP_PLACEHOLDERS) {
+            val replacement = when (placeholder) {
+                CustomTipPlaceholder.BOOK_NAME -> bookName
+                CustomTipPlaceholder.CHAPTER_TITLE -> chapterTitle
+                CustomTipPlaceholder.TIME -> time
+                CustomTipPlaceholder.BATTERY_PERCENT -> batteryPercentDisplay
+                CustomTipPlaceholder.CHAPTER_INDEX -> chapterIndexDisplay
+                CustomTipPlaceholder.CHAPTER_SIZE -> chapterSizeDisplay
+                CustomTipPlaceholder.PAGE_INDEX -> pageIndexDisplay
+                CustomTipPlaceholder.PAGE_SIZE -> pageSizeDisplay
+                CustomTipPlaceholder.READ_PROGRESS -> readProgressDisplay
+            }
+            result = result.replace(placeholder.token, replacement)
+        }
+        return result
+    }
+
     /**
      * 设置内容
      */
@@ -467,6 +558,7 @@ class PageView(
      */
     @SuppressLint("SetTextI18n")
     fun setProgress(textPage: TextPage) = textPage.apply {
+        currentTextPage = textPage
         tvBookName?.setTextIfNotEqual(ReadBook.book?.name)
         tvTitle?.setTextIfNotEqual(textPage.title)
         tvTitleArrow?.setTextIfNotEqual(textPage.title)
@@ -483,6 +575,7 @@ class PageView(
             tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
             tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         }
+        upCustomTip(textPage)
         this@PageView.layoutSync()
     }
 
@@ -643,4 +736,13 @@ class PageView(
     val selectedText: String get() = binding.contentTextView.getSelectedText()
 
     val selectStartPos get() = binding.contentTextView.selectStart
+
+    companion object {
+        /**
+         * 缓存枚举数组,避免每次调用 `values()` 触发 Kotlin 内部数组克隆。
+         * 模板渲染在 `upCustomTip` / `upTime` / `upBattery` 等热路径上会反复触发。
+         */
+        private val CUSTOM_TIP_TARGETS: Array<CustomTipTarget> = CustomTipTarget.values()
+        private val CUSTOM_TIP_PLACEHOLDERS: Array<CustomTipPlaceholder> = CustomTipPlaceholder.values()
+    }
 }

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

@@ -0,0 +1,71 @@
+package io.legado.app.ui.book.read.sheet
+
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.ConfigUpdate
+import io.legado.app.ui.book.read.ReadBookIntent
+
+/**
+ * 标识页眉/页脚的 6 个位置之一,用于自定义模板编辑。
+ *
+ * 每个目标集中管理:
+ *  - [tipValue]:该位置在 [ReadBookConfig] 中保存的 tip 类型常量
+ *  - [customTemplate]:自定义模板字符串的 getter / setter(直接读写 [ReadBookConfig])
+ *  - [configUpdate]:持久化时使用的 [ConfigUpdate] 工厂
+ *  - [applyTemplate]:把模板保存到 [ReadBookConfig] 并派发 [ReadBookIntent.UpdateConfig]
+ *
+ * 这样的设计让 6 个位置的逻辑只在一处声明,避免散落各处的 if 链。
+ */
+internal enum class CustomTipTarget {
+    HEADER_LEFT,
+    HEADER_MIDDLE,
+    HEADER_RIGHT,
+    FOOTER_LEFT,
+    FOOTER_MIDDLE,
+    FOOTER_RIGHT;
+
+    /** 把模板字符串持久化到 [ReadBookConfig] 并通过 ViewModel 派发 [ConfigUpdate]。 */
+    fun applyTemplate(template: String, onIntent: (ReadBookIntent) -> Unit) {
+        customTemplate = template
+        onIntent(ReadBookIntent.UpdateConfig(configUpdate(template)))
+    }
+
+    private fun configUpdate(template: String): ConfigUpdate = when (this) {
+        HEADER_LEFT -> ConfigUpdate.CustomTipHeaderLeft(template)
+        HEADER_MIDDLE -> ConfigUpdate.CustomTipHeaderMiddle(template)
+        HEADER_RIGHT -> ConfigUpdate.CustomTipHeaderRight(template)
+        FOOTER_LEFT -> ConfigUpdate.CustomTipFooterLeft(template)
+        FOOTER_MIDDLE -> ConfigUpdate.CustomTipFooterMiddle(template)
+        FOOTER_RIGHT -> ConfigUpdate.CustomTipFooterRight(template)
+    }
+
+    /** 该位置当前选中的 tip 类型(如 [ReadBookConfig.tipCustom] / [ReadBookConfig.tipBookName] 等)。 */
+    val tipValue: Int
+        get() = when (this) {
+            HEADER_LEFT -> ReadBookConfig.tipHeaderLeft
+            HEADER_MIDDLE -> ReadBookConfig.tipHeaderMiddle
+            HEADER_RIGHT -> ReadBookConfig.tipHeaderRight
+            FOOTER_LEFT -> ReadBookConfig.tipFooterLeft
+            FOOTER_MIDDLE -> ReadBookConfig.tipFooterMiddle
+            FOOTER_RIGHT -> ReadBookConfig.tipFooterRight
+        }
+
+    var customTemplate: String
+        get() = when (this) {
+            HEADER_LEFT -> ReadBookConfig.customTipHeaderLeft
+            HEADER_MIDDLE -> ReadBookConfig.customTipHeaderMiddle
+            HEADER_RIGHT -> ReadBookConfig.customTipHeaderRight
+            FOOTER_LEFT -> ReadBookConfig.customTipFooterLeft
+            FOOTER_MIDDLE -> ReadBookConfig.customTipFooterMiddle
+            FOOTER_RIGHT -> ReadBookConfig.customTipFooterRight
+        }
+        set(value) {
+            when (this) {
+                HEADER_LEFT -> ReadBookConfig.customTipHeaderLeft = value
+                HEADER_MIDDLE -> ReadBookConfig.customTipHeaderMiddle = value
+                HEADER_RIGHT -> ReadBookConfig.customTipHeaderRight = value
+                FOOTER_LEFT -> ReadBookConfig.customTipFooterLeft = value
+                FOOTER_MIDDLE -> ReadBookConfig.customTipFooterMiddle = value
+                FOOTER_RIGHT -> ReadBookConfig.customTipFooterRight = value
+            }
+        }
+}

+ 59 - 30
app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt

@@ -52,6 +52,7 @@ import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
+import io.legado.app.ui.widget.components.dialog.CustomTipDialog
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.pager.pagerHeight
@@ -111,6 +112,9 @@ internal fun HeaderFooterPage(
     var footerMiddle by remember { mutableIntStateOf(ReadBookConfig.tipFooterMiddle) }
     var footerRight by remember { mutableIntStateOf(ReadBookConfig.tipFooterRight) }
 
+    // 对哪个位置正在编辑自定义模板(null 表示未打开弹窗)
+    var editingCustomTarget by remember { mutableStateOf<CustomTipTarget?>(null) }
+
     // Line toggles
     var showHeaderLine by remember { mutableStateOf(ReadBookConfig.showHeaderLine) }
     var showFooterLine by remember { mutableStateOf(ReadBookConfig.showFooterLine) }
@@ -190,6 +194,43 @@ internal fun HeaderFooterPage(
         }
     }
 
+    /**
+     * 当某个位置的 tip 被切换时统一处理:清空同值重复、更新本地 state、派发 ConfigUpdate,
+     * 必要时自动弹出自定义模板编辑弹窗。
+     */
+    fun handleTipChange(target: CustomTipTarget, value: Int) {
+        clearRepeat(value)
+        when (target) {
+            CustomTipTarget.HEADER_LEFT -> {
+                headerLeft = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(value)))
+            }
+            CustomTipTarget.HEADER_MIDDLE -> {
+                headerMiddle = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(value)))
+            }
+            CustomTipTarget.HEADER_RIGHT -> {
+                headerRight = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(value)))
+            }
+            CustomTipTarget.FOOTER_LEFT -> {
+                footerLeft = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(value)))
+            }
+            CustomTipTarget.FOOTER_MIDDLE -> {
+                footerMiddle = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(value)))
+            }
+            CustomTipTarget.FOOTER_RIGHT -> {
+                footerRight = value
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(value)))
+            }
+        }
+        if (value == ReadBookConfig.tipCustom) {
+            editingCustomTarget = target
+        }
+    }
+
     LaunchedEffect(pagerState) {
         snapshotFlow { pagerState.currentPage }.collect { page ->
             if (clickScrollCount == 0) {
@@ -270,33 +311,21 @@ internal fun HeaderFooterPage(
                             value = headerLeft,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                headerLeft = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_LEFT, it) },
                         )
                         TipPositionDropdown(
                             label = stringResource(R.string.middle),
                             value = headerMiddle,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                headerMiddle = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_MIDDLE, it) },
                         )
                         TipPositionDropdown(
                             label = stringResource(R.string.right),
                             value = headerRight,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                headerRight = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.HEADER_RIGHT, it) },
                         )
                         TinyColorModeSettingItem(
                             title = stringResource(R.string.header_color),
@@ -414,33 +443,21 @@ internal fun HeaderFooterPage(
                             value = footerLeft,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                footerLeft = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_LEFT, it) },
                         )
                         TipPositionDropdown(
                             label = stringResource(R.string.middle),
                             value = footerMiddle,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                footerMiddle = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_MIDDLE, it) },
                         )
                         TipPositionDropdown(
                             label = stringResource(R.string.right),
                             value = footerRight,
                             tipNames = tipNames,
                             tipValues = tipValues,
-                            onValueChange = {
-                                clearRepeat(it)
-                                footerRight = it
-                                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(it)))
-                            },
+                            onValueChange = { handleTipChange(CustomTipTarget.FOOTER_RIGHT, it) },
                         )
                         TinyColorModeSettingItem(
                             title = stringResource(R.string.footer_color),
@@ -623,6 +640,18 @@ internal fun HeaderFooterPage(
     }
     val systemTypefaces = stringArrayResource(R.array.system_typefaces)
 
+    val editingCustomInitial: String = editingCustomTarget?.customTemplate.orEmpty()
+
+    CustomTipDialog(
+        show = editingCustomTarget != null,
+        initialTemplate = editingCustomInitial,
+        onConfirm = { template ->
+            editingCustomTarget?.applyTemplate(template, onIntent)
+            editingCustomTarget = null
+        },
+        onDismissRequest = { editingCustomTarget = null },
+    )
+
     FontSelectSheet(
         show = showFontSelect,
         title = stringResource(R.string.select_font),

+ 120 - 0
app/src/main/java/io/legado/app/ui/widget/components/dialog/CustomTipDialog.kt

@@ -0,0 +1,120 @@
+package io.legado.app.ui.widget.components.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.help.config.CustomTipPlaceholder
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+
+/**
+ * 自定义页眉/页脚模板编辑弹窗。
+ *
+ * 用户在输入框中输入模板,点击 chip 可在**光标位置**插入预定义占位符。
+ * 点击"确定"前会先校验所有 `{Xxx}` 是否为预定义占位符;任何未知占位符都会使
+ * 确定按钮处于禁用状态,并显示错误提示。
+ *
+ * @param show 是否显示弹窗
+ * @param initialTemplate 初始模板字符串
+ * @param onConfirm 用户点击确定时回调,参数为校验通过后的模板字符串
+ * @param onDismissRequest 关闭弹窗回调(点击取消或外部)
+ */
+@Composable
+fun CustomTipDialog(
+    show: Boolean,
+    initialTemplate: String,
+    onConfirm: (String) -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    // 使用 TextFieldValue 携带光标位置,chip 点击时在光标处插入 token。
+    var template by remember(show, initialTemplate) {
+        mutableStateOf(TextFieldValue(initialTemplate, TextRange(initialTemplate.length)))
+    }
+
+    // 缓存成不可变 List,避免每次重组时调用 values() 触发数组克隆
+    val placeholders = remember { CustomTipPlaceholder.values().toList() }
+
+    // 校验:所有 {Xxx} 必须是预定义占位符
+    val isValid by remember(template) {
+        derivedStateOf { CustomTipPlaceholder.isValid(template.text) }
+    }
+
+    AppAlertDialog(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.custom_tip),
+        content = {
+            Column {
+                OutlinedTextField(
+                    value = template,
+                    onValueChange = { template = it },
+                    placeholder = { Text(stringResource(R.string.custom_tip_template_hint)) },
+                    isError = !isValid && template.text.isNotEmpty(),
+                    supportingText = {
+                        if (!isValid && template.text.isNotEmpty()) {
+                            Text(
+                                text = stringResource(R.string.custom_tip_invalid),
+                                color = MaterialTheme.colorScheme.error,
+                            )
+                        }
+                    },
+                    modifier = Modifier.fillMaxWidth(),
+                )
+                Spacer(Modifier.height(12.dp))
+                Text(
+                    text = stringResource(R.string.custom_tip_template_hint),
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                )
+                Spacer(Modifier.height(8.dp))
+                LazyRow(
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    contentPadding = PaddingValues(vertical = 4.dp),
+                ) {
+                    items(placeholders, key = { it.token }) { ph ->
+                        AssistChip(
+                            label = { Text(stringResource(ph.labelRes)) },
+                            onClick = { template = template.insertAtCursor(ph.token) },
+                        )
+                    }
+                }
+            }
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = if (isValid) {
+            { onConfirm(template.text) }
+        } else null,
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismissRequest,
+    )
+}
+
+/**
+ * 在 [TextFieldValue] 当前光标位置插入 [token],并把光标移到插入文本末尾。
+ */
+private fun TextFieldValue.insertAtCursor(token: String): TextFieldValue {
+    val cursor = selection.end.coerceIn(0, text.length)
+    val newText = text.substring(0, cursor) + token + text.substring(cursor)
+    val newCursor = cursor + token.length
+    return TextFieldValue(newText, TextRange(newCursor))
+}

+ 1 - 0
app/src/main/res/values-zh-rCN/arrays.xml

@@ -104,6 +104,7 @@
         <item>时间及电量</item>
         <item>经典电量及时间</item>
         <item>时间及电量%</item>
+        <item>自定义</item>
     </string-array>
 
     <string-array name="text_font_weight">

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

@@ -2316,6 +2316,18 @@
     <string name="custom_button">自定义按钮</string>
     <string name="is_event_listener">EventListener</string>
     <string name="rule_call_back">callBackJs</string>
+    <string name="custom_tip">自定义模板</string>
+    <string name="custom_tip_template_hint">使用占位符,如 {BookName}、{ChapterTitle}…</string>
+    <string name="custom_tip_invalid">模板包含未知占位符</string>
+    <string name="placeholder_book_name">书名</string>
+    <string name="placeholder_chapter_title">章节标题</string>
+    <string name="placeholder_time">时间</string>
+    <string name="placeholder_battery_percent">电量百分比</string>
+    <string name="placeholder_chapter_index">当前章节序号</string>
+    <string name="placeholder_chapter_size">章节总数</string>
+    <string name="placeholder_page_index">当前页码</string>
+    <string name="placeholder_page_size">本章页数</string>
+    <string name="placeholder_read_progress">阅读进度</string>
     <!-- Missing zh-rTW/HK translations (683 keys) -->
 
     <!-- 日夜间/深浅色主题切换提醒 -->

+ 1 - 0
app/src/main/res/values-zh-rHK/arrays.xml

@@ -73,6 +73,7 @@
         <item>時間同埋電量</item>
         <item>時間同埋電量經典</item>
         <item>時間同埋電量%</item>
+        <item>自訂</item>
     </string-array>
 
     <string-array name="text_font_weight">

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

@@ -1608,6 +1608,18 @@
     <string name="custom_button">自訂按鈕</string>
     <string name="custom_mode">自訂模式</string>
     <string name="custom_regex">自訂規則</string>
+    <string name="custom_tip">自訂範本</string>
+    <string name="custom_tip_template_hint">使用佔位符,如 {BookName}、{ChapterTitle}…</string>
+    <string name="custom_tip_invalid">範本包含未知佔位符</string>
+    <string name="placeholder_book_name">書名</string>
+    <string name="placeholder_chapter_title">章節標題</string>
+    <string name="placeholder_time">時間</string>
+    <string name="placeholder_battery_percent">電量百分比</string>
+    <string name="placeholder_chapter_index">當前章節序號</string>
+    <string name="placeholder_chapter_size">章節總數</string>
+    <string name="placeholder_page_index">當前頁碼</string>
+    <string name="placeholder_page_size">本章頁數</string>
+    <string name="placeholder_read_progress">閱讀進度</string>
     <string name="custom_regex_rule">自訂正則規則</string>
     <string name="custom_tag_colors">自訂標籤顏色</string>
     <string name="custom_theme">自訂主題</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/arrays.xml

@@ -110,6 +110,7 @@
         <item>時間及電量</item>
         <item>時間及電量经典</item>
         <item>時間及電量%</item>
+        <item>自訂</item>
     </string-array>
 
     <string-array name="text_font_weight">

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

@@ -1560,6 +1560,18 @@
     <string name="custom_button">自訂按鈕</string>
     <string name="rule_call_back">回呼 JS(callBackJs)</string>
     <string name="cover_rule_fields_required">搜尋 URL 和封面規則不能為空</string>
+    <string name="custom_tip">自訂範本</string>
+    <string name="custom_tip_template_hint">使用佔位符,如 {BookName}、{ChapterTitle}…</string>
+    <string name="custom_tip_invalid">範本包含未知佔位符</string>
+    <string name="placeholder_book_name">書名</string>
+    <string name="placeholder_chapter_title">章節標題</string>
+    <string name="placeholder_time">時間</string>
+    <string name="placeholder_battery_percent">電量百分比</string>
+    <string name="placeholder_chapter_index">當前章節序號</string>
+    <string name="placeholder_chapter_size">章節總數</string>
+    <string name="placeholder_page_index">當前頁碼</string>
+    <string name="placeholder_page_size">本章頁數</string>
+    <string name="placeholder_read_progress">閱讀進度</string>
     <string name="cover_selected_images_count">已選擇 %1$d 張圖片</string>
     <string name="confirm_skip_to_chapter">是否跳轉到此章節?</string>
     <string name="restore_progress">復原進度</string>

+ 1 - 0
app/src/main/res/values/arrays.xml

@@ -201,6 +201,7 @@
 		<item>Time and Battery</item>
 		<item>Time and Battery Classic</item>
 		<item>Time and Battery%</item>
+		<item>Custom</item>
 	</string-array>
 
 	<string-array name="text_font_weight">

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

@@ -1182,6 +1182,18 @@
     <string name="is_event_listener">EventListener</string>
     <string name="custom_button">Custom Button</string>
     <string name="rule_call_back">callBackJs</string>
+    <string name="custom_tip">Custom tip</string>
+    <string name="custom_tip_template_hint">Use placeholders like {BookName}, {ChapterTitle}&#8230;</string>
+    <string name="custom_tip_invalid">Template contains unknown placeholders</string>
+    <string name="placeholder_book_name">BookName</string>
+    <string name="placeholder_chapter_title">ChapterTitle</string>
+    <string name="placeholder_time">Time</string>
+    <string name="placeholder_battery_percent">BatteryPercent</string>
+    <string name="placeholder_chapter_index">ChapterIndex</string>
+    <string name="placeholder_chapter_size">ChapterSize</string>
+    <string name="placeholder_page_index">PageIndex</string>
+    <string name="placeholder_page_size">PageSize</string>
+    <string name="placeholder_read_progress">ReadProgress</string>
     <string name="sort_by_size">Sort by size</string>
     <string name="welcome_style">Launch interface style</string>
     <string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string>