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

fix: resolve PR 1897 migration and preserve variable font weight

HapeLee 3 месяцев назад
Родитель
Сommit
2e51d7031a
78 измененных файлов с 4121 добавлено и 4412 удалено
  1. 40 4
      app/schemas/io.legado.app.data.AppDatabase/99.json
  2. 6 0
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  3. 7 0
      app/src/main/java/io/legado/app/data/entities/HighlightRule.kt
  4. 7 1
      app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt
  5. 10 0
      app/src/main/java/io/legado/app/di/appModule.kt
  6. 98 33
      app/src/main/java/io/legado/app/model/Debug.kt
  7. 2 2
      app/src/main/java/io/legado/app/model/webBook/BookList.kt
  8. 5 5
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  9. 3 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  10. 93 45
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  11. 2 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  12. 166 7
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt
  13. 6 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt
  14. 23 4
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  15. 6 0
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt
  16. 7 1
      app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt
  17. 192 29
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  18. 207 15
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt
  19. 75 8
      app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyTabs.kt
  20. 2 1
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt
  21. 26 226
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt
  22. 0 44
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt
  23. 56 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugContract.kt
  24. 0 60
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt
  25. 316 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugScreen.kt
  26. 150 0
      app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugViewModel.kt
  27. 88 693
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt
  28. 0 100
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt
  29. 88 0
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditContract.kt
  30. 409 0
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditScreen.kt
  31. 329 100
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt
  32. 31 779
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt
  33. 67 0
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt
  34. 507 0
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt
  35. 232 256
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt
  36. 0 137
      app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt
  37. 18 33
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  38. 96 143
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt
  39. 6 1
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt
  40. 2 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  41. 1 2
      app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt
  42. 2 2
      app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
  43. 2 2
      app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt
  44. 15 130
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt
  45. 0 44
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt
  46. 40 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugContract.kt
  47. 0 59
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt
  48. 193 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugScreen.kt
  49. 97 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugViewModel.kt
  50. 27 454
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt
  51. 0 132
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt
  52. 49 0
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditContract.kt
  53. 77 0
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditScreen.kt
  54. 64 100
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt
  55. 3 9
      app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt
  56. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt
  57. 3 3
      app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt
  58. 39 28
      app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt
  59. 6 4
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt
  60. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt
  61. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt
  62. 5 14
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt
  63. 37 118
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt
  64. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt
  65. 8 2
      app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
  66. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt
  67. 1 2
      app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
  68. 12 24
      app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt
  69. 12 23
      app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt
  70. 0 127
      app/src/main/res/layout/activity_book_source_edit.xml
  71. 0 129
      app/src/main/res/layout/activity_rss_source_edit.xml
  72. 0 196
      app/src/main/res/layout/activity_source_debug.xml
  73. 0 43
      app/src/main/res/menu/book_source_debug.xml
  74. 0 17
      app/src/main/res/menu/rss_source_debug.xml
  75. 10 1
      app/src/main/res/values-zh-rCN/strings.xml
  76. 10 1
      app/src/main/res/values-zh-rHK/strings.xml
  77. 10 1
      app/src/main/res/values-zh-rTW/strings.xml
  78. 11 1
      app/src/main/res/values/strings.xml

+ 40 - 4
app/schemas/io.legado.app.data.AppDatabase/99.json

@@ -2,7 +2,7 @@
   "formatVersion": 1,
   "database": {
     "version": 99,
-    "identityHash": "03f1075dce341269734c0b0d9f1cbc55",
+    "identityHash": "0458658832e21f9dde1ecae6a827691f",
     "entities": [
       {
         "tableName": "books",
@@ -2306,7 +2306,7 @@
       },
       {
         "tableName": "highlightRules",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, PRIMARY KEY(`id`))",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, `fontWeight` INTEGER NOT NULL, `isItalic` INTEGER NOT NULL, `npLeft` REAL NOT NULL, `npRight` REAL NOT NULL, `npTop` REAL NOT NULL, `npBottom` REAL NOT NULL, PRIMARY KEY(`id`))",
         "fields": [
           {
             "fieldPath": "id",
@@ -2414,6 +2414,42 @@
             "fieldPath": "fontPath",
             "columnName": "fontPath",
             "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontWeight",
+            "columnName": "fontWeight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isItalic",
+            "columnName": "isItalic",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npLeft",
+            "columnName": "npLeft",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npRight",
+            "columnName": "npRight",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npTop",
+            "columnName": "npTop",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "npBottom",
+            "columnName": "npBottom",
+            "affinity": "REAL",
+            "notNull": true
           }
         ],
         "primaryKey": {
@@ -4626,7 +4662,7 @@
     ],
     "setupQueries": [
       "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
-      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '03f1075dce341269734c0b0d9f1cbc55')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0458658832e21f9dde1ecae6a827691f')"
     ]
   }
-}
+}

+ 6 - 0
app/src/main/java/io/legado/app/data/DatabaseMigrations.kt

@@ -521,6 +521,12 @@ object DatabaseMigrations {
             )
             database.execSQL("DROP TABLE txtTocRules")
             database.execSQL("ALTER TABLE txtTocRules_new RENAME TO txtTocRules")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN fontWeight INTEGER NOT NULL DEFAULT 400")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN isItalic INTEGER NOT NULL DEFAULT 0")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npLeft REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npRight REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npTop REAL NOT NULL DEFAULT 0.1")
+            database.execSQL("ALTER TABLE highlightRules ADD COLUMN npBottom REAL NOT NULL DEFAULT 0.1")
         }
     }
 }

+ 7 - 0
app/src/main/java/io/legado/app/data/entities/HighlightRule.kt

@@ -26,6 +26,12 @@ data class HighlightRule(
     var bgImageScale: Float = 1f,
     var configName: String? = null,
     var fontPath: String? = null,
+    var fontWeight: Int = 400,
+    var isItalic: Boolean = false,
+    var npLeft: Float = 0.1f,
+    var npRight: Float = 0.1f,
+    var npTop: Float = 0.1f,
+    var npBottom: Float = 0.1f,
 ) {
 
     fun styleSummary(): String {
@@ -54,6 +60,7 @@ data class HighlightRule(
                 when (bgImageFit) {
                     1 -> "背景图(拉伸)"
                     2 -> "背景图(裁剪)"
+                    3 -> "背景图(九宫格)"
                     else -> "背景图(平铺)"
                 }
             )

+ 7 - 1
app/src/main/java/io/legado/app/data/repository/HighlightRuleRepository.kt

@@ -170,10 +170,16 @@ class HighlightRuleRepository(
                 .coerceIn(0f, 20f),
             underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(),
             bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() },
-            bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2),
+            bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 3),
             bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f),
             configName = runCatching { rule.configName }.getOrNull()?.takeIf { it.isNotBlank() },
             fontPath = runCatching { rule.fontPath }.getOrNull()?.takeIf { it.isNotBlank() },
+            fontWeight = runCatching { rule.fontWeight }.getOrDefault(400).coerceIn(300, 700),
+            isItalic = runCatching { rule.isItalic }.getOrDefault(false),
+            npLeft = runCatching { rule.npLeft }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npRight = runCatching { rule.npRight }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npTop = runCatching { rule.npTop }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
+            npBottom = runCatching { rule.npBottom }.getOrDefault(0.1f).coerceIn(0f, 0.5f),
         )
     }
 

+ 10 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -221,6 +221,11 @@ import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
+import io.legado.app.ui.book.source.debug.BookSourceDebugViewModel
+import io.legado.app.ui.book.source.manage.BookSourceViewModel
+import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
+import io.legado.app.ui.rss.source.debug.RssSourceDebugViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
@@ -447,6 +452,11 @@ val appModule = module {
     viewModelOf(::TagGroupRuleViewModel)
     viewModelOf(::DictViewModel)
     viewModelOf(::RssSourceViewModel)
+    viewModelOf(::BookSourceViewModel)
+    viewModelOf(::BookSourceEditViewModel)
+    viewModelOf(::BookSourceDebugViewModel)
+    viewModelOf(::RssSourceEditViewModel)
+    viewModelOf(::RssSourceDebugViewModel)
     viewModelOf(::RssSortViewModel)
     viewModelOf(::RssArticlesViewModel)
     viewModelOf(::ReadRssViewModel)

+ 98 - 33
app/src/main/java/io/legado/app/model/Debug.kt

@@ -14,11 +14,16 @@ import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.stackTraceStr
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import java.util.concurrent.atomic.AtomicLong
 import java.text.SimpleDateFormat
 import java.util.*
 
 object Debug {
-    var callback: Callback? = null
+    private val nextSessionId = AtomicLong()
+    private var activeSession: Session? = null
     private var debugSource: String? = null
     private val tasks: CompositeCoroutine = CompositeCoroutine()
     val debugMessageMap = HashMap<String, String>()
@@ -42,17 +47,28 @@ object Debug {
             Log.d("sourceDebug", msg)
         }
         //调试信息始终要执行
-        callback?.let {
-            if ((debugSource != sourceUrl || !print)) return
-            var printMsg = msg
-            if (isHtml) {
-                printMsg = HtmlFormatter.format(msg)
-            }
+        val event = Event(
+            kind = when (state) {
+                -1 -> EventKind.Error
+                10 -> EventKind.SearchSource
+                20 -> EventKind.InfoSource
+                30 -> EventKind.TocSource
+                40 -> EventKind.ContentSource
+                1000 -> EventKind.Completed
+                else -> EventKind.Message
+            },
+            message = if (isHtml) HtmlFormatter.format(msg) else msg,
+            timestamp = System.currentTimeMillis(),
+            elapsedMillis = System.currentTimeMillis() - startTime,
+        )
+        if (debugSource == sourceUrl && print) {
+            var printMsg = event.message
             if (showTime) {
                 val time = debugTimeFormat.format(Date(System.currentTimeMillis() - startTime))
                 printMsg = "$time $printMsg"
             }
-            it.printLog(state, printMsg)
+            val structuredEvent = event.copy(message = printMsg)
+            activeSession?.emit(structuredEvent)
         }
         if (isChecking && sourceUrl != null && (msg).length < 30) {
             var printMsg = msg
@@ -71,18 +87,30 @@ object Debug {
 
     @Synchronized
     fun log(msg: String?) {
-        log(debugSource, msg ?: "", true)
+        log(debugSource, if (msg == null) "" else msg, true)
     }
 
-    fun cancelDebug(destroy: Boolean = false) {
+    @Synchronized
+    private fun replaceSession(sourceUrl: String): Session {
         tasks.clear()
+        activeSession?.close()
+        debugSource = sourceUrl
+        startTime = System.currentTimeMillis()
+        return Session(nextSessionId.incrementAndGet(), sourceUrl).also { activeSession = it }
+    }
 
-        if (destroy) {
-            debugSource = null
-            callback = null
-        }
+    @Synchronized
+    private fun cancel(session: Session) {
+        if (activeSession?.id != session.id) return
+        tasks.clear()
+        activeSession = null
+        debugSource = null
+        session.close()
     }
 
+    val hasActiveSession: Boolean
+        @Synchronized get() = activeSession != null
+
     fun startChecking(source: BookSource) {
         isChecking = true
         debugTimeMap[source.bookSourceUrl] = System.currentTimeMillis()
@@ -94,7 +122,8 @@ object Debug {
     }
 
     fun getRespondTime(sourceUrl: String): Long {
-        return debugTimeMap[sourceUrl] ?: CheckSource.timeout
+        val responseTime = debugTimeMap[sourceUrl]
+        return if (responseTime == null) CheckSource.timeout else responseTime
     }
 
     fun updateFinalMessage(sourceUrl: String, state: String) {
@@ -107,11 +136,13 @@ object Debug {
         }
     }
 
-    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
+    suspend fun startDebug(scope: CoroutineScope, rssSource: RssSource): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         log(debugSource, "︾开始解析")
-        val sort = rssSource.sortUrls().first()
+        val sort = runCatching { rssSource.sortUrls().first() }.getOrElse {
+            log(debugSource, it.stackTraceStr, state = -1)
+            return session
+        }
         Rss.getArticles(scope, sort.first, sort.second, rssSource, 1)
             .onSuccess {
                 if (it.first.isEmpty()) {
@@ -136,12 +167,11 @@ object Debug {
             .onError {
                 log(debugSource, it.stackTraceStr, state = -1)
             }
+        return session
     }
 
-    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) {
-        cancelDebug()
-        debugSource = rssSource.sourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String): Session {
+        val session = replaceSession(rssSource.sourceUrl)
         when {
             key.contains("@js:") -> {
                 val ruleContent = rssSource.ruleContent
@@ -186,13 +216,14 @@ object Debug {
                 val searchUrl = rssSource.searchUrl
                 if (searchUrl.isNullOrEmpty()) {
                     log(debugSource, "⇒搜索URL为空", state = -1)
-                    return
+                    return session
                 }
                 log(debugSource, "⇒开始搜索关键字:$key")
                 log(debugSource, "︾开始解析搜索页")
                 rssSortDebug(scope, rssSource, "搜索", searchUrl, key)
             }
         }
+        return session
     }
 
     private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) {
@@ -239,10 +270,8 @@ object Debug {
             }
     }
 
-    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String) {
-        cancelDebug()
-        debugSource = bookSource.bookSourceUrl
-        startTime = System.currentTimeMillis()
+    fun startDebug(scope: CoroutineScope, bookSource: BookSource, key: String): Session {
+        val session = replaceSession(bookSource.bookSourceUrl)
         when {
             key.isAbsUrl() -> {
                 val book = Book()
@@ -283,6 +312,7 @@ object Debug {
                 searchDebug(scope, bookSource, key)
             }
         }
+        return session
     }
 
     private fun exploreDebug(scope: CoroutineScope, bookSource: BookSource, url: String) {
@@ -353,10 +383,11 @@ object Debug {
                 log(debugSource, showTime = false)
                 val toc = chapters.filter { !(it.isVolume && it.url.startsWith(it.title)) }
                 if (toc.isEmpty()) {
-                    log(debugSource, "≡没有正文章节")
+                    log(debugSource, "≡没有正文章节", state = -1)
                     return@onSuccess
                 }
-                val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
+                val secondChapter = toc.getOrNull(1)
+                val nextChapterUrl = if (secondChapter == null) toc.first().url else secondChapter.url
                 contentDebug(scope, bookSource, book, toc.first(), nextChapterUrl)
             }
             .onError {
@@ -388,7 +419,41 @@ object Debug {
         tasks.add(content)
     }
 
-    interface Callback {
-        fun printLog(state: Int, msg: String)
+    enum class EventKind {
+        Message, SearchSource, InfoSource, TocSource, ContentSource, Error, Completed;
+
+        val isSourcePayload: Boolean
+            get() = this == SearchSource || this == InfoSource || this == TocSource || this == ContentSource
+        val isTerminal: Boolean get() = this == Error || this == Completed
+    }
+
+    data class Event(
+        val kind: EventKind,
+        val message: String,
+        val timestamp: Long,
+        val elapsedMillis: Long,
+    )
+
+    class Session internal constructor(
+        internal val id: Long,
+        val sourceUrl: String,
+    ) {
+        private val channel = Channel<Event>(Channel.UNLIMITED)
+        val events: Flow<Event> = channel.receiveAsFlow()
+
+        internal fun emit(event: Event) {
+            channel.trySend(event)
+            if (event.kind == EventKind.Error || event.kind == EventKind.Completed) {
+                if (activeSession?.id == id) {
+                    activeSession = null
+                    debugSource = null
+                }
+                close()
+            }
+        }
+
+        internal fun close() { channel.close() }
+
+        fun cancel() { Debug.cancel(this) }
     }
-}
+}

+ 2 - 2
app/src/main/java/io/legado/app/model/webBook/BookList.kt

@@ -289,7 +289,7 @@ object BookList {
     }
 
     private fun checkExploreJson(bookSource: BookSource) {
-        if (Debug.callback == null) {
+        if (!Debug.hasActiveSession) {
             return
         }
         val json = bookSource.exploreKindsJson()
@@ -305,4 +305,4 @@ object BookList {
         }
     }
 
-}
+}

+ 5 - 5
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.UnfoldLess
 import androidx.compose.material.icons.filled.UnfoldMore
@@ -53,6 +52,7 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.stateDescription
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.ui.theme.LegadoTheme
@@ -64,10 +64,9 @@ import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.bookmark.BookmarkItem
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -75,7 +74,8 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
@@ -200,7 +200,7 @@ fun AllBookmarkScreen(
                         )
                         TopBarActionButton(
                             onClick = { showMenu = true },
-                            imageVector = Icons.Default.MoreVert,
+                            imageVector = AppIcons.MoreVert,
                             contentDescription = stringResource(R.string.more_menu)
                         )
                         RoundDropdownMenu(

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

@@ -39,7 +39,6 @@ import androidx.compose.material.icons.filled.BookmarkAdd
 import androidx.compose.material.icons.filled.Code
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Group
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.Timeline
@@ -81,8 +80,8 @@ import coil.compose.AsyncImage
 import coil.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
-import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -105,6 +104,7 @@ import io.legado.app.ui.widget.components.card.HighlightTagRow
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
@@ -650,7 +650,7 @@ private fun BookInfoOverflowAction(
     Box {
         TopBarActionButton(
             onClick = { expanded = true },
-            imageVector = Icons.Default.MoreVert,
+            imageVector = AppIcons.MoreVert,
             contentDescription = stringResource(R.string.more_actions),
         )
         BookInfoOverflowMenu(

+ 93 - 45
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Bookmarks
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.Upload
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.RadioButton
+import androidx.compose.material3.VerticalDivider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -52,6 +54,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
@@ -78,13 +81,12 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.adaptiveContentPadding
-import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButtonMenu
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FabMenuItem
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -99,10 +101,10 @@ import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.utils.ACache
@@ -712,11 +714,13 @@ private fun BookshelfManageScreen(
                         Column(
                             modifier = Modifier
                                 .fillMaxWidth()
-                                .adaptiveHorizontalPadding(vertical = 12.dp),
+                                .padding(vertical = 12.dp, horizontal = 4.dp),
                             verticalArrangement = Arrangement.SpaceBetween
                         ) {
                             Row(
-                                modifier = Modifier.fillMaxWidth(),
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 4.dp),
                                 horizontalArrangement = Arrangement.spacedBy(8.dp)
                             ) {
                                 Column(
@@ -725,14 +729,27 @@ private fun BookshelfManageScreen(
                                 ) {
                                     AppText(
                                         text = book.name,
+                                        modifier = Modifier
+                                            .clip(RoundedCornerShape(4.dp))
+                                            .clickable {
+                                                viewModel.dispatch(
+                                                    BookshelfManageScreenIntent.OpenBookInfoPreview(
+                                                        book,
+                                                        true
+                                                    )
+                                                )
+                                            }
+                                            .padding(horizontal = 4.dp),
                                         style = LegadoTheme.typography.titleSmallEmphasized,
                                         maxLines = 1
                                     )
                                     AppText(
+                                        modifier = Modifier.padding(horizontal = 4.dp),
                                         text = book.getRealAuthor(),
                                         style = LegadoTheme.typography.bodySmall
                                     )
                                     AppText(
+                                        modifier = Modifier.padding(horizontal = 4.dp),
                                         text = "${groupNameResolver(book)} | ${book.originName.ifBlank { book.origin }}",
                                         style = LegadoTheme.typography.labelSmallEmphasized.copy(color = LegadoTheme.colorScheme.primary)
                                     )
@@ -752,27 +769,69 @@ private fun BookshelfManageScreen(
                                     }
                                 )
                             }
-                            Row(
+                            Column(
                                 modifier = Modifier
                                     .fillMaxWidth()
-                                    .padding(top = 4.dp),
-                                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                                verticalAlignment = Alignment.CenterVertically
+                                    .padding(top = 12.dp),
+                                verticalArrangement = Arrangement.spacedBy(4.dp)
                             ) {
-                                if (downloadFailureText != null) {
-                                    AppText(
-                                        text = downloadFailureText,
-                                        modifier = Modifier.weight(1f),
-                                        style = LegadoTheme.typography.labelSmall,
-                                        color = LegadoTheme.colorScheme.error,
-                                        maxLines = 1
-                                    )
-                                } else {
-                                    Spacer(modifier = Modifier.weight(1f))
-                                }
                                 Row(
-                                    horizontalArrangement = Arrangement.spacedBy(6.dp)
+                                    modifier = Modifier.fillMaxWidth(),
+                                    verticalAlignment = Alignment.CenterVertically
                                 ) {
+                                    SmallTonalButton(
+                                        onClick = {
+                                            if (!book.isLocal) {
+                                                viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
+                                            }
+                                        },
+                                        modifier = Modifier.weight(1f),
+                                        icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
+                                        text = if (isDownloading) "停止" else "下载",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
+                                    SmallTonalButton(
+                                        onClick = { exportBook(book) },
+                                        modifier = Modifier.weight(1f),
+                                        icon = Icons.Default.Upload,
+                                        text = "导出",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
+                                    SmallTonalButton(
+                                        onClick = {
+                                            pendingMoveGroupBookUrl = book.bookUrl
+                                            groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
+                                            showGroupSelectSheet = true
+                                        },
+                                        modifier = Modifier.weight(1f),
+                                        icon = Icons.Default.Bookmarks,
+                                        text = "分组",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                    VerticalDivider(
+                                        modifier = Modifier
+                                            .height(16.dp)
+                                            .padding(horizontal = 4.dp),
+                                        color = LegadoTheme.colorScheme.outlineVariant
+                                    )
                                     RoundDropdownMenu(
                                         expanded = moreMenuBookUrl == book.bookUrl,
                                         onDismissRequest = { moreMenuBookUrl = null }
@@ -805,33 +864,22 @@ private fun BookshelfManageScreen(
                                             }
                                         )
                                     }
-                                    SmallTonalButton(
-                                        onClick = {
-                                            if (!book.isLocal) {
-                                                viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
-                                            }
-                                        },
-                                        icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
-                                        contentDescription = stringResource(R.string.action_download)
-                                    )
-                                    SmallTonalButton(
-                                        onClick = { exportBook(book) },
-                                        icon = Icons.Default.Upload,
-                                        contentDescription = stringResource(R.string.upload_to_remote)
-                                    )
-                                    SmallTonalButton(
-                                        onClick = {
-                                            pendingMoveGroupBookUrl = book.bookUrl
-                                            groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
-                                            showGroupSelectSheet = true
-                                        },
-                                        icon = Icons.Default.Bookmarks,
-                                        contentDescription = stringResource(R.string.group)
-                                    )
                                     SmallTonalButton(
                                         onClick = { moreMenuBookUrl = book.bookUrl },
+                                        modifier = Modifier.weight(1f),
                                         icon = Icons.Default.MoreVert,
-                                        contentDescription = stringResource(R.string.more_menu)
+                                        text = "更多",
+                                        contentColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(
+                                            alpha = 0.8f
+                                        )
+                                    )
+                                }
+                                if (downloadFailureText != null) {
+                                    AppText(
+                                        text = downloadFailureText,
+                                        style = LegadoTheme.typography.labelSmall,
+                                        color = LegadoTheme.colorScheme.error,
+                                        maxLines = 1
                                     )
                                 }
                             }

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -170,6 +170,7 @@ import io.legado.app.ui.widget.components.AppVerticalSlider
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -1715,7 +1716,7 @@ private fun MenuTitleBarMergedGlassButton(
                     },
             ) {
                 Icon(
-                    imageVector = Icons.Default.MoreVert,
+                    imageVector = AppIcons.MoreVert,
                     contentDescription = null,
                     tint = tint,
                     modifier = Modifier.size(20.dp),

+ 166 - 7
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -63,6 +63,7 @@ data class TextLine(
     val chapterIndices: IntRange get() = chapterPosition..chapterPosition + charSize
     val height: Float inline get() = lineBottom - lineTop
     val canvasRecorder = CanvasRecorderFactory.create()
+    private var nineSliceFrames: List<NineSliceFrameData> = emptyList()
     var searchResultColumnCount = 0
     var isReadAloud: Boolean = false
         set(value) {
@@ -82,7 +83,7 @@ data class TextLine(
     fun addColumn(column: BaseColumn) {
         if (column !is TextColumn) {
             onlyTextColumn = false
-        } else if (column.textColor != null || column.bgColor != null || column.underlineMode != 0 || column.bgImage.isNotEmpty() || column.fontPath.isNotEmpty()) {
+        } else if (column.textColor != null || column.bgColor != null || column.underlineMode != 0 || column.bgImage.isNotEmpty() || column.fontPath.isNotEmpty() || column.fontWeight != 400 || column.isItalic) {
             onlyTextColumn = false
         }
         column.textLine = this
@@ -167,10 +168,15 @@ data class TextLine(
         } else {
             drawTextLine(view, canvas)
         }
+        // Draw nine-slice frames on the real canvas (not the line recorder)
+        // to avoid clipping by the recorder's height bounds
+        drawNineSliceFrames(canvas, nineSliceFrames)
     }
 
     private fun drawTextLine(view: ContentTextView, canvas: Canvas) {
-        drawStyledBackgrounds(canvas)
+        val frames = mutableListOf<NineSliceFrameData>()
+        drawStyledBackgrounds(canvas, frames)
+        nineSliceFrames = frames
         drawBgColors(canvas)
         if (checkFastDraw()) {
             fastDrawTextLine(view, canvas)
@@ -267,7 +273,7 @@ data class TextLine(
     /**
      * 绘制高亮规则匹配文本的背景图
      */
-    private fun drawStyledBackgrounds(canvas: Canvas) {
+    private fun drawStyledBackgrounds(canvas: Canvas, nineSliceFrames: MutableList<NineSliceFrameData>) {
         if (isImage || columns.isEmpty()) return
         if (columns.none { (it as? TextBaseColumn)?.bgImage?.isNotEmpty() == true }) return
         var rangeStart = 0f
@@ -275,15 +281,23 @@ data class TextLine(
         var currentBgImage = ""
         var currentBgImageFit = 0
         var currentBgImageScale = 1f
+        var currentNpLeft = 0.1f
+        var currentNpRight = 0.1f
+        var currentNpTop = 0.1f
+        var currentNpBottom = 0.1f
         var active = false
         columns.forEachIndexed { index, column ->
             val textColumn = column as? TextBaseColumn
             val bgImage = textColumn?.bgImage ?: ""
             val bgImageFit = textColumn?.bgImageFit ?: 0
             val bgImageScale = textColumn?.bgImageScale ?: 1f
+            val npLeft = textColumn?.npLeft ?: 0.1f
+            val npRight = textColumn?.npRight ?: 0.1f
+            val npTop = textColumn?.npTop ?: 0.1f
+            val npBottom = textColumn?.npBottom ?: 0.1f
             when {
                 bgImage.isEmpty() && active -> {
-                    drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale)
+                    drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale, nineSliceFrames, currentNpLeft, currentNpRight, currentNpTop, currentNpBottom)
                     active = false
                 }
                 bgImage.isNotEmpty() && !active -> {
@@ -292,22 +306,30 @@ data class TextLine(
                     currentBgImage = bgImage
                     currentBgImageFit = bgImageFit
                     currentBgImageScale = bgImageScale
+                    currentNpLeft = npLeft
+                    currentNpRight = npRight
+                    currentNpTop = npTop
+                    currentNpBottom = npBottom
                     active = true
                 }
-                bgImage.isNotEmpty() && bgImage == currentBgImage && bgImageFit == currentBgImageFit && bgImageScale == currentBgImageScale -> {
+                bgImage.isNotEmpty() && bgImage == currentBgImage && bgImageFit == currentBgImageFit && bgImageScale == currentBgImageScale && npLeft == currentNpLeft && npRight == currentNpRight && npTop == currentNpTop && npBottom == currentNpBottom -> {
                     rangeEnd = textColumn!!.end
                 }
                 bgImage.isNotEmpty() -> {
-                    drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale)
+                    drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale, nineSliceFrames, currentNpLeft, currentNpRight, currentNpTop, currentNpBottom)
                     rangeStart = textColumn!!.start
                     rangeEnd = textColumn.end
                     currentBgImage = bgImage
                     currentBgImageFit = bgImageFit
                     currentBgImageScale = bgImageScale
+                    currentNpLeft = npLeft
+                    currentNpRight = npRight
+                    currentNpTop = npTop
+                    currentNpBottom = npBottom
                 }
             }
             if (active && index == columns.lastIndex) {
-                drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale)
+                drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale, nineSliceFrames, currentNpLeft, currentNpRight, currentNpTop, currentNpBottom)
             }
         }
     }
@@ -511,6 +533,11 @@ data class TextLine(
         bgImage: String,
         bgImageFit: Int,
         bgImageScale: Float,
+        nineSliceFrames: MutableList<NineSliceFrameData> = mutableListOf(),
+        npLeft: Float = 0.1f,
+        npRight: Float = 0.1f,
+        npTop: Float = 0.1f,
+        npBottom: Float = 0.1f,
     ) {
         val bitmap = getBgBitmap(bgImage) ?: return
         val paint = PaintPool.obtain()
@@ -549,6 +576,9 @@ data class TextLine(
                 canvas.drawBitmap(bitmap, null, android.graphics.RectF(dx, dy, dx + scaledW, dy + scaledH), paint)
                 canvas.restore()
             }
+            3 -> {
+                drawNineSliceCenter(canvas, bitmap, startX, endX, npLeft, npRight, npTop, npBottom, paint, nineSliceFrames)
+            }
             else -> {
                 val tileBitmap = if (scale != 1f) {
                     val sw = (bitmap.width * scale).toInt().coerceAtLeast(1)
@@ -569,6 +599,119 @@ data class TextLine(
         PaintPool.recycle(paint)
     }
 
+    private fun drawNineSliceCenter(
+        canvas: Canvas,
+        bitmap: Bitmap,
+        startX: Float,
+        endX: Float,
+        npLeft: Float,
+        npRight: Float,
+        npTop: Float,
+        npBottom: Float,
+        paint: Paint,
+        nineSliceFrames: MutableList<NineSliceFrameData>,
+    ) {
+        val bw = bitmap.width.toFloat()
+        val bh = bitmap.height.toFloat()
+        val leftPx = (bw * npLeft).toInt().coerceAtLeast(0)
+        val rightPx = (bw * (1f - npRight)).toInt().coerceAtMost(bitmap.width)
+        val topPx = (bh * npTop).toInt().coerceAtLeast(0)
+        val bottomPx = (bh * (1f - npBottom)).toInt().coerceAtMost(bitmap.height)
+
+        // 中心(5)覆盖全部文字高度
+        canvas.drawBitmap(
+            bitmap,
+            android.graphics.Rect(leftPx, topPx, rightPx, bottomPx),
+            android.graphics.RectF(startX, 0f, endX, height),
+            paint,
+        )
+
+        nineSliceFrames.add(
+            NineSliceFrameData(
+                bitmap = bitmap,
+                startX = startX,
+                endX = endX,
+                frameStartX = startX - leftPx.toFloat(),
+                frameEndX = endX + (bw - rightPx.toFloat()),
+                leftPx = leftPx,
+                rightPx = rightPx,
+                topPx = topPx,
+                bottomPx = bottomPx,
+                lineH = height,
+            )
+        )
+    }
+
+    /**
+     * 绘制九宫格边框(1,2,3,4,6,7,8,9),在文字之上。
+     * 上下边框延伸到行外,但根据行间距自适应缩放避免多行重叠。
+     */
+    private fun drawNineSliceFrames(canvas: Canvas, frames: List<NineSliceFrameData>) {
+        if (frames.isEmpty()) return
+        val paint = PaintPool.obtain()
+        paint.style = Paint.Style.FILL
+        paint.isAntiAlias = true
+        paint.isFilterBitmap = true
+
+        // 行间距可用空间:每行上下各分得一半间距
+        val lineH = frames.first().lineH
+        val gap = (ChapterProvider.lineSpacingExtra - 1f).coerceAtLeast(0f) * lineH
+        val halfGap = gap * 0.5f
+
+        for (frame in frames) {
+            val bitmap = frame.bitmap
+            val startX = frame.startX
+            val endX = frame.endX
+            val frameStartX = frame.frameStartX
+            val frameEndX = frame.frameEndX
+            val leftPx = frame.leftPx
+            val rightPx = frame.rightPx
+            val topPx = frame.topPx
+            val bottomPx = frame.bottomPx
+
+            val rawTopH = topPx.toFloat()
+            val rawBotH = (bitmap.height - bottomPx).toFloat()
+
+            // 上下边框等比例缩放以适应行间距,避免多行高亮时重叠
+            val maxFrameH = maxOf(rawTopH, rawBotH).coerceAtLeast(0.1f)
+            val overflowScale = (halfGap / maxFrameH).coerceIn(0f, 1f)
+            val topH = rawTopH * overflowScale
+            val botH = rawBotH * overflowScale
+
+            // Row 1: 上方边框 (1, 2, 3) - 向上延伸到文字区域之上
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(0, 0, leftPx, topPx),
+                android.graphics.RectF(frameStartX, -topH, startX, 0f), paint)
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(leftPx, 0, rightPx, topPx),
+                android.graphics.RectF(startX, -topH, endX, 0f), paint)
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(rightPx, 0, bitmap.width, topPx),
+                android.graphics.RectF(endX, -topH, frameEndX, 0f), paint)
+
+            // Row 2: 同行边框 (4, 6) - 全高
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(0, topPx, leftPx, bottomPx),
+                android.graphics.RectF(frameStartX, 0f, startX, lineH), paint)
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(rightPx, topPx, bitmap.width, bottomPx),
+                android.graphics.RectF(endX, 0f, frameEndX, lineH), paint)
+
+            // Row 3: 下方边框 (7, 8, 9) - 向下延伸到文字区域之下
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(0, bottomPx, leftPx, bitmap.height),
+                android.graphics.RectF(frameStartX, lineH, startX, lineH + botH), paint)
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(leftPx, bottomPx, rightPx, bitmap.height),
+                android.graphics.RectF(startX, lineH, endX, lineH + botH), paint)
+            canvas.drawBitmap(bitmap,
+                android.graphics.Rect(rightPx, bottomPx, bitmap.width, bitmap.height),
+                android.graphics.RectF(endX, lineH, frameEndX, lineH + botH), paint)
+        }
+
+        PaintPool.recycle(paint)
+    }
+
     private fun drawNinePatchBitmap(
         canvas: Canvas,
         bitmap: Bitmap,
@@ -710,6 +853,22 @@ data class TextLine(
         canvasRecorder.recycle()
     }
 
+    /**
+     * 九宫格边框数据,用于在文字之上绘制 1,2,3,4,6,7,8,9
+     */
+    private class NineSliceFrameData(
+        val bitmap: Bitmap,
+        val startX: Float,
+        val endX: Float,
+        val frameStartX: Float,
+        val frameEndX: Float,
+        val leftPx: Int,
+        val rightPx: Int,
+        val topPx: Int,
+        val bottomPx: Int,
+        val lineH: Float,
+    )
+
     @SuppressLint("NewApi")
     companion object {
         val emptyTextLine = TextLine()

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt

@@ -18,6 +18,12 @@ interface TextBaseColumn : BaseColumn {
     val bgImageFit: Int
     val bgImageScale: Float
     val fontPath: String
+    val fontWeight: Int
+    val isItalic: Boolean
+    val npLeft: Float
+    val npRight: Float
+    val npTop: Float
+    val npBottom: Float
     var selected: Boolean
     var isSearchResult: Boolean
 }

+ 23 - 4
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -31,6 +31,12 @@ data class TextColumn(
     override val bgImageFit: Int = 0,
     override val bgImageScale: Float = 1f,
     override val fontPath: String = "",
+    override val fontWeight: Int = 400,
+    override val isItalic: Boolean = false,
+    override val npLeft: Float = 0.1f,
+    override val npRight: Float = 0.1f,
+    override val npTop: Float = 0.1f,
+    override val npBottom: Float = 0.1f,
 ) : TextBaseColumn {
 
     override var textLine: TextLine = emptyTextLine
@@ -113,21 +119,34 @@ data class TextColumn(
     }
 
     private fun getCustomTypeface(): Typeface? {
-        return getTypeface(fontPath)
+        return getTypeface(fontPath, fontWeight, isItalic)
     }
 
     companion object {
         private val typefaceCache = HashMap<String, Typeface>()
         private val failedTypefaceLoads = ResourceLoadFailureCache<String>()
 
-        internal fun getTypeface(fontPath: String): Typeface? {
-            if (fontPath.isEmpty()) return null
-            typefaceCache[fontPath]?.let { return it }
+        internal fun getTypeface(fontPath: String, fontWeight: Int = 400, isItalic: Boolean = false): Typeface? {
+            if (fontPath.isEmpty()) {
+                return applyStyleTypeface(null, fontWeight, isItalic)
+            }
+            typefaceCache[fontPath]?.let { return applyStyleTypeface(it, fontWeight, isItalic) }
             return failedTypefaceLoads.load(fontPath) {
                 loadTypeface(fontPath)?.also { typeface ->
                     typefaceCache[fontPath] = typeface
                 }
+            }?.let { applyStyleTypeface(it, fontWeight, isItalic) }
+        }
+
+        private fun applyStyleTypeface(typeface: Typeface?, fontWeight: Int, isItalic: Boolean): Typeface? {
+            if (fontWeight == 400 && !isItalic) return typeface
+            val style = when {
+                isItalic && fontWeight == 700 -> Typeface.BOLD_ITALIC
+                isItalic -> Typeface.ITALIC
+                fontWeight == 700 -> Typeface.BOLD
+                else -> Typeface.NORMAL // 300 (Light) falls back to NORMAL via Typeface.create
             }
+            return Typeface.create(typeface ?: Typeface.DEFAULT, style)
         }
 
         private fun loadTypeface(fontPath: String): Typeface? {

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt

@@ -31,6 +31,12 @@ data class TextHtmlColumn(
     override val bgImageFit: Int = 0,
     override val bgImageScale: Float = 1f,
     override val fontPath: String = "",
+    override val fontWeight: Int = 400,
+    override val isItalic: Boolean = false,
+    override val npLeft: Float = 0.1f,
+    override val npRight: Float = 0.1f,
+    override val npTop: Float = 0.1f,
+    override val npBottom: Float = 0.1f,
 ) : TextBaseColumn {
 
     override val textColor: Int? get() = mTextColor

+ 7 - 1
app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt

@@ -15,7 +15,13 @@ data class CharStyle(
     val bgImageFit: Int = 0,
     val bgImageScale: Float = 1f,
     val fontPath: String = "",
+    val fontWeight: Int = 400,
+    val isItalic: Boolean = false,
+    val npLeft: Float = 0.1f,
+    val npRight: Float = 0.1f,
+    val npTop: Float = 0.1f,
+    val npBottom: Float = 0.1f,
 ) {
     val hasStyle: Boolean
-        get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() || fontPath.isNotEmpty()
+        get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() || fontPath.isNotEmpty() || fontWeight != 400 || isItalic
 }

+ 192 - 29
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -1,5 +1,6 @@
-package io.legado.app.ui.book.read.page.provider
+package io.legado.app.ui.book.read.page.provider
 
+import android.graphics.BitmapFactory
 import android.graphics.Paint
 import android.os.Build
 import android.text.Layout
@@ -83,7 +84,11 @@ class TextChapterLayout(
         fun invalidateRegexCache() {
             cachedHighlightRules = null
             cachedHighlightRulesConfigName = null
+            bitmapDimsCache.clear()
         }
+
+        /** 九宫格图片尺寸缓存 (width, height),避免重复读取文件 */
+        private val bitmapDimsCache = mutableMapOf<String, Pair<Int, Int>?>()
     }
 
     private val compiledHighlightRules: List<CompiledHighlightRule>
@@ -1011,9 +1016,10 @@ class TextChapterLayout(
                     }
                     addCharsToLineNatural(
                         book, absStartX, textLine, words,
-                        startX, !isTitle && lineIndex == 0, widths, srcList, clickList, charStyles, lineStart
+                        startX, !isTitle && lineIndex == 0, widths, srcList, clickList, charStyles, lineStart, textPaint,
                     )
                 }
+
                 else -> {
                     if (
                         isTitle &&
@@ -1023,7 +1029,7 @@ class TextChapterLayout(
                         val startX = (visibleWidth - desiredWidth) / 2
                         addCharsToLineNatural(
                             book, absStartX, textLine, words,
-                            startX, false, widths, srcList, clickList, charStyles, lineStart
+                            startX, false, widths, srcList, clickList, charStyles, lineStart, textPaint,
                         )
                     } else {
                         addCharsToLineMiddle(
@@ -1090,7 +1096,7 @@ class TextChapterLayout(
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                x, true, textWidths, srcList, clickList, charStyles, lineStart
+                x, true, textWidths, srcList, clickList, charStyles, lineStart, textPaint,
             )
             return
         }
@@ -1139,20 +1145,56 @@ class TextChapterLayout(
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                startX, false, textWidths, srcList, clickList, charStyles, lineStart
+                startX, false, textWidths, srcList, clickList, charStyles, lineStart, textPaint,
             )
             return
         }
         val residualWidth = visibleWidth - desiredWidth
         val spaceSize = words.count { it == " " }
         textLine.startX = absStartX + startX
+
+        // 九宫格状态追踪
+        val continuingFromPrevLine = lineStart > 0 && charStyles?.getOrNull(lineStart - 1)?.bgImageFit == 3
+        var inNineSlice = false
+        var nsBgImage = ""
+        var nsNpLeft = 0.1f
+        var nsNpRight = 0.1f
+
         if (spaceSize > 1) {
             val d = residualWidth / spaceSize
             textLine.wordSpacing = d
             var x = startX
+            // 跨行延续:添加 margin-left
+            if (continuingFromPrevLine) {
+                val prevStyle = charStyles.getOrNull(lineStart - 1)
+                if (prevStyle != null) {
+                    nsBgImage = prevStyle.bgImage
+                    nsNpLeft = prevStyle.npLeft
+                    nsNpRight = prevStyle.npRight
+                    val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginLeft
+                    inNineSlice = true
+                }
+            }
             for (index in words.indices) {
                 val char = words[index]
                 val cw = textWidths[index]
+                val style = charStyles?.getOrNull(lineStart + index)
+                val isNineSlice = style?.bgImageFit == 3
+
+                if (isNineSlice && !inNineSlice) {
+                    nsBgImage = style.bgImage
+                    nsNpLeft = style.npLeft
+                    nsNpRight = style.npRight
+                    val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginLeft
+                    inNineSlice = true
+                } else if (!isNineSlice && inNineSlice) {
+                    val (_, marginRight) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginRight
+                    inNineSlice = false
+                }
+
                 val x1 = if (char == " ") {
                     if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 } else {
@@ -1171,9 +1213,37 @@ class TextChapterLayout(
             textLine.extraLetterSpacingOffsetX = -d / 2
             textLine.extraLetterSpacing = d / textPaint.textSize
             var x = startX
+            // 跨行延续:添加 margin-left
+            if (continuingFromPrevLine) {
+                val prevStyle = charStyles.getOrNull(lineStart - 1)
+                if (prevStyle != null) {
+                    nsBgImage = prevStyle.bgImage
+                    nsNpLeft = prevStyle.npLeft
+                    nsNpRight = prevStyle.npRight
+                    val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginLeft
+                    inNineSlice = true
+                }
+            }
             for (index in words.indices) {
                 val char = words[index]
                 val cw = textWidths[index]
+                val style = charStyles?.getOrNull(lineStart + index)
+                val isNineSlice = style?.bgImageFit == 3
+
+                if (isNineSlice && !inNineSlice) {
+                    nsBgImage = style.bgImage
+                    nsNpLeft = style.npLeft
+                    nsNpRight = style.npRight
+                    val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginLeft
+                    inNineSlice = true
+                } else if (!isNineSlice && inNineSlice) {
+                    val (_, marginRight) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                    x += marginRight
+                    inNineSlice = false
+                }
+
                 val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 addCharToLine(
                     book, absStartX, textLine, char,
@@ -1183,7 +1253,38 @@ class TextChapterLayout(
                 x = x1
             }
         }
-        exceed(absStartX, textLine, words)
+        // 行末处理:传递 margin-right 给 exceed
+        var extraRightMargin = 0f
+        if (inNineSlice) {
+            val (_, marginRight) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+            extraRightMargin = marginRight
+        }
+        exceed(absStartX, textLine, words, extraRightMargin)
+    }
+
+    /**
+     * 计算九宫格左右 margin(即边4/6的真实渲染宽度),与渲染层 drawNineSliceCenter 保持一致
+     */
+    private fun calcNineSliceMargin(
+        bgImage: String,
+        npLeft: Float,
+        npRight: Float,
+    ): Pair<Float, Float> {
+        val dims = bitmapDimsCache.getOrPut(bgImage) {
+            val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+            BitmapFactory.decodeFile(bgImage, opts)
+            if (opts.outWidth > 0 && opts.outHeight > 0) {
+                opts.outWidth to opts.outHeight
+            } else null
+        } ?: return 0f to 0f
+
+        val (bw, _) = dims
+        val leftPx = (bw * npLeft).toInt().coerceAtLeast(0)
+        val rightPx = (bw * (1f - npRight)).toInt().coerceAtMost(bw)
+
+        val marginLeft = leftPx.toFloat()
+        val marginRight = (bw - rightPx).toFloat()
+        return marginLeft to marginRight
     }
 
     /**
@@ -1200,34 +1301,72 @@ class TextChapterLayout(
         srcList: LinkedList<String>?,
         clickList: LinkedList<String?>?,
         charStyles: Array<CharStyle?>?,
-        lineStart: Int
+        lineStart: Int,
+        textPaint: android.graphics.Paint,
     ) {
         val indentLength = paragraphIndent.length
         var x = startX
         textLine.startX = absStartX + startX
+
+        // 检查是否从前一行延续九宫格段落
+        val continuingFromPrevLine = lineStart > 0 && charStyles?.getOrNull(lineStart - 1)?.bgImageFit == 3
+
+        var inNineSlice = false
+        var nsBgImage = ""
+        var nsNpLeft = 0.1f
+        var nsNpRight = 0.1f
+
+        // 跨行延续:在新行开头添加 margin-left(边4宽度)
+        if (continuingFromPrevLine) {
+            val prevStyle = charStyles.getOrNull(lineStart - 1)
+            if (prevStyle != null) {
+                nsBgImage = prevStyle.bgImage
+                nsNpLeft = prevStyle.npLeft
+                nsNpRight = prevStyle.npRight
+                val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                x += marginLeft
+                inNineSlice = true
+            }
+        }
+
         for (index in words.indices) {
             val char = words[index]
             val cw = textWidths[index]
+            val style = charStyles?.getOrNull(lineStart + index)
+            val isNineSlice = style?.bgImageFit == 3
+
+            if (isNineSlice && !inNineSlice) {
+                // 进入九宫格段落:添加 margin-left(边4宽度)
+                nsBgImage = style.bgImage
+                nsNpLeft = style.npLeft
+                nsNpRight = style.npRight
+                val (marginLeft, _) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                x += marginLeft
+                inNineSlice = true
+            } else if (!isNineSlice && inNineSlice) {
+                // 离开九宫格段落:添加 margin-right(边6宽度)
+                val (_, marginRight) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+                x += marginRight
+                inNineSlice = false
+            }
+
             val x1 = x + cw
             addCharToLine(
-                book,
-                absStartX,
-                textLine,
-                char,
-                x,
-                x1,
-                index + 1 == words.size,
-                srcList,
-                clickList,
-                charStyles,
-                lineStart + index
+                book, absStartX, textLine, char, x, x1,
+                index + 1 == words.size, srcList, clickList, charStyles, lineStart + index,
             )
             x = x1
             if (hasIndent && index == indentLength - 1) {
                 textLine.indentWidth = x
             }
         }
-        exceed(absStartX, textLine, words)
+        // 行末处理:计算 margin-right 并传递给 exceed,确保右侧边框有足够空间
+        var extraRightMargin = 0f
+        if (inNineSlice) {
+            val (_, marginRight) = calcNineSliceMargin(nsBgImage, nsNpLeft, nsNpRight)
+            extraRightMargin = marginRight
+        }
+        exceed(absStartX, textLine, words, extraRightMargin)
     }
 
     /**
@@ -1275,7 +1414,13 @@ class TextChapterLayout(
                     bgImage = style?.bgImage ?: "",
                     bgImageFit = style?.bgImageFit ?: 0,
                     bgImageScale = style?.bgImageScale ?: 1f,
-                    fontPath = style?.fontPath ?: ""
+                    fontPath = style?.fontPath ?: "",
+                    fontWeight = style?.fontWeight ?: 400,
+                    isItalic = style?.isItalic ?: false,
+                    npLeft = style?.npLeft ?: 0.1f,
+                    npRight = style?.npRight ?: 0.1f,
+                    npTop = style?.npTop ?: 0.1f,
+                    npBottom = style?.npBottom ?: 0.1f,
                 )
             }
         }
@@ -1285,7 +1430,12 @@ class TextChapterLayout(
     /**
      * 超出边界处理
      */
-    private fun exceed(absStartX: Int, textLine: TextLine, words: List<String>) {
+    private fun exceed(
+        absStartX: Int,
+        textLine: TextLine,
+        words: List<String>,
+        extraRightMargin: Float = 0f,
+    ) {
         var size = words.size
         if (size < 2) return
         val visibleEnd = absStartX + visibleWidth
@@ -1298,10 +1448,10 @@ class TextChapterLayout(
         } else {
             columns.last()
         }
-        val endX = endColumn.end.roundToInt()
-        if (endX > visibleEnd) {
+        val effectiveEndX = endColumn.end.roundToInt() + extraRightMargin.roundToInt()
+        if (effectiveEndX > visibleEnd) {
             textLine.exceed = true
-            val cc = (endX - visibleEnd) / size
+            val cc = (effectiveEndX - visibleEnd) / size
             for (i in 0..<size) {
                 textLine.getColumnReverseAt(i, offset).let {
                     val py = cc * (size - i)
@@ -1389,14 +1539,21 @@ class TextChapterLayout(
         val measurePaint = TextPaint(textPaint)
         var i = 0
         while (i < text.length) {
-            val fontPath = charStyles[i]?.fontPath.orEmpty()
+            val style = charStyles[i]
+            val fontPath = style?.fontPath.orEmpty()
             if (fontPath.isEmpty()) { i++; continue }
-            // 找连续使用同一字体的区间
+            val fontWeight = style?.fontWeight ?: 400
+            val isItalic = style?.isItalic ?: false
+            // 找连续使用同一字体且同一字重/斜体的区间
             val segStart = i
             i++
-            while (i < text.length && charStyles[i]?.fontPath.orEmpty() == fontPath) i++
+            while (i < text.length) {
+                val s = charStyles[i]
+                if (s?.fontPath.orEmpty() != fontPath || (s?.fontWeight ?: 400) != fontWeight || (s?.isItalic ?: false) != isItalic) break
+                i++
+            }
             val segEnd = i
-            val typeface = TextColumn.getTypeface(fontPath) ?: continue
+            val typeface = TextColumn.getTypeface(fontPath, fontWeight, isItalic) ?: continue
             measurePaint.typeface = typeface
             val segLen = segEnd - segStart
             val segWidths = FloatArray(segLen)
@@ -1527,7 +1684,13 @@ class TextChapterLayout(
             bgImage = bgImage.orEmpty(),
             bgImageFit = bgImageFit,
             bgImageScale = bgImageScale,
-            fontPath = fontPath.orEmpty()
+            fontPath = fontPath.orEmpty(),
+            fontWeight = fontWeight,
+            isItalic = isItalic,
+            npLeft = npLeft,
+            npRight = npRight,
+            npTop = npTop,
+            npBottom = npBottom,
         )
     }
 

+ 207 - 15
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

@@ -1,15 +1,19 @@
 package io.legado.app.ui.book.read.sheet
 
 import android.content.Intent
+import android.graphics.BitmapFactory
 import android.net.Uri
 import android.provider.OpenableColumns
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
@@ -32,6 +36,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringArrayResource
@@ -124,6 +129,17 @@ fun HighlightRuleEditSheet(
     var bgImageScale by remember(show, rule) { mutableFloatStateOf(initial.bgImageScale) }
     var hasBgImage by remember(show, rule) { mutableStateOf(initial.bgImage?.isNotBlank() == true) }
 
+    // Font weight state
+    var fontWeight by remember(show, rule) { mutableIntStateOf(initial.fontWeight) }
+    var isItalic by remember(show, rule) { mutableStateOf(initial.isItalic) }
+
+    // Nine-slice state
+    var npLeft by remember(show, rule) { mutableFloatStateOf(initial.npLeft) }
+    var npRight by remember(show, rule) { mutableFloatStateOf(initial.npRight) }
+    var npTop by remember(show, rule) { mutableFloatStateOf(initial.npTop) }
+    var npBottom by remember(show, rule) { mutableFloatStateOf(initial.npBottom) }
+    var showNinePatchEditor by remember(show, rule) { mutableStateOf(false) }
+
     // Config binding state — empty set = global (applies to all configs)
     var configNames by remember(show, rule) {
         mutableStateOf(initial.configName.orEmpty().configNames().toSet())
@@ -142,9 +158,9 @@ fun HighlightRuleEditSheet(
     // Validation
     var patternError by remember(show, rule) { mutableStateOf<String?>(null) }
 
-    // System photo picker, with the contract's built-in fallback on older devices.
+    // File picker for background images (uses OpenDocument to avoid MediaStore transcoding)
     val imagePicker = rememberLauncherForActivityResult(
-        ActivityResultContracts.GetContent()
+        ActivityResultContracts.OpenDocument()
     ) { uri: Uri? ->
         if (uri != null) {
             coroutineScope.launch {
@@ -228,6 +244,12 @@ fun HighlightRuleEditSheet(
                             bgImageScale = bgImageScale,
                             configName = if (configNames.isEmpty()) null else configNames.toList().toJsonArray(),
                             fontPath = if (hasFont) fontPath.ifBlank { null } else null,
+                            fontWeight = fontWeight,
+                            isItalic = isItalic,
+                            npLeft = npLeft,
+                            npRight = npRight,
+                            npTop = npTop,
+                            npBottom = npBottom,
                         )
                     )
                 },
@@ -245,6 +267,16 @@ fun HighlightRuleEditSheet(
             // === Section 1: Rule Info ===
             SectionTitle(stringResource(R.string.rule_info))
 
+            AppTextField(
+                value = name,
+                onValueChange = { name = it },
+                label = stringResource(R.string.rule_name),
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Spacer(Modifier.height(8.dp))
+
             AppTextField(
                 value = pattern,
                 onValueChange = {
@@ -262,16 +294,6 @@ fun HighlightRuleEditSheet(
 
             Spacer(Modifier.height(8.dp))
 
-            AppTextField(
-                value = name,
-                onValueChange = { name = it },
-                label = stringResource(R.string.rule_name),
-                singleLine = true,
-                modifier = Modifier.fillMaxWidth(),
-            )
-
-            Spacer(Modifier.height(8.dp))
-
             val scopeEntries = arrayOf(
                 stringResource(R.string.target_all),
                 stringResource(R.string.target_title),
@@ -313,6 +335,23 @@ fun HighlightRuleEditSheet(
                 )
             }
 
+            // Font weight — three options: Regular(400), Bold(700), Light(300)
+            val weightEntries = stringArrayResource(R.array.text_font_weight)
+            TinyDropdownSettingItem(
+                title = stringResource(R.string.font_weight_text),
+                selectedValue = fontWeight.toString(),
+                displayEntries = weightEntries,
+                entryValues = arrayOf("400", "700", "300"),
+                onValueChange = { fontWeight = it.toIntOrNull() ?: 400 },
+            )
+
+            // Italic
+            TinySwitchSettingItem(
+                title = stringResource(R.string.read_config_italic),
+                checked = isItalic,
+                onCheckedChange = { isItalic = it },
+            )
+
             // Underline
             TinySwitchSettingItem(
                 title = stringResource(R.string.underline_style),
@@ -402,7 +441,7 @@ fun HighlightRuleEditSheet(
                     title = stringResource(R.string.highlight_bg_image),
                     description = bgImage.ifBlank { null }?.let { File(it).name },
                     onClick = {
-                        imagePicker.launch("image/*")
+                        imagePicker.launch(arrayOf("image/*"))
                     },
                 )
             }
@@ -412,14 +451,21 @@ fun HighlightRuleEditSheet(
                         stringResource(R.string.bg_fit_tile),
                         stringResource(R.string.bg_fit_stretch),
                         stringResource(R.string.bg_fit_crop),
+                        stringResource(R.string.bg_fit_nine_patch),
                     )
-                    val fitValues = arrayOf("0", "1", "2")
+                    val fitValues = arrayOf("0", "1", "2", "3")
                     TinyDropdownSettingItem(
                         title = stringResource(R.string.bg_image_fit),
                         selectedValue = bgImageFit.toString(),
                         displayEntries = fitEntries,
                         entryValues = fitValues,
-                        onValueChange = { bgImageFit = it.toIntOrNull() ?: 0 },
+                        onValueChange = {
+                            val newFit = it.toIntOrNull() ?: 0
+                            bgImageFit = newFit
+                            if (newFit == 3) {
+                                showNinePatchEditor = true
+                            }
+                        },
                     )
 
                     TinySliderSettingItem(
@@ -554,6 +600,24 @@ fun HighlightRuleEditSheet(
         },
     )
 
+    // Nine-patch editor
+    NinePatchEditorDialog(
+        show = showNinePatchEditor,
+        imagePath = bgImage,
+        initialLeft = npLeft,
+        initialRight = npRight,
+        initialTop = npTop,
+        initialBottom = npBottom,
+        onDismissRequest = { showNinePatchEditor = false },
+        onSave = { left, right, top, bottom ->
+            npLeft = left
+            npRight = right
+            npTop = top
+            npBottom = bottom
+            showNinePatchEditor = false
+        },
+    )
+
     // Font selector
     val readSettingsRepository: ReadSettingsRepository = org.koin.compose.koinInject()
     val fontSelectScope = rememberCoroutineScope()
@@ -695,3 +759,131 @@ private fun HighlightRulePreview(
         }
     }
 }
+
+@Composable
+private fun NinePatchEditorDialog(
+    show: Boolean,
+    imagePath: String,
+    initialLeft: Float,
+    initialRight: Float,
+    initialTop: Float,
+    initialBottom: Float,
+    onDismissRequest: () -> Unit,
+    onSave: (left: Float, right: Float, top: Float, bottom: Float) -> Unit,
+) {
+    var left by remember(show, imagePath) { mutableFloatStateOf(initialLeft) }
+    var right by remember(show, imagePath) { mutableFloatStateOf(initialRight) }
+    var top by remember(show, imagePath) { mutableFloatStateOf(initialTop) }
+    var bottom by remember(show, imagePath) { mutableFloatStateOf(initialBottom) }
+
+    val bitmap = remember(imagePath) {
+        runCatching {
+            val file = File(imagePath)
+            if (file.exists()) {
+                BitmapFactory.decodeFile(imagePath)
+            } else null
+        }.getOrNull()
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.nine_patch_editor),
+        endAction = {
+            MediumTonalButton(
+                onClick = { onSave(left, right, top, bottom) },
+                icon = Icons.Default.Done,
+                contentDescription = stringResource(R.string.save),
+            )
+        },
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(bottom = 16.dp)
+                .verticalScroll(rememberScrollState()),
+        ) {
+            // Image preview with split lines — use single Canvas to avoid coordinate mismatch
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(250.dp)
+                    .padding(8.dp)
+                    .background(MaterialTheme.colorScheme.surfaceContainerLow),
+            ) {
+                if (bitmap != null) {
+                    Canvas(modifier = Modifier.fillMaxSize()) {
+                        val canvasWidth = size.width
+                        val canvasHeight = size.height
+                        val bw = bitmap.width.toFloat()
+                        val bh = bitmap.height.toFloat()
+                        val imageAspect = bw / bh
+                        val canvasAspect = canvasWidth / canvasHeight
+                        val (imageW, imageH, offsetX, offsetY) = if (imageAspect > canvasAspect) {
+                            val w = canvasWidth
+                            val h = canvasWidth / imageAspect
+                            listOf(w, h, 0f, (canvasHeight - h) / 2f)
+                        } else {
+                            val h = canvasHeight
+                            val w = canvasHeight * imageAspect
+                            listOf(w, h, (canvasWidth - w) / 2f, 0f)
+                        }
+
+                        // Draw bitmap
+                        drawImage(
+                            image = bitmap.asImageBitmap(),
+                            dstOffset = androidx.compose.ui.unit.IntOffset(offsetX.toInt(), offsetY.toInt()),
+                            dstSize = androidx.compose.ui.unit.IntSize(imageW.toInt(), imageH.toInt()),
+                        )
+
+                        val lineColor = Color.Red
+                        val lineWidth = 2.dp.toPx()
+
+                        // Left line
+                        val lx = offsetX + imageW * left
+                        drawLine(lineColor, Offset(lx, offsetY), Offset(lx, offsetY + imageH), lineWidth)
+                        // Right line
+                        val rx = offsetX + imageW * (1f - right)
+                        drawLine(lineColor, Offset(rx, offsetY), Offset(rx, offsetY + imageH), lineWidth)
+                        // Top line
+                        val ty = offsetY + imageH * top
+                        drawLine(lineColor, Offset(offsetX, ty), Offset(offsetX + imageW, ty), lineWidth)
+                        // Bottom line
+                        val by = offsetY + imageH * (1f - bottom)
+                        drawLine(lineColor, Offset(offsetX, by), Offset(offsetX + imageW, by), lineWidth)
+                    }
+                }
+            }
+
+            // Sliders
+            TinySliderSettingItem(
+                title = stringResource(R.string.nine_patch_split_left),
+                value = left,
+                valueRange = 0f..0.5f,
+                description = String.format("%.0f%%", left * 100),
+                onValueChange = { left = (it * 100).toInt() / 100f },
+            )
+            TinySliderSettingItem(
+                title = stringResource(R.string.nine_patch_split_right),
+                value = right,
+                valueRange = 0f..0.5f,
+                description = String.format("%.0f%%", right * 100),
+                onValueChange = { right = (it * 100).toInt() / 100f },
+            )
+            TinySliderSettingItem(
+                title = stringResource(R.string.nine_patch_split_top),
+                value = top,
+                valueRange = 0f..0.5f,
+                description = String.format("%.0f%%", top * 100),
+                onValueChange = { top = (it * 100).toInt() / 100f },
+            )
+            TinySliderSettingItem(
+                title = stringResource(R.string.nine_patch_split_bottom),
+                value = bottom,
+                valueRange = 0f..0.5f,
+                description = String.format("%.0f%%", bottom * 100),
+                onValueChange = { bottom = (it * 100).toInt() / 100f },
+            )
+        }
+    }
+}

+ 75 - 8
app/src/main/java/io/legado/app/ui/book/read/sheet/TypographyTabs.kt

@@ -1,5 +1,7 @@
 package io.legado.app.ui.book.read.sheet
 
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
@@ -8,6 +10,7 @@ import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
@@ -26,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
@@ -40,11 +44,15 @@ import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadSheetConfigUiState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppSlider
+import io.legado.app.ui.widget.components.ValueStepper
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.CustomTipDialog
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -81,15 +89,74 @@ private fun FontWeightSetting(
     value: Int,
     onValueChange: (Int) -> Unit,
 ) {
+    var showVariableWeight by remember { mutableStateOf(false) }
+    var sliderValue by remember(value) {
+        mutableFloatStateOf(
+            when (value) {
+                2 -> 300f
+                0 -> 400f
+                1 -> 900f
+                else -> value.coerceIn(100, 900).toFloat()
+            }
+        )
+    }
     val weightEntries = stringArrayResource(R.array.text_font_weight)
-    val weightValues = remember { arrayOf("0", "1", "2") }
-    TinyDropdownSettingItem(
-        title = stringResource(R.string.font_weight_text),
-        selectedValue = value.toString(),
-        displayEntries = weightEntries,
-        entryValues = weightValues,
-        onValueChange = { onValueChange(it.toInt()) },
-    )
+
+    Column {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Box(modifier = Modifier.weight(1f)) {
+                TinyDropdownSettingItem(
+                    title = stringResource(R.string.font_weight_text),
+                    selectedValue = value.toString(),
+                    displayEntries = arrayOf(weightEntries[2], weightEntries[0], weightEntries[1]),
+                    entryValues = arrayOf("2", "0", "1"),
+                    onValueChange = { onValueChange(it.toInt()) },
+                )
+            }
+            NormalCard(
+                onClick = { showVariableWeight = !showVariableWeight },
+                modifier = Modifier.padding(bottom = 4.dp).height(56.dp).aspectRatio(1f),
+                containerColor = if (showVariableWeight) LegadoTheme.colorScheme.secondaryContainer else LegadoTheme.colorScheme.surfaceContainerLow,
+                contentColor = if (showVariableWeight) LegadoTheme.colorScheme.onSecondaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
+                cornerRadius = 12.dp,
+            ) {
+                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+                    Icon(Icons.Default.Tune, stringResource(R.string.font_weight_text))
+                }
+            }
+        }
+
+        AnimatedVisibility(visible = showVariableWeight) {
+            NormalCard(
+                modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).height(56.dp),
+                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                cornerRadius = 12.dp,
+            ) {
+                ValueStepper(
+                    value = sliderValue,
+                    displayValue = sliderValue,
+                    valueRange = 100f..900f,
+                    onValueChange = {
+                        sliderValue = it
+                        onValueChange(it.toInt())
+                    },
+                    modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
+                    content = {
+                        AppSlider(
+                            value = sliderValue,
+                            onValueChange = { sliderValue = it },
+                            onValueChangeFinished = { onValueChange(sliderValue.toInt()) },
+                            valueRange = 100f..900f,
+                            modifier = Modifier.weight(1f),
+                        )
+                    },
+                )
+            }
+        }
+    }
 }
 
 @Composable

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt

@@ -56,6 +56,7 @@ import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -143,7 +144,7 @@ fun CloudTtsScreen(
                         Box {
                             TopBarActionButton(
                                 onClick = { expanded = true },
-                                imageVector = Icons.Default.MoreVert,
+                                imageVector = AppIcons.MoreVert,
                                 contentDescription = stringResource(R.string.more_menu),
                             )
                             RoundDropdownMenu(expanded, { expanded = false }) {

+ 26 - 226
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugActivity.kt

@@ -1,235 +1,35 @@
 package io.legado.app.ui.book.source.debug
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.widget.EditText
-import androidx.activity.viewModels
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.databinding.ActivitySourceDebugBinding
-import io.legado.app.help.source.clearExploreKindsCache
-import io.legado.app.help.source.exploreKinds
-import io.legado.app.lib.dialogs.selector
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.dialog.TextDialog
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.launch
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import splitties.views.onClick
-import splitties.views.onLongClick
-
-class BookSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, BookSourceDebugModel>() {
-
-    override val binding by viewBinding(ActivitySourceDebugBinding::inflate)
-    override val viewModel by viewModels<BookSourceDebugModel>()
-
-    private val adapter by lazy { BookSourceDebugAdapter(this) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it?.let {
-            startSearch(it)
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        viewModel.init(intent.getStringExtra("key")) {
-            initHelpView()
-        }
-        viewModel.observe { state, msg ->
-            lifecycleScope.launch {
-                adapter.addItem(msg)
-                if (state == -1 || state == 1000) {
-                    binding.rotateLoading.gone()
-                }
-            }
-        }
-    }
-
-    private fun initRecyclerView() {
-        //binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-        //binding.rotateLoading.loadingColor = accentColor
-    }
-
-    private fun initSearchView() {
-        searchView.onActionViewExpanded()
-        searchView.isSubmitButtonEnabled = true
-        searchView.queryHint = getString(R.string.search_book_key)
-        searchView.contentDescription = getString(R.string.search_book_key)
-        searchView.findViewById<EditText>(androidx.appcompat.R.id.search_src_text)?.apply {
-            hint = getString(R.string.search_book_key)
-            contentDescription = getString(R.string.search_book_key)
-        }
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                searchView.clearFocus()
-                openOrCloseHelp(false)
-                startSearch(query ?: "我的")
-                return true
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                return false
-            }
-        })
-        searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
-            openOrCloseHelp(hasFocus)
-        }
-        openOrCloseHelp(true)
-    }
-
-    @SuppressLint("SetTextI18n")
-    private fun initHelpView() {
-        viewModel.bookSource?.ruleSearch?.checkKeyWord?.let {
-            if (it.isNotBlank()) {
-                binding.textMy.text = it
-            }
-        }
-        updateHelpContentDescriptions()
-        binding.textMy.onClick {
-            searchView.setQuery(binding.textMy.text, true)
-        }
-        binding.textXt.onClick {
-            searchView.setQuery(binding.textXt.text, true)
-        }
-        binding.textFx.onClick {
-            if (!binding.textFx.text.startsWith("ERROR:")) {
-                searchView.setQuery(binding.textFx.text, true)
-            }
-        }
-        binding.textInfo.onClick {
-            if (!searchView.query.isNullOrBlank()) {
-                searchView.setQuery(searchView.query, true)
-            }
-        }
-        binding.textToc.onClick {
-            prefixAutoComplete("++")
-        }
-        binding.textContent.onClick {
-            prefixAutoComplete("--")
-        }
-        initExploreKinds()
-    }
-    @SuppressLint("SetTextI18n")
-    private fun initExploreKinds() {
-        lifecycleScope.launch {
-            try {
-                val exploreKinds = viewModel.bookSource?.exploreKinds()?.filter {
-                    !it.url.isNullOrBlank()
-                }
-                exploreKinds?.firstOrNull()?.let {
-                    binding.textFx.text = "${it.title}::${it.url}"
-                    updateHelpContentDescriptions()
-                    if (it.title.startsWith("ERROR:")) {
-                        adapter.addItem("获取发现出错\n${it.url}")
-                        openOrCloseHelp(false)
-                        searchView.clearFocus()
-                        return@launch
-
-                    }
-                }
-                @Suppress("USELESS_ELVIS")
-                exploreKinds?.map { it.title ?: "" }?.let { exploreKindTitles ->
-                    binding.textFx.onLongClick {
-                        selector("选择发现", exploreKindTitles) { _, index ->
-                            val explore = exploreKinds[index]
-                            binding.textFx.text = "${explore.title}::${explore.url}"
-                            updateHelpContentDescriptions()
-                            searchView.setQuery(binding.textFx.text, true)
-                        }
-                    }
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+class BookSourceDebugActivity : BaseComposeActivity() {
+    @Composable
+    override fun Content() {
+        val viewModel = koinViewModel<BookSourceDebugViewModel>()
+        val state = viewModel.uiState.collectAsStateWithLifecycle().value
+        LaunchedEffect(Unit) {
+            viewModel.onIntent(BookSourceDebugIntent.Load(intent.getStringExtra("key")))
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is BookSourceDebugEffect.ShowMessage -> toastOnUi(effect.message)
                 }
-            } catch (e: NullPointerException) {
-                adapter.addItem("获取发现出错 JSON 数据错误\n$e")
-                openOrCloseHelp(false)
-                searchView.clearFocus()
             }
         }
-    }
-
-    private fun updateHelpContentDescriptions() = binding.run {
-        textMy.contentDescription = getString(R.string.a11y_debug_example, textMy.text)
-        textXt.contentDescription = getString(R.string.a11y_debug_example, textXt.text)
-        textFx.contentDescription = getString(R.string.a11y_debug_example, textFx.text)
-        textInfo.contentDescription = getString(R.string.a11y_debug_example, textInfo.text)
-        textToc.contentDescription = getString(R.string.a11y_debug_example, textToc.text)
-        textContent.contentDescription = getString(R.string.a11y_debug_example, textContent.text)
-    }
-
-    private fun prefixAutoComplete(prefix: String) {
-        val query = searchView.query
-        if (query.isNullOrBlank() || query.length <= 2) {
-            searchView.setQuery(prefix, false)
-        } else {
-            if (!query.startsWith(prefix)) {
-                searchView.setQuery("$prefix$query", true)
-            } else {
-                searchView.setQuery(query, true)
-            }
+        LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
+            viewModel.onIntent(BookSourceDebugIntent.Stop)
         }
+        BookSourceDebugScreen(
+            state = state,
+            onIntent = viewModel::onIntent,
+            onBack = ::finish,
+        )
     }
-
-    /**
-     * 打开关闭历史界面
-     */
-    private fun openOrCloseHelp(open: Boolean) {
-        if (open) {
-            binding.help.visibility = View.VISIBLE
-        } else {
-            binding.help.visibility = View.GONE
-        }
-    }
-
-    private fun startSearch(key: String) {
-        adapter.clearItems()
-        viewModel.startDebug(key, {
-            binding.rotateLoading.visible()
-        }, {
-            toastOnUi("未获取到书源")
-        })
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_source_debug, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_scan -> qrCodeResult.launch()
-            R.id.menu_search_src -> showDialogFragment(TextDialog("html", viewModel.searchSrc))
-            R.id.menu_book_src -> showDialogFragment(TextDialog("html", viewModel.bookSrc))
-            R.id.menu_toc_src -> showDialogFragment(TextDialog("html", viewModel.tocSrc))
-            R.id.menu_content_src -> showDialogFragment(TextDialog("html", viewModel.contentSrc))
-            R.id.menu_refresh_explore -> lifecycleScope.launch {
-                viewModel.bookSource?.clearExploreKindsCache()
-                adapter.clearItems()
-                openOrCloseHelp(true)
-                initExploreKinds()
-            }
-
-            R.id.menu_help -> showHelp("debugHelp")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
 }

+ 0 - 44
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugAdapter.kt

@@ -1,44 +0,0 @@
-package io.legado.app.ui.book.source.debug
-
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.databinding.ItemLogBinding
-
-class BookSourceDebugAdapter(context: Context) :
-    RecyclerAdapter<String, ItemLogBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
-        return ItemLogBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemLogBinding,
-        item: String,
-        payloads: MutableList<Any>
-    ) {
-        binding.apply {
-            if (textView.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        textView.isCursorVisible = false
-                        textView.isCursorVisible = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {}
-                }
-                textView.addOnAttachStateChangeListener(listener)
-                textView.setTag(R.id.tag1, listener)
-            }
-            textView.text = item
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
-        //nothing
-    }
-}

+ 56 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugContract.kt

@@ -0,0 +1,56 @@
+package io.legado.app.ui.book.source.debug
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.legado.app.model.Debug
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class BookSourceDebugTarget { Search, Explore, Info, Toc, Content }
+enum class BookSourceDebugStatus { Loading, Idle, Running, Success, Failed, Cancelled }
+enum class BookSourceDebugFilter { All, Messages, Sources, Errors }
+
+@Immutable
+data class BookSourceDebugEntryUi(
+    val id: Long,
+    val kind: Debug.EventKind,
+    val message: String,
+    val timestamp: Long,
+    val elapsedMillis: Long,
+)
+
+@Immutable
+data class BookSourceDebugExampleUi(
+    val title: String,
+    val target: BookSourceDebugTarget,
+    val value: String,
+)
+
+@Stable
+data class BookSourceDebugUiState(
+    val sourceName: String = "",
+    val query: String = "",
+    val target: BookSourceDebugTarget = BookSourceDebugTarget.Search,
+    val status: BookSourceDebugStatus = BookSourceDebugStatus.Loading,
+    val filter: BookSourceDebugFilter = BookSourceDebugFilter.All,
+    val entries: ImmutableList<BookSourceDebugEntryUi> = persistentListOf(),
+    val examples: ImmutableList<BookSourceDebugExampleUi> = persistentListOf(),
+    val selectedEntryId: Long? = null,
+)
+
+sealed interface BookSourceDebugIntent {
+    data class Load(val sourceUrl: String?) : BookSourceDebugIntent
+    data class SetQuery(val value: String) : BookSourceDebugIntent
+    data class SelectTarget(val target: BookSourceDebugTarget) : BookSourceDebugIntent
+    data class SelectFilter(val filter: BookSourceDebugFilter) : BookSourceDebugIntent
+    data class UseExample(val example: BookSourceDebugExampleUi) : BookSourceDebugIntent
+    data class ShowEntry(val id: Long) : BookSourceDebugIntent
+    data object DismissEntry : BookSourceDebugIntent
+    data object Start : BookSourceDebugIntent
+    data object Stop : BookSourceDebugIntent
+    data object Clear : BookSourceDebugIntent
+}
+
+sealed interface BookSourceDebugEffect {
+    data class ShowMessage(val message: String) : BookSourceDebugEffect
+}

+ 0 - 60
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugModel.kt

@@ -1,60 +0,0 @@
-package io.legado.app.ui.book.source.debug
-
-import android.app.Application
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSource
-import io.legado.app.model.Debug
-
-class BookSourceDebugModel(application: Application) : BaseViewModel(application),
-    Debug.Callback {
-
-    var bookSource: BookSource? = null
-    private var callback: ((Int, String) -> Unit)? = null
-    var searchSrc: String? = null
-    var bookSrc: String? = null
-    var tocSrc: String? = null
-    var contentSrc: String? = null
-
-    fun init(sourceUrl: String?, finally: () -> Unit) {
-        sourceUrl?.let {
-            //优先使用这个,不会抛出异常
-            execute {
-                bookSource = appDb.bookSourceDao.getBookSource(sourceUrl)
-            }.onFinally {
-                finally.invoke()
-            }
-        }
-    }
-
-    fun observe(callback: (Int, String) -> Unit) {
-        this.callback = callback
-    }
-
-    fun startDebug(key: String, start: (() -> Unit)? = null, error: (() -> Unit)? = null) {
-        execute {
-            Debug.callback = this@BookSourceDebugModel
-            Debug.startDebug(this, bookSource!!, key)
-        }.onStart {
-            start?.invoke()
-        }.onError {
-            error?.invoke()
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        when (state) {
-            10 -> searchSrc = msg
-            20 -> bookSrc = msg
-            30 -> tocSrc = msg
-            40 -> contentSrc = msg
-            else -> callback?.invoke(state, msg)
-        }
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        Debug.cancelDebug(true)
-    }
-
-}

+ 316 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugScreen.kt

@@ -0,0 +1,316 @@
+package io.legado.app.ui.book.source.debug
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ClearAll
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.model.Debug
+import io.legado.app.ui.about.MarkdownSheet
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.ToggleChip
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.LogUtils
+import java.util.Date
+
+@Composable
+fun BookSourceDebugScreen(
+    state: BookSourceDebugUiState,
+    onIntent: (BookSourceDebugIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    BackHandler(onBack = onBack)
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val listState = rememberLazyListState()
+    val visibleEntries = state.entries.filter { entry ->
+        when (state.filter) {
+            BookSourceDebugFilter.All -> true
+            BookSourceDebugFilter.Messages -> entry.kind == Debug.EventKind.Message || entry.kind == Debug.EventKind.Completed
+            BookSourceDebugFilter.Sources -> entry.kind.isSourcePayload
+            BookSourceDebugFilter.Errors -> entry.kind == Debug.EventKind.Error
+        }
+    }
+    LaunchedEffect(visibleEntries.size, state.status) {
+        if (state.status == BookSourceDebugStatus.Running && visibleEntries.isNotEmpty()) {
+            listState.animateScrollToItem(visibleEntries.lastIndex)
+        }
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "调试",
+                navigationIcon = { TopBarNavigationButton(onClick = onBack) },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(BookSourceDebugIntent.Clear) },
+                        imageVector = Icons.Default.ClearAll,
+                        contentDescription = "清空日志",
+                    )
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = {
+                    onIntent(
+                        if (state.status == BookSourceDebugStatus.Running) BookSourceDebugIntent.Stop
+                        else BookSourceDebugIntent.Start
+                    )
+                },
+                icon = if (state.status == BookSourceDebugStatus.Running) Icons.Default.Stop else Icons.Default.PlayArrow,
+                tooltipText = if (state.status == BookSourceDebugStatus.Running) "停止" else "开始调试",
+            )
+        },
+    ) { padding ->
+        LazyColumn(
+            state = listState,
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = PaddingValues(
+                start = 16.dp,
+                end = 16.dp,
+                top = padding.calculateTopPadding() + 12.dp,
+                bottom = padding.calculateBottomPadding() + 96.dp,
+            ),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            item(key = "controls", contentType = "controls") {
+                DebugControls(state, onIntent)
+            }
+            item(key = "filters", contentType = "filters") {
+                DebugChipRow {
+                    items(BookSourceDebugFilter.entries, key = { it.name }) { filter ->
+                        ToggleChip(
+                            label = filter.title,
+                            selected = state.filter == filter,
+                            onToggle = { onIntent(BookSourceDebugIntent.SelectFilter(filter)) },
+                        )
+                    }
+                }
+            }
+            if (visibleEntries.isEmpty()) {
+                item(key = "empty") {
+                    EmptyMessage(
+                        message = if (state.status == BookSourceDebugStatus.Running) "等待调试日志…" else "输入内容并开始调试",
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .height(240.dp),
+                    )
+                }
+            }
+            items(visibleEntries, key = { it.id }, contentType = { it.kind }) { entry ->
+                DebugEntryCard(
+                    entry = entry,
+                    modifier = Modifier.animateItem(),
+                    onClick = { onIntent(BookSourceDebugIntent.ShowEntry(entry.id)) },
+                )
+            }
+        }
+    }
+    val selectedEntry = state.entries.firstOrNull { it.id == state.selectedEntryId }
+    MarkdownSheet(
+        show = selectedEntry != null,
+        title = selectedEntry?.kind?.title.orEmpty(),
+        content = selectedEntry?.message.orEmpty(),
+        onDismissRequest = { onIntent(BookSourceDebugIntent.DismissEntry) },
+    )
+}
+
+@Composable
+private fun DebugControls(
+    state: BookSourceDebugUiState,
+    onIntent: (BookSourceDebugIntent) -> Unit
+) {
+    val visibleExamples = remember(state.examples, state.target) {
+        state.examples.filter { it.target == state.target }.take(8)
+    }
+    GlassCard {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.Center
+            ) {
+                AppText(
+                    text = state.sourceName,
+                    style = LegadoTheme.typography.labelMediumEmphasized
+                )
+            }
+            DebugChipRow {
+                items(BookSourceDebugTarget.entries, key = { it.name }) { target ->
+                    ToggleChip(
+                        label = target.title,
+                        selected = state.target == target,
+                        onToggle = { onIntent(BookSourceDebugIntent.SelectTarget(target)) },
+                    )
+                }
+            }
+            AppTextField(
+                value = state.query,
+                onValueChange = { onIntent(BookSourceDebugIntent.SetQuery(it)) },
+                label = state.target.hint,
+                modifier = Modifier.fillMaxWidth(),
+                enabled = state.status != BookSourceDebugStatus.Loading,
+                maxLines = 4,
+            )
+            if (visibleExamples.isNotEmpty()) {
+                DebugChipRow {
+                    items(visibleExamples, key = { "${it.target}:${it.value}" }) { example ->
+                        ToggleChip(
+                            label = example.title,
+                            selected = state.target == example.target && state.query == example.value,
+                            onToggle = { onIntent(BookSourceDebugIntent.UseExample(example)) },
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun DebugChipRow(content: LazyListScope.() -> Unit) {
+    val state = rememberLazyListState()
+    LazyRow(
+        state = state,
+        modifier = Modifier
+            .fillMaxWidth()
+            .fadingEdge(state, gradientWidth = 16.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        content = content,
+    )
+}
+
+@Composable
+fun DebugEntryCard(
+    entry: BookSourceDebugEntryUi,
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit,
+) {
+    val containerColor = when (entry.kind) {
+        Debug.EventKind.Message -> LegadoTheme.colorScheme.surfaceContainerHigh
+        Debug.EventKind.SearchSource,
+        Debug.EventKind.InfoSource,
+        Debug.EventKind.TocSource,
+        Debug.EventKind.ContentSource -> LegadoTheme.colorScheme.primaryContainer
+        Debug.EventKind.Error -> LegadoTheme.colorScheme.errorContainer
+        Debug.EventKind.Completed -> LegadoTheme.colorScheme.tertiaryContainer
+    }
+    val accentColor = when (entry.kind) {
+        Debug.EventKind.Message -> LegadoTheme.colorScheme.onSurfaceVariant
+        Debug.EventKind.SearchSource,
+        Debug.EventKind.InfoSource,
+        Debug.EventKind.TocSource,
+        Debug.EventKind.ContentSource -> LegadoTheme.colorScheme.onPrimaryContainer
+        Debug.EventKind.Error -> LegadoTheme.colorScheme.onErrorContainer
+        Debug.EventKind.Completed -> LegadoTheme.colorScheme.onTertiaryContainer
+    }
+    GlassCard(
+        modifier = modifier,
+        onClick = onClick,
+        containerColor = containerColor,
+        contentColor = accentColor,
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween
+            ) {
+                AppText(
+                    text = entry.kind.title,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = accentColor,
+                )
+                AppText(
+                    text = "+%.3fs".format(entry.elapsedMillis / 1000.0),
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+            AppText(
+                text = entry.message,
+                maxLines = 4,
+                overflow = TextOverflow.Ellipsis,
+                style = LegadoTheme.typography.bodySmall,
+            )
+            AppText(
+                text = LogUtils.logTimeFormat.format(Date(entry.timestamp)),
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+    }
+}
+
+private val Debug.EventKind.title: String
+    get() = when (this) {
+        Debug.EventKind.Message -> "过程"
+        Debug.EventKind.SearchSource -> "搜索/发现响应"
+        Debug.EventKind.InfoSource -> "详情响应"
+        Debug.EventKind.TocSource -> "目录响应"
+        Debug.EventKind.ContentSource -> "正文响应"
+        Debug.EventKind.Error -> "错误"
+        Debug.EventKind.Completed -> "完成"
+    }
+
+private val BookSourceDebugTarget.title
+    get() = when (this) {
+        BookSourceDebugTarget.Search -> "搜索"
+        BookSourceDebugTarget.Explore -> "发现"
+        BookSourceDebugTarget.Info -> "详情"
+        BookSourceDebugTarget.Toc -> "目录"
+        BookSourceDebugTarget.Content -> "正文"
+    }
+private val BookSourceDebugTarget.hint
+    get() = when (this) {
+        BookSourceDebugTarget.Search -> "搜索关键字"
+        BookSourceDebugTarget.Explore -> "发现 URL"
+        BookSourceDebugTarget.Info -> "详情页 URL"
+        BookSourceDebugTarget.Toc -> "目录页 URL"
+        BookSourceDebugTarget.Content -> "正文页 URL"
+    }
+private val BookSourceDebugFilter.title
+    get() = when (this) {
+        BookSourceDebugFilter.All -> "全部"
+        BookSourceDebugFilter.Messages -> "过程"
+        BookSourceDebugFilter.Sources -> "响应"
+        BookSourceDebugFilter.Errors -> "错误"
+    }

+ 150 - 0
app/src/main/java/io/legado/app/ui/book/source/debug/BookSourceDebugViewModel.kt

@@ -0,0 +1,150 @@
+package io.legado.app.ui.book.source.debug
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BookSource
+import io.legado.app.help.source.exploreKinds
+import io.legado.app.model.Debug
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicLong
+
+class BookSourceDebugViewModel(application: Application) : AndroidViewModel(application) {
+    private val _uiState = MutableStateFlow(BookSourceDebugUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<BookSourceDebugEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+    private val nextId = AtomicLong()
+    private var source: BookSource? = null
+    private var session: Debug.Session? = null
+
+    fun onIntent(intent: BookSourceDebugIntent) {
+        when (intent) {
+            is BookSourceDebugIntent.Load -> load(intent.sourceUrl)
+            is BookSourceDebugIntent.SetQuery -> _uiState.update { it.copy(query = intent.value) }
+            is BookSourceDebugIntent.SelectTarget -> _uiState.update { it.copy(target = intent.target) }
+            is BookSourceDebugIntent.SelectFilter -> _uiState.update { it.copy(filter = intent.filter) }
+            is BookSourceDebugIntent.UseExample -> _uiState.update {
+                it.copy(target = intent.example.target, query = intent.example.value)
+            }
+            is BookSourceDebugIntent.ShowEntry -> _uiState.update { it.copy(selectedEntryId = intent.id) }
+            BookSourceDebugIntent.DismissEntry -> _uiState.update { it.copy(selectedEntryId = null) }
+            BookSourceDebugIntent.Start -> start()
+            BookSourceDebugIntent.Stop -> stop()
+            BookSourceDebugIntent.Clear -> _uiState.update { it.copy(entries = persistentListOf()) }
+        }
+    }
+
+    private fun load(sourceUrl: String?) = viewModelScope.launch {
+        val loaded: BookSource? = withContext(Dispatchers.IO) {
+            sourceUrl?.let { appDb.bookSourceDao.getBookSource(it) }
+        }
+        source = loaded
+        if (loaded == null) {
+            _uiState.update { it.copy(status = BookSourceDebugStatus.Failed) }
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("未获取到书源"))
+            return@launch
+        }
+        val examples = buildList<BookSourceDebugExampleUi> {
+            val configuredKeyword = loaded.ruleSearch?.checkKeyWord
+            val keyword = if (configuredKeyword.isNullOrBlank()) "我的" else configuredKeyword
+            add(BookSourceDebugExampleUi(keyword, BookSourceDebugTarget.Search, keyword))
+            add(BookSourceDebugExampleUi("系统", BookSourceDebugTarget.Search, "系统"))
+            val kinds = try { loaded.exploreKinds() } catch (_: Exception) { emptyList() }
+            kinds.filter { !it.url.isNullOrBlank() }.forEach { kind ->
+                add(BookSourceDebugExampleUi(kind.title, BookSourceDebugTarget.Explore, kind.url.orEmpty()))
+            }
+        }.toImmutableList()
+        _uiState.update {
+            it.copy(
+                sourceName = loaded.bookSourceName,
+                query = examples.firstOrNull()?.value.orEmpty(),
+                status = BookSourceDebugStatus.Idle,
+                examples = examples,
+            )
+        }
+    }
+
+    private fun start() {
+        val currentSource = source
+        if (currentSource == null) {
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("未获取到书源"))
+            return
+        }
+        val state = _uiState.value
+        if (state.query.isBlank()) {
+            _effects.tryEmit(BookSourceDebugEffect.ShowMessage("调试内容不能为空")); return
+        }
+        session?.cancel()
+        _uiState.update { it.copy(entries = persistentListOf(), status = BookSourceDebugStatus.Running) }
+        val key = when (state.target) {
+            BookSourceDebugTarget.Search -> state.query
+            BookSourceDebugTarget.Explore -> "发现::${state.query}"
+            BookSourceDebugTarget.Info -> state.query
+            BookSourceDebugTarget.Toc -> "++${state.query.removePrefix("++")}"
+            BookSourceDebugTarget.Content -> "--${state.query.removePrefix("--")}"
+        }
+        session = Debug.startDebug(viewModelScope, currentSource, key).also(::collectSession)
+    }
+
+    private fun stop() {
+        session?.cancel()
+        session = null
+        _uiState.update { state ->
+            if (state.status == BookSourceDebugStatus.Running) state.copy(status = BookSourceDebugStatus.Cancelled)
+            else state
+        }
+    }
+
+    private fun collectSession(debugSession: Debug.Session) {
+        viewModelScope.launch {
+            debugSession.events.collect(::onDebugEvent)
+            if (session === debugSession) {
+                session = null
+                _uiState.update { state ->
+                    if (state.status == BookSourceDebugStatus.Running) {
+                        state.copy(status = BookSourceDebugStatus.Cancelled)
+                    } else state
+                }
+            }
+        }
+    }
+
+    private fun onDebugEvent(event: Debug.Event) {
+        _uiState.update { state ->
+            val entry = BookSourceDebugEntryUi(
+                id = nextId.incrementAndGet(),
+                kind = event.kind,
+                message = event.message,
+                timestamp = event.timestamp,
+                elapsedMillis = event.elapsedMillis,
+            )
+            state.copy(
+                entries = (state.entries + entry).takeLast(MAX_LOG_ENTRIES).toImmutableList(),
+                status = when (event.kind) {
+                    Debug.EventKind.Error -> BookSourceDebugStatus.Failed
+                    Debug.EventKind.Completed -> BookSourceDebugStatus.Success
+                    else -> state.status
+                },
+            )
+        }
+    }
+
+    override fun onCleared() {
+        session?.cancel()
+        session = null
+        super.onCleared()
+    }
+
+    companion object { private const val MAX_LOG_ENTRIES = 1_000 }
+}

+ 88 - 693
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt

@@ -1,733 +1,128 @@
 package io.legado.app.ui.book.source.edit
 
 import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.widget.EditText
-import androidx.activity.viewModels
-import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
-import com.google.android.material.tabs.TabLayout
-import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.BookSourceType
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.rule.BookInfoRule
-import io.legado.app.data.entities.rule.ContentRule
-import io.legado.app.data.entities.rule.ExploreRule
-import io.legado.app.data.entities.rule.SearchRule
-import io.legado.app.data.entities.rule.TocRule
-import io.legado.app.databinding.ActivityBookSourceEditBinding
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
-import io.legado.app.ui.about.AppLogDialog
 import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
-import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.dialog.UrlOptionDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.ui.widget.keyboard.KeyboardToolPop
-import io.legado.app.ui.widget.recycler.NoChildScrollLinearLayoutManager
-import io.legado.app.ui.widget.text.EditEntity
 import io.legado.app.utils.GSON
-import io.legado.app.utils.imeHeight
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.launch
-import io.legado.app.utils.navigationBarHeight
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getClipText
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.share
-import io.legado.app.utils.shareWithQr
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.views.bottomPadding
-
-class BookSourceEditActivity :
-    VMBaseActivity<ActivityBookSourceEditBinding, BookSourceEditViewModel>(),
-    KeyboardToolPop.CallBack,
-    VariableDialog.Callback {
-
-    override val binding by viewBinding(ActivityBookSourceEditBinding::inflate)
-    override val viewModel by viewModels<BookSourceEditViewModel>()
-
-    private val adapter by lazy { BookSourceEditAdapter() }
-    private val sourceEntities: ArrayList<EditEntity> = ArrayList()
-    private val searchEntities: ArrayList<EditEntity> = ArrayList()
-    private val exploreEntities: ArrayList<EditEntity> = ArrayList()
-    private val homepageEntities: ArrayList<EditEntity> = ArrayList()
-    private val infoEntities: ArrayList<EditEntity> = ArrayList()
-    private val tocEntities: ArrayList<EditEntity> = ArrayList()
-    private val contentEntities: ArrayList<EditEntity> = ArrayList()
-
-    //    private val reviewEntities: ArrayList<EditEntity> = ArrayList()
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        viewModel.importSource(it) { source ->
-            upSourceView(source)
-        }
-    }
-    private val selectDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            if (uri.isContentScheme()) {
-                sendText(uri.toString())
-            } else {
-                sendText(uri.path.toString())
-            }
-        }
-    }
-
-    private val softKeyboardTool by lazy {
-        KeyboardToolPop(this, lifecycleScope, binding.root, this)
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        softKeyboardTool.attachToWindow(window)
-        initView()
-        viewModel.initData(intent) {
-            upSourceView(viewModel.bookSource)
-        }
-    }
-
-    override fun onPostCreate(savedInstanceState: Bundle?) {
-        super.onPostCreate(savedInstanceState)
-        if (!LocalConfig.ruleHelpVersionIsLast) {
-            showHelp("ruleHelp")
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.source_edit, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !getSource().loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_save -> viewModel.save(getSource()) {
-                setResult(RESULT_OK, Intent().putExtra("origin", it.bookSourceUrl))
-                finish()
-            }
-
-            R.id.menu_debug_source -> viewModel.save(getSource()) { source ->
-                startActivity<BookSourceDebugActivity> {
-                    putExtra("key", source.bookSourceUrl)
-                }
-            }
-
-            R.id.menu_clear_cookie -> viewModel.clearCookie(getSource().bookSourceUrl)
-            R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete
-            R.id.menu_copy_source -> sendToClip(GSON.toJson(getSource()))
-            R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) }
-            R.id.menu_qr_code_camera -> qrCodeResult.launch()
-            R.id.menu_share_str -> share(GSON.toJson(getSource()))
-            R.id.menu_share_qr -> shareWithQr(
-                GSON.toJson(getSource()),
-                getString(R.string.share_book_source),
-                ErrorCorrectionLevel.L
-            )
-
-            R.id.menu_log -> showDialogFragment<AppLogDialog>()
-            R.id.menu_help -> showHelp("ruleHelp")
-            R.id.menu_login -> viewModel.save(getSource()) { source ->
-                startActivity<SourceLoginActivity> {
-                    putExtra("type", "bookSource")
-                    putExtra("key", source.bookSourceUrl)
-                }
-            }
-
-            R.id.menu_set_source_variable -> setSourceVariable()
-            R.id.menu_search -> viewModel.save(getSource()) { source ->
-                startActivity<SearchActivity> {
-                    putExtra("searchScope", SearchScope(source).toString())
-                }
-            }
-
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initView() {
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_base)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_search)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_find)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_homepage)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_info)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_toc)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_content)
-        })
-//        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.layoutManager = NoChildScrollLinearLayoutManager(this)
-        binding.recyclerView.adapter = adapter
-//        binding.tabLayout.setBackgroundColor(backgroundColor)
-//        binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
-        binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
-            override fun onTabReselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabUnselected(tab: TabLayout.Tab?) {
+import org.koin.androidx.compose.koinViewModel
+
+class BookSourceEditActivity : BaseComposeActivity(), VariableDialog.Callback {
+
+    @Composable
+    override fun Content() {
+        val viewModel = koinViewModel<BookSourceEditViewModel>()
+        val state by viewModel.uiState.collectAsStateWithLifecycle()
+        var menuExpanded by remember { mutableStateOf(false) }
+
+        LaunchedEffect(Unit) {
+            viewModel.onIntent(BookSourceEditIntent.Load(intent.getStringExtra("sourceUrl")))
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is BookSourceEditEffect.Finish -> {
+                        if (effect.sourceUrl.isNotEmpty()) {
+                            setResult(RESULT_OK, Intent().putExtra("origin", effect.sourceUrl))
+                        }
+                        finish()
+                    }
 
-            }
+                    is BookSourceEditEffect.OpenDebug -> startActivity<BookSourceDebugActivity> {
+                        putExtra(
+                            "key",
+                            effect.sourceUrl
+                        )
+                    }
 
-            override fun onTabSelected(tab: TabLayout.Tab?) {
-                setEditEntities(tab?.position)
-            }
-        })
-        binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
-            val navigationBarHeight = windowInsets.navigationBarHeight
-            val imeHeight = windowInsets.imeHeight
-            view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0
-            softKeyboardTool.initialPadding = imeHeight
-            windowInsets
-        }
-    }
+                    is BookSourceEditEffect.OpenLogin -> startActivity<SourceLoginActivity> {
+                        putExtra("type", "bookSource"); putExtra("key", effect.sourceUrl)
+                    }
 
-    override fun finish() {
-        val source = getSource()
-        if (!source.equal(viewModel.bookSource ?: BookSource())) {
-            alert(R.string.exit) {
-                setMessage(R.string.exit_no_save)
-                positiveButton(R.string.yes)
-                negativeButton(R.string.no) {
-                    super.finish()
-                }
-            }
-        } else {
-            super.finish()
-        }
-    }
+                    is BookSourceEditEffect.OpenSearch -> GSON.fromJsonObject<BookSource>(effect.sourceJson)
+                        .getOrNull()?.let { source ->
+                        startActivity<SearchActivity> {
+                            putExtra(
+                                "searchScope",
+                                SearchScope(source).toString()
+                            )
+                        }
+                    }
 
-    override fun onDestroy() {
-        super.onDestroy()
-        softKeyboardTool.dismiss()
-    }
+                    is BookSourceEditEffect.CopyText -> sendToClip(effect.text)
+                    is BookSourceEditEffect.ShareText -> share(effect.text)
+                    BookSourceEditEffect.ReadClipboard -> {
+                        val text = getClipText()
+                        if (text.isNullOrBlank()) toastOnUi("剪贴板为空")
+                        else viewModel.onIntent(BookSourceEditIntent.ImportText(text))
+                    }
 
-    private fun setEditEntities(tabPosition: Int?) {
-        adapter.editEntities = when (tabPosition) {
-            1 -> searchEntities
-            2 -> exploreEntities
-            3 -> homepageEntities
-            4 -> infoEntities
-            5 -> tocEntities
-            6 -> contentEntities
-            else -> sourceEntities
-        }
-        binding.recyclerView.scrollToPosition(0)
-    }
+                    BookSourceEditEffect.ConfirmDiscard -> alert(R.string.exit) {
+                        setMessage(R.string.exit_no_save)
+                        positiveButton(R.string.yes) { viewModel.onIntent(BookSourceEditIntent.DiscardChanges) }
+                        negativeButton(R.string.no)
+                    }
 
-    private fun upSourceView(bookSource: BookSource?) {
-        val bs = bookSource ?: BookSource()
-        bs.let {
-            binding.cbIsEnable.isChecked = it.enabled
-            binding.cbIsEnableExplore.isChecked = it.enabledExplore
-            binding.cbIsEnableCookie.isChecked = it.enabledCookieJar ?: false
-            binding.spType.setSelection(
-                when (it.bookSourceType) {
-                    BookSourceType.file -> 3
-                    BookSourceType.image -> 2
-                    BookSourceType.audio -> 1
-                    else -> 0
+                    is BookSourceEditEffect.OpenVariable -> openVariable(effect.sourceUrl)
+                    is BookSourceEditEffect.ShowMessage -> toastOnUi(effect.message)
                 }
-            )
-            binding.cbIsEventListener.isChecked = it.eventListener
-            binding.cbIsCustomButton.isChecked = it.customButton
-        }
-        // 基本信息
-        sourceEntities.clear()
-        sourceEntities.apply {
-            add(EditEntity("bookSourceUrl", bs.bookSourceUrl, R.string.source_url))
-            add(EditEntity("bookSourceName", bs.bookSourceName, R.string.source_name))
-            add(EditEntity("bookSourceGroup", bs.bookSourceGroup, R.string.source_group))
-            add(EditEntity("bookSourceComment", bs.bookSourceComment, R.string.comment))
-            add(EditEntity("loginUrl", bs.loginUrl, R.string.login_url))
-            add(EditEntity("loginUi", bs.loginUi, R.string.login_ui))
-            add(EditEntity("loginCheckJs", bs.loginCheckJs, R.string.login_check_js))
-            add(EditEntity("coverDecodeJs", bs.coverDecodeJs, R.string.cover_decode_js))
-            add(EditEntity("bookUrlPattern", bs.bookUrlPattern, R.string.book_url_pattern))
-            add(EditEntity("header", bs.header, R.string.source_http_header))
-            add(EditEntity("variableComment", bs.variableComment, R.string.variable_comment))
-            add(EditEntity("concurrentRate", bs.concurrentRate, R.string.concurrent_rate))
-            add(EditEntity("jsLib", bs.jsLib, "jsLib"))
-        }
-        // 搜索
-        val sr = bs.getSearchRule()
-        searchEntities.clear()
-        searchEntities.apply {
-            add(EditEntity("searchUrl", bs.searchUrl, R.string.r_search_url))
-            add(EditEntity("checkKeyWord", sr.checkKeyWord, R.string.check_key_word))
-            add(EditEntity("bookList", sr.bookList, R.string.r_book_list))
-            add(EditEntity("name", sr.name, R.string.r_book_name))
-            add(EditEntity("author", sr.author, R.string.r_author))
-            add(EditEntity("kind", sr.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", sr.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", sr.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", sr.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", sr.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("bookUrl", sr.bookUrl, R.string.r_book_url))
-        }
-        // 发现
-        val er = bs.getExploreRule()
-        exploreEntities.clear()
-        exploreEntities.apply {
-            add(EditEntity("exploreUrl", bs.exploreUrl, R.string.r_find_url))
-            add(EditEntity("bookList", er.bookList, R.string.r_book_list))
-            add(EditEntity("name", er.name, R.string.r_book_name))
-            add(EditEntity("author", er.author, R.string.r_author))
-            add(EditEntity("kind", er.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", er.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", er.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", er.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url))
-        }
-        // 主页模块
-        homepageEntities.clear()
-        homepageEntities.apply {
-            add(EditEntity("homepageModules", bs.homepageModules, R.string.homepage_modules))
-        }
-        // 详情页
-        val ir = bs.getBookInfoRule()
-        infoEntities.clear()
-        infoEntities.apply {
-            add(EditEntity("init", ir.init, R.string.rule_book_info_init))
-            add(EditEntity("name", ir.name, R.string.r_book_name))
-            add(EditEntity("author", ir.author, R.string.r_author))
-            add(EditEntity("kind", ir.kind, R.string.rule_book_kind))
-            add(EditEntity("wordCount", ir.wordCount, R.string.rule_word_count))
-            add(EditEntity("lastChapter", ir.lastChapter, R.string.rule_last_chapter))
-            add(EditEntity("intro", ir.intro, R.string.rule_book_intro))
-            add(EditEntity("coverUrl", ir.coverUrl, R.string.rule_cover_url))
-            add(EditEntity("tocUrl", ir.tocUrl, R.string.rule_toc_url))
-            add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name))
-            add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule))
-            add(EditEntity("relatedBooks", ir.relatedBooks, R.string.related_books))
-        }
-        // 目录页
-        val tr = bs.getTocRule()
-        tocEntities.clear()
-        tocEntities.apply {
-            add(EditEntity("preUpdateJs", tr.preUpdateJs, R.string.pre_update_js))
-            add(EditEntity("chapterList", tr.chapterList, R.string.rule_chapter_list))
-            add(EditEntity("chapterName", tr.chapterName, R.string.rule_chapter_name))
-            add(EditEntity("chapterUrl", tr.chapterUrl, R.string.rule_chapter_url))
-            add(EditEntity("formatJs", tr.formatJs, R.string.format_js_rule))
-            add(EditEntity("isVolume", tr.isVolume, R.string.rule_is_volume))
-            add(EditEntity("updateTime", tr.updateTime, R.string.rule_update_time))
-            add(EditEntity("isVip", tr.isVip, R.string.rule_is_vip))
-            add(EditEntity("isPay", tr.isPay, R.string.rule_is_pay))
-            add(EditEntity("nextTocUrl", tr.nextTocUrl, R.string.rule_next_toc_url))
-        }
-        // 正文页
-        val cr = bs.getContentRule()
-        contentEntities.clear()
-        contentEntities.apply {
-            add(EditEntity("content", cr.content, R.string.rule_book_content))
-            add(EditEntity("subContent", cr.subContent, R.string.rule_sub_content))
-            add(EditEntity("title", cr.title, R.string.rule_chapter_name))
-            add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content))
-            add(EditEntity("webJs", cr.webJs, R.string.rule_web_js))
-            add(EditEntity("sourceRegex", cr.sourceRegex, R.string.rule_source_regex))
-            add(EditEntity("replaceRegex", cr.replaceRegex, R.string.rule_replace_regex))
-            add(EditEntity("imageStyle", cr.imageStyle, R.string.rule_image_style))
-            add(EditEntity("imageDecode", cr.imageDecode, R.string.rule_image_decode))
-            add(EditEntity("payAction", cr.payAction, R.string.rule_pay_action))
-            add(EditEntity("callBackJs", cr.callBackJs, R.string.rule_call_back))
-        }
-
-//        val rr = bs.getReviewRule()
-//        reviewEntities.clear()
-//        reviewEntities.apply {
-//            add(EditEntity("reviewUrl", rr.reviewUrl, R.string.rule_review_url))
-//            add(EditEntity("avatarRule", rr.avatarRule, R.string.rule_avatar))
-//            add(EditEntity("contentRule", rr.contentRule, R.string.rule_review_content))
-//            add(EditEntity("postTimeRule", rr.postTimeRule, R.string.rule_post_time))
-//            add(EditEntity("reviewQuoteUrl", rr.reviewQuoteUrl, R.string.rule_review_quote))
-//            add(EditEntity("voteUpUrl", rr.voteUpUrl, R.string.review_vote_up))
-//            add(EditEntity("voteDownUrl", rr.voteDownUrl, R.string.review_vote_down))
-//            add(EditEntity("postReviewUrl", rr.postReviewUrl, R.string.post_review_url))
-//            add(EditEntity("postQuoteUrl", rr.postQuoteUrl, R.string.post_quote_url))
-//            add(EditEntity("deleteUrl", rr.deleteUrl, R.string.delete_review_url))
-//        }
-        binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0))
-        setEditEntities(0)
-    }
-
-    private fun getSource(): BookSource {
-        val source = viewModel.bookSource?.copy() ?: BookSource()
-        source.enabled = binding.cbIsEnable.isChecked
-        source.enabledExplore = binding.cbIsEnableExplore.isChecked
-        source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
-        source.bookSourceType = when (binding.spType.selectedItemPosition) {
-            3 -> BookSourceType.file
-            2 -> BookSourceType.image
-            1 -> BookSourceType.audio
-            else -> BookSourceType.default
-        }
-        source.eventListener = binding.cbIsEventListener.isChecked
-        source.customButton = binding.cbIsCustomButton.isChecked
-        val searchRule = SearchRule()
-        val exploreRule = ExploreRule()
-        val bookInfoRule = BookInfoRule()
-        val tocRule = TocRule()
-        val contentRule = ContentRule()
-//        val reviewRule = ReviewRule()
-        sourceEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "bookSourceUrl" -> source.bookSourceUrl = it.value ?: ""
-                "bookSourceName" -> source.bookSourceName = it.value ?: ""
-                "bookSourceGroup" -> source.bookSourceGroup = it.value
-                "loginUrl" -> source.loginUrl = it.value
-                "loginUi" -> source.loginUi = it.value
-                "loginCheckJs" -> source.loginCheckJs = it.value
-                "coverDecodeJs" -> source.coverDecodeJs = it.value
-                "bookUrlPattern" -> source.bookUrlPattern = it.value
-                "header" -> source.header = it.value
-                "bookSourceComment" -> source.bookSourceComment = it.value
-                "concurrentRate" -> source.concurrentRate = it.value
-                "variableComment" -> source.variableComment = it.value
-                "jsLib" -> source.jsLib = it.value
             }
         }
-        searchEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "searchUrl" -> source.searchUrl = it.value
-                "checkKeyWord" -> searchRule.checkKeyWord = it.value
-                "bookList" -> searchRule.bookList = it.value
-                "name" -> searchRule.name =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "author" -> searchRule.author =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "kind" -> searchRule.kind =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
 
-                "intro" -> searchRule.intro =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-//                "updateTime" -> searchRule.updateTime =
-//                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "wordCount" -> searchRule.wordCount =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "lastChapter" -> searchRule.lastChapter =
-                    viewModel.ruleComplete(it.value, searchRule.bookList)
-
-                "coverUrl" -> searchRule.coverUrl =
-                    viewModel.ruleComplete(it.value, searchRule.bookList, 3)
-
-                "bookUrl" -> searchRule.bookUrl =
-                    viewModel.ruleComplete(it.value, searchRule.bookList, 2)
-            }
-        }
-        exploreEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "exploreUrl" -> source.exploreUrl = it.value
-                "bookList" -> exploreRule.bookList = it.value
-                "name" -> exploreRule.name =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "author" -> exploreRule.author =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "kind" -> exploreRule.kind =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "intro" -> exploreRule.intro =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-//                "updateTime" -> exploreRule.updateTime =
-//                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "wordCount" -> exploreRule.wordCount =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "lastChapter" -> exploreRule.lastChapter =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList)
-
-                "coverUrl" -> exploreRule.coverUrl =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList, 3)
-
-                "bookUrl" -> exploreRule.bookUrl =
-                    viewModel.ruleComplete(it.value, exploreRule.bookList, 2)
-            }
-        }
-        homepageEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "homepageModules" -> source.homepageModules = it.value
-            }
-        }
-        infoEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "init" -> bookInfoRule.init = it.value
-                "name" -> bookInfoRule.name = viewModel.ruleComplete(it.value, bookInfoRule.init)
-                "author" -> bookInfoRule.author =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "kind" -> bookInfoRule.kind =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "intro" -> bookInfoRule.intro =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-//                "updateTime" -> bookInfoRule.updateTime =
-//                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "wordCount" -> bookInfoRule.wordCount =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "lastChapter" -> bookInfoRule.lastChapter =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-
-                "coverUrl" -> bookInfoRule.coverUrl =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init, 3)
-
-                "tocUrl" -> bookInfoRule.tocUrl =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init, 2)
-
-                "canReName" -> bookInfoRule.canReName = it.value
-                "downloadUrls" -> bookInfoRule.downloadUrls =
-                    viewModel.ruleComplete(it.value, bookInfoRule.init)
-                "relatedBooks" -> bookInfoRule.relatedBooks = it.value
-            }
-        }
-        tocEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "preUpdateJs" -> tocRule.preUpdateJs = it.value
-                "chapterList" -> tocRule.chapterList = it.value
-                "chapterName" -> tocRule.chapterName =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList)
-
-                "chapterUrl" -> tocRule.chapterUrl =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList, 2)
-
-                "formatJs" -> tocRule.formatJs = it.value
-                "isVolume" -> tocRule.isVolume = it.value
-                "updateTime" -> tocRule.updateTime = it.value
-                "isVip" -> tocRule.isVip = it.value
-                "isPay" -> tocRule.isPay = it.value
-                "nextTocUrl" -> tocRule.nextTocUrl =
-                    viewModel.ruleComplete(it.value, tocRule.chapterList, 2)
-            }
-        }
-        contentEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "content" -> contentRule.content = viewModel.ruleComplete(it.value)
-                "subContent" -> contentRule.subContent = viewModel.ruleComplete(it.value)
-                "title" -> contentRule.title = viewModel.ruleComplete(it.value)
-                "nextContentUrl" -> contentRule.nextContentUrl =
-                    viewModel.ruleComplete(it.value, type = 2)
-
-                "webJs" -> contentRule.webJs = it.value
-                "sourceRegex" -> contentRule.sourceRegex = it.value
-                "replaceRegex" -> contentRule.replaceRegex = it.value
-                "imageStyle" -> contentRule.imageStyle = it.value
-                "imageDecode" -> contentRule.imageDecode = it.value
-                "payAction" -> contentRule.payAction = it.value
-                "callBackJs" -> contentRule.callBackJs = it.value
-            }
-        }
-//        reviewEntities.forEach {
-//            when (it.key) {
-//                "reviewUrl" -> reviewRule.reviewUrl = it.value
-//                "avatarRule" -> reviewRule.avatarRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 3)
-//
-//                "contentRule" -> reviewRule.contentRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl)
-//
-//                "postTimeRule" -> reviewRule.postTimeRule =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl)
-//
-//                "reviewQuoteUrl" -> reviewRule.reviewQuoteUrl =
-//                    viewModel.ruleComplete(it.value, reviewRule.reviewUrl, 2)
-//
-//                "voteUpUrl" -> reviewRule.voteUpUrl = it.value
-//                "voteDownUrl" -> reviewRule.voteDownUrl = it.value
-//                "postReviewUrl" -> reviewRule.postReviewUrl = it.value
-//                "postQuoteUrl" -> reviewRule.postQuoteUrl = it.value
-//                "deleteUrl" -> reviewRule.deleteUrl = it.value
-//            }
-//        }
-        source.ruleSearch = searchRule
-        source.ruleExplore = exploreRule
-        source.ruleBookInfo = bookInfoRule
-        source.ruleToc = tocRule
-        source.ruleContent = contentRule
-//        source.ruleReview = reviewRule
-        return source
-    }
-
-    @RequiresApi(Build.VERSION_CODES.M)
-    override fun onUndoClicked() {
-        val editText = window.decorView.findFocus()
-        if (editText is EditText) {
-            editText.onTextContextMenuItem(android.R.id.undo)
-        }
-    }
-
-    @RequiresApi(Build.VERSION_CODES.M)
-    override fun onRedoClicked() {
-        val editText = window.decorView.findFocus()
-        if (editText is EditText) {
-            editText.onTextContextMenuItem(android.R.id.redo)
-        }
-    }
-
-    private fun alertGroups() {
-        lifecycleScope.launch {
-            val groups = withContext(IO) {
-                appDb.bookSourceDao.allGroups()
-            }
-            selector(groups) { _, s, _ ->
-                sendText(s)
-            }
-        }
-    }
-
-    override fun helpActions(): List<SelectItem<String>> {
-        val helpActions = arrayListOf(
-            SelectItem("插入URL参数", "urlOption"),
-            SelectItem("书源教程", "ruleHelp"),
-            SelectItem("js教程", "jsHelp"),
-            SelectItem("正则教程", "regexHelp"),
+        BookSourceEditScreen(
+            state = state,
+            menuExpanded = menuExpanded,
+            onMenuExpandedChange = { menuExpanded = it },
+            onIntent = viewModel::onIntent,
         )
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            when (view.getTag(R.id.tag)) {
-                "bookSourceGroup" -> {
-                    helpActions.add(
-                        SelectItem("插入分组", "addGroup")
-                    )
-                }
-
-                else -> {
-                    helpActions.add(
-                        SelectItem("选择文件", "selectFile")
-                    )
-                }
-            }
-        }
-        return helpActions
     }
 
-    override fun onHelpActionSelect(action: String) {
-        when (action) {
-            "addGroup" -> alertGroups()
-            "urlOption" -> UrlOptionDialog(this) { sendText(it) }.show()
-            "ruleHelp" -> showHelp("ruleHelp")
-            "jsHelp" -> showHelp("jsHelp")
-            "regexHelp" -> showHelp("regexHelp")
-            "selectFile" -> selectDoc.launch {
-                mode = HandleFileContract.FILE
-            }
-        }
-    }
-
-    override fun sendText(text: String) {
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            var start = view.selectionStart
-            var end = view.selectionEnd
-            if (start > end) {
-                val temp = start
-                start = end
-                end = temp
-            }
-            if (text.isNotEmpty()) {
-                val edit = view.editableText//获取EditText的文字
-                if (start < 0 || start >= edit.length) {
-                    edit.append(text)
-                } else {
-                    edit.replace(start, end, text)//光标所在位置插入文字
-                }
-            }
-            if (adapter.editEntityMaxLine >= 999) {
-                view.post {
-                    val editTextLocation = IntArray(2)
-                    view.getLocationOnScreen(editTextLocation)
-                    val recyclerViewLocation = IntArray(2)
-                    binding.recyclerView.getLocationOnScreen(recyclerViewLocation)
-                    val layout = view.layout
-                    if (layout != null) {
-                        val line = layout.getLineForOffset(end)
-                        val cursorYInEditText = layout.getLineTop(line)
-                        val cursorYOnScreen = editTextLocation[1] + cursorYInEditText
-                        val cursorYInRecyclerView = cursorYOnScreen - recyclerViewLocation[1]
-                        val recyclerViewBottom = binding.recyclerView.height - 120
-                        if (cursorYInRecyclerView !in 0..recyclerViewBottom) {
-                            val scrollDistance = cursorYInRecyclerView - recyclerViewBottom / 3
-                            if (scrollDistance > 0 && binding.recyclerView.canScrollVertically(1) || scrollDistance < 0 && binding.recyclerView.canScrollVertically(-1)) {
-                                binding.recyclerView.smoothScrollBy(0, scrollDistance)
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    private fun setSourceVariable() {
-        viewModel.save(getSource()) { source ->
-            lifecycleScope.launch {
-                val comment =
-                    source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-                val variable = withContext(IO) { source.getVariable() }
-                showDialogFragment(
-                    VariableDialog(
-                        getString(R.string.set_source_variable),
-                        source.getKey(),
-                        variable,
-                        comment
-                    )
+    private fun openVariable(sourceUrl: String) {
+        lifecycleScope.launch {
+            val source =
+                withContext(Dispatchers.IO) { appDb.bookSourceDao.getBookSource(sourceUrl) }
+                    ?: return@launch
+            val comment =
+                source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
+            val variable = withContext(Dispatchers.IO) { source.getVariable() }
+            showDialogFragment(
+                VariableDialog(
+                    getString(R.string.set_source_variable),
+                    source.getKey(),
+                    variable,
+                    comment
                 )
-            }
+            )
         }
     }
 
     override fun setVariable(key: String, variable: String?) {
-        viewModel.bookSource?.setVariable(variable)
+        lifecycleScope.launch(Dispatchers.IO) {
+            appDb.bookSourceDao.getBookSource(key)?.setVariable(variable)
+        }
     }
-
 }

+ 0 - 100
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditAdapter.kt

@@ -1,100 +0,0 @@
-package io.legado.app.ui.book.source.edit
-
-import android.annotation.SuppressLint
-import android.text.Editable
-import android.text.TextWatcher
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.databinding.ItemSourceEditBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.widget.code.addJsPattern
-import io.legado.app.ui.widget.code.addJsonPattern
-import io.legado.app.ui.widget.code.addLegadoPattern
-import io.legado.app.ui.widget.text.EditEntity
-
-class BookSourceEditAdapter : RecyclerView.Adapter<BookSourceEditAdapter.MyViewHolder>() {
-
-    val editEntityMaxLine = AppConfig.sourceEditMaxLine
-
-    var editEntities: ArrayList<EditEntity> = ArrayList()
-        @SuppressLint("NotifyDataSetChanged")
-        set(value) {
-            field = value
-            notifyDataSetChanged()
-        }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
-        val binding = ItemSourceEditBinding
-            .inflate(LayoutInflater.from(parent.context), parent, false)
-        binding.editText.addLegadoPattern()
-        binding.editText.addJsonPattern()
-        binding.editText.addJsPattern()
-        return MyViewHolder(binding)
-    }
-
-    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
-        holder.bind(editEntities[position])
-    }
-
-    override fun getItemCount(): Int {
-        return editEntities.size
-    }
-
-    inner class MyViewHolder(val binding: ItemSourceEditBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            editText.setTag(R.id.tag, editEntity.key)
-            editText.maxLines = editEntityMaxLine
-            if (editText.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        editText.isCursorVisible = false
-                        editText.isCursorVisible = true
-                        editText.isFocusable = true
-                        editText.isFocusableInTouchMode = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {
-
-                    }
-                }
-                editText.addOnAttachStateChangeListener(listener)
-                editText.setTag(R.id.tag1, listener)
-            }
-            editText.getTag(R.id.tag2)?.let {
-                if (it is TextWatcher) {
-                    editText.removeTextChangedListener(it)
-                }
-            }
-            editText.setText(editEntity.value)
-            textInputLayout.hint = editEntity.hint
-            editText.hint = editEntity.hint
-            val textWatcher = object : TextWatcher {
-                override fun beforeTextChanged(
-                    s: CharSequence,
-                    start: Int,
-                    count: Int,
-                    after: Int
-                ) {
-
-                }
-
-                override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
-
-                }
-
-                override fun afterTextChanged(s: Editable?) {
-                    editEntity.value = (s?.toString())
-                }
-            }
-            editText.addTextChangedListener(textWatcher)
-            editText.setTag(R.id.tag2, textWatcher)
-        }
-    }
-
-
-}

+ 88 - 0
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditContract.kt

@@ -0,0 +1,88 @@
+package io.legado.app.ui.book.source.edit
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+
+enum class BookSourceEditTab(@StringRes val titleRes: Int) {
+    Base(io.legado.app.R.string.source_tab_base),
+    Search(io.legado.app.R.string.source_tab_search),
+    Explore(io.legado.app.R.string.source_tab_find),
+    Info(io.legado.app.R.string.source_tab_info),
+    Toc(io.legado.app.R.string.source_tab_toc),
+    Content(io.legado.app.R.string.source_tab_content),
+}
+
+@Immutable
+data class BookSourceEditFieldUi(
+    val path: String,
+    @StringRes val labelRes: Int? = null,
+    val label: String? = null,
+    val value: String = "",
+)
+
+@Stable
+data class BookSourceEditUiState(
+    val loading: Boolean = true,
+    val saving: Boolean = false,
+    val selectedTab: BookSourceEditTab = BookSourceEditTab.Base,
+    val fieldGroups: ImmutableMap<BookSourceEditTab, ImmutableList<BookSourceEditFieldUi>> = persistentMapOf(),
+    val enabled: Boolean = true,
+    val enabledExplore: Boolean = true,
+    val enabledCookieJar: Boolean = true,
+    val eventListener: Boolean = false,
+    val customButton: Boolean = false,
+    val bookSourceType: Int = 0,
+    val autoComplete: Boolean = false,
+    val dirty: Boolean = false,
+    val activeSheet: BookSourceEditSheet? = null,
+)
+
+sealed interface BookSourceEditSheet {
+    data object Log : BookSourceEditSheet
+    data class Help(val content: String) : BookSourceEditSheet
+}
+
+sealed interface BookSourceEditIntent {
+    data class Load(val sourceUrl: String?) : BookSourceEditIntent
+    data class SelectTab(val tab: BookSourceEditTab) : BookSourceEditIntent
+    data class UpdateField(val path: String, val value: String) : BookSourceEditIntent
+    data class SetEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetExploreEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetCookieJarEnabled(val value: Boolean) : BookSourceEditIntent
+    data class SetEventListener(val value: Boolean) : BookSourceEditIntent
+    data class SetCustomButton(val value: Boolean) : BookSourceEditIntent
+    data class SetSourceType(val value: Int) : BookSourceEditIntent
+    data class ImportText(val text: String) : BookSourceEditIntent
+    data object ToggleAutoComplete : BookSourceEditIntent
+    data object Save : BookSourceEditIntent
+    data object SaveAndDebug : BookSourceEditIntent
+    data object SaveAndLogin : BookSourceEditIntent
+    data object SaveAndSearch : BookSourceEditIntent
+    data object Copy : BookSourceEditIntent
+    data object Share : BookSourceEditIntent
+    data object Paste : BookSourceEditIntent
+    data object ClearCookie : BookSourceEditIntent
+    data object ShowLog : BookSourceEditIntent
+    data object ShowHelp : BookSourceEditIntent
+    data object DismissSheet : BookSourceEditIntent
+    data object SaveAndSetVariable : BookSourceEditIntent
+    data object RequestBack : BookSourceEditIntent
+    data object DiscardChanges : BookSourceEditIntent
+}
+
+sealed interface BookSourceEditEffect {
+    data class Finish(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenDebug(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenLogin(val sourceUrl: String) : BookSourceEditEffect
+    data class OpenSearch(val sourceJson: String) : BookSourceEditEffect
+    data class CopyText(val text: String) : BookSourceEditEffect
+    data class ShareText(val text: String) : BookSourceEditEffect
+    data object ReadClipboard : BookSourceEditEffect
+    data object ConfirmDiscard : BookSourceEditEffect
+    data class OpenVariable(val sourceUrl: String) : BookSourceEditEffect
+    data class ShowMessage(val message: String) : BookSourceEditEffect
+}

+ 409 - 0
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditScreen.kt

@@ -0,0 +1,409 @@
+package io.legado.app.ui.book.source.edit
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.about.MarkdownSheet
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.log.AppLogSheet
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookSourceEditScreen(
+    state: BookSourceEditUiState,
+    menuExpanded: Boolean,
+    onMenuExpandedChange: (Boolean) -> Unit,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    BackHandler { onIntent(BookSourceEditIntent.RequestBack) }
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val tabs = BookSourceEditTab.entries
+    val pagerState = rememberPagerState(initialPage = state.selectedTab.ordinal) { tabs.size }
+    val pagerScope = rememberCoroutineScope()
+    var editingFieldPath by remember { mutableStateOf<String?>(null) }
+    val editingField =
+        state.fieldGroups.values.flatten().firstOrNull { it.path == editingFieldPath }
+    androidx.compose.runtime.LaunchedEffect(state.selectedTab) {
+        if (pagerState.currentPage != state.selectedTab.ordinal) {
+            pagerState.animateScrollToPage(state.selectedTab.ordinal)
+        }
+    }
+    androidx.compose.runtime.LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }.collect { page ->
+            tabs.getOrNull(page)?.let { onIntent(BookSourceEditIntent.SelectTab(it)) }
+        }
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            Column {
+                GlassMediumFlexibleTopAppBar(
+                    title = stringResource(R.string.edit_book_source),
+                    scrollBehavior = scrollBehavior,
+                    navigationIcon = {
+                        TopBarNavigationButton(onClick = {
+                            onIntent(
+                                BookSourceEditIntent.RequestBack
+                            )
+                        })
+                    },
+                    actions = {
+                        TopBarActionButton(
+                            onClick = { onIntent(BookSourceEditIntent.SaveAndDebug) },
+                            imageVector = Icons.Default.BugReport,
+                            contentDescription = stringResource(R.string.debug_source),
+                        )
+                        TopBarActionButton(
+                            onClick = { onMenuExpandedChange(true) },
+                            imageVector = AppIcons.MoreVert,
+                            contentDescription = stringResource(R.string.more_menu),
+                        )
+                        BookSourceEditMenu(menuExpanded, onMenuExpandedChange, state, onIntent)
+                    },
+                )
+                AppTabRow(
+                    tabTitles = BookSourceEditTab.entries.map { stringResource(it.titleRes) },
+                    selectedTabIndex = state.selectedTab.ordinal,
+                    onTabSelected = { page ->
+                        onIntent(BookSourceEditIntent.SelectTab(tabs[page]))
+                        pagerScope.launch { pagerState.animateScrollToPage(page) }
+                    },
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(BookSourceEditIntent.Save) },
+                tooltipText = stringResource(R.string.action_save),
+                icon = Icons.Default.Save,
+            )
+        },
+    ) { padding ->
+        HorizontalPager(
+            state = pagerState,
+            modifier = Modifier.fillMaxSize(),
+        ) { page ->
+            val tab = tabs[page]
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = androidx.compose.foundation.layout.PaddingValues(
+                    start = 16.dp,
+                    end = 16.dp,
+                    top = padding.calculateTopPadding() + 12.dp,
+                    bottom = padding.calculateBottomPadding() + 96.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                if (tab == BookSourceEditTab.Base) {
+                    item(key = "options", contentType = "options") {
+                        BookSourceOptions(state, onIntent)
+                    }
+                }
+                items(
+                    items = state.fieldGroups[tab].orEmpty(),
+                    key = { it.path },
+                    contentType = { "field" },
+                ) { field ->
+                    SourceEditFieldCard(
+                        field = field,
+                        modifier = Modifier.fillMaxWidth(),
+                        onClick = { editingFieldPath = field.path },
+                    )
+                }
+            }
+        }
+    }
+    SourceEditFieldSheet(
+        field = editingField,
+        onDismissRequest = { editingFieldPath = null },
+        onValueChange = { path, value ->
+            onIntent(BookSourceEditIntent.UpdateField(path, value))
+        },
+    )
+    AppLogSheet(
+        show = state.activeSheet is BookSourceEditSheet.Log,
+        onDismissRequest = { onIntent(BookSourceEditIntent.DismissSheet) },
+    )
+    val helpSheet = state.activeSheet as? BookSourceEditSheet.Help
+    MarkdownSheet(
+        show = helpSheet != null,
+        title = stringResource(R.string.help),
+        content = helpSheet?.content.orEmpty(),
+        onDismissRequest = { onIntent(BookSourceEditIntent.DismissSheet) },
+    )
+}
+
+@Composable
+fun SourceEditFieldCard(
+    field: BookSourceEditFieldUi,
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit,
+) {
+    val title = field.labelRes?.let { stringResource(it) } ?: field.label.orEmpty()
+    GlassCard(modifier = modifier, onClick = onClick) {
+        Column(
+            modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AppText(
+                text = title,
+                style = LegadoTheme.typography.titleSmall,
+            )
+            AppText(
+                text = field.value.ifBlank { "—" },
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 4,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+    }
+}
+
+@Composable
+fun SourceEditFieldSheet(
+    field: BookSourceEditFieldUi?,
+    onDismissRequest: () -> Unit,
+    onValueChange: (String, String) -> Unit,
+) {
+    val title = field?.let {
+        it.labelRes?.let { labelRes -> stringResource(labelRes) } ?: it.label.orEmpty()
+    }
+    AppModalBottomSheet(
+        show = field != null,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        animateContentSize = false,
+    ) {
+        if (field != null) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .verticalScroll(rememberScrollState()),
+            ) {
+                AppTextField(
+                    value = field.value,
+                    onValueChange = { onValueChange(field.path, it) },
+                    label = title,
+                    modifier = Modifier.fillMaxWidth(),
+                    minLines = 4,
+                    maxLines = 24,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookSourceOptions(
+    state: BookSourceEditUiState,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    val sourceTypes = stringArrayResource(R.array.book_type)
+    var typeMenuExpanded by remember { mutableStateOf(false) }
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            Box(modifier = Modifier.weight(1f)) {
+                SourceEditOptionCard(
+                    title = stringResource(R.string.book_type),
+                    subtitle = sourceTypes.getOrNull(state.bookSourceType),
+                    onClick = { typeMenuExpanded = true },
+                )
+                RoundDropdownMenu(
+                    expanded = typeMenuExpanded,
+                    onDismissRequest = { typeMenuExpanded = false },
+                ) {
+                    sourceTypes.forEachIndexed { index, title ->
+                        RoundDropdownMenuItem(
+                            text = title,
+                            isSelected = state.bookSourceType == index,
+                            onClick = {
+                                typeMenuExpanded = false
+                                onIntent(BookSourceEditIntent.SetSourceType(index))
+                            },
+                        )
+                    }
+                }
+            }
+            SourceEditOptionCard(
+                title = stringResource(R.string.is_enable),
+                checked = state.enabled,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetEnabled(!state.enabled)) },
+            )
+            SourceEditOptionCard(
+                title = stringResource(R.string.discovery),
+                checked = state.enabledExplore,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetExploreEnabled(!state.enabledExplore)) },
+            )
+        }
+        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+            SourceEditOptionCard(
+                title = stringResource(R.string.auto_save_cookie),
+                checked = state.enabledCookieJar,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetCookieJarEnabled(!state.enabledCookieJar)) },
+            )
+            SourceEditOptionCard(
+                title = stringResource(R.string.is_event_listener),
+                checked = state.eventListener,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetEventListener(!state.eventListener)) },
+            )
+            SourceEditOptionCard(
+                title = stringResource(R.string.custom_button),
+                checked = state.customButton,
+                modifier = Modifier.weight(1f),
+                onClick = { onIntent(BookSourceEditIntent.SetCustomButton(!state.customButton)) },
+            )
+        }
+    }
+}
+
+@Composable
+fun SourceEditOptionCard(
+    title: String,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    subtitle: String? = null,
+    checked: Boolean? = null,
+) {
+    GlassCard(
+        modifier = modifier,
+        onClick = onClick
+    ) {
+        Row(
+            modifier = Modifier.padding(start = 12.dp, end = 4.dp),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = title,
+                maxLines = 1,
+                modifier = Modifier
+                    .weight(1f)
+                    .padding(vertical = 8.dp)
+                    .basicMarquee(),
+                style = LegadoTheme.typography.labelSmall,
+            )
+            AnimatedVisibility(
+                visible = !subtitle.isNullOrBlank()
+            ) {
+                TextCard(
+                    text = subtitle,
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    cornerRadius = 16.dp
+                )
+            }
+            AnimatedVisibility(
+                visible = checked == true
+            ) {
+                AppIcon(
+                    imageVector = AppIcons.Check,
+                    contentDescription = null,
+                    modifier = Modifier
+                        .padding(end = 8.dp)
+                        .size(16.dp),
+                    tint = LegadoTheme.colorScheme.primary,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookSourceEditMenu(
+    expanded: Boolean,
+    onExpandedChange: (Boolean) -> Unit,
+    state: BookSourceEditUiState,
+    onIntent: (BookSourceEditIntent) -> Unit,
+) {
+    fun click(intent: BookSourceEditIntent) {
+        onExpandedChange(false); onIntent(intent)
+    }
+    RoundDropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) {
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.login),
+            onClick = { click(BookSourceEditIntent.SaveAndLogin) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.search),
+            onClick = { click(BookSourceEditIntent.SaveAndSearch) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.cookie),
+            onClick = { click(BookSourceEditIntent.ClearCookie) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.auto_complete),
+            isSelected = state.autoComplete,
+            onClick = { click(BookSourceEditIntent.ToggleAutoComplete) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.copy_source),
+            onClick = { click(BookSourceEditIntent.Copy) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.paste_source),
+            onClick = { click(BookSourceEditIntent.Paste) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.set_source_variable),
+            onClick = { click(BookSourceEditIntent.SaveAndSetVariable) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.str_share),
+            onClick = { click(BookSourceEditIntent.Share) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.log),
+            onClick = { click(BookSourceEditIntent.ShowLog) })
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.help),
+            onClick = { click(BookSourceEditIntent.ShowHelp) })
+    }
+}

+ 329 - 100
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditViewModel.kt

@@ -1,9 +1,11 @@
 package io.legado.app.ui.book.source.edit
 
 import android.app.Application
-import android.content.Intent
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.exception.NoStackTraceException
@@ -20,136 +22,363 @@ import io.legado.app.model.SharedJsScope
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getClipText
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.jsonPath
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.toastOnUi
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
+class BookSourceEditViewModel(application: Application) : AndroidViewModel(application) {
+    private val _uiState = MutableStateFlow(BookSourceEditUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<BookSourceEditEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
-class BookSourceEditViewModel(application: Application) : BaseViewModel(application) {
-    var autoComplete = false
-    var bookSource: BookSource? = null
+    private var originalSource: BookSource? = null
+    private var draftJson = JsonObject()
+    private var baselineJson = ""
 
-    fun initData(intent: Intent, onFinally: () -> Unit) {
-        execute {
-            val sourceUrl = intent.getStringExtra("sourceUrl")
-            var source: BookSource? = null
-            if (sourceUrl != null) {
-                source = appDb.bookSourceDao.getBookSource(sourceUrl)
+    fun onIntent(intent: BookSourceEditIntent) {
+        when (intent) {
+            is BookSourceEditIntent.Load -> load(intent.sourceUrl)
+            is BookSourceEditIntent.SelectTab -> selectTab(intent.tab)
+            is BookSourceEditIntent.UpdateField -> updateField(intent.path, intent.value)
+            is BookSourceEditIntent.SetEnabled -> updateFlags { copy(enabled = intent.value) }
+            is BookSourceEditIntent.SetExploreEnabled -> updateFlags { copy(enabledExplore = intent.value) }
+            is BookSourceEditIntent.SetCookieJarEnabled -> updateFlags { copy(enabledCookieJar = intent.value) }
+            is BookSourceEditIntent.SetEventListener -> updateFlags { copy(eventListener = intent.value) }
+            is BookSourceEditIntent.SetCustomButton -> updateFlags { copy(customButton = intent.value) }
+            is BookSourceEditIntent.SetSourceType -> updateFlags { copy(bookSourceType = intent.value) }
+            is BookSourceEditIntent.ImportText -> importText(intent.text)
+            BookSourceEditIntent.ToggleAutoComplete -> _uiState.update { it.copy(autoComplete = !it.autoComplete) }
+            BookSourceEditIntent.Save -> save(BookSourceEditEffect::Finish)
+            BookSourceEditIntent.SaveAndDebug -> save { BookSourceEditEffect.OpenDebug(it) }
+            BookSourceEditIntent.SaveAndLogin -> save { BookSourceEditEffect.OpenLogin(it) }
+            BookSourceEditIntent.SaveAndSearch -> save {
+                BookSourceEditEffect.OpenSearch(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
             }
-            source?.let {
-                bookSource = it
-            }
-        }.onFinally {
-            onFinally()
+
+            BookSourceEditIntent.Copy -> _effects.tryEmit(
+                BookSourceEditEffect.CopyText(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
+            )
+
+            BookSourceEditIntent.Share -> _effects.tryEmit(
+                BookSourceEditEffect.ShareText(
+                    GSON.toJson(
+                        currentSource()
+                    )
+                )
+            )
+
+            BookSourceEditIntent.Paste -> _effects.tryEmit(BookSourceEditEffect.ReadClipboard)
+            BookSourceEditIntent.ClearCookie -> clearCookie()
+            BookSourceEditIntent.ShowLog -> _uiState.update { it.copy(activeSheet = BookSourceEditSheet.Log) }
+            BookSourceEditIntent.ShowHelp -> showHelp()
+            BookSourceEditIntent.DismissSheet -> _uiState.update { it.copy(activeSheet = null) }
+            BookSourceEditIntent.SaveAndSetVariable -> save { BookSourceEditEffect.OpenVariable(it) }
+            BookSourceEditIntent.RequestBack -> if (_uiState.value.dirty) {
+                _effects.tryEmit(BookSourceEditEffect.ConfirmDiscard)
+            } else _effects.tryEmit(BookSourceEditEffect.Finish(""))
+
+            BookSourceEditIntent.DiscardChanges -> _effects.tryEmit(BookSourceEditEffect.Finish(""))
+        }
+    }
+
+    private fun load(sourceUrl: String?) = viewModelScope.launch(Dispatchers.IO) {
+        val source = sourceUrl?.let { appDb.bookSourceDao.getBookSource(it) } ?: BookSource()
+        withContext(Dispatchers.Main) {
+            applySource(source, asOriginal = true)
+            if (sourceUrl == null) originalSource = null
+        }
+    }
+
+    private fun showHelp() = viewModelScope.launch(Dispatchers.IO) {
+        val content = getApplication<Application>().assets
+            .open("web/help/md/ruleHelp.md")
+            .bufferedReader()
+            .use { it.readText() }
+        _uiState.update { it.copy(activeSheet = BookSourceEditSheet.Help(content)) }
+    }
+
+    private fun applySource(source: BookSource, asOriginal: Boolean = false) {
+        if (asOriginal) originalSource = source
+        draftJson = JsonParser.parseString(GSON.toJson(source)).asJsonObject
+        if (asOriginal) baselineJson = GSON.toJson(source)
+        val tab = _uiState.value.selectedTab
+        _uiState.value = BookSourceEditUiState(
+            loading = false,
+            selectedTab = tab,
+            fieldGroups = fieldGroups(),
+            enabled = source.enabled,
+            enabledExplore = source.enabledExplore,
+            enabledCookieJar = source.enabledCookieJar == true,
+            eventListener = source.eventListener,
+            customButton = source.customButton,
+            bookSourceType = source.bookSourceType,
+            autoComplete = _uiState.value.autoComplete,
+        )
+    }
+
+    private fun selectTab(tab: BookSourceEditTab) {
+        _uiState.update { it.copy(selectedTab = tab) }
+    }
+
+    private fun updateField(path: String, value: String) {
+        draftJson.setPath(path, value)
+        _uiState.update { state ->
+            state.copy(
+                fieldGroups = state.fieldGroups.mapValues { (_, fields) ->
+                    fields.map { if (it.path == path) it.copy(value = value) else it }
+                        .toImmutableList()
+                }.toImmutableMap(),
+                dirty = isDirty(state),
+            )
+        }
+    }
+
+    private fun updateFlags(transform: BookSourceEditUiState.() -> BookSourceEditUiState) {
+        _uiState.update { state -> transform(state).let { it.copy(dirty = isDirty(it)) } }
+    }
+
+    private fun isDirty(state: BookSourceEditUiState): Boolean =
+        GSON.toJson(currentSource(state)) != baselineJson
+
+    private fun currentSource(state: BookSourceEditUiState = _uiState.value): BookSource {
+        return GSON.fromJson(draftJson, BookSource::class.java).apply {
+            enabled = state.enabled
+            enabledExplore = state.enabledExplore
+            enabledCookieJar = state.enabledCookieJar
+            eventListener = state.eventListener
+            customButton = state.customButton
+            bookSourceType = state.bookSourceType
+            if (state.autoComplete) completeRules(this)
         }
     }
 
-    fun save(source: BookSource, success: ((BookSource) -> Unit)? = null) {
-        execute {
+    private fun completeRules(source: BookSource) {
+        source.getSearchRule().apply {
+            name = RuleComplete.autoComplete(name, bookList)
+            author = RuleComplete.autoComplete(author, bookList)
+            kind = RuleComplete.autoComplete(kind, bookList)
+            wordCount = RuleComplete.autoComplete(wordCount, bookList)
+            lastChapter = RuleComplete.autoComplete(lastChapter, bookList)
+            intro = RuleComplete.autoComplete(intro, bookList)
+            coverUrl = RuleComplete.autoComplete(coverUrl, bookList, 3)
+            bookUrl = RuleComplete.autoComplete(bookUrl, bookList, 2)
+        }
+        source.getExploreRule().apply {
+            name = RuleComplete.autoComplete(name, bookList)
+            author = RuleComplete.autoComplete(author, bookList)
+            kind = RuleComplete.autoComplete(kind, bookList)
+            wordCount = RuleComplete.autoComplete(wordCount, bookList)
+            lastChapter = RuleComplete.autoComplete(lastChapter, bookList)
+            intro = RuleComplete.autoComplete(intro, bookList)
+            coverUrl = RuleComplete.autoComplete(coverUrl, bookList, 3)
+            bookUrl = RuleComplete.autoComplete(bookUrl, bookList, 2)
+        }
+    }
+
+    private fun save(effect: (String) -> BookSourceEditEffect) =
+        viewModelScope.launch(Dispatchers.IO) {
+            _uiState.update { it.copy(saving = true) }
+            runCatching {
+                val source = currentSource()
             if (source.bookSourceUrl.isBlank() || source.bookSourceName.isBlank()) {
-                throw NoStackTraceException(context.getString(R.string.non_null_name_url))
+                throw NoStackTraceException(getApplication<Application>().getString(R.string.non_null_name_url))
             }
-            val oldSource = bookSource ?: BookSource()
-            if (!source.equal(oldSource)) {
+                val old = originalSource ?: BookSource()
+                if (!source.equal(old)) {
                 source.lastUpdateTime = System.currentTimeMillis()
-                if (oldSource.exploreUrl != source.exploreUrl) {
-                    oldSource.clearExploreKindsCache()
-                }
-                if (oldSource.jsLib != source.jsLib) {
-                    SharedJsScope.remove(oldSource.jsLib)
-                }
-            }
-            bookSource?.let {
-                if (it.bookSourceUrl != source.bookSourceUrl) {
-                    SourceHelp.deleteBookSource(it.bookSourceUrl)
-                } else {
-                    appDb.bookSourceDao.delete(it)
-                    SourceConfig.removeSource(it.bookSourceUrl)
+                    if (old.exploreUrl != source.exploreUrl) old.clearExploreKindsCache()
+                    if (old.jsLib != source.jsLib) SharedJsScope.remove(old.jsLib)
                 }
+                originalSource?.let {
+                    if (it.bookSourceUrl != source.bookSourceUrl) SourceHelp.deleteBookSource(it.bookSourceUrl)
+                    else {
+                        appDb.bookSourceDao.delete(it); SourceConfig.removeSource(it.bookSourceUrl)
+                    }
             }
             appDb.bookSourceDao.insert(source)
-            bookSource = source
             concurrentRecordMap.remove(source.bookSourceUrl)
-            source
-        }.onSuccess {
-            success?.invoke(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-            it.printOnDebug()
+                originalSource = source
+                baselineJson = GSON.toJson(source)
+                source.bookSourceUrl
+            }.onSuccess { url ->
+                _uiState.update { it.copy(saving = false, dirty = false) }
+                _effects.emit(effect(url))
+            }.onFailure { error ->
+                _uiState.update { it.copy(saving = false) }
+                _effects.emit(BookSourceEditEffect.ShowMessage(error.localizedMessage ?: "Error"))
         }
     }
 
-    fun pasteSource(onSuccess: (source: BookSource) -> Unit) {
-        execute(context = Dispatchers.Main) {
-            val text = context.getClipText()
-            if (text.isNullOrBlank()) {
-                throw NoStackTraceException("剪贴板为空")
-            } else {
-                importSource(text, onSuccess)
-            }
-        }.onError {
-            context.toastOnUi(it.localizedMessage ?: "Error")
-            it.printOnDebug()
+    private fun importText(text: String) = viewModelScope.launch(Dispatchers.IO) {
+        runCatching { parseSource(text) }.onSuccess { source ->
+            withContext(Dispatchers.Main) { applySource(source) }
+            _uiState.update { it.copy(dirty = true) }
+        }.onFailure {
+            _effects.emit(
+                BookSourceEditEffect.ShowMessage(
+                    it.localizedMessage ?: "Error"
+                )
+            )
         }
     }
 
-    fun importSource(text: String, finally: (source: BookSource) -> Unit) {
-        execute {
-            importSource(text)
-        }.onSuccess {
-            finally.invoke(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage ?: "Error")
-            it.printOnDebug()
-        }
+    private suspend fun parseSource(text: String): BookSource = when {
+        text.isAbsUrl() -> parseSource(okHttpClient.newCallStrResponse { url(text) }.body.orEmpty())
+        text.isJsonArray() -> if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
+            val items: List<Map<String, Any>> = jsonPath.parse(text).read("$")
+            ImportOldData.fromOldBookSource(jsonPath.parse(items[0]))
+        } else GSON.fromJsonArray<BookSource>(text).getOrThrow().first()
+
+        text.isJsonObject() -> if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
+            ImportOldData.fromOldBookSource(jsonPath.parse(text))
+        } else GSON.fromJsonObject<BookSource>(text).getOrThrow()
+
+        else -> throw NoStackTraceException("格式不对")
     }
 
-    suspend fun importSource(text: String): BookSource {
-        return when {
-            text.isAbsUrl() -> {
-                val text1 = okHttpClient.newCallStrResponse { url(text) }.body
-                importSource(text1!!)
-            }
+    private fun clearCookie() = viewModelScope.launch(Dispatchers.IO) {
+        CookieStore.removeCookie(currentSource().bookSourceUrl)
+        _effects.emit(BookSourceEditEffect.ShowMessage(getApplication<Application>().getString(R.string.success)))
+    }
 
-            text.isJsonArray() -> {
-                if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
-                    val items: List<Map<String, Any>> = jsonPath.parse(text).read("$")
-                    val jsonItem = jsonPath.parse(items[0])
-                    ImportOldData.fromOldBookSource(jsonItem)
-                } else {
-                    GSON.fromJsonArray<BookSource>(text).getOrThrow()[0]
-                }
-            }
+    private fun fieldsFor(tab: BookSourceEditTab) = FIELD_SPECS.getValue(tab).map { spec ->
+        BookSourceEditFieldUi(spec.path, spec.labelRes, spec.label, draftJson.stringAt(spec.path))
+    }.toImmutableList()
 
-            text.isJsonObject() -> {
-                if (text.contains("ruleSearchUrl") || text.contains("ruleFindUrl")) {
-                    val jsonItem = jsonPath.parse(text)
-                    ImportOldData.fromOldBookSource(jsonItem)
-                } else {
-                    GSON.fromJsonObject<BookSource>(text).getOrThrow()
-                }
-            }
+    private fun fieldGroups() =
+        BookSourceEditTab.entries.associateWith(::fieldsFor).toImmutableMap()
 
-            else -> throw NoStackTraceException("格式不对")
-        }
-    }
+    private data class FieldSpec(
+        val path: String,
+        val labelRes: Int? = null,
+        val label: String? = null
+    )
 
-    fun clearCookie(url: String) {
-        execute {
-            CookieStore.removeCookie(url)
-        }
+    companion object {
+        private fun f(path: String, label: Int) = FieldSpec(path, labelRes = label)
+        private val FIELD_SPECS = mapOf(
+            BookSourceEditTab.Base to listOf(
+                f("bookSourceUrl", R.string.source_url),
+                f("bookSourceName", R.string.source_name),
+                f("bookSourceGroup", R.string.source_group),
+                f("bookSourceComment", R.string.comment),
+                f("loginUrl", R.string.login_url),
+                f("loginUi", R.string.login_ui),
+                f("loginCheckJs", R.string.login_check_js),
+                f("coverDecodeJs", R.string.cover_decode_js),
+                f("bookUrlPattern", R.string.book_url_pattern),
+                f("header", R.string.source_http_header),
+                f("variableComment", R.string.variable_comment),
+                f("concurrentRate", R.string.concurrent_rate),
+                FieldSpec("jsLib", label = "jsLib"),
+            ),
+            BookSourceEditTab.Search to listOf(
+                f("searchUrl", R.string.r_search_url),
+                f("ruleSearch.checkKeyWord", R.string.check_key_word),
+                f("ruleSearch.bookList", R.string.r_book_list),
+                f("ruleSearch.name", R.string.r_book_name),
+                f("ruleSearch.author", R.string.r_author),
+                f("ruleSearch.kind", R.string.rule_book_kind),
+                f("ruleSearch.wordCount", R.string.rule_word_count),
+                f("ruleSearch.lastChapter", R.string.rule_last_chapter),
+                f("ruleSearch.intro", R.string.rule_book_intro),
+                f("ruleSearch.coverUrl", R.string.rule_cover_url),
+                f("ruleSearch.bookUrl", R.string.r_book_url),
+            ),
+            BookSourceEditTab.Explore to listOf(
+                f("exploreUrl", R.string.r_find_url),
+                f("ruleExplore.bookList", R.string.r_book_list),
+                f("ruleExplore.name", R.string.r_book_name),
+                f("ruleExplore.author", R.string.r_author),
+                f("ruleExplore.kind", R.string.rule_book_kind),
+                f("ruleExplore.wordCount", R.string.rule_word_count),
+                f("ruleExplore.lastChapter", R.string.rule_last_chapter),
+                f("ruleExplore.intro", R.string.rule_book_intro),
+                f("ruleExplore.coverUrl", R.string.rule_cover_url),
+                f("ruleExplore.bookUrl", R.string.r_book_url),
+                f("homepageModules", R.string.homepage_modules),
+            ),
+            BookSourceEditTab.Info to listOf(
+                f("ruleBookInfo.init", R.string.rule_book_info_init),
+                f("ruleBookInfo.name", R.string.r_book_name),
+                f("ruleBookInfo.author", R.string.r_author),
+                f("ruleBookInfo.kind", R.string.rule_book_kind),
+                f("ruleBookInfo.wordCount", R.string.rule_word_count),
+                f("ruleBookInfo.lastChapter", R.string.rule_last_chapter),
+                f("ruleBookInfo.intro", R.string.rule_book_intro),
+                f("ruleBookInfo.coverUrl", R.string.rule_cover_url),
+                f("ruleBookInfo.tocUrl", R.string.rule_toc_url),
+                f("ruleBookInfo.canReName", R.string.rule_can_re_name),
+                f("ruleBookInfo.downloadUrls", R.string.download_url_rule),
+                f("ruleBookInfo.relatedBooks", R.string.related_books),
+            ),
+            BookSourceEditTab.Toc to listOf(
+                f("ruleToc.preUpdateJs", R.string.pre_update_js),
+                f("ruleToc.chapterList", R.string.rule_chapter_list),
+                f("ruleToc.chapterName", R.string.rule_chapter_name),
+                f("ruleToc.chapterUrl", R.string.rule_chapter_url),
+                f("ruleToc.formatJs", R.string.format_js_rule),
+                f("ruleToc.isVolume", R.string.rule_is_volume),
+                f("ruleToc.updateTime", R.string.rule_update_time),
+                f("ruleToc.isVip", R.string.rule_is_vip),
+                f("ruleToc.isPay", R.string.rule_is_pay),
+                f("ruleToc.nextTocUrl", R.string.rule_next_toc_url),
+            ),
+            BookSourceEditTab.Content to listOf(
+                f("ruleContent.content", R.string.rule_book_content),
+                f("ruleContent.subContent", R.string.rule_sub_content),
+                f("ruleContent.title", R.string.rule_chapter_name),
+                f("ruleContent.nextContentUrl", R.string.rule_next_content),
+                f("ruleContent.webJs", R.string.rule_web_js),
+                f("ruleContent.sourceRegex", R.string.rule_source_regex),
+                f("ruleContent.replaceRegex", R.string.rule_replace_regex),
+                f("ruleContent.imageStyle", R.string.rule_image_style),
+                f("ruleContent.imageDecode", R.string.rule_image_decode),
+                f("ruleContent.payAction", R.string.rule_pay_action),
+                f("ruleContent.callBackJs", R.string.rule_call_back),
+            ),
+        )
     }
+}
 
-    fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? {
-        if (autoComplete) {
-            return RuleComplete.autoComplete(rule, preRule, type)
-        }
-        return rule
+private fun JsonObject.stringAt(path: String): String {
+    val parts = path.split('.')
+    var current: JsonObject = this
+    parts.dropLast(1).forEach {
+        val child = current.get(it)
+        if (child == null || !child.isJsonObject) return ""
+        current = child.asJsonObject
     }
+    return current.get(parts.last())?.takeUnless { it.isJsonNull }?.asString.orEmpty()
+}
 
-}
+private fun JsonObject.setPath(path: String, value: String) {
+    val parts = path.split('.')
+    var current = this
+    parts.dropLast(1).forEach { part ->
+        val child = current.get(part)
+        current = if (child != null && child.isJsonObject) child.asJsonObject
+        else JsonObject().also { current.add(part, it) }
+    }
+    if (value.isBlank()) current.remove(parts.last()) else current.addProperty(parts.last(), value)
+}

+ 31 - 779
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceActivity.kt

@@ -1,788 +1,40 @@
 package io.legado.app.ui.book.source.manage
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.SubMenu
-import android.view.WindowManager
-import androidx.activity.viewModels
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.widget.PopupMenu
-import androidx.appcompat.widget.SearchView
-import androidx.core.os.bundleOf
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LifecycleRegistry
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.ItemTouchHelper
-import com.google.android.material.snackbar.Snackbar
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
-import io.legado.app.data.AppDatabase
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.databinding.ActivityBookSourceBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.theme.primaryColor
-import io.legado.app.model.CheckSource
-import io.legado.app.model.Debug
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.association.ImportBookSourceDialog
-import io.legado.app.ui.book.search.SearchActivity
-import io.legado.app.ui.book.search.SearchScope
-import io.legado.app.ui.book.source.debug.BookSourceDebugActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
-import io.legado.app.ui.config.CheckSourceConfig
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.SelectActionBar
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.ACache
-import io.legado.app.utils.NetworkUtils
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
-import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
-import io.legado.app.utils.observeEvent
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setEdgeEffectColor
-import io.legado.app.utils.share
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.transaction
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 
-/**
- * 书源管理界面
- */
-class BookSourceActivity : VMBaseActivity<ActivityBookSourceBinding, BookSourceViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    BookSourceAdapter.CallBack,
-    SelectActionBar.CallBack,
-    SearchView.OnQueryTextListener {
-    override val binding by viewBinding(ActivityBookSourceBinding::inflate)
-    override val viewModel by viewModels<BookSourceViewModel>()
-    private val importRecordKey = "bookSourceRecordKey"
-    private val adapter by lazy { BookSourceAdapter(this, this, binding.recyclerView) }
-    private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private var sourceFlowJob: Job? = null
-    private var checkMessageRefreshJob: Job? = null
-    private val groups = linkedSetOf<String>()
-    private var groupMenu: SubMenu? = null
-    override var sort = BookSourceSort.Default
-        private set
-    override var sortAscending = true
-        private set
-    private var snackBar: Snackbar? = null
-    private var groupSourcesByDomain = false
-    private val hostMap = hashMapOf<String, String>()
-    private val qrResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(ImportBookSourceDialog(it))
-    }
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            showDialogFragment(ImportBookSourceDialog(uri.toString()))
-        }
-    }
-    private val exportDir = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-    private val groupMenuLifecycleOwner = object : LifecycleOwner {
-        private val registry = LifecycleRegistry(this)
-        override val lifecycle: Lifecycle get() = registry
-
-        fun onMenuOpened() {
-            registry.handleLifecycleEvent(Lifecycle.Event.ON_START)
-        }
-
-        fun onMenuClosed() {
-            registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
-        }
-
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        upBookSource()
-        initLiveDataGroup()
-        initSelectActionBar()
-        resumeCheckSource()
-        if (!LocalConfig.bookSourcesHelpVersionIsLast) {
-            showHelp("SourceMBookHelp")
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_source, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        groupMenu = menu.findItem(R.id.menu_group).subMenu
-        val sortSubMenu = menu.findItem(R.id.action_sort).subMenu!!
-        sortSubMenu.findItem(R.id.menu_sort_desc).isChecked = !sortAscending
-        sortSubMenu.setGroupCheckable(R.id.menu_group_sort, true, true)
-        upGroupMenu()
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add_book_source -> startActivity<BookSourceEditActivity>()
-            R.id.menu_import_qr -> qrResult.launch()
-            R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-
-            R.id.menu_import_onLine -> showImportDialog()
-
-            R.id.menu_sort_desc -> {
-                sortAscending = !sortAscending
-                item.isChecked = !sortAscending
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_manual -> {
-                item.isChecked = true
-                sort = BookSourceSort.Default
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_auto -> {
-                item.isChecked = true
-                sort = BookSourceSort.Weight
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_name -> {
-                item.isChecked = true
-                sort = BookSourceSort.Name
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_url -> {
-                item.isChecked = true
-                sort = BookSourceSort.Url
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_time -> {
-                item.isChecked = true
-                sort = BookSourceSort.Update
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_respondTime -> {
-                item.isChecked = true
-                sort = BookSourceSort.Respond
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_sort_enable -> {
-                item.isChecked = true
-                sort = BookSourceSort.Enable
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_enabled_group -> {
-                searchView.setQuery(getString(R.string.enabled), true)
-            }
-
-            R.id.menu_disabled_group -> {
-                searchView.setQuery(getString(R.string.disabled), true)
-            }
-
-            R.id.menu_group_login -> {
-                searchView.setQuery(getString(R.string.need_login), true)
-            }
-
-            R.id.menu_group_null -> {
-                searchView.setQuery(getString(R.string.no_group), true)
-            }
-
-            R.id.menu_enabled_explore_group -> {
-                searchView.setQuery(getString(R.string.enabled_explore), true)
-            }
-
-            R.id.menu_disabled_explore_group -> {
-                searchView.setQuery(getString(R.string.disabled_explore), true)
-            }
-
-            R.id.menu_group_sources_by_domain -> {
-                item.isChecked = !item.isChecked
-                groupSourcesByDomain = item.isChecked
-                adapter.showSourceHost = item.isChecked
-                upBookSource(searchView.query?.toString())
-            }
-
-            R.id.menu_help -> showHelp("SourceMBookHelp")
-        }
-        if (item.groupId == R.id.source_group) {
-            searchView.setQuery("group:${item.title}", true)
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initRecyclerView() {
-        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.addItemDecoration(VerticalDivider(this))
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.recycledViewPool.setMaxRecycledViews(0, 15)
-        // When this page is opened, it is in selection mode
-        val dragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        dragSelectTouchHelper.activeSlideSelect()
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initSearchView() {
-        searchView.queryHint = getString(R.string.search_book_source)
-        searchView.setOnQueryTextListener(this)
-    }
-
-
-    private fun upBookSource(searchKey: String? = null) {
-        sourceFlowJob?.cancel()
-        sourceFlowJob = lifecycleScope.launch {
-            when {
-                searchKey.isNullOrEmpty() -> {
-                    appDb.bookSourceDao.flowAll()
-                }
-
-                searchKey == getString(R.string.enabled) -> {
-                    appDb.bookSourceDao.flowEnabled()
-                }
-
-                searchKey == getString(R.string.disabled) -> {
-                    appDb.bookSourceDao.flowDisabled()
-                }
-
-                searchKey == getString(R.string.need_login) -> {
-                    appDb.bookSourceDao.flowLogin()
-                }
-
-                searchKey == getString(R.string.no_group) -> {
-                    appDb.bookSourceDao.flowNoGroup()
-                }
-
-                searchKey == getString(R.string.enabled_explore) -> {
-                    appDb.bookSourceDao.flowEnabledExplore()
-                }
-
-                searchKey == getString(R.string.disabled_explore) -> {
-                    appDb.bookSourceDao.flowDisabledExplore()
-                }
-
-                searchKey.startsWith("group:") -> {
-                    val key = searchKey.substringAfter("group:")
-                    appDb.bookSourceDao.flowGroupSearch(key)
-                }
-
-                else -> {
-                    appDb.bookSourceDao.flowSearch(searchKey)
-                }
-            }.map { data ->
-                hostMap.clear()
-                if (groupSourcesByDomain) {
-                    data.sortedWith(
-                        compareBy<BookSourcePart> { getSourceHost(it.bookSourceUrl) == "#" }
-                            .thenBy { getSourceHost(it.bookSourceUrl) }
-                            .thenByDescending { it.lastUpdateTime })
-                } else if (sortAscending) {
-                    when (sort) {
-                        BookSourceSort.Weight -> data.sortedBy { it.weight }
-                        BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                            o1.bookSourceName.cnCompare(o2.bookSourceName)
-                        }
-
-                        BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl }
-                        BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime }
-                        BookSourceSort.Respond -> data.sortedBy { it.respondTime }
-                        BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                            var sort = -o1.enabled.compareTo(o2.enabled)
-                            if (sort == 0) {
-                                sort = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                            }
-                            sort
-                        }
-
-                        else -> data
-                    }
-                } else {
-                    when (sort) {
-                        BookSourceSort.Weight -> data.sortedByDescending { it.weight }
-                        BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                            o2.bookSourceName.cnCompare(o1.bookSourceName)
-                        }
-
-                        BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl }
-                        BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime }
-                        BookSourceSort.Respond -> data.sortedByDescending { it.respondTime }
-                        BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                            var sort = o1.enabled.compareTo(o2.enabled)
-                            if (sort == 0) {
-                                sort = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                            }
-                            sort
-                        }
-
-                        else -> data.reversed()
-                    }
-                }
-            }.flowWithLifecycleAndDatabaseChange(
-                lifecycle,
-                table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-            ).catch {
-                AppLog.put("书源界面更新书源出错", it)
-            }.flowOn(IO).conflate().collect { data ->
-                adapter.setItems(data, adapter.diffItemCallback, !Debug.isChecking)
-                itemTouchCallback.isCanDrag =
-                    sort == BookSourceSort.Default && !groupSourcesByDomain
-                delay(500)
-            }
-        }
-    }
-
-    override fun onResume() {
-        super.onResume()
-        adapter.upResumed(true)
-    }
-
-    override fun onPause() {
-        adapter.upResumed(false)
-        super.onPause()
-    }
-
-    private fun initLiveDataGroup() {
-        lifecycleScope.launch {
-            appDb.bookSourceDao.flowGroups()
-                .flowWithLifecycleAndDatabaseChange(
-                    lifecycle,
-                    table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-                )
-                .flowWithLifecycleAndDatabaseChangeFirst(
-                    groupMenuLifecycleOwner.lifecycle,
-                    table = AppDatabase.BOOK_SOURCE_TABLE_NAME
-                )
-                .conflate()
-                .distinctUntilChanged()
-                .collect {
-                    groups.clear()
-                    groups.addAll(it)
-                    upGroupMenu()
-                    delay(500)
-                }
-        }
-    }
-
-    override fun selectAll(selectAll: Boolean) {
-        if (selectAll) {
-            adapter.selectAll()
-        } else {
-            adapter.revertSelection()
-        }
-    }
-
-    override fun revertSelection() {
-        adapter.revertSelection()
-    }
-
-    override fun onClickSelectBarMainAction() {
-        alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
-            yesButton { viewModel.del(adapter.selection) }
-            noButton()
-        }
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        if (menu === groupMenu) {
-            groupMenuLifecycleOwner.onMenuOpened()
-        }
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onPanelClosed(featureId: Int, menu: Menu) {
-        super.onPanelClosed(featureId, menu)
-        if (menu === groupMenu) {
-            groupMenuLifecycleOwner.onMenuClosed()
-        }
-    }
-
-    private fun initSelectActionBar() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.book_source_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
-            R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
-            R.id.menu_enable_explore -> viewModel.enableSelectExplore(adapter.selection)
-            R.id.menu_disable_explore -> viewModel.disableSelectExplore(adapter.selection)
-            R.id.menu_check_source -> checkSource()
-            R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray())
-            R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray())
-            R.id.menu_add_group -> selectionAddToGroups()
-            R.id.menu_remove_group -> selectionRemoveFromGroups()
-            R.id.menu_export_selection -> viewModel.saveToFile(
-                adapter,
-                searchView.query?.toString(),
-                sortAscending,
-                sort
-            ) { file, name ->
-                exportDir.launch {
-                    mode = HandleFileContract.EXPORT
-                    fileData = HandleFileContract.FileData(
-                        name,
-                        file,
-                        "application/json"
+/** Legacy entry point retained for callers outside the Compose navigation graph. */
+class BookSourceActivity : BaseComposeActivity() {
+    private val importDocument =
+        registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+            uri?.let { showDialogFragment(ImportBookSourceDialog(it.toString())) }
+    }
+
+    @Composable
+    override fun Content() {
+        BookSourceRouteScreen(
+            onBackClick = ::finish,
+            onAddSource = { startActivity<BookSourceEditActivity>() },
+            onEditSource = { sourceUrl ->
+                startActivity<BookSourceEditActivity> {
+                    putExtra("sourceUrl", sourceUrl)
+                }
+            },
+            onImportLocal = {
+                importDocument.launch(
+                    arrayOf(
+                        "application/json",
+                        "text/plain",
+                        "text/*"
                     )
-                }
-            }
-
-            R.id.menu_share_source -> viewModel.saveToFile(
-                adapter,
-                searchView.query?.toString(),
-                sortAscending,
-                sort
-            ) { file, name ->
-                share(file)
-            }
-
-            R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
-        }
-        return true
-    }
-
-    @SuppressLint("InflateParams")
-    private fun checkSource() {
-        val dialog = alert(titleResource = R.string.search_book_key) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "search word"
-                editView.setText(CheckSource.keyword)
-            }
-            customView { alertBinding.root }
-            okButton {
-                keepScreenOn(true)
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        CheckSource.keyword = it
-                    }
-                }
-                val selectItems = adapter.selection
-                CheckSource.start(this@BookSourceActivity, selectItems)
-                val adapterItems = adapter.getItems()
-                val firstItem = adapterItems.indexOf(selectItems.firstOrNull())
-                val lastItem = adapterItems.indexOf(selectItems.lastOrNull())
-                Debug.isChecking = firstItem >= 0 && lastItem >= 0
-                startCheckMessageRefreshJob(firstItem, lastItem)
-            }
-            neutralButton(R.string.check_source_config)
-            cancelButton()
-        }
-        //手动设置监听 避免点击打开校验设置后对话框关闭
-        dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
-            showDialogFragment<CheckSourceConfig>()
-        }
-    }
-
-    private fun resumeCheckSource() {
-        if (!Debug.isChecking) {
-            return
-        }
-        keepScreenOn(true)
-        CheckSource.resume(this)
-        startCheckMessageRefreshJob(0, 0)
-    }
-
-    @SuppressLint("InflateParams")
-    private fun selectionAddToGroups() {
-        alert(titleResource = R.string.add_group) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setFilterValues(groups.toList())
-                editView.dropDownHeight = 180.dpToPx()
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        viewModel.selectionAddToGroups(adapter.selection, it)
-                    }
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun selectionRemoveFromGroups() {
-        alert(titleResource = R.string.remove_group) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setFilterValues(groups.toList())
-                editView.dropDownHeight = 180.dpToPx()
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        viewModel.selectionRemoveFromGroups(adapter.selection, it)
-                    }
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    private fun upGroupMenu() = groupMenu?.transaction { menu ->
-        menu.removeGroup(R.id.source_group)
-        groups.forEach {
-            menu.add(R.id.source_group, Menu.NONE, Menu.NONE, it)
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun showImportDialog() {
-        val aCache = ACache.get(cacheDir = false)
-        val cacheUrls: MutableList<String> = aCache
-            .getAsString(importRecordKey)
-            ?.splitNotBlank(",")
-            ?.toMutableList() ?: mutableListOf()
-        alert(titleResource = R.string.import_on_line) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "url"
-                editView.setFilterValues(cacheUrls)
-                editView.delCallBack = {
-                    cacheUrls.remove(it)
-                    aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                }
-            }
-            customView { alertBinding.root }
-            okButton {
-                val text = alertBinding.editView.text?.toString()
-                text?.let {
-                    if (it.isAbsUrl() && !cacheUrls.contains(it)) {
-                        cacheUrls.add(0, it)
-                        aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                    }
-                    showDialogFragment(ImportBookSourceDialog(it))
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    override fun observeLiveBus() {
-        observeEvent<String>(EventBus.CHECK_SOURCE) { msg ->
-            snackBar?.setText(msg) ?: let {
-                snackBar = Snackbar
-                    .make(binding.root, msg, Snackbar.LENGTH_INDEFINITE)
-                    .setAction(R.string.cancel) {
-                        CheckSource.stop(this)
-                        Debug.finishChecking()
-                    }.apply { show() }
-            }
-        }
-        observeEvent<Int>(EventBus.CHECK_SOURCE_DONE) {
-            keepScreenOn(false)
-            snackBar?.dismiss()
-            snackBar = null
-            adapter.notifyItemRangeChanged(
-                0,
-                adapter.itemCount,
-                bundleOf(Pair("checkSourceMessage", null))
-            )
-            groups.forEach { group ->
-                if (group.contains("失效") && searchView.query.isEmpty()) {
-                    searchView.setQuery("失效", true)
-                    toastOnUi("发现有失效书源,已为您自动筛选!")
-                }
-            }
-        }
-    }
-
-    private fun startCheckMessageRefreshJob(firstItem: Int, lastItem: Int) {
-        checkMessageRefreshJob?.cancel()
-        checkMessageRefreshJob = lifecycleScope.launch {
-            repeatOnLifecycle(Lifecycle.State.STARTED) {
-                while (isActive) {
-                    if (lastItem == 0) {
-                        adapter.notifyItemRangeChanged(
-                            0,
-                            adapter.itemCount,
-                            bundleOf(Pair("checkSourceMessage", null))
-                        )
-                    } else {
-                        adapter.notifyItemRangeChanged(
-                            firstItem,
-                            lastItem + 1,
-                            bundleOf(Pair("checkSourceMessage", null))
-                        )
-                    }
-                    if (!Debug.isChecking) {
-                        checkMessageRefreshJob?.cancel()
-                    }
-                    delay(300L)
-                }
-            }
-        }
-    }
-
-    /**
-     * 保持亮屏
-     */
-    private fun keepScreenOn(on: Boolean) {
-        val isScreenOn =
-            (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0
-        if (on == isScreenOn) return
-        if (on) {
-            window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-        } else {
-            window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-        }
-    }
-
-    override fun upCountView() {
-        binding.selectActionBar
-            .upCountView(adapter.selection.size, adapter.itemCount)
-    }
-
-    override fun getSourceHost(origin: String): String {
-        return hostMap.getOrPut(origin) {
-            NetworkUtils.getSubDomainOrNull(origin) ?: "#"
-        }
-    }
-
-    override fun onQueryTextChange(newText: String?): Boolean {
-        newText?.let {
-            upBookSource(it)
-        }
-        return false
-    }
-
-    override fun onQueryTextSubmit(query: String?): Boolean {
-        return false
-    }
-
-    override fun del(bookSource: BookSourcePart) {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n" + bookSource.bookSourceName)
-            noButton()
-            yesButton {
-                viewModel.del(listOf(bookSource))
-            }
-        }
-    }
-
-    override fun edit(bookSource: BookSourcePart) {
-        startActivity<BookSourceEditActivity> {
-            putExtra("sourceUrl", bookSource.bookSourceUrl)
-        }
-    }
-
-    override fun upOrder(items: List<BookSourcePart>) {
-        viewModel.upOrder(items)
-    }
-
-    override fun enable(enable: Boolean, bookSource: BookSourcePart) {
-        viewModel.enable(enable, listOf(bookSource))
-    }
-
-    override fun enableExplore(enable: Boolean, bookSource: BookSourcePart) {
-        viewModel.enableExplore(enable, listOf(bookSource))
-    }
-
-    override fun toTop(bookSource: BookSourcePart) {
-        if (sortAscending) {
-            viewModel.topSource(bookSource)
-        } else {
-            viewModel.bottomSource(bookSource)
-        }
-    }
-
-    override fun toBottom(bookSource: BookSourcePart) {
-        if (sortAscending) {
-            viewModel.bottomSource(bookSource)
-        } else {
-            viewModel.topSource(bookSource)
-        }
-    }
-
-    override fun searchBook(bookSource: BookSourcePart) {
-        startActivity<SearchActivity> {
-            putExtra("searchScope", SearchScope(bookSource).toString())
-        }
-    }
-
-    override fun debug(bookSource: BookSourcePart) {
-        startActivity<BookSourceDebugActivity> {
-            putExtra("key", bookSource.bookSourceUrl)
-        }
-    }
-
-    override fun finish() {
-        if (searchView.query.isNullOrEmpty()) {
-            super.finish()
-        } else {
-            searchView.setQuery("", true)
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        if (!Debug.isChecking) {
-            Debug.debugMessageMap.clear()
-        }
+                )
+            },
+            onImportOnline = { text -> showDialogFragment(ImportBookSourceDialog(text)) },
+        )
     }
-
-}
+}

+ 67 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt

@@ -0,0 +1,67 @@
+package io.legado.app.ui.book.source.manage
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.legado.app.ui.widget.components.list.InteractionState
+import io.legado.app.ui.widget.components.list.ListUiState
+import io.legado.app.ui.widget.components.list.SelectableItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+
+@Immutable
+data class BookSourceItemUi(
+    override val id: String,
+    val domain: String,
+    val name: String,
+    val group: String?,
+    val enabled: Boolean,
+    val customOrder: Int,
+) : SelectableItem<String> {
+}
+
+@Stable
+data class BookSourceUiState(
+    override val items: ImmutableList<BookSourceItemUi> = persistentListOf(),
+    override val selectedIds: ImmutableSet<String> = persistentSetOf(),
+    override val searchKey: String = "",
+    val groupFilterName: String? = null,
+    val activeFilter: String? = null,
+    val groups: ImmutableList<String> = persistentListOf(),
+    val sort: BookSourceSort = BookSourceSort.Default,
+    val sortAscending: Boolean = true,
+    val groupByDomain: Boolean = false,
+    val interaction: InteractionState = InteractionState(isLoading = true),
+) : ListUiState<BookSourceItemUi> {
+    override val isSearch get() = interaction.isSearchMode
+    override val isLoading get() = interaction.isLoading
+}
+
+sealed interface BookSourceIntent {
+    data class SetSearchMode(val enabled: Boolean) : BookSourceIntent
+    data class SetSearchQuery(val query: String) : BookSourceIntent
+    data class SetSelection(val ids: Set<String>) : BookSourceIntent
+    data class ToggleSelection(val id: String) : BookSourceIntent
+    data class SetFilter(val filter: String?) : BookSourceIntent
+    data class SetSort(val sort: BookSourceSort) : BookSourceIntent
+    data object ToggleSortDirection : BookSourceIntent
+    data object ToggleGroupByDomain : BookSourceIntent
+    data class SetEnabled(val id: String, val enabled: Boolean) : BookSourceIntent
+    data class SetEnabledForSelection(val ids: Set<String>, val enabled: Boolean) : BookSourceIntent
+    data class SetExploreEnabled(val ids: Set<String>, val enabled: Boolean) : BookSourceIntent
+    data class Delete(val ids: Set<String>) : BookSourceIntent
+    data class MoveToEdge(val ids: Set<String>, val toTop: Boolean) : BookSourceIntent
+    data class MoveItem(val from: Int, val to: Int) : BookSourceIntent
+    data object SaveSortOrder : BookSourceIntent
+    data class CommitSortOrder(
+        val ids: List<String>,
+        val ascending: Boolean,
+    ) : BookSourceIntent
+
+    data class AddToGroup(val ids: Set<String>, val group: String) : BookSourceIntent
+    data class RemoveFromGroup(val ids: Set<String>, val group: String) : BookSourceIntent
+    data class UpdateGroup(val old: String, val new: String) : BookSourceIntent
+    data class DeleteGroup(val group: String) : BookSourceIntent
+    data class CheckSelectedInterval(val ids: Set<String>) : BookSourceIntent
+}

+ 507 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -0,0 +1,507 @@
+package io.legado.app.ui.book.source.manage
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
+import io.legado.app.ui.widget.components.GroupManageBottomSheet
+import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.dialog.TextListInputDialog
+import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.rules.RuleListScaffold
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import org.koin.androidx.compose.koinViewModel
+import sh.calvin.reorderable.rememberReorderableLazyListState
+import androidx.compose.foundation.lazy.grid.items as gridItems
+
+@Composable
+fun BookSourceRouteScreen(
+    viewModel: BookSourceViewModel = koinViewModel(),
+    onBackClick: () -> Unit,
+    onAddSource: () -> Unit,
+    onEditSource: (String) -> Unit,
+    onImportLocal: () -> Unit,
+    onImportOnline: (String) -> Unit,
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    BookSourceScreen(
+        state,
+        viewModel::onIntent,
+        onBackClick,
+        onAddSource,
+        onEditSource,
+        onImportLocal,
+        onImportOnline
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun BookSourceScreen(
+    state: BookSourceUiState,
+    onIntent: (BookSourceIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onAddSource: () -> Unit,
+    onEditSource: (String) -> Unit,
+    onImportLocal: () -> Unit,
+    onImportOnline: (String) -> Unit,
+) {
+    val rules = state.items
+    val selectedIds = state.selectedIds
+    val listState = rememberLazyListState()
+    var deleteIds by remember { mutableStateOf<Set<String>?>(null) }
+    var addGroup by remember { mutableStateOf(false) }
+    var removeGroup by remember { mutableStateOf(false) }
+    var groupManage by remember { mutableStateOf(false) }
+    var showGroupFilterSheet by remember { mutableStateOf(false) }
+    var showOnlineImport by remember { mutableStateOf(false) }
+    var dragOrder by remember { mutableStateOf<List<BookSourceItemUi>?>(null) }
+    val displayedRules = dragOrder ?: rules
+    val reorderState = rememberReorderableLazyListState(listState) { from, to ->
+        val moved = (dragOrder ?: rules).toMutableList()
+        if (from.index in moved.indices && to.index in moved.indices) {
+            moved.add(to.index, moved.removeAt(from.index))
+            dragOrder = moved
+        }
+    }
+    LaunchedEffect(reorderState.isAnyItemDragging, rules) {
+        if (!reorderState.isAnyItemDragging) {
+            dragOrder?.let { pending ->
+                val pendingIds = pending.map { it.id }
+                if (rules.map { it.id } == pendingIds) {
+                    dragOrder = null
+                } else {
+                    onIntent(BookSourceIntent.CommitSortOrder(pendingIds, state.sortAscending))
+                }
+            }
+        }
+    }
+    SourceInputDialog(
+        show = showOnlineImport,
+        title = stringResource(R.string.import_on_line),
+        onDismissRequest = { showOnlineImport = false },
+        onConfirm = { text -> showOnlineImport = false; onImportOnline(text) })
+
+    BookSourceGroupFilterSheet(
+        show = showGroupFilterSheet,
+        state = state,
+        onDismissRequest = { showGroupFilterSheet = false },
+        onSelect = { value ->
+            showGroupFilterSheet = false
+            onIntent(BookSourceIntent.SetFilter(value))
+        },
+    )
+
+    TextListInputDialog(
+        show = addGroup,
+        title = stringResource(R.string.add_group),
+        hint = stringResource(R.string.group_name),
+        suggestions = state.groups,
+        onDismissRequest = { addGroup = false },
+        onConfirm = { onIntent(BookSourceIntent.AddToGroup(selectedIds, it)); addGroup = false })
+    TextListInputDialog(
+        show = removeGroup,
+        title = stringResource(R.string.remove_group),
+        hint = stringResource(R.string.group_name),
+        suggestions = state.groups,
+        onDismissRequest = { removeGroup = false },
+        onConfirm = {
+            onIntent(BookSourceIntent.RemoveFromGroup(selectedIds, it)); removeGroup = false
+        })
+    GroupManageBottomSheet(
+        groupManage, state.groups, { groupManage = false },
+        onUpdateGroup = { old, new -> onIntent(BookSourceIntent.UpdateGroup(old, new)) },
+        onDeleteGroup = { onIntent(BookSourceIntent.DeleteGroup(it)) }
+    )
+    AppAlertDialog(
+        deleteIds,
+        { deleteIds = null },
+        stringResource(R.string.delete),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { ids -> onIntent(BookSourceIntent.Delete(ids)); deleteIds = null },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { deleteIds = null })
+
+    RuleListScaffold(
+        title = stringResource(R.string.book_source),
+        subtitle = state.groupFilterName ?: stringResource(R.string.all),
+        state = state,
+        onBackClick = onBackClick,
+        onSearchToggle = { onIntent(BookSourceIntent.SetSearchMode(it)) },
+        onSearchQueryChange = { onIntent(BookSourceIntent.SetSearchQuery(it)) },
+        searchPlaceholder = stringResource(R.string.search_book_source),
+        onClearSelection = { onIntent(BookSourceIntent.SetSelection(emptySet())) },
+        onSelectAll = {
+            onIntent(BookSourceIntent.SetSelection(displayedRules.map { it.id }.toSet()))
+        },
+        onSelectInvert = {
+            onIntent(BookSourceIntent.SetSelection(displayedRules.map { it.id }
+                .toSet() - selectedIds))
+        },
+        topBarActions = {
+            TopBarActionButton(
+                onClick = { showGroupFilterSheet = true },
+                imageVector = AppIcons.Filter,
+                contentDescription = stringResource(R.string.menu_action_group),
+            )
+        },
+        snackbarHostState = remember { SnackbarHostState() },
+        onAddClick = onAddSource,
+        selectionSecondaryActions = listOf(
+            ActionItem(stringResource(R.string.enable_selection)) {
+                onIntent(
+                    BookSourceIntent.SetEnabledForSelection(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.disable_selection)) {
+                onIntent(
+                    BookSourceIntent.SetEnabledForSelection(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.enable_explore)) {
+                onIntent(
+                    BookSourceIntent.SetExploreEnabled(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.disable_explore)) {
+                onIntent(
+                    BookSourceIntent.SetExploreEnabled(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.add_group)) { addGroup = true },
+            ActionItem(stringResource(R.string.remove_group)) { removeGroup = true },
+            ActionItem(stringResource(R.string.selection_to_top)) {
+                onIntent(
+                    BookSourceIntent.MoveToEdge(
+                        selectedIds,
+                        true
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.selection_to_bottom)) {
+                onIntent(
+                    BookSourceIntent.MoveToEdge(
+                        selectedIds,
+                        false
+                    )
+                )
+            },
+            ActionItem(stringResource(R.string.check_selected_interval)) {
+                onIntent(
+                    BookSourceIntent.CheckSelectedInterval(
+                        selectedIds
+                    )
+                )
+            },
+        ),
+        onDeleteSelected = { deleteIds = it as Set<String> },
+        dropDownMenuContent = { dismiss ->
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.group_manage),
+                onClick = { dismiss(); groupManage = true })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_local),
+                onClick = { dismiss(); onImportLocal() })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_on_line),
+                onClick = { dismiss(); showOnlineImport = true })
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.group_sources_by_domain),
+                isSelected = state.groupByDomain,
+                onClick = { dismiss(); onIntent(BookSourceIntent.ToggleGroupByDomain) },
+            )
+            PillDivider()
+            SortMenuItem(R.string.sort_manual, BookSourceSort.Default, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_auto, BookSourceSort.Weight, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_by_name, BookSourceSort.Name, state, dismiss, onIntent)
+            SortMenuItem(R.string.sort_by_url, BookSourceSort.Url, state, dismiss, onIntent)
+            SortMenuItem(
+                R.string.sort_by_lastUpdateTime,
+                BookSourceSort.Update,
+                state,
+                dismiss,
+                onIntent
+            )
+            SortMenuItem(
+                R.string.sort_by_respondTime,
+                BookSourceSort.Respond,
+                state,
+                dismiss,
+                onIntent
+            )
+            SortMenuItem(R.string.is_enabled, BookSourceSort.Enable, state, dismiss, onIntent)
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.sort_desc),
+                isSelected = !state.sortAscending,
+                onClick = { dismiss(); onIntent(BookSourceIntent.ToggleSortDirection) },
+            )
+        }
+    ) { padding ->
+        Box(Modifier.fillMaxSize()) {
+            FastScrollLazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = listState,
+                contentPadding = adaptiveContentPadding(
+                    top = padding.calculateTopPadding(),
+                    bottom = padding.calculateBottomPadding() + 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                displayedRules.forEachIndexed { index, item ->
+                    if (state.groupByDomain && (index == 0 || displayedRules[index - 1].domain != item.domain)) {
+                        item(key = "domain:${item.domain}", contentType = "domain-header") {
+                            AppText(
+                                text = item.domain,
+                                style = LegadoTheme.typography.titleSmall,
+                                color = LegadoTheme.colorScheme.primary,
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 8.dp),
+                            )
+                        }
+                    }
+                    item(key = item.id, contentType = "book-source") {
+                        ReorderableSelectionItem(
+                            state = reorderState,
+                            key = item.id,
+                            reorderIndex = index,
+                            reorderItemCount = displayedRules.size,
+                            onMoveItem = { from, to ->
+                                val moved = displayedRules.toMutableList()
+                                if (from in moved.indices && to in moved.indices) {
+                                    moved.add(to, moved.removeAt(from))
+                                    dragOrder = moved
+                                    onIntent(
+                                        BookSourceIntent.CommitSortOrder(
+                                            moved.map { it.id },
+                                            state.sortAscending
+                                        )
+                                    )
+                                }
+                            },
+                            title = item.name,
+                            subtitle = item.group,
+                            isEnabled = item.enabled,
+                            isSelected = item.id in selectedIds,
+                            canReorder = state.sort == BookSourceSort.Default && !state.groupByDomain,
+                            inSelectionMode = selectedIds.isNotEmpty(),
+                            onToggleSelection = { onIntent(BookSourceIntent.ToggleSelection(item.id)) },
+                            onEnabledChange = {
+                                onIntent(
+                                    BookSourceIntent.SetEnabled(
+                                        item.id,
+                                        it
+                                    )
+                                )
+                            },
+                            onClickEdit = { onEditSource(item.id) },
+                            trailingAction = {
+                                SmallPlainButton(
+                                    icon = Icons.Default.Delete,
+                                    contentDescription = stringResource(R.string.delete),
+                                    onClick = { deleteIds = setOf(item.id) })
+                            })
+                    }
+                }
+            }
+            if (selectedIds.isNotEmpty()) DraggableSelectionHandler(
+                listState = listState,
+                items = displayedRules,
+                selectedIds = selectedIds,
+                onSelectionChange = { onIntent(BookSourceIntent.SetSelection(it)) },
+                idProvider = { it.id },
+                modifier = Modifier
+                    .fillMaxHeight()
+                    .width(60.dp)
+                    .align(Alignment.TopStart)
+            )
+        }
+    }
+}
+
+@Composable
+private fun SortMenuItem(
+    textRes: Int,
+    sort: BookSourceSort,
+    state: BookSourceUiState,
+    dismiss: () -> Unit,
+    onIntent: (BookSourceIntent) -> Unit,
+) = RoundDropdownMenuItem(
+    text = stringResource(textRes),
+    isSelected = state.sort == sort,
+    onClick = { dismiss(); onIntent(BookSourceIntent.SetSort(sort)) },
+)
+
+@Composable
+private fun BookSourceGroupFilterSheet(
+    show: Boolean,
+    state: BookSourceUiState,
+    onDismissRequest: () -> Unit,
+    onSelect: (String?) -> Unit,
+) {
+    var query by remember(show) { mutableStateOf("") }
+    val defaultOptions = listOf(
+        stringResource(R.string.all) to null,
+        stringResource(R.string.enabled) to BookSourceViewModel.FILTER_ENABLED,
+        stringResource(R.string.disabled) to BookSourceViewModel.FILTER_DISABLED,
+        stringResource(R.string.need_login) to BookSourceViewModel.FILTER_LOGIN,
+        stringResource(R.string.no_group) to BookSourceViewModel.FILTER_NO_GROUP,
+        stringResource(R.string.enabled_explore) to BookSourceViewModel.FILTER_ENABLED_EXPLORE,
+        stringResource(R.string.disabled_explore) to BookSourceViewModel.FILTER_DISABLED_EXPLORE,
+    )
+    val otherOptions = state.groups.map { group ->
+        group to "${BookSourceViewModel.PREFIX_GROUP}$group"
+    }
+    val filteredDefaultOptions = remember(defaultOptions, query) {
+        if (query.isBlank()) defaultOptions else defaultOptions.filter { (label, _) ->
+            label.contains(query, ignoreCase = true)
+        }
+    }
+    val filteredOtherOptions = remember(otherOptions, query) {
+        if (query.isBlank()) otherOptions else otherOptions.filter { (label, _) ->
+            label.contains(query, ignoreCase = true)
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.menu_action_group),
+    ) {
+        Column(Modifier.fillMaxWidth()) {
+            SearchBar(
+                query = query,
+                onQueryChange = { query = it },
+                placeholder = stringResource(R.string.search_placeholder),
+                autoFocus = false,
+            )
+            Spacer(Modifier.height(8.dp))
+            LazyVerticalGrid(
+                columns = GridCells.Fixed(2),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalArrangement = Arrangement.spacedBy(6.dp),
+            ) {
+                if (filteredDefaultOptions.isNotEmpty()) {
+                    item(key = "default-groups-header", span = { GridItemSpan(maxLineSpan) }) {
+                        GroupFilterSectionTitle(stringResource(R.string.book_source_default_groups))
+                    }
+                }
+                gridItems(
+                    filteredDefaultOptions,
+                    key = { (_, value) -> value ?: "@all" }) { (label, value) ->
+                    GroupFilterItem(label, value, state.activeFilter, onSelect)
+                }
+                if (filteredOtherOptions.isNotEmpty()) {
+                    item(key = "other-groups-header", span = { GridItemSpan(maxLineSpan) }) {
+                        GroupFilterSectionTitle(stringResource(R.string.book_source_other_groups))
+                    }
+                }
+                gridItems(filteredOtherOptions, key = { (_, value) -> value }) { (label, value) ->
+                    GroupFilterItem(label, value, state.activeFilter, onSelect)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun GroupFilterSectionTitle(text: String) {
+    AppText(
+        text = text,
+        style = LegadoTheme.typography.labelMedium,
+        color = LegadoTheme.colorScheme.onSurfaceVariant,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 4.dp, vertical = 6.dp),
+    )
+}
+
+@Composable
+private fun GroupFilterItem(
+    label: String,
+    value: String?,
+    activeFilter: String?,
+    onSelect: (String?) -> Unit,
+) {
+    val selected = activeFilter == value
+    Box(
+        contentAlignment = Alignment.Center,
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(shape = RoundedCornerShape(12.dp))
+            .background(
+                color = if (selected) LegadoTheme.colorScheme.primaryContainer
+                else LegadoTheme.colorScheme.onSheetContent
+            )
+            .clickable { onSelect(value) }
+            .padding(horizontal = 12.dp, vertical = 8.dp),
+    ) {
+        AppText(
+            text = label,
+            style = LegadoTheme.typography.labelMediumEmphasized,
+            color = if (selected) LegadoTheme.colorScheme.onPrimaryContainer
+            else LegadoTheme.colorScheme.onSurface,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            textAlign = TextAlign.Center,
+        )
+    }
+}

+ 232 - 256
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt

@@ -2,302 +2,278 @@ package io.legado.app.ui.book.source.manage
 
 import android.app.Application
 import android.text.TextUtils
-import io.legado.app.R
-import io.legado.app.base.BaseViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
 import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.data.entities.toBookSource
 import io.legado.app.help.source.SourceHelp
-import io.legado.app.utils.FileUtils
-import io.legado.app.utils.GSON
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.normalizeFileName
-import io.legado.app.utils.outputStream
+import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.splitNotBlank
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.writeToOutputStream
-import splitties.init.appCtx
-import java.io.File
-import java.util.Date
-import java.util.Locale
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class BookSourceViewModel(application: Application) : ViewModel() {
+    companion object {
+        const val FILTER_ENABLED = "@enabled"
+        const val FILTER_DISABLED = "@disabled"
+        const val FILTER_LOGIN = "@login"
+        const val FILTER_NO_GROUP = "@noGroup"
+        const val FILTER_ENABLED_EXPLORE = "@enabledExplore"
+        const val FILTER_DISABLED_EXPLORE = "@disabledExplore"
+        const val PREFIX_GROUP = "group:"
+    }
 
-/**
- * 书源管理数据修改
- * 修改数据要copy,直接修改会导致界面不刷新
- */
-class BookSourceViewModel(application: Application) : BaseViewModel(application) {
+    private val dao = appDb.bookSourceDao
+    private val searchKey = MutableStateFlow("")
+    private val isSearchMode = MutableStateFlow(false)
+    private val filter = MutableStateFlow<String?>(null)
+    private val selectedIds = MutableStateFlow<Set<String>>(emptySet())
+    private val sort = MutableStateFlow(BookSourceSort.Default)
+    private val sortAscending = MutableStateFlow(true)
+    private val groupByDomain = MutableStateFlow(false)
+    private val localItems = MutableStateFlow<List<BookSourcePart>?>(null)
+    private val enabledOverrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
+
+    val uiState = combine(
+        dao.flowAll(),
+        dao.flowGroups(),
+        searchKey,
+        isSearchMode,
+        filter,
+        selectedIds,
+        sort,
+        sortAscending,
+        groupByDomain,
+        localItems,
+        enabledOverrides,
+    ) { values ->
+        @Suppress("UNCHECKED_CAST")
+        val sourceItems = (values[0] as List<BookSourcePart>)
+        val groups = values[1] as List<String>
+        val query = values[2] as String
+        val searchMode = values[3] as Boolean
+        val activeFilter = values[4] as String?
+        val selected = values[5] as Set<String>
+        val activeSort = values[6] as BookSourceSort
+        val ascending = values[7] as Boolean
+        val byDomain = values[8] as Boolean
+        val local = values[9] as List<BookSourcePart>?
+        val pendingEnabled = values[10] as Map<String, Boolean>
+        val visible = if (local == null) {
+            sourceItems.filterFor(activeFilter, query).sortFor(activeSort, ascending, byDomain)
+        } else {
+            val latestById = sourceItems.associateBy { it.bookSourceUrl }
+            local.mapNotNull { latestById[it.bookSourceUrl] }
+        }
+        BookSourceUiState(
+            items = visible.map { source ->
+                BookSourceItemUi(
+                    id = source.bookSourceUrl,
+                    domain = NetworkUtils.getSubDomainOrNull(source.bookSourceUrl) ?: "#",
+                    name = source.bookSourceName,
+                    group = source.bookSourceGroup,
+                    enabled = pendingEnabled[source.bookSourceUrl] ?: source.enabled,
+                    customOrder = source.customOrder,
+                )
+            }.toImmutableList(),
+            selectedIds = selected.intersect(visible.map { it.bookSourceUrl }.toSet())
+                .toImmutableSet(),
+            searchKey = query,
+            groupFilterName = activeFilter?.displayName(application),
+            activeFilter = activeFilter,
+            groups = groups.toImmutableList(),
+            sort = activeSort,
+            sortAscending = ascending,
+            groupByDomain = byDomain,
+            interaction = io.legado.app.ui.widget.components.list.InteractionState(isSearchMode = searchMode),
+        )
+    }.flowOn(Dispatchers.Default)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BookSourceUiState())
+
+    fun onIntent(intent: BookSourceIntent) {
+        when (intent) {
+            is BookSourceIntent.SetSearchMode -> {
+                isSearchMode.value = intent.enabled
+                if (!intent.enabled) searchKey.value = ""
+            }
 
-    fun topSource(vararg sources: BookSourcePart) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val minOrder = appDb.bookSourceDao.minOrder - 1
-            val array = sources.mapIndexed { index, it ->
-                it.copy(customOrder = minOrder - index)
+            is BookSourceIntent.SetSearchQuery -> {
+                localItems.value = null; searchKey.value = intent.query
             }
-            appDb.bookSourceDao.upOrder(array)
-        }
-    }
 
-    fun bottomSource(vararg sources: BookSourcePart) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val maxOrder = appDb.bookSourceDao.maxOrder + 1
-            val array = sources.mapIndexed { index, it ->
-                it.copy(customOrder = maxOrder + index)
+            is BookSourceIntent.SetSelection -> selectedIds.value = intent.ids
+            is BookSourceIntent.ToggleSelection -> selectedIds.update { if (intent.id in it) it - intent.id else it + intent.id }
+            is BookSourceIntent.SetFilter -> {
+                localItems.value = null; filter.value = intent.filter
             }
-            appDb.bookSourceDao.upOrder(array)
-        }
-    }
 
-    fun del(sources: List<BookSourcePart>) {
-        execute {
-            SourceHelp.deleteBookSourceParts(sources)
-        }
-    }
+            is BookSourceIntent.SetSort -> {
+                localItems.value = null; sort.value = intent.sort
+            }
 
-    fun update(vararg bookSource: BookSource) {
-        execute { appDb.bookSourceDao.update(*bookSource) }
-    }
+            BookSourceIntent.ToggleSortDirection -> {
+                localItems.value = null; sortAscending.update { !it }
+            }
 
-    fun upOrder(items: List<BookSourcePart>) {
-        if (items.isEmpty()) return
-        execute {
-            appDb.bookSourceDao.upOrder(items)
-        }
-    }
+            BookSourceIntent.ToggleGroupByDomain -> {
+                localItems.value = null; groupByDomain.update { !it }
+            }
 
-    fun enable(enable: Boolean, items: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(enable, items)
-        }
-    }
+            is BookSourceIntent.SetEnabled -> setEnabled(intent.id, intent.enabled)
+            is BookSourceIntent.SetEnabledForSelection -> launch {
+                dao.enable(
+                    intent.enabled,
+                    parts(intent.ids)
+                )
+            }
 
-    fun enableSelection(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(true, sources)
+            is BookSourceIntent.SetExploreEnabled -> updateExplore(intent.ids, intent.enabled)
+            is BookSourceIntent.Delete -> launch { SourceHelp.deleteBookSourceParts(parts(intent.ids)); selectedIds.update { it - intent.ids } }
+            is BookSourceIntent.MoveToEdge -> moveToEdge(intent.ids, intent.toTop)
+            is BookSourceIntent.MoveItem -> moveItem(intent.from, intent.to)
+            BookSourceIntent.SaveSortOrder -> saveSortOrder()
+            is BookSourceIntent.CommitSortOrder -> commitSortOrder(intent.ids, intent.ascending)
+            is BookSourceIntent.AddToGroup -> updateGroups(intent.ids, intent.group, true)
+            is BookSourceIntent.RemoveFromGroup -> updateGroups(intent.ids, intent.group, false)
+            is BookSourceIntent.UpdateGroup -> updateGroup(intent.old, intent.new)
+            is BookSourceIntent.DeleteGroup -> updateGroup(intent.group, "")
+            is BookSourceIntent.CheckSelectedInterval -> checkInterval(intent.ids)
         }
     }
 
-    fun disableSelection(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enable(false, sources)
-        }
-    }
+    private fun launch(block: suspend () -> Unit) =
+        viewModelScope.launch(Dispatchers.IO) { block() }
 
-    fun enableExplore(enable: Boolean, items: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(enable, items)
+    private fun setEnabled(id: String, enabled: Boolean) {
+        enabledOverrides.update { it + (id to enabled) }
+        viewModelScope.launch(Dispatchers.IO) {
+            runCatching {
+                dao.enable(id, enabled)
+                dao.flowAll().first { sources ->
+                    sources.firstOrNull { it.bookSourceUrl == id }?.enabled == enabled
+                }
+            }
+            enabledOverrides.update { it - id }
         }
     }
 
-    fun enableSelectExplore(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(true, sources)
-        }
-    }
+    private fun parts(ids: Set<String>) = dao.allPart.filter { it.bookSourceUrl in ids }
+    private fun updateExplore(ids: Set<String>, enabled: Boolean) =
+        launch { dao.enableExplore(enabled, parts(ids)) }
 
-    fun disableSelectExplore(sources: List<BookSourcePart>) {
-        execute {
-            appDb.bookSourceDao.enableExplore(false, sources)
+    private fun updateGroups(ids: Set<String>, group: String, add: Boolean) = launch {
+        val changed = parts(ids).map { part ->
+            part.copy().apply { if (add) addGroup(group) else removeGroup(group) }
         }
+        dao.upGroup(changed)
     }
 
-    fun selectionAddToGroups(sources: List<BookSourcePart>, groups: String) {
-        execute {
-            val array = sources.map {
-                it.copy().apply {
-                    addGroup(groups)
-                }
-            }
-            appDb.bookSourceDao.upGroup(array)
+    private fun updateGroup(old: String, new: String) = launch {
+        val sources = dao.getByGroup(old)
+        sources.forEach { source ->
+            source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()
+                ?.apply { remove(old); if (new.isNotBlank()) add(new) }
+                ?.let { source.bookSourceGroup = TextUtils.join(",", it) }
         }
+        dao.update(*sources.toTypedArray())
     }
 
-    fun selectionRemoveFromGroups(sources: List<BookSourcePart>, groups: String) {
-        execute {
-            val array = sources.map {
-                it.copy().apply {
-                    removeGroup(groups)
-                }
-            }
-            appDb.bookSourceDao.upGroup(array)
-        }
+    private fun moveToEdge(ids: Set<String>, toTop: Boolean) = launch {
+        val selected = parts(ids).sortedBy { it.customOrder }
+        val start = if (toTop) dao.minOrder - selected.size else dao.maxOrder + 1
+        dao.upOrder(selected.mapIndexed { index, part -> part.copy(customOrder = start + index) })
     }
 
-    private fun saveToFile(
-        sources: List<BookSource>,
-        name: String,
-        success: (file: File, name: String) -> Unit
-    ) {
-        execute {
-            val path = "${context.filesDir}/shareBookSource.json"
-            FileUtils.delete(path)
-            val file = FileUtils.createFileWithReplace(path)
-            file.outputStream().buffered().use {
-                GSON.writeToOutputStream(it, sources)
-            }
-            file
-        }.onSuccess {
-            success.invoke(it, name)
-        }.onError {
-            context.toastOnUi(it.stackTraceStr)
-        }
+    private fun moveItem(from: Int, to: Int) {
+        if (sort.value != BookSourceSort.Default || groupByDomain.value) return
+        val moved = (localItems.value ?: uiState.value.items.map { item ->
+            BookSourcePart(bookSourceUrl = item.id, customOrder = item.customOrder)
+        }).toMutableList()
+        if (from !in moved.indices || to !in moved.indices) return
+        moved.add(to, moved.removeAt(from)); localItems.value = moved
     }
 
-    fun saveToFile(
-        adapter: BookSourceAdapter,
-        searchKey: String?,
-        sortAscending: Boolean,
-        sort: BookSourceSort,
-        success: (file: File, name: String) -> Unit
-    ) {
-        execute {
-            val selection = adapter.selection
-            val selectionSize = selection.size
-            val selectedRate = selectionSize.toFloat() / adapter.itemCount.toFloat()
-            val sources = if (selectedRate == 1f) {
-                getBookSources(searchKey, sortAscending, sort)
-            } else if (selectedRate < 0.3) {
-                selection.toBookSource()
-            } else {
-                val keys = selection.map { it.bookSourceUrl }.toHashSet()
-                val bookSources = getBookSources(searchKey, sortAscending, sort)
-                bookSources.filter {
-                    keys.contains(it.bookSourceUrl)
-                }
-            }
-            val name = if (selectionSize == 1) {
-                "bookSource_${selection.first().bookSourceName.normalizeFileName()}.json"
-            } else {
-                val timestamp =
-                    java.text.SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault()).format(Date())
-                "bookSource_$timestamp.json"
-            }
-            saveToFile(sources, name, success)
+    private fun saveSortOrder() {
+        val items = localItems.value ?: return
+        launch {
+            dao.upOrder(items.mapIndexed { index, item -> item.copy(customOrder = index + 1) }); localItems.value =
+            null
         }
     }
 
-    private fun getBookSources(
-        searchKey: String?,
-        sortAscending: Boolean,
-        sort: BookSourceSort
-    ): List<BookSource> {
-        return when {
-            searchKey.isNullOrEmpty() -> {
-                appDb.bookSourceDao.all
-            }
-
-            searchKey == appCtx.getString(R.string.enabled) -> {
-                appDb.bookSourceDao.allEnabled
-            }
-
-            searchKey == appCtx.getString(R.string.disabled) -> {
-                appDb.bookSourceDao.allDisabled
-            }
-
-            searchKey == appCtx.getString(R.string.need_login) -> {
-                appDb.bookSourceDao.allLogin
-            }
-
-            searchKey == appCtx.getString(R.string.no_group) -> {
-                appDb.bookSourceDao.allNoGroup
-            }
-
-            searchKey == appCtx.getString(R.string.enabled_explore) -> {
-                appDb.bookSourceDao.allEnabledExplore
-            }
-
-            searchKey == appCtx.getString(R.string.disabled_explore) -> {
-                appDb.bookSourceDao.allDisabledExplore
-            }
-
-            searchKey.startsWith("group:") -> {
-                val key = searchKey.substringAfter("group:")
-                appDb.bookSourceDao.groupSearch(key)
-            }
-
-            else -> {
-                appDb.bookSourceDao.search(searchKey)
-            }
-        }.let { data ->
-            if (sortAscending) when (sort) {
-                BookSourceSort.Weight -> data.sortedBy { it.weight }
-                BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                    o1.bookSourceName.cnCompare(o2.bookSourceName)
-                }
-
-                BookSourceSort.Url -> data.sortedBy { it.bookSourceUrl }
-                BookSourceSort.Update -> data.sortedByDescending { it.lastUpdateTime }
-                BookSourceSort.Respond -> data.sortedBy { it.respondTime }
-                BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                    var sortNum = -o1.enabled.compareTo(o2.enabled)
-                    if (sortNum == 0) {
-                        sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                    }
-                    sortNum
-                }
-
-                else -> data
-            }
-            else when (sort) {
-                BookSourceSort.Weight -> data.sortedByDescending { it.weight }
-                BookSourceSort.Name -> data.sortedWith { o1, o2 ->
-                    o2.bookSourceName.cnCompare(o1.bookSourceName)
-                }
-
-                BookSourceSort.Url -> data.sortedByDescending { it.bookSourceUrl }
-                BookSourceSort.Update -> data.sortedBy { it.lastUpdateTime }
-                BookSourceSort.Respond -> data.sortedByDescending { it.respondTime }
-                BookSourceSort.Enable -> data.sortedWith { o1, o2 ->
-                    var sortNum = o1.enabled.compareTo(o2.enabled)
-                    if (sortNum == 0) {
-                        sortNum = o1.bookSourceName.cnCompare(o2.bookSourceName)
-                    }
-                    sortNum
-                }
-
-                else -> data.reversed()
-            }
+    private fun commitSortOrder(ids: List<String>, ascending: Boolean) = launch {
+        val sourcesById = dao.allPart.associateBy { it.bookSourceUrl }
+        val orderSlots = ids.mapNotNull { sourcesById[it]?.customOrder }
+            .let { if (ascending) it.sorted() else it.sortedDescending() }
+        val ordered = ids.mapIndexedNotNull { index, id ->
+            sourcesById[id]?.copy(
+                customOrder = orderSlots.getOrNull(index) ?: return@mapIndexedNotNull null
+            )
         }
+        dao.upOrder(ordered)
     }
 
-    fun addGroup(group: String) {
-        execute {
-            val sources = appDb.bookSourceDao.noGroup
-            sources.forEach { source ->
-                source.bookSourceGroup = group
-            }
-            appDb.bookSourceDao.update(*sources.toTypedArray())
-        }
+    private fun checkInterval(ids: Set<String>) {
+        val items = uiState.value.items
+        val positions = items.mapIndexedNotNull { index, item -> index.takeIf { item.id in ids } }
+        if (positions.isNotEmpty()) selectedIds.value =
+            items.subList(positions.min(), positions.max() + 1).map { it.id }.toSet()
     }
-
-    fun upGroup(oldGroup: String, newGroup: String?) {
-        execute {
-            val sources = appDb.bookSourceDao.getByGroup(oldGroup)
-            sources.forEach { source ->
-                source.bookSourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
-                    it.remove(oldGroup)
-                    if (!newGroup.isNullOrEmpty())
-                        it.add(newGroup)
-                    source.bookSourceGroup = TextUtils.join(",", it)
-                }
-            }
-            appDb.bookSourceDao.update(*sources.toTypedArray())
+}
+
+private fun List<BookSourcePart>.filterFor(filter: String?, query: String): List<BookSourcePart> =
+    filter { source ->
+        val filterMatch = when (filter) {
+            null -> true; BookSourceViewModel.FILTER_ENABLED -> source.enabled; BookSourceViewModel.FILTER_DISABLED -> !source.enabled
+            BookSourceViewModel.FILTER_LOGIN -> source.hasLoginUrl; BookSourceViewModel.FILTER_NO_GROUP -> source.bookSourceGroup.isNullOrBlank()
+            BookSourceViewModel.FILTER_ENABLED_EXPLORE -> source.enabledExplore; BookSourceViewModel.FILTER_DISABLED_EXPLORE -> !source.enabledExplore
+            else -> filter.startsWith(BookSourceViewModel.PREFIX_GROUP) && source.bookSourceGroup?.split(
+                ","
+            )?.contains(filter.removePrefix(BookSourceViewModel.PREFIX_GROUP)) == true
         }
+        filterMatch && (query.isBlank() || listOf(
+            source.bookSourceName,
+            source.bookSourceUrl,
+            source.bookSourceGroup
+        ).any { it?.contains(query, true) == true })
     }
 
-    fun delGroup(group: String) {
-        execute {
-            execute {
-                val sources = appDb.bookSourceDao.getByGroup(group)
-                sources.forEach { source ->
-                    source.removeGroup(group)
-                }
-                appDb.bookSourceDao.update(*sources.toTypedArray())
-            }
-        }
+private fun List<BookSourcePart>.sortFor(
+    sort: BookSourceSort,
+    ascending: Boolean,
+    byDomain: Boolean
+): List<BookSourcePart> {
+    if (byDomain) {
+        val domains = associateWith { NetworkUtils.getSubDomainOrNull(it.bookSourceUrl) ?: "#" }
+        return sortedWith(compareBy<BookSourcePart> { domains.getValue(it) == "#" }
+            .thenBy { domains.getValue(it) }
+            .thenByDescending { it.lastUpdateTime })
     }
-
-}
+    val comparator = when (sort) {
+        BookSourceSort.Name -> compareBy<BookSourcePart> { it.bookSourceName }
+        BookSourceSort.Url -> compareBy { it.bookSourceUrl }; BookSourceSort.Weight -> compareBy { it.weight }
+        BookSourceSort.Update -> compareByDescending<BookSourcePart> { it.lastUpdateTime }; BookSourceSort.Respond -> compareBy { it.respondTime }
+        BookSourceSort.Enable -> compareByDescending<BookSourcePart> { it.enabled }.thenBy { it.bookSourceName }
+        BookSourceSort.Default -> compareBy { it.customOrder }
+    }
+    return if (ascending) sortedWith(comparator) else sortedWith(comparator.reversed())
+}
+
+private fun String.displayName(application: Application) = when (this) {
+    BookSourceViewModel.FILTER_ENABLED -> application.getString(io.legado.app.R.string.enabled)
+    BookSourceViewModel.FILTER_DISABLED -> application.getString(io.legado.app.R.string.disabled)
+    BookSourceViewModel.FILTER_LOGIN -> application.getString(io.legado.app.R.string.need_login)
+    BookSourceViewModel.FILTER_NO_GROUP -> application.getString(io.legado.app.R.string.no_group)
+    BookSourceViewModel.FILTER_ENABLED_EXPLORE -> application.getString(io.legado.app.R.string.enabled_explore)
+    BookSourceViewModel.FILTER_DISABLED_EXPLORE -> application.getString(io.legado.app.R.string.disabled_explore)
+    else -> removePrefix(BookSourceViewModel.PREFIX_GROUP)
+}

+ 0 - 137
app/src/main/java/io/legado/app/ui/book/source/manage/GroupManageDialog.kt

@@ -1,137 +0,0 @@
-package io.legado.app.ui.book.source.manage
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.BaseDialogFragment
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.appDb
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.databinding.DialogRecyclerViewBinding
-import io.legado.app.databinding.ItemGroupManageBinding
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.backgroundColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.requestInputMethod
-import io.legado.app.utils.setLayout
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.launch
-
-
-class GroupManageDialog : BaseDialogFragment(R.layout.dialog_recycler_view),
-    Toolbar.OnMenuItemClickListener {
-
-    private val viewModel: BookSourceViewModel by activityViewModels()
-    private val binding by viewBinding(DialogRecyclerViewBinding::bind)
-    private val adapter by lazy { GroupAdapter(requireContext()) }
-
-    override fun onStart() {
-        super.onStart()
-        setLayout(0.9f, 0.9f)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-//        view.setBackgroundColor(backgroundColor)
-//        binding.toolBar.setBackgroundColor(primaryColor)
-        binding.toolBar.title = getString(R.string.group_manage)
-        binding.toolBar.inflateMenu(R.menu.group_manage)
-        //binding.toolBar.menu.applyTint(requireContext())
-        binding.toolBar.setOnMenuItemClickListener(this)
-        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
-        binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
-        binding.recyclerView.adapter = adapter
-        initData()
-    }
-
-    private fun initData() {
-        lifecycleScope.launch {
-            appDb.bookSourceDao.flowGroups().collect {
-                adapter.setItems(it)
-            }
-        }
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_add -> addGroup()
-        }
-        return true
-    }
-
-    @SuppressLint("InflateParams")
-    private fun addGroup() {
-        alert(title = getString(R.string.add_group)) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotBlank()) {
-                        viewModel.addGroup(it)
-                    }
-                }
-            }
-            cancelButton()
-        }.requestInputMethod()
-    }
-
-    @SuppressLint("InflateParams")
-    private fun editGroup(group: String) {
-        alert(title = getString(R.string.group_edit)) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setText(group)
-            }
-            customView { alertBinding.root }
-            okButton {
-                viewModel.upGroup(group, alertBinding.editView.text?.toString())
-            }
-            cancelButton()
-        }.requestInputMethod()
-    }
-
-    private inner class GroupAdapter(context: Context) :
-        RecyclerAdapter<String, ItemGroupManageBinding>(context) {
-
-        override fun getViewBinding(parent: ViewGroup): ItemGroupManageBinding {
-            return ItemGroupManageBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemGroupManageBinding,
-            item: String,
-            payloads: MutableList<Any>
-        ) {
-            binding.run {
-                //root.setBackgroundColor(context.backgroundColor)
-                tvGroup.text = item
-            }
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemGroupManageBinding) {
-            binding.apply {
-                tvEdit.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let {
-                        editGroup(it)
-                    }
-                }
-                tvDel.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let { viewModel.delGroup(it) }
-                }
-            }
-        }
-    }
-
-}

+ 18 - 33
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -71,8 +71,8 @@ import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.TimePickerDialog
@@ -206,12 +206,16 @@ fun ThemeConfigScreen(
                         )
 
                         if (theme.useMiuixMonet) {
-                            SwitchSettingItem(
-                                title = stringResource(R.string.dynamic_colors),
-                                description = stringResource(R.string.dynamic_colors_summary),
-                                checked = theme.appTheme == "0",
-                                onCheckedChange = {
-                                    onIntent(ThemeConfigIntent.SetDynamicColors(it))
+                            val visibleThemes = themes.filter { (_, value) ->
+                                value != "4" || state.showEInkTheme
+                            }
+                            DropdownListSettingItem(
+                                title = stringResource(R.string.theme),
+                                selectedValue = theme.appTheme,
+                                displayEntries = visibleThemes.map { it.first }.toTypedArray(),
+                                entryValues = visibleThemes.map { it.second }.toTypedArray(),
+                                onValueChange = { value ->
+                                    onIntent(ThemeConfigIntent.SelectTheme(value))
                                 }
                             )
                         }
@@ -219,9 +223,7 @@ fun ThemeConfigScreen(
                         ThemeModeSelector(
                             selectedMode = appShell.themeMode,
                             onModeSelected = { mode ->
-                                onIntent(
-                                    ThemeConfigIntent.SetThemeMode(mode)
-                                )
+                                onIntent(ThemeConfigIntent.SetThemeMode(mode))
                             }
                         )
                     }
@@ -1098,42 +1100,25 @@ fun ThemeModeSelector(
         .coerceAtLeast(0)
 
     Row(
-        modifier = Modifier
-            .fillMaxWidth(),
-        horizontalArrangement = Arrangement.spacedBy(
-            ButtonGroupDefaults.ConnectedSpaceBetween
-        )
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)
     ) {
         val modifiers = listOf(Modifier.weight(1.2f), Modifier.weight(1f), Modifier.weight(1f))
 
         modes.forEachIndexed { index, (value, label, icon) ->
-            val checked = selectedIndex == index
             ToggleButton(
-                checked = checked,
+                checked = selectedIndex == index,
                 onCheckedChange = { onModeSelected(value) },
-                modifier = modifiers[index]
-                    .semantics {
-                        role = Role.RadioButton
-                    },
-
+                modifier = modifiers[index].semantics { role = Role.RadioButton },
                 shapes = when (index) {
                     0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
                     modes.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
                     else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
                 }
             ) {
-                Icon(
-                    imageVector = icon,
-                    contentDescription = null
-                )
-
+                Icon(imageVector = icon, contentDescription = null)
                 Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing))
-
-                Text(
-                    text = label,
-                    overflow = TextOverflow.Ellipsis,
-                    maxLines = 1
-                )
+                Text(text = label, overflow = TextOverflow.Ellipsis, maxLines = 1)
             }
         }
     }

+ 96 - 143
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt

@@ -1,20 +1,20 @@
 package io.legado.app.ui.config.themeManage
 
 import androidx.compose.foundation.background
+import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.GridItemSpan
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
@@ -22,19 +22,20 @@ import androidx.compose.material.icons.filled.Share
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.help.config.SavedTheme
-import io.legado.app.help.config.ThemePackageManager
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
@@ -54,6 +55,11 @@ fun ThemeManageScreen(
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val savedThemes = state.savedThemes
+    val filteredSavedThemes = remember(savedThemes, state.searchQuery) {
+        savedThemes.filter { theme ->
+            theme.name.contains(state.searchQuery, ignoreCase = true)
+        }
+    }
 
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -67,17 +73,15 @@ fun ThemeManageScreen(
             )
         }
     ) { paddingValues ->
-        LazyVerticalGrid(
-            columns = GridCells.Fixed(2),
+        LazyColumn(
             modifier = Modifier.fillMaxSize(),
             contentPadding = adaptiveContentPadding(
                 top = paddingValues.calculateTopPadding(),
                 bottom = 120.dp
             ),
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
             verticalArrangement = Arrangement.spacedBy(8.dp)
         ) {
-            item(span = { GridItemSpan(maxLineSpan) }) {
+            item {
                 SplicedColumnGroup {
                     ClickableSettingItem(
                         title = stringResource(R.string.theme_manage_save_current),
@@ -114,7 +118,18 @@ fun ThemeManageScreen(
             }
 
             if (savedThemes.isNotEmpty()) {
-                item(span = { GridItemSpan(maxLineSpan) }) {
+                item {
+                    SearchBar(
+                        query = state.searchQuery,
+                        onQueryChange = {
+                            onIntent(ThemeManageIntent.UpdateSearchQuery(it))
+                        },
+                        placeholder = stringResource(R.string.search_placeholder),
+                        autoFocus = false,
+                    )
+                }
+
+                item {
                     AppText(
                         text = stringResource(R.string.theme_manage_saved_themes),
                         style = MaterialTheme.typography.titleSmall,
@@ -123,7 +138,7 @@ fun ThemeManageScreen(
                     )
                 }
 
-                items(savedThemes, key = { it.name }) { theme ->
+                items(filteredSavedThemes, key = { it.name }) { theme ->
                     SavedThemeItem(
                         theme = theme,
                         onApply = { onIntent(ThemeManageIntent.OpenApplyDialog(theme)) },
@@ -229,150 +244,88 @@ private fun SavedThemeItem(
     onExport: () -> Unit,
     onDelete: () -> Unit
 ) {
+    val data = theme.data
+    val lightPrimary = data.themeColor.takeIf { it != 0 }?.let(::Color)
+        ?: data.cPrimary.takeIf { it != 0 }?.let(::Color)
+        ?: MaterialTheme.colorScheme.primary
+    val darkPrimary = data.themeColorNight.takeIf { it != 0 }?.let(::Color)
+        ?: data.cNPrimary.takeIf { it != 0 }?.let(::Color)
+        ?: lightPrimary
+    val lightSecondary = data.secondaryThemeColor.takeIf { it != 0 }?.let(::Color)
+        ?: lightPrimary
+    val darkSecondary = data.secondaryThemeColorNight.takeIf { it != 0 }?.let(::Color)
+        ?: lightSecondary
+    val lightSurface = data.themeBackgroundColor.takeIf { it != 0 }?.let(::Color)
+        ?: Color(0xFFF7F2FA)
+    val darkSurface = data.themeBackgroundColorNight.takeIf { it != 0 }?.let(::Color)
+        ?: if (data.enableDeepPersonalization && data.themeBackgroundColor != 0) {
+            Color(data.themeBackgroundColor)
+        } else if (data.isPureBlack) {
+            Color.Black
+        } else {
+            Color(0xFF1C1B1F)
+        }
+
     GlassCard(
         onClick = onApply,
         modifier = Modifier.fillMaxWidth()
     ) {
-        Column(
-            Modifier.fillMaxWidth()
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 12.dp, vertical = 8.dp),
+            verticalAlignment = Alignment.CenterVertically,
         ) {
-            val lightPrimary = if (theme.data.themeColor != 0) Color(theme.data.themeColor)
-            else if (theme.data.cPrimary != 0) Color(theme.data.cPrimary)
-            else MaterialTheme.colorScheme.primary
-
-            val darkPrimary = if (theme.data.themeColorNight != 0) {
-                Color(theme.data.themeColorNight)
-            } else if (theme.data.cNPrimary != 0) {
-                Color(theme.data.cNPrimary)
-            } else {
-                lightPrimary
-            }
-
-            val lightBg =
-                if (theme.data.themeBackgroundColor != 0) Color(theme.data.themeBackgroundColor)
-                else Color(0xFFF7F2FA)
-
-            val darkBg = if (theme.data.themeBackgroundColorNight != 0) {
-                Color(theme.data.themeBackgroundColorNight)
-            } else if (theme.data.enableDeepPersonalization &&
-                theme.data.themeBackgroundColor != 0
-            ) {
-                Color(theme.data.themeBackgroundColor)
-            } else if (theme.data.isPureBlack) {
-                Color.Black
-            } else {
-                Color(0xFF1C1B1F)
-            }
             Column(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .aspectRatio(1.4f)
-            ) {
-                // 日间行
-                Box(
-                    modifier = Modifier
-                        .weight(1f)
-                        .fillMaxWidth()
-                        .background(lightBg)
-                ) {
-                    AppText(
-                        text = stringResource(R.string.theme_manage_preview_day),
-                        style = MaterialTheme.typography.labelMediumEmphasized,
-                        color = if (theme.data.primaryTextColor != 0) Color(theme.data.primaryTextColor).copy(
-                            alpha = 0.6f
-                        )
-                        else Color.Black.copy(alpha = 0.5f),
-                        modifier = Modifier
-                            .align(Alignment.CenterStart)
-                            .padding(start = 12.dp)
-                    )
-
-                    Box(
-                        modifier = Modifier
-                            .align(Alignment.CenterEnd)
-                            .padding(end = 16.dp)
-                            .size(28.dp)
-                            .shadow(
-                                elevation = 6.dp,
-                                shape = RoundedCornerShape(6.dp),
-                                ambientColor = lightPrimary,
-                                spotColor = lightPrimary
-                            )
-                            .background(lightPrimary, RoundedCornerShape(6.dp))
-                    )
-                }
-
-                // 夜间行
-                Box(
-                    modifier = Modifier
-                        .weight(1f)
-                        .fillMaxWidth()
-                        .background(darkBg)
-                ) {
-                    AppText(
-                        text = stringResource(R.string.theme_manage_preview_night),
-                        style = MaterialTheme.typography.labelMediumEmphasized,
-                        color = if (theme.data.primaryTextColorNight != 0) {
-                            Color(theme.data.primaryTextColorNight).copy(alpha = 0.6f)
-                        } else if (theme.data.enableDeepPersonalization &&
-                            theme.data.primaryTextColor != 0
-                        ) {
-                            Color(theme.data.primaryTextColor).copy(alpha = 0.6f)
-                        } else {
-                            Color.White.copy(alpha = 0.5f)
-                        },
-                        modifier = Modifier
-                            .align(Alignment.CenterStart)
-                            .padding(start = 12.dp)
-                    )
-
-                    Box(
-                        modifier = Modifier
-                            .align(Alignment.CenterEnd)
-                            .padding(end = 16.dp)
-                            .size(28.dp)
-                            .shadow(
-                                elevation = 6.dp,
-                                shape = RoundedCornerShape(6.dp),
-                                ambientColor = darkPrimary,
-                                spotColor = darkPrimary
-                            )
-                            .background(darkPrimary, RoundedCornerShape(6.dp))
-                    )
-                }
-            }
-
-            Column(
-                modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
+                modifier = Modifier.weight(1f)
             ) {
                 AppText(
                     text = theme.name,
-                    style = MaterialTheme.typography.titleSmall,
+                    style = LegadoTheme.typography.titleSmallEmphasized,
                     maxLines = 1
                 )
-
                 Row(
-                    modifier = Modifier.fillMaxWidth(),
-                    horizontalArrangement = Arrangement.End,
-                    verticalAlignment = Alignment.CenterVertically
+                    modifier = Modifier.padding(top = 4.dp),
+                    horizontalArrangement = Arrangement.spacedBy(4.dp),
                 ) {
-                    SmallPlainButton(
-                        onClick = onEdit,
-                        icon = Icons.Default.Edit,
-                        contentDescription = stringResource(R.string.edit)
-                    )
-                    SmallPlainButton(
-                        onClick = onExport,
-                        icon = Icons.Default.Share,
-                        contentDescription = stringResource(R.string.share)
-                    )
-                    SmallPlainButton(
-                        onClick = onDelete,
-                        icon = Icons.Default.Delete,
-                        contentDescription = stringResource(R.string.delete)
-                    )
+                    ThemeColorGroup(lightPrimary, lightSecondary, lightSurface)
+                    Spacer(Modifier.width(8.dp))
+                    ThemeColorGroup(darkPrimary, darkSecondary, darkSurface)
                 }
             }
+            SmallPlainButton(
+                onClick = onEdit,
+                icon = Icons.Default.Edit,
+                contentDescription = stringResource(R.string.edit)
+            )
+            SmallPlainButton(
+                onClick = onExport,
+                icon = Icons.Default.Share,
+                contentDescription = stringResource(R.string.share)
+            )
+            SmallPlainButton(
+                onClick = onDelete,
+                icon = Icons.Default.Delete,
+                contentDescription = stringResource(R.string.delete)
+            )
+        }
+    }
+}
+
+@Composable
+private fun ThemeColorGroup(
+    primary: Color,
+    secondary: Color,
+    surface: Color,
+) {
+    Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+        listOf(primary, secondary, surface).forEach { color ->
+            Box(
+                modifier = Modifier
+                    .size(12.dp)
+                    .background(color, CircleShape)
+                    .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
+            )
         }
     }
 }

+ 6 - 1
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt

@@ -6,8 +6,8 @@ import androidx.compose.runtime.Stable
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
-import io.legado.app.help.config.SavedTheme
 import io.legado.app.domain.model.settings.ThemeExportData
+import io.legado.app.help.config.SavedTheme
 import io.legado.app.help.config.ThemePackageManager
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
@@ -52,6 +52,9 @@ class ThemeManageViewModel(
                 val dialog = state.dialog as? ThemeManageDialog.Save ?: return@update state
                 state.copy(dialog = dialog.copy(name = intent.value))
             }
+            is ThemeManageIntent.UpdateSearchQuery -> _uiState.update {
+                it.copy(searchQuery = intent.value)
+            }
             is ThemeManageIntent.OpenApplyDialog ->
                 _uiState.update { it.copy(dialog = ThemeManageDialog.Apply(intent.theme)) }
             is ThemeManageIntent.OpenDeleteDialog ->
@@ -239,6 +242,7 @@ class ThemeManageViewModel(
 data class ThemeManageUiState(
     val loading: Boolean = false,
     val savedThemes: ImmutableList<SavedTheme> = persistentListOf(),
+    val searchQuery: String = "",
     val hasLegacyThemes: Boolean = false,
     val dialog: ThemeManageDialog? = null,
 )
@@ -273,6 +277,7 @@ sealed interface ThemeManageIntent {
     data object MigrateLegacyThemes : ThemeManageIntent
     data object OpenSaveDialog : ThemeManageIntent
     data class UpdateSaveName(val value: String) : ThemeManageIntent
+    data class UpdateSearchQuery(val value: String) : ThemeManageIntent
     data class OpenApplyDialog(val theme: SavedTheme) : ThemeManageIntent
     data class OpenDeleteDialog(val theme: SavedTheme) : ThemeManageIntent
     data class OpenEditSheet(val theme: SavedTheme) : ThemeManageIntent

+ 2 - 4
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -45,7 +45,6 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.LazyGridState
-import androidx.compose.foundation.lazy.grid.items
 import androidx.compose.foundation.lazy.grid.itemsIndexed
 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
 import androidx.compose.foundation.pager.HorizontalPager
@@ -60,7 +59,6 @@ import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.History
 import androidx.compose.material.icons.filled.Link
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.SelectAll
@@ -131,9 +129,9 @@ import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
@@ -527,7 +525,7 @@ fun BookshelfScreen(
                         Box {
                             TopBarActionButton(
                                 onClick = { showTopBarMenu = true },
-                                imageVector = Icons.Default.MoreVert,
+                                imageVector = AppIcons.MoreVert,
                                 contentDescription = stringResource(R.string.more_menu)
                             )
                             RoundDropdownMenu(

+ 1 - 2
app/src/main/java/io/legado/app/ui/main/home/HomeScreen.kt

@@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.CloudSync
 import androidx.compose.material.icons.filled.CloudUpload
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Leaderboard
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.TrackChanges
@@ -444,7 +443,7 @@ fun HomeScreen(
                     Box {
                         TopBarActionButton(
                             onClick = { showPageMenu = true },
-                            imageVector = Icons.Default.MoreVert,
+                            imageVector = AppIcons.MoreVert,
                             contentDescription = stringResource(R.string.more_menu),
                         )
                         RoundDropdownMenu(

+ 2 - 2
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material3.AssistChip
@@ -65,6 +64,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ToggleChip
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
@@ -161,7 +161,7 @@ fun ReplaceEditScreen(
                     }
                     TopBarActionButton(
                         onClick = { showMenu = true },
-                        imageVector = Icons.Default.MoreVert,
+                        imageVector = AppIcons.MoreVert,
                         contentDescription = stringResource(R.string.more_actions)
                     )
                     RoundDropdownMenu(

+ 2 - 2
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -23,7 +23,6 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.Login
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.CleaningServices
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.OpenInBrowser
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Share
@@ -61,6 +60,7 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -235,7 +235,7 @@ fun RssReadRouteScreen(
                         }
                         Box {
                         TopBarActionButton(
-                            imageVector = Icons.Default.MoreVert,
+                            imageVector = AppIcons.MoreVert,
                             contentDescription = stringResource(R.string.more_menu),
                             onClick = { showMenu = true }
                         )

+ 15 - 130
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt

@@ -1,135 +1,20 @@
 package io.legado.app.ui.rss.source.debug
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import androidx.appcompat.widget.SearchView
-import androidx.activity.viewModels
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.databinding.ActivitySourceDebugBinding
-import io.legado.app.help.source.sortUrls
-import io.legado.app.ui.widget.dialog.TextDialog
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.showDialogFragment
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import splitties.views.onClick
-import splitties.views.onLongClick
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
 
-
-class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() {
-
-    override val binding by viewBinding(ActivitySourceDebugBinding::inflate)
-    override val viewModel by viewModels<RssSourceDebugModel>()
-
-    private val adapter by lazy { RssSourceDebugAdapter(this) }
-
-    @SuppressLint("SetTextI18n")
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        initHelpView()
-        viewModel.observe { state, msg ->
-            lifecycleScope.launch {
-                adapter.addItem(msg)
-                if (state == -1 || state == 1000) {
-                    binding.rotateLoading.gone()
-                }
-            }
-        }
-        viewModel.initData(intent.getStringExtra("key")) {
-            // initData 完成后的回调
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_source_debug, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_list_src -> showDialogFragment(TextDialog("Html", viewModel.listSrc))
-            R.id.menu_content_src -> showDialogFragment(TextDialog("Html", viewModel.contentSrc))
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initRecyclerView() {
-        binding.recyclerView.adapter = adapter
-        binding.recyclerView.applyNavigationBarPadding()
-    }
-
-    private fun initSearchView() {
-        val searchView = binding.titleBar.findViewById<SearchView>(R.id.search_view)
-        searchView.visible()
-        searchView.isSubmitButtonEnabled = true
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                searchView.clearFocus()
-                startDebug(query ?: "")
-                return true
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                return false
-            }
-        })
-    }
-
-    @SuppressLint("SetTextI18n")
-    private fun initHelpView() {
-        binding.help.visible()
-        binding.textMy.onClick {
-            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textMy.text, true)
-        }
-        binding.textXt.onClick {
-            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textXt.text, true)
-        }
-        binding.textFx.onClick {
-            if (!binding.textFx.text.startsWith("ERROR:")) {
-                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textFx.text, true)
-            }
-        }
-        binding.textContent.onClick {
-            val query = binding.titleBar.findViewById<SearchView>(R.id.search_view).query
-            if (!query.isNullOrBlank()) {
-                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(query, true)
-            }
-        }
-        initSortKinds()
-    }
-
-    private fun initSortKinds() {
-        lifecycleScope.launch {
-            val sortKinds = viewModel.rssSource?.sortUrls()?.filter {
-                it.second.isNotBlank()
-            }
-            sortKinds?.firstOrNull()?.let {
-                binding.textFx.text = "${it.first}::${it.second}"
-                if (it.first.startsWith("ERROR:")) {
-                    adapter.addItem("获取发现出错\n${it.second}")
-                    binding.titleBar.findViewById<SearchView>(R.id.search_view).clearFocus()
-                    return@launch
-                }
-            }
-        }
-    }
-
-    private fun startDebug(key: String) {
-        adapter.clearItems()
-        binding.help.gone() // 关闭调试选项面板
-        viewModel.rssSource?.let {
-            binding.rotateLoading.visible()
-            viewModel.startDebug(it, key)
-        } ?: toastOnUi(R.string.error_no_source)
+class RssSourceDebugActivity : BaseComposeActivity() {
+    @Composable override fun Content() {
+        val vm = koinViewModel<RssSourceDebugViewModel>(); val state = vm.uiState.collectAsStateWithLifecycle().value
+        LaunchedEffect(Unit) { vm.onIntent(RssSourceDebugIntent.Load(intent.getStringExtra("key"))); vm.effects.collectLatest { if (it is RssSourceDebugEffect.ShowMessage) toastOnUi(it.value) } }
+        LifecycleEventEffect(Lifecycle.Event.ON_STOP) { vm.onIntent(RssSourceDebugIntent.Stop) }
+        RssSourceDebugScreen(state, vm::onIntent, ::finish)
     }
-}
+}

+ 0 - 44
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugAdapter.kt

@@ -1,44 +0,0 @@
-package io.legado.app.ui.rss.source.debug
-
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.databinding.ItemLogBinding
-
-class RssSourceDebugAdapter(context: Context) :
-    RecyclerAdapter<String, ItemLogBinding>(context) {
-
-    override fun getViewBinding(parent: ViewGroup): ItemLogBinding {
-        return ItemLogBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemLogBinding,
-        item: String,
-        payloads: MutableList<Any>
-    ) {
-        binding.apply {
-            if (textView.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        textView.isCursorVisible = false
-                        textView.isCursorVisible = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {}
-                }
-                textView.addOnAttachStateChangeListener(listener)
-                textView.setTag(R.id.tag1, listener)
-            }
-            textView.text = item
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemLogBinding) {
-        //nothing
-    }
-}

+ 40 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugContract.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.rss.source.debug
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.Immutable
+import io.legado.app.ui.book.source.debug.BookSourceDebugEntryUi
+import io.legado.app.ui.book.source.debug.BookSourceDebugFilter
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class RssSourceDebugTarget { Search, Sort, Content }
+
+@Stable data class RssSourceDebugUiState(
+    val sourceName: String = "",
+    val query: String = "",
+    val target: RssSourceDebugTarget = RssSourceDebugTarget.Search,
+    val status: BookSourceDebugStatus = BookSourceDebugStatus.Loading,
+    val filter: BookSourceDebugFilter = BookSourceDebugFilter.All,
+    val entries: ImmutableList<BookSourceDebugEntryUi> = persistentListOf(),
+    val examples: ImmutableList<RssSourceDebugExampleUi> = persistentListOf(),
+    val selectedEntryId: Long? = null,
+)
+
+@Immutable
+data class RssSourceDebugExampleUi(val title: String, val target: RssSourceDebugTarget, val value: String)
+
+sealed interface RssSourceDebugIntent {
+    data class Load(val sourceUrl: String?) : RssSourceDebugIntent
+    data class SetQuery(val value: String) : RssSourceDebugIntent
+    data class SelectTarget(val value: RssSourceDebugTarget) : RssSourceDebugIntent
+    data class SelectFilter(val value: BookSourceDebugFilter) : RssSourceDebugIntent
+    data class UseExample(val value: RssSourceDebugExampleUi) : RssSourceDebugIntent
+    data class ShowEntry(val id: Long) : RssSourceDebugIntent
+    data object DismissEntry : RssSourceDebugIntent
+    data object Start : RssSourceDebugIntent
+    data object Stop : RssSourceDebugIntent
+    data object Clear : RssSourceDebugIntent
+}
+
+sealed interface RssSourceDebugEffect { data class ShowMessage(val value: String) : RssSourceDebugEffect }

+ 0 - 59
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt

@@ -1,59 +0,0 @@
-package io.legado.app.ui.rss.source.debug
-
-import android.app.Application
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.RssSource
-import io.legado.app.model.Debug
-
-class RssSourceDebugModel(application: Application) : BaseViewModel(application),
-    Debug.Callback {
-    var rssSource: RssSource? = null
-    private var callback: ((Int, String) -> Unit)? = null
-    var listSrc: String? = null
-    var contentSrc: String? = null
-
-    fun initData(sourceUrl: String?, finally: () -> Unit) {
-        sourceUrl?.let {
-            execute {
-                rssSource = appDb.rssSourceDao.getByKey(sourceUrl)
-            }.onFinally {
-                finally()
-            }
-        }
-    }
-
-    fun observe(callback: (Int, String) -> Unit) {
-        this.callback = callback
-    }
-
-    fun startDebug(source: RssSource) {
-        execute {
-            Debug.callback = this@RssSourceDebugModel
-            Debug.startDebug(this, source)
-        }
-    }
-
-    fun startDebug(source: RssSource, key: String) {
-        execute {
-            Debug.callback = this@RssSourceDebugModel
-            Debug.startDebug(this, source, key)
-        }.onStart {
-            // 可以在这里添加开始回调
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        when (state) {
-            10 -> listSrc = msg
-            20 -> contentSrc = msg
-            else -> callback?.invoke(state, msg)
-        }
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        Debug.cancelDebug(true)
-    }
-
-}

+ 193 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugScreen.kt

@@ -0,0 +1,193 @@
+package io.legado.app.ui.rss.source.debug
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ClearAll
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
+import io.legado.app.model.Debug
+import io.legado.app.ui.about.MarkdownSheet
+import io.legado.app.ui.book.source.debug.BookSourceDebugFilter
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import io.legado.app.ui.book.source.debug.DebugChipRow
+import io.legado.app.ui.book.source.debug.DebugEntryCard
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.button.ToggleChip
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+
+@Composable
+fun RssSourceDebugScreen(
+    state: RssSourceDebugUiState,
+    onIntent: (RssSourceDebugIntent) -> Unit,
+    onBack: () -> Unit
+) {
+    val scroll = GlassTopAppBarDefaults.defaultScrollBehavior();
+    val list = rememberLazyListState()
+    val visibleExamples = remember(state.examples, state.target) {
+        state.examples.filter { it.target == state.target }.take(8)
+    }
+    val shown = state.entries.filter {
+        when (state.filter) {
+            BookSourceDebugFilter.All -> true
+            BookSourceDebugFilter.Messages -> it.kind == Debug.EventKind.Message || it.kind == Debug.EventKind.Completed
+            BookSourceDebugFilter.Sources -> it.kind.isSourcePayload
+            BookSourceDebugFilter.Errors -> it.kind == Debug.EventKind.Error
+        }
+    }
+    LaunchedEffect(shown.size) {
+        if (state.status == BookSourceDebugStatus.Running && shown.isNotEmpty()) list.animateScrollToItem(
+            shown.lastIndex
+        )
+    }
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scroll.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "调试",
+                navigationIcon = { TopBarNavigationButton(onClick = onBack) },
+                actions = {
+                    TopBarActionButton(
+                        { onIntent(RssSourceDebugIntent.Clear) },
+                        Icons.Default.ClearAll,
+                        "清空日志"
+                    )
+                },
+                scrollBehavior = scroll,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(if (state.status == BookSourceDebugStatus.Running) RssSourceDebugIntent.Stop else RssSourceDebugIntent.Start) },
+                icon = if (state.status == BookSourceDebugStatus.Running) Icons.Default.Stop else Icons.Default.PlayArrow,
+                tooltipText = if (state.status == BookSourceDebugStatus.Running) "停止" else "开始调试",
+            )
+        },
+    ) { padding ->
+        LazyColumn(
+            state = list, modifier = Modifier.fillMaxSize(),
+            contentPadding = PaddingValues(
+                16.dp,
+                padding.calculateTopPadding() + 12.dp,
+                16.dp,
+                padding.calculateBottomPadding() + 96.dp
+            ),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            item("controls") {
+                GlassCard {
+                    Column(
+                        Modifier.padding(12.dp),
+                        verticalArrangement = Arrangement.spacedBy(8.dp)
+                    ) {
+                        Row(
+                            modifier = Modifier.fillMaxWidth(),
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center,
+                        ) {
+                            AppText(
+                                text = state.sourceName,
+                                style = LegadoTheme.typography.labelMediumEmphasized,
+                            )
+                        }
+                        DebugChipRow {
+                            items(RssSourceDebugTarget.entries, key = { it.name }) { target ->
+                                ToggleChip(
+                                    target.label,
+                                    state.target == target,
+                                    { onIntent(RssSourceDebugIntent.SelectTarget(target)) })
+                            }
+                        }
+                        AppTextField(
+                            value = state.query,
+                            onValueChange = { onIntent(RssSourceDebugIntent.SetQuery(it)) },
+                            modifier = Modifier.fillMaxWidth(),
+                            label = state.target.hint,
+                            maxLines = 4,
+                        )
+                        if (visibleExamples.isNotEmpty()) {
+                            DebugChipRow {
+                                items(visibleExamples, key = { "${it.target}:${it.value}" }) { example ->
+                                    ToggleChip(
+                                        example.title,
+                                        state.query == example.value,
+                                        { onIntent(RssSourceDebugIntent.UseExample(example)) })
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            item("filters") {
+                DebugChipRow {
+                    items(BookSourceDebugFilter.entries, key = { it.name }) { filter ->
+                        ToggleChip(
+                            filter.label,
+                            state.filter == filter,
+                            { onIntent(RssSourceDebugIntent.SelectFilter(filter)) })
+                    }
+                }
+            }
+            if (shown.isEmpty()) item("empty") {
+                EmptyMessage(
+                    message = "输入内容并开始调试",
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(240.dp),
+                )
+            }
+            items(shown, key = { it.id }) { entry ->
+                DebugEntryCard(
+                    entry = entry,
+                    modifier = Modifier.animateItem(),
+                    onClick = { onIntent(RssSourceDebugIntent.ShowEntry(entry.id)) },
+                )
+            }
+        }
+    }
+    val selected = state.entries.firstOrNull { it.id == state.selectedEntryId }
+    MarkdownSheet(
+        show = selected != null,
+        title = "日志详情",
+        content = selected?.message.orEmpty(),
+        onDismissRequest = { onIntent(RssSourceDebugIntent.DismissEntry) },
+    )
+}
+
+private val RssSourceDebugTarget.label
+    get() = when (this) {
+        RssSourceDebugTarget.Search -> "搜索"; RssSourceDebugTarget.Sort -> "分类"; RssSourceDebugTarget.Content -> "内容"
+    }
+private val RssSourceDebugTarget.hint
+    get() = when (this) {
+        RssSourceDebugTarget.Search -> "搜索关键字"; RssSourceDebugTarget.Sort -> "分类 URL"; RssSourceDebugTarget.Content -> "内容页 URL 或 @js 链接"
+    }
+private val BookSourceDebugFilter.label
+    get() = when (this) {
+        BookSourceDebugFilter.All -> "全部"; BookSourceDebugFilter.Messages -> "过程"; BookSourceDebugFilter.Sources -> "响应"; BookSourceDebugFilter.Errors -> "错误"
+    }

+ 97 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugViewModel.kt

@@ -0,0 +1,97 @@
+package io.legado.app.ui.rss.source.debug
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.RssSource
+import io.legado.app.help.source.sortUrls
+import io.legado.app.model.Debug
+import io.legado.app.ui.book.source.debug.BookSourceDebugEntryUi
+import io.legado.app.ui.book.source.debug.BookSourceDebugStatus
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.atomic.AtomicLong
+
+class RssSourceDebugViewModel(app: Application) : AndroidViewModel(app) {
+    private val _uiState = MutableStateFlow(RssSourceDebugUiState())
+    val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<RssSourceDebugEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+    private var source: RssSource? = null
+    private var session: Debug.Session? = null
+    private val ids = AtomicLong()
+
+    fun onIntent(intent: RssSourceDebugIntent) = when (intent) {
+        is RssSourceDebugIntent.Load -> { load(intent.sourceUrl); Unit }
+        is RssSourceDebugIntent.SetQuery -> _uiState.update { it.copy(query = intent.value) }
+        is RssSourceDebugIntent.SelectTarget -> _uiState.update { it.copy(target = intent.value) }
+        is RssSourceDebugIntent.SelectFilter -> _uiState.update { it.copy(filter = intent.value) }
+        is RssSourceDebugIntent.UseExample -> _uiState.update { it.copy(target = intent.value.target, query = intent.value.value) }
+        is RssSourceDebugIntent.ShowEntry -> _uiState.update { it.copy(selectedEntryId = intent.id) }
+        RssSourceDebugIntent.DismissEntry -> _uiState.update { it.copy(selectedEntryId = null) }
+        RssSourceDebugIntent.Start -> start()
+        RssSourceDebugIntent.Stop -> stop()
+        RssSourceDebugIntent.Clear -> _uiState.update { it.copy(entries = persistentListOf()) }
+    }
+
+    private fun load(url: String?) = viewModelScope.launch {
+        val loaded = withContext(Dispatchers.IO) { url?.let { appDb.rssSourceDao.getByKey(it) } }
+        source = loaded
+        if (loaded == null) {
+            _uiState.update { it.copy(status = BookSourceDebugStatus.Failed) }
+            _effects.tryEmit(RssSourceDebugEffect.ShowMessage("未获取到订阅源"))
+            return@launch
+        }
+        val examples = buildList {
+            add(RssSourceDebugExampleUi("我的", RssSourceDebugTarget.Search, "我的"))
+            add(RssSourceDebugExampleUi("系统", RssSourceDebugTarget.Search, "系统"))
+            runCatching { loaded.sortUrls() }.getOrNull()?.filter { it.second.isNotBlank() }?.forEach {
+                add(RssSourceDebugExampleUi(it.first, RssSourceDebugTarget.Sort, it.second))
+            }
+        }.toImmutableList()
+        _uiState.update { it.copy(sourceName = loaded.sourceName, query = "我的", examples = examples, status = BookSourceDebugStatus.Idle) }
+    }
+
+    private fun start() {
+        val rss = source ?: run {
+            _effects.tryEmit(RssSourceDebugEffect.ShowMessage("未获取到订阅源"))
+            return
+        }
+        val state = _uiState.value
+        if (state.query.isBlank()) { _effects.tryEmit(RssSourceDebugEffect.ShowMessage("调试内容不能为空")); return }
+        session?.cancel()
+        _uiState.update { it.copy(entries = persistentListOf(), status = BookSourceDebugStatus.Running) }
+        val key = if (state.target == RssSourceDebugTarget.Sort) "分类::${state.query}" else state.query
+        session = Debug.startDebug(viewModelScope, rss, key).also(::collectSession)
+    }
+    private fun stop() {
+        session?.cancel()
+        session = null
+        _uiState.update { state ->
+            if (state.status == BookSourceDebugStatus.Running) {
+                state.copy(status = BookSourceDebugStatus.Cancelled)
+            } else state
+        }
+    }
+    private fun collectSession(debugSession: Debug.Session) {
+        viewModelScope.launch {
+            debugSession.events.collect(::onDebugEvent)
+            if (session === debugSession) {
+                session = null
+                _uiState.update { state -> if (state.status == BookSourceDebugStatus.Running) state.copy(status = BookSourceDebugStatus.Cancelled) else state }
+            }
+        }
+    }
+    private fun onDebugEvent(event: Debug.Event) {
+        _uiState.update { state -> state.copy(
+            entries = (state.entries + BookSourceDebugEntryUi(ids.incrementAndGet(), event.kind, event.message, event.timestamp, event.elapsedMillis)).takeLast(1000).toImmutableList(),
+            status = when (event.kind) { Debug.EventKind.Error -> BookSourceDebugStatus.Failed; Debug.EventKind.Completed -> BookSourceDebugStatus.Success; else -> state.status }
+        ) }
+    }
+    override fun onCleared() { session?.cancel(); session = null; super.onCleared() }
+}

+ 27 - 454
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt

@@ -1,468 +1,41 @@
 package io.legado.app.ui.rss.source.edit
 
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.widget.EditText
-import androidx.activity.viewModels
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
-import com.google.android.material.tabs.TabLayout
-import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.data.entities.RssSource
-import io.legado.app.databinding.ActivityRssSourceEditBinding
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.backgroundColor
-//import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.file.HandleFileContract
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.data.appDb
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.qrcode.QrCodeResult
 import io.legado.app.ui.rss.source.debug.RssSourceDebugActivity
-import io.legado.app.ui.widget.dialog.UrlOptionDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
-import io.legado.app.ui.widget.keyboard.KeyboardToolPop
-import io.legado.app.ui.widget.text.EditEntity
-import io.legado.app.utils.GSON
-import io.legado.app.utils.imeHeight
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.isTrue
-import io.legado.app.utils.launch
-import io.legado.app.utils.navigationBarHeight
+import io.legado.app.utils.getClipText
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.share
-import io.legado.app.utils.shareWithQr
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.views.bottomPadding
-
-class RssSourceEditActivity :
-    VMBaseActivity<ActivityRssSourceEditBinding, RssSourceEditViewModel>(),
-    KeyboardToolPop.CallBack,
-    VariableDialog.Callback {
-
-    override val binding by viewBinding(ActivityRssSourceEditBinding::inflate)
-    override val viewModel by viewModels<RssSourceEditViewModel>()
-    private val softKeyboardTool by lazy {
-        KeyboardToolPop(this, lifecycleScope, binding.root, this)
-    }
-    private val adapter by lazy { RssSourceEditAdapter() }
-    private val sourceEntities: ArrayList<EditEntity> = ArrayList()
-    private val startEntities: ArrayList<EditEntity> = ArrayList()
-    private val listEntities: ArrayList<EditEntity> = ArrayList()
-    private val webViewEntities: ArrayList<EditEntity> = ArrayList()
-    private val selectDoc = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            if (uri.isContentScheme()) {
-                sendText(uri.toString())
-            } else {
-                sendText(uri.path.toString())
-            }
-        }
-    }
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it?.let {
-            viewModel.importSource(it) { source: RssSource ->
-                upSourceView(source)
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        softKeyboardTool.attachToWindow(window)
-        initView()
-        viewModel.initData(intent) {
-            upSourceView(viewModel.rssSource)
-        }
-    }
-
-    override fun onPostCreate(savedInstanceState: Bundle?) {
-        super.onPostCreate(savedInstanceState)
-        if (!LocalConfig.ruleHelpVersionIsLast) {
-            showHelp("ruleHelp")
-        }
-    }
-
-    override fun finish() {
-        val source = getRssSource()
-        if (!source.equal(viewModel.rssSource ?: RssSource())) {
-            alert(R.string.exit) {
-                setMessage(R.string.exit_no_save)
-                positiveButton(R.string.yes)
-                negativeButton(R.string.no) {
-                    super.finish()
-                }
-            }
-        } else {
-            super.finish()
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        softKeyboardTool.dismiss()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.source_edit, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !viewModel.rssSource?.loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_auto_complete)?.isChecked = viewModel.autoComplete
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_save -> viewModel.save(getRssSource()) {
-                setResult(RESULT_OK)
-                finish()
-            }
-
-            R.id.menu_debug_source -> viewModel.save(getRssSource()) { source ->
-                startActivity<RssSourceDebugActivity> {
-                    putExtra("key", source.sourceUrl)
-                }
-            }
-
-            R.id.menu_login -> viewModel.save(getRssSource()) {
-                startActivity<SourceLoginActivity> {
-                    putExtra("type", "rssSource")
-                    putExtra("key", it.sourceUrl)
-                }
-            }
-
-            R.id.menu_set_source_variable -> setSourceVariable()
-            R.id.menu_clear_cookie -> viewModel.clearCookie(getRssSource().sourceUrl)
-            R.id.menu_auto_complete -> viewModel.autoComplete = !viewModel.autoComplete
-            R.id.menu_copy_source -> sendToClip(GSON.toJson(getRssSource()))
-            R.id.menu_qr_code_camera -> qrCodeResult.launch()
-            R.id.menu_paste_source -> viewModel.pasteSource { upSourceView(it) }
-            R.id.menu_share_str -> share(GSON.toJson(getRssSource()))
-            R.id.menu_share_qr -> shareWithQr(
-                GSON.toJson(getRssSource()),
-                getString(R.string.share_rss_source),
-                ErrorCorrectionLevel.L
-            )
-
-            R.id.menu_log -> showDialogFragment<io.legado.app.ui.about.AppLogDialog>()
-
-            R.id.menu_help -> showHelp("ruleHelp")
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun initView() {
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_base)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_start)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            setText(R.string.source_tab_list)
-        })
-        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
-            text = "WEB_VIEW"
-        })
-//        binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.adapter = adapter
-//        binding.tabLayout.setBackgroundColor(backgroundColor)
-//        binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
-        binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
-            override fun onTabReselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabUnselected(tab: TabLayout.Tab?) {
-
-            }
-
-            override fun onTabSelected(tab: TabLayout.Tab?) {
-                setEditEntities(tab?.position)
-            }
-        })
-        binding.recyclerView.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
-            val navigationBarHeight = windowInsets.navigationBarHeight
-            val imeHeight = windowInsets.imeHeight
-            view.bottomPadding = if (imeHeight == 0) navigationBarHeight else 0
-            softKeyboardTool.initialPadding = imeHeight
-            windowInsets
-        }
-    }
-
-    private fun setEditEntities(tabPosition: Int?) {
-        when (tabPosition) {
-            1 -> adapter.editEntities = startEntities
-            2 -> adapter.editEntities = listEntities
-            3 -> adapter.editEntities = webViewEntities
-            else -> adapter.editEntities = sourceEntities
-        }
-        binding.recyclerView.scrollToPosition(0)
-    }
-
-    private fun upSourceView(rssSource: RssSource?) {
-        val rs = rssSource ?: RssSource()
-        rs.let {
-            binding.cbIsEnable.isChecked = rs.enabled
-            binding.cbSingleUrl.isChecked = rs.singleUrl
-            binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true
-            binding.cbIsEnablePreload.isChecked = rs.preload
-            val typeCount = binding.spType.adapter?.count ?: 0
-            if (typeCount > 0 && rs.type !in 0..<typeCount) {
-                rs.type = 0
-            }
-            if (typeCount > 0) {
-                binding.spType.setSelection(rs.type)
-            }
-            val styleCount = binding.lyType.adapter?.count ?: 0
-            if (styleCount > 0 && rs.articleStyle !in 0..<styleCount) {
-                rs.articleStyle = 0
-            }
-            if (styleCount > 0) {
-                binding.lyType.setSelection(rs.articleStyle)
-            }
-        }
-        sourceEntities.clear()
-        sourceEntities.apply {
-            add(EditEntity("sourceName", rs.sourceName, R.string.source_name))
-            add(EditEntity("sourceUrl", rs.sourceUrl, R.string.source_url))
-            add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon))
-            add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group))
-            add(EditEntity("sourceComment", rs.sourceComment, R.string.comment))
-            add(EditEntity("searchUrl", rs.searchUrl, R.string.r_search_url))
-            add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url))
-            add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url))
-            add(EditEntity("loginUi", rs.loginUi, R.string.login_ui))
-            add(EditEntity("loginCheckJs", rs.loginCheckJs, R.string.login_check_js))
-            add(EditEntity("coverDecodeJs", rs.coverDecodeJs, R.string.cover_decode_js))
-            add(EditEntity("header", rs.header, R.string.source_http_header))
-            add(EditEntity("variableComment", rs.variableComment, R.string.variable_comment))
-            add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate))
-            add(EditEntity("jsLib", rs.jsLib, "jsLib"))
-        }
-        startEntities.clear()
-        startEntities.apply {
-            add(EditEntity("startHtml", rs.startHtml, R.string.r_startHtml))
-            add(EditEntity("startStyle", rs.startStyle, R.string.r_startStyle))
-            add(EditEntity("startJs", rs.startJs, R.string.r_startJs))
-            add(EditEntity("preloadJs", rs.preloadJs, R.string.r_preloadJs))
-        }
-        listEntities.clear()
-        listEntities.apply {
-            add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles))
-            add(EditEntity("ruleNextPage", rs.ruleNextPage, R.string.r_next))
-            add(EditEntity("ruleTitle", rs.ruleTitle, R.string.r_title))
-            add(EditEntity("rulePubDate", rs.rulePubDate, R.string.r_date))
-            add(EditEntity("ruleDescription", rs.ruleDescription, R.string.r_description))
-            add(EditEntity("ruleImage", rs.ruleImage, R.string.r_image))
-            add(EditEntity("ruleLink", rs.ruleLink, R.string.r_link))
-        }
-        webViewEntities.clear()
-        webViewEntities.apply {
-            add(
-                EditEntity(
-                    "enableJs",
-                    rs.enableJs.toString(),
-                    R.string.enable_js,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "loadWithBaseUrl",
-                    rs.loadWithBaseUrl.toString(),
-                    R.string.load_with_base_url,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "showWebLog",
-                    rs.showWebLog.toString(),
-                    R.string.load_with_web_log,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(
-                EditEntity(
-                    "cacheFirst",
-                    rs.cacheFirst.toString(),
-                    R.string.cache_first,
-                    EditEntity.ViewType.checkBox
-                )
-            )
-            add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content))
-            add(EditEntity("style", rs.style, R.string.r_style))
-            add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js))
-            add(EditEntity("contentWhitelist", rs.contentWhitelist, R.string.c_whitelist))
-            add(EditEntity("contentBlacklist", rs.contentBlacklist, R.string.c_blacklist))
-            add(
-                EditEntity(
-                    "shouldOverrideUrlLoading",
-                    rs.shouldOverrideUrlLoading,
-                    "url跳转拦截(js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入)"
-                )
-            )
-        }
-        binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0))
-        setEditEntities(0)
-    }
-
-    private fun getRssSource(): RssSource {
-        val source = viewModel.rssSource?.copy() ?: RssSource()
-        source.enabled = binding.cbIsEnable.isChecked
-        source.singleUrl = binding.cbSingleUrl.isChecked
-        source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
-        source.preload = binding.cbIsEnablePreload.isChecked
-        source.type = binding.spType.selectedItemPosition
-        source.articleStyle = binding.lyType.selectedItemPosition
-        sourceEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "sourceName" -> source.sourceName = it.value ?: ""
-                "sourceUrl" -> source.sourceUrl = it.value ?: ""
-                "sourceIcon" -> source.sourceIcon = it.value ?: ""
-                "sourceGroup" -> source.sourceGroup = it.value
-                "sourceComment" -> source.sourceComment = it.value
-                "loginUrl" -> source.loginUrl = it.value
-                "loginUi" -> source.loginUi = it.value
-                "loginCheckJs" -> source.loginCheckJs = it.value
-                "coverDecodeJs" -> source.coverDecodeJs = it.value
-                "header" -> source.header = it.value
-                "variableComment" -> source.variableComment = it.value
-                "concurrentRate" -> source.concurrentRate = it.value
-                "searchUrl" -> source.searchUrl = it.value
-                "sortUrl" -> source.sortUrl = it.value
-                "jsLib" -> source.jsLib = it.value
-            }
-        }
-        startEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "startHtml" -> source.startHtml = it.value
-                "startStyle" -> source.startStyle = it.value
-                "startJs" -> source.startJs = it.value
-                "preloadJs" -> source.preloadJs = it.value
-            }
-        }
-        listEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "ruleArticles" -> source.ruleArticles = it.value
-                "ruleNextPage" -> source.ruleNextPage =
-                    viewModel.ruleComplete(it.value, source.ruleArticles, 2)
-
-                "ruleTitle" -> source.ruleTitle =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "rulePubDate" -> source.rulePubDate =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "ruleDescription" -> source.ruleDescription =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "ruleImage" -> source.ruleImage =
-                    viewModel.ruleComplete(it.value, source.ruleArticles, 3)
-
-                "ruleLink" -> source.ruleLink =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-            }
-        }
-        webViewEntities.forEach {
-            it.value = it.value?.takeIf { s -> s.isNotBlank() }
-            when (it.key) {
-                "enableJs" -> source.enableJs = it.value.isTrue()
-                "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue()
-                "showWebLog" -> source.showWebLog = it.value.isTrue()
-                "cacheFirst" -> source.cacheFirst = it.value.isTrue()
-                "ruleContent" -> source.ruleContent =
-                    viewModel.ruleComplete(it.value, source.ruleArticles)
-
-                "style" -> source.style = it.value
-                "injectJs" -> source.injectJs = it.value
-                "contentWhitelist" -> source.contentWhitelist = it.value
-                "contentBlacklist" -> source.contentBlacklist = it.value
-                "shouldOverrideUrlLoading" -> source.shouldOverrideUrlLoading = it.value
-            }
-        }
-        return source
-    }
-
-    private fun setSourceVariable() {
-        viewModel.save(getRssSource()) { source ->
-            lifecycleScope.launch {
-                val comment =
-                    source.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")
-                val variable = withContext(Dispatchers.IO) { source.getVariable() }
-                showDialogFragment(
-                    VariableDialog(
-                        getString(R.string.set_source_variable),
-                        source.getKey(),
-                        variable,
-                        comment
-                    )
-                )
-            }
-        }
-    }
-
-    override fun setVariable(key: String, variable: String?) {
-        viewModel.rssSource?.setVariable(variable)
-    }
-
-    override fun helpActions(): List<SelectItem<String>> {
-        return arrayListOf(
-            SelectItem("插入URL参数", "urlOption"),
-            SelectItem("订阅源教程", "ruleHelp"),
-            SelectItem("js教程", "jsHelp"),
-            SelectItem("正则教程", "regexHelp"),
-            SelectItem("选择文件", "selectFile"),
-        )
-    }
-
-    override fun onHelpActionSelect(action: String) {
-        when (action) {
-            "urlOption" -> UrlOptionDialog(this) {
-                sendText(it)
-            }.show()
-
-            "ruleHelp" -> showHelp("ruleHelp")
-            "jsHelp" -> showHelp("jsHelp")
-            "regexHelp" -> showHelp("regexHelp")
-            "selectFile" -> selectDoc.launch {
-                mode = HandleFileContract.FILE
-            }
-        }
-    }
-
-    override fun sendText(text: String) {
-        if (text.isBlank()) return
-        val view = window.decorView.findFocus()
-        if (view is EditText) {
-            val start = view.selectionStart
-            val end = view.selectionEnd
-            val edit = view.editableText//获取EditText的文字
-            if (start < 0 || start >= edit.length) {
-                edit.append(text)
-            } else if (start > end) {
-                edit.replace(end, start, text)
-            } else {
-                edit.replace(start, end, text)//光标所在位置插入文字
-            }
-        }
-    }
-
-}
+import org.koin.androidx.compose.koinViewModel
+
+class RssSourceEditActivity : BaseComposeActivity(), VariableDialog.Callback {
+ @Composable override fun Content(){val vm=koinViewModel<RssSourceEditViewModel>();val state=vm.uiState.collectAsStateWithLifecycle().value
+  LaunchedEffect(Unit){vm.onIntent(RssSourceEditIntent.Load(intent.getStringExtra("sourceUrl")));vm.effects.collectLatest{e->when(e){
+   is RssSourceEditEffect.Finish->{if(e.url.isNotEmpty())setResult(RESULT_OK,Intent().putExtra("origin",e.url));finish()}
+   is RssSourceEditEffect.Debug->startActivity<RssSourceDebugActivity>{putExtra("key",e.url)}
+   is RssSourceEditEffect.Login->startActivity<SourceLoginActivity>{putExtra("type","rssSource");putExtra("key",e.url)}
+   is RssSourceEditEffect.Copy->sendToClip(e.text);is RssSourceEditEffect.Share->share(e.text);RssSourceEditEffect.ReadClipboard->{val t=getClipText();if(t.isNullOrBlank())toastOnUi("剪贴板为空")else vm.onIntent(RssSourceEditIntent.Import(t))}
+      is RssSourceEditEffect.Variable -> openVariable(e.url); is RssSourceEditEffect.Message -> toastOnUi(
+          e.text
+      )
+  }}}
+  RssSourceEditScreen(state,vm::onIntent){vm.onIntent(RssSourceEditIntent.Back)}
+ }
+ private fun openVariable(url:String)=lifecycleScope.launch{val s=withContext(Dispatchers.IO){appDb.rssSourceDao.getByKey(url)}?:return@launch;showDialogFragment(VariableDialog(getString(R.string.set_source_variable),s.getKey(),withContext(Dispatchers.IO){s.getVariable()},s.getDisplayVariableComment("源变量可在js中通过source.getVariable()获取")))}
+ override fun setVariable(key:String,variable:String?){lifecycleScope.launch(Dispatchers.IO){appDb.rssSourceDao.getByKey(key)?.setVariable(variable)}}
+}

+ 0 - 132
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditAdapter.kt

@@ -1,132 +0,0 @@
-package io.legado.app.ui.rss.source.edit
-
-import android.annotation.SuppressLint
-import android.text.Editable
-import android.text.TextWatcher
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.databinding.ItemSourceEditBinding
-import io.legado.app.databinding.ItemSourceEditCheckBoxBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.widget.code.addJsPattern
-import io.legado.app.ui.widget.code.addJsonPattern
-import io.legado.app.ui.widget.code.addLegadoPattern
-import io.legado.app.ui.widget.text.EditEntity
-import io.legado.app.utils.isTrue
-
-class RssSourceEditAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
-
-    val editEntityMaxLine = AppConfig.sourceEditMaxLine
-
-    var editEntities: ArrayList<EditEntity> = ArrayList()
-        @SuppressLint("NotifyDataSetChanged")
-        set(value) {
-            field = value
-            notifyDataSetChanged()
-        }
-
-    override fun getItemViewType(position: Int): Int {
-        val item = editEntities[position]
-        return item.viewType
-    }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
-        return when (viewType) {
-            EditEntity.ViewType.checkBox -> {
-                val binding = ItemSourceEditCheckBoxBinding
-                    .inflate(LayoutInflater.from(parent.context), parent, false)
-                CheckBoxViewHolder(binding)
-            }
-            else -> {
-                val binding = ItemSourceEditBinding
-                    .inflate(LayoutInflater.from(parent.context), parent, false)
-                binding.editText.addLegadoPattern()
-                binding.editText.addJsonPattern()
-                binding.editText.addJsPattern()
-                EditTextViewHolder(binding)
-            }
-        }
-    }
-
-    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
-        when (holder) {
-            is CheckBoxViewHolder -> holder.bind(editEntities[position])
-            is EditTextViewHolder -> holder.bind(editEntities[position])
-        }
-    }
-
-    override fun getItemCount(): Int {
-        return editEntities.size
-    }
-
-    inner class EditTextViewHolder(val binding: ItemSourceEditBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            editText.maxLines = editEntityMaxLine
-            if (editText.getTag(R.id.tag1) == null) {
-                val listener = object : View.OnAttachStateChangeListener {
-                    override fun onViewAttachedToWindow(v: View) {
-                        editText.isCursorVisible = false
-                        editText.isCursorVisible = true
-                        editText.isFocusable = true
-                        editText.isFocusableInTouchMode = true
-                    }
-
-                    override fun onViewDetachedFromWindow(v: View) {
-
-                    }
-                }
-                editText.addOnAttachStateChangeListener(listener)
-                editText.setTag(R.id.tag1, listener)
-            }
-            editText.getTag(R.id.tag2)?.let {
-                if (it is TextWatcher) {
-                    editText.removeTextChangedListener(it)
-                }
-            }
-            editText.setText(editEntity.value)
-            textInputLayout.hint = editEntity.hint
-            val textWatcher = object : TextWatcher {
-                override fun beforeTextChanged(
-                    s: CharSequence,
-                    start: Int,
-                    count: Int,
-                    after: Int
-                ) {
-
-                }
-
-                override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
-
-                }
-
-                override fun afterTextChanged(s: Editable?) {
-                    editEntity.value = (s?.toString())
-                }
-            }
-            editText.addTextChangedListener(textWatcher)
-            editText.setTag(R.id.tag2, textWatcher)
-        }
-    }
-
-    inner class CheckBoxViewHolder(val binding: ItemSourceEditCheckBoxBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-
-        fun bind(editEntity: EditEntity) = binding.run {
-            checkBox.setOnCheckedChangeListener(null)
-            checkBox.text = editEntity.hint
-            checkBox.isChecked = editEntity.value.isTrue()
-            checkBox.setOnCheckedChangeListener { _, isChecked ->
-                editEntity.value = isChecked.toString()
-            }
-        }
-
-
-    }
-
-
-}

+ 49 - 0
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditContract.kt

@@ -0,0 +1,49 @@
+package io.legado.app.ui.rss.source.edit
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Stable
+import io.legado.app.ui.book.source.edit.BookSourceEditFieldUi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+
+enum class RssSourceEditTab(@StringRes val title: Int) {
+    Base(io.legado.app.R.string.source_tab_base),
+    Start(io.legado.app.R.string.source_tab_start),
+    List(io.legado.app.R.string.source_tab_list),
+    WebView(io.legado.app.R.string.source_tab_web_view),
+}
+@Stable data class RssSourceEditUiState(
+    val loading: Boolean = true, val selectedTab: RssSourceEditTab = RssSourceEditTab.Base,
+    val fields: ImmutableMap<RssSourceEditTab, ImmutableList<BookSourceEditFieldUi>> = persistentMapOf(),
+    val enabled: Boolean = true, val singleUrl: Boolean = false, val cookieJar: Boolean = true,
+    val preload: Boolean = false, val type: Int = 0, val articleStyle: Int = 0,
+    val autoComplete: Boolean = false, val dirty: Boolean = false, val selectedField: String? = null,
+    val activeSheet: RssSourceEditSheet? = null,
+)
+
+sealed interface RssSourceEditSheet {
+    data object Log : RssSourceEditSheet;
+    data class Help(val content: String) : RssSourceEditSheet
+}
+sealed interface RssSourceEditIntent {
+    data class Load(val url: String?) : RssSourceEditIntent; data class SelectTab(val tab: RssSourceEditTab) : RssSourceEditIntent
+    data class UpdateField(val key: String, val value: String) : RssSourceEditIntent; data class EditField(val key: String?) : RssSourceEditIntent
+    data class SetEnabled(val value: Boolean) : RssSourceEditIntent; data class SetSingleUrl(val value: Boolean) : RssSourceEditIntent
+    data class SetCookieJar(val value: Boolean) : RssSourceEditIntent; data class SetPreload(val value: Boolean) : RssSourceEditIntent
+    data class SetType(val value: Int) : RssSourceEditIntent; data class SetArticleStyle(val value: Int) : RssSourceEditIntent
+    data object ToggleAutoComplete : RssSourceEditIntent; data object Save : RssSourceEditIntent; data object SaveDebug : RssSourceEditIntent
+    data object SaveLogin : RssSourceEditIntent; data object Copy : RssSourceEditIntent; data object Paste : RssSourceEditIntent
+    data class Import(val text: String) : RssSourceEditIntent; data object Share : RssSourceEditIntent; data object ClearCookie : RssSourceEditIntent
+    data object SetVariable : RssSourceEditIntent;
+    data object ShowLog : RssSourceEditIntent;
+    data object Help : RssSourceEditIntent;
+    data object DismissSheet : RssSourceEditIntent;
+    data object Back : RssSourceEditIntent
+}
+sealed interface RssSourceEditEffect {
+    data class Finish(val url: String) : RssSourceEditEffect; data class Debug(val url: String) : RssSourceEditEffect; data class Login(val url: String) : RssSourceEditEffect
+    data class Copy(val text: String) : RssSourceEditEffect; data class Share(val text: String) : RssSourceEditEffect; data object ReadClipboard : RssSourceEditEffect
+    data class Variable(val url: String) : RssSourceEditEffect;
+    data class Message(val text: String) : RssSourceEditEffect
+}

+ 77 - 0
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditScreen.kt

@@ -0,0 +1,77 @@
+package io.legado.app.ui.rss.source.edit
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.about.MarkdownSheet
+import io.legado.app.ui.book.source.edit.SourceEditFieldCard
+import io.legado.app.ui.book.source.edit.SourceEditFieldSheet
+import io.legado.app.ui.book.source.edit.SourceEditOptionCard
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.log.AppLogSheet
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import kotlinx.coroutines.launch
+
+@Composable fun RssSourceEditScreen(state:RssSourceEditUiState,onIntent:(RssSourceEditIntent)->Unit,onBack:()->Unit){
+    BackHandler{onBack()};val tabs=RssSourceEditTab.entries;val pager=rememberPagerState(state.selectedTab.ordinal){tabs.size};val scope=rememberCoroutineScope();val scroll=GlassTopAppBarDefaults.defaultScrollBehavior();var menu by remember{mutableStateOf(false)}
+    LaunchedEffect(state.selectedTab){if(pager.currentPage!=state.selectedTab.ordinal)pager.animateScrollToPage(state.selectedTab.ordinal)}
+    LaunchedEffect(pager){snapshotFlow{pager.currentPage}.collect{onIntent(RssSourceEditIntent.SelectTab(tabs[it]))}}
+    AppScaffold(modifier=Modifier.nestedScroll(scroll.nestedScrollConnection),topBar={Column{GlassMediumFlexibleTopAppBar(
+        title=stringResource(R.string.rss_source_edit),navigationIcon={TopBarNavigationButton(onClick=onBack)},scrollBehavior=scroll,
+        actions={TopBarActionButton({onIntent(RssSourceEditIntent.SaveDebug)},Icons.Default.BugReport,stringResource(R.string.debug_source));TopBarActionButton({menu=true},Icons.Default.MoreVert,stringResource(R.string.more_menu));RssEditMenu(menu,{menu=false},onIntent)}
+    );AppTabRow(tabs.map{stringResource(it.title)},state.selectedTab.ordinal,{p->onIntent(RssSourceEditIntent.SelectTab(tabs[p]));scope.launch{pager.animateScrollToPage(p)}},Modifier.fillMaxWidth())}},
+        floatingActionButton={AppFloatingActionButton({onIntent(RssSourceEditIntent.Save)},icon=Icons.Default.Save,tooltipText=stringResource(R.string.action_save))}){padding->
+        HorizontalPager(pager,Modifier.fillMaxSize()){page->val tab=tabs[page];LazyColumn(Modifier.fillMaxSize(),contentPadding=PaddingValues(16.dp,padding.calculateTopPadding()+12.dp,16.dp,padding.calculateBottomPadding()+96.dp),verticalArrangement=Arrangement.spacedBy(10.dp)){
+            if(tab==RssSourceEditTab.Base)item("options"){RssEditOptions(state,onIntent)}
+            items(state.fields[tab].orEmpty(),key={it.path}){field->SourceEditFieldCard(field,Modifier.fillMaxWidth()){onIntent(RssSourceEditIntent.EditField(field.path))}}
+        }} }
+    val field=state.fields.values.flatten().firstOrNull{it.path==state.selectedField};SourceEditFieldSheet(field,{onIntent(RssSourceEditIntent.EditField(null))}){k,v->onIntent(RssSourceEditIntent.UpdateField(k,v))}
+    AppLogSheet(
+        show = state.activeSheet is RssSourceEditSheet.Log,
+        onDismissRequest = { onIntent(RssSourceEditIntent.DismissSheet) })
+    val helpSheet = state.activeSheet as? RssSourceEditSheet.Help
+    MarkdownSheet(
+        show = helpSheet != null,
+        title = stringResource(R.string.help),
+        content = helpSheet?.content.orEmpty(),
+        onDismissRequest = { onIntent(RssSourceEditIntent.DismissSheet) })
+}
+@Composable private fun RssEditOptions(s:RssSourceEditUiState,on:(RssSourceEditIntent)->Unit){val types=stringArrayResource(R.array.rss_type);val styles=stringArrayResource(R.array.layout_type);var typeMenu by remember{mutableStateOf(false)};var styleMenu by remember{mutableStateOf(false)}
+    Column(verticalArrangement=Arrangement.spacedBy(8.dp)){Row(horizontalArrangement=Arrangement.spacedBy(8.dp)){Box(Modifier.weight(1f)){SourceEditOptionCard("类型:${types.getOrNull(s.type).orEmpty()}",{typeMenu=true});RoundDropdownMenu(typeMenu,{typeMenu=false}){types.forEachIndexed{i,t->RoundDropdownMenuItem(text=t,isSelected=s.type==i,onClick={typeMenu=false;on(RssSourceEditIntent.SetType(i))})}}};Box(Modifier.weight(1f)){SourceEditOptionCard("布局:${styles.getOrNull(s.articleStyle).orEmpty()}",{styleMenu=true});RoundDropdownMenu(styleMenu,{styleMenu=false}){styles.forEachIndexed{i,t->RoundDropdownMenuItem(text=t,isSelected=s.articleStyle==i,onClick={styleMenu=false;on(RssSourceEditIntent.SetArticleStyle(i))})}}};SourceEditOptionCard(stringResource(R.string.is_enable),{on(RssSourceEditIntent.SetEnabled(!s.enabled))},Modifier.weight(1f),checked=s.enabled)}
+        Row(horizontalArrangement=Arrangement.spacedBy(8.dp)){SourceEditOptionCard(stringResource(R.string.single_url),{on(RssSourceEditIntent.SetSingleUrl(!s.singleUrl))},Modifier.weight(1f),checked=s.singleUrl);SourceEditOptionCard(stringResource(R.string.auto_save_cookie),{on(RssSourceEditIntent.SetCookieJar(!s.cookieJar))},Modifier.weight(1f),checked=s.cookieJar);SourceEditOptionCard(stringResource(R.string.enable_preload),{on(RssSourceEditIntent.SetPreload(!s.preload))},Modifier.weight(1f),checked=s.preload)}}
+}
+@Composable private fun RssEditMenu(show:Boolean,dismiss:()->Unit,on:(RssSourceEditIntent)->Unit){fun go(i:RssSourceEditIntent){dismiss();on(i)};RoundDropdownMenu(show,dismiss){RoundDropdownMenuItem(text=stringResource(R.string.login),onClick={go(RssSourceEditIntent.SaveLogin)});RoundDropdownMenuItem(text=stringResource(R.string.auto_complete),onClick={go(RssSourceEditIntent.ToggleAutoComplete)});RoundDropdownMenuItem(text=stringResource(R.string.copy_source),onClick={go(RssSourceEditIntent.Copy)});RoundDropdownMenuItem(text=stringResource(R.string.paste_source),onClick={go(RssSourceEditIntent.Paste)});RoundDropdownMenuItem(text=stringResource(R.string.set_source_variable),onClick={go(RssSourceEditIntent.SetVariable)});RoundDropdownMenuItem(text=stringResource(R.string.str_share),onClick={go(RssSourceEditIntent.Share)});RoundDropdownMenuItem(text=stringResource(R.string.cookie),onClick={go(RssSourceEditIntent.ClearCookie)});RoundDropdownMenuItem(text=stringResource(R.string.log),onClick={go(RssSourceEditIntent.ShowLog)});RoundDropdownMenuItem(text=stringResource(R.string.help),onClick={go(RssSourceEditIntent.Help)})}}

+ 64 - 100
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditViewModel.kt

@@ -1,9 +1,11 @@
 package io.legado.app.ui.rss.source.edit
 
 import android.app.Application
-import android.content.Intent
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.exception.NoStackTraceException
@@ -12,108 +14,70 @@ import io.legado.app.help.RuleComplete
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.removeSortCache
 import io.legado.app.model.SharedJsScope
+import io.legado.app.ui.book.source.edit.BookSourceEditFieldUi
 import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.getClipText
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 
-
-class RssSourceEditViewModel(application: Application) : BaseViewModel(application) {
-    var autoComplete = false
-    var rssSource: RssSource? = null
-
-    fun initData(intent: Intent, onFinally: () -> Unit) {
-        execute {
-            val key = intent.getStringExtra("sourceUrl")
-            if (key != null) {
-                appDb.rssSourceDao.getByKey(key)?.let {
-                    rssSource = it
-                }
-            }
-        }.onFinally {
-            onFinally()
-        }
-    }
-
-    fun save(source: RssSource, success: ((RssSource) -> Unit)) {
-        execute {
-            if (source.sourceName.isBlank() || source.sourceName.isBlank()) {
-                throw NoStackTraceException(context.getString(R.string.non_null_name_url))
-            }
-            val oldSource = rssSource ?: RssSource()
-            if (!source.equal(oldSource)) {
-                source.lastUpdateTime = System.currentTimeMillis()
-                if (oldSource.sortUrl != source.sortUrl) {
-                    oldSource.removeSortCache()
-                }
-                if (oldSource.jsLib != source.jsLib) {
-                    SharedJsScope.remove(oldSource.jsLib)
-                }
-            }
-            rssSource?.let {
-                appDb.rssSourceDao.delete(it)
-                //更新收藏的源地址
-                if (it.sourceUrl != source.sourceUrl) {
-                    appDb.rssStarDao.updateOrigin(source.sourceUrl, it.sourceUrl)
-                    appDb.rssArticleDao.updateOrigin(source.sourceUrl, it.sourceUrl)
-                    appDb.cacheDao.deleteSourceVariables(it.sourceUrl)
-                    AppCacheManager.clearSourceVariables()
-                }
-            }
-            appDb.rssSourceDao.insert(source)
-            rssSource = source
-            source
-        }.onSuccess {
-            success(it)
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-            it.printOnDebug()
-        }
-    }
-
-    fun pasteSource(onSuccess: (source: RssSource) -> Unit) {
-        execute(context = Dispatchers.Main) {
-            var source: RssSource? = null
-            context.getClipText()?.let { json ->
-                source = GSON.fromJsonObject<RssSource>(json).getOrThrow()
-            }
-            source
-        }.onError {
-            context.toastOnUi(it.localizedMessage)
-        }.onSuccess {
-            if (it != null) {
-                onSuccess(it)
-            } else {
-                context.toastOnUi("格式不对")
-            }
+class RssSourceEditViewModel(app: Application) : AndroidViewModel(app) {
+    private val _uiState = MutableStateFlow(RssSourceEditUiState()); val uiState = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<RssSourceEditEffect>(extraBufferCapacity = 16); val effects = _effects.asSharedFlow()
+    private var original: RssSource? = null; private var json = JsonObject(); private var baseline = ""
+    fun onIntent(i: RssSourceEditIntent) { when(i) {
+        is RssSourceEditIntent.Load -> load(i.url); is RssSourceEditIntent.SelectTab -> _uiState.update { it.copy(selectedTab=i.tab) }
+        is RssSourceEditIntent.UpdateField -> update(i.key,i.value); is RssSourceEditIntent.EditField -> _uiState.update { it.copy(selectedField=i.key) }
+        is RssSourceEditIntent.SetEnabled -> flag { copy(enabled=i.value) }; is RssSourceEditIntent.SetSingleUrl -> flag { copy(singleUrl=i.value) }
+        is RssSourceEditIntent.SetCookieJar -> flag { copy(cookieJar=i.value) }; is RssSourceEditIntent.SetPreload -> flag { copy(preload=i.value) }
+        is RssSourceEditIntent.SetType -> flag { copy(type=i.value) }; is RssSourceEditIntent.SetArticleStyle -> flag { copy(articleStyle=i.value) }
+        RssSourceEditIntent.ToggleAutoComplete -> _uiState.update { it.copy(autoComplete=!it.autoComplete) }
+        RssSourceEditIntent.Save -> save { RssSourceEditEffect.Finish(it) }; RssSourceEditIntent.SaveDebug -> save { RssSourceEditEffect.Debug(it) }
+        RssSourceEditIntent.SaveLogin -> save { RssSourceEditEffect.Login(it) }; RssSourceEditIntent.Copy -> _effects.tryEmit(RssSourceEditEffect.Copy(GSON.toJson(current())))
+        RssSourceEditIntent.Paste -> _effects.tryEmit(RssSourceEditEffect.ReadClipboard); is RssSourceEditIntent.Import -> import(i.text)
+        RssSourceEditIntent.Share -> _effects.tryEmit(RssSourceEditEffect.Share(GSON.toJson(current()))); RssSourceEditIntent.ClearCookie -> viewModelScope.launch(Dispatchers.IO){ CookieStore.removeCookie(current().sourceUrl) }
+        RssSourceEditIntent.SetVariable -> save { RssSourceEditEffect.Variable(it) }; RssSourceEditIntent.ShowLog -> _uiState.update {
+            it.copy(
+                activeSheet = RssSourceEditSheet.Log
+            )
         }
-    }
-
-    fun importSource(text: String, finally: (source: RssSource) -> Unit) {
-        execute {
-            val text1 = text.trim()
-            GSON.fromJsonObject<RssSource>(text1).getOrThrow().let {
-                finally.invoke(it)
-            }
-        }.onError {
-            context.toastOnUi(it.stackTraceStr)
-        }
-    }
 
-    fun clearCookie(url: String) {
-        execute {
-            CookieStore.removeCookie(url)
-        }
+        RssSourceEditIntent.Help -> showHelp(); RssSourceEditIntent.DismissSheet -> _uiState.update {
+            it.copy(
+                activeSheet = null
+            )
+        }; RssSourceEditIntent.Back -> _effects.tryEmit(RssSourceEditEffect.Finish(""))
+    } }
+    private fun load(url:String?)=viewModelScope.launch(Dispatchers.IO){ apply(url?.let{appDb.rssSourceDao.getByKey(it)}?:RssSource(),true) }
+    private fun showHelp() = viewModelScope.launch(Dispatchers.IO) {
+        val content =
+            getApplication<Application>().assets.open("web/help/md/ruleHelp.md").bufferedReader()
+                .use { it.readText() }; _uiState.update {
+        it.copy(
+            activeSheet = RssSourceEditSheet.Help(
+                content
+            )
+        )
     }
-
-    fun ruleComplete(rule: String?, preRule: String? = null, type: Int = 1): String? {
-        if (autoComplete) {
-            return RuleComplete.autoComplete(rule, preRule, type)
-        }
-        return rule
     }
-
-}
+    private fun apply(s:RssSource, asOriginal:Boolean=false){ if(asOriginal){original=s;baseline=GSON.toJson(s)};json=JsonParser.parseString(GSON.toJson(s)).asJsonObject;_uiState.value=RssSourceEditUiState(false,fields=groups(),enabled=s.enabled,singleUrl=s.singleUrl,cookieJar=s.enabledCookieJar==true,preload=s.preload,type=s.type,articleStyle=s.articleStyle) }
+    private fun update(k:String,v:String){ if(v.isBlank())json.remove(k) else json.addProperty(k,v);_uiState.update{st->st.copy(fields=st.fields.mapValues{(_,fs)->fs.map{if(it.path==k)it.copy(value=v)else it}.toImmutableList()}.toImmutableMap(),dirty=true)} }
+    private fun flag(f:RssSourceEditUiState.()->RssSourceEditUiState)=_uiState.update{f(it).copy(dirty=true)}
+    private fun current():RssSource=GSON.fromJson(json,RssSource::class.java).apply{val s=_uiState.value;enabled=s.enabled;singleUrl=s.singleUrl;enabledCookieJar=s.cookieJar;preload=s.preload;type=s.type;articleStyle=s.articleStyle;if(s.autoComplete){ruleNextPage=RuleComplete.autoComplete(ruleNextPage,ruleArticles,2);ruleTitle=RuleComplete.autoComplete(ruleTitle,ruleArticles);rulePubDate=RuleComplete.autoComplete(rulePubDate,ruleArticles);ruleDescription=RuleComplete.autoComplete(ruleDescription,ruleArticles);ruleImage=RuleComplete.autoComplete(ruleImage,ruleArticles,3);ruleLink=RuleComplete.autoComplete(ruleLink,ruleArticles);ruleContent=RuleComplete.autoComplete(ruleContent,ruleArticles)}}
+    private fun save(effect:(String)->RssSourceEditEffect)=viewModelScope.launch(Dispatchers.IO){runCatching{val s=current();if(s.sourceName.isBlank()||s.sourceUrl.isBlank())throw NoStackTraceException(getApplication<Application>().getString(R.string.non_null_name_url));val old=original?:RssSource();if(!s.equal(old)){s.lastUpdateTime=System.currentTimeMillis();if(old.sortUrl!=s.sortUrl)old.removeSortCache();if(old.jsLib!=s.jsLib)SharedJsScope.remove(old.jsLib)};original?.let{appDb.rssSourceDao.delete(it);if(it.sourceUrl!=s.sourceUrl){appDb.rssStarDao.updateOrigin(s.sourceUrl,it.sourceUrl);appDb.rssArticleDao.updateOrigin(s.sourceUrl,it.sourceUrl);appDb.cacheDao.deleteSourceVariables(it.sourceUrl);AppCacheManager.clearSourceVariables()}};appDb.rssSourceDao.insert(s);original=s;baseline=GSON.toJson(s);s.sourceUrl}.onSuccess{_uiState.update{s->s.copy(dirty=false)};_effects.emit(effect(it))}.onFailure{_effects.emit(RssSourceEditEffect.Message(it.localizedMessage?:"Error"))}}
+    private fun import(text:String)=runCatching{GSON.fromJson(text,RssSource::class.java)}.onSuccess{apply(it);_uiState.update{s->s.copy(dirty=true)}}.onFailure{_effects.tryEmit(RssSourceEditEffect.Message(it.localizedMessage?:"格式不对"))}
+    private data class F(val k:String,val r:Int?=null,val l:String?=null)
+    private fun groups()=SPECS.mapValues{(_,v)->v.map{BookSourceEditFieldUi(it.k,it.r,it.l,json.get(it.k)?.takeUnless{e->e.isJsonNull}?.asString.orEmpty())}.toImmutableList()}.toImmutableMap()
+    companion object {
+        private fun f(k:String,r:Int)=F(k,r)
+        private val SPECS=mapOf(
+        RssSourceEditTab.Base to listOf(f("sourceName",R.string.source_name),f("sourceUrl",R.string.source_url),f("sourceIcon",R.string.source_icon),f("sourceGroup",R.string.source_group),f("sourceComment",R.string.comment),f("searchUrl",R.string.r_search_url),f("sortUrl",R.string.sort_url),f("loginUrl",R.string.login_url),f("loginUi",R.string.login_ui),f("loginCheckJs",R.string.login_check_js),f("coverDecodeJs",R.string.cover_decode_js),f("header",R.string.source_http_header),f("variableComment",R.string.variable_comment),f("concurrentRate",R.string.concurrent_rate),F("jsLib",l="jsLib")),
+        RssSourceEditTab.Start to listOf(f("startHtml",R.string.r_startHtml),f("startStyle",R.string.r_startStyle),f("startJs",R.string.r_startJs),f("preloadJs",R.string.r_preloadJs)),
+        RssSourceEditTab.List to listOf(f("ruleArticles",R.string.r_articles),f("ruleNextPage",R.string.r_next),f("ruleTitle",R.string.r_title),f("rulePubDate",R.string.r_date),f("ruleDescription",R.string.r_description),f("ruleImage",R.string.r_image),f("ruleLink",R.string.r_link)),
+        RssSourceEditTab.WebView to listOf(f("ruleContent",R.string.r_content),f("style",R.string.r_style),f("injectJs",R.string.r_inject_js),f("contentWhitelist",R.string.c_whitelist),f("contentBlacklist",R.string.c_blacklist),F("shouldOverrideUrlLoading",l="URL 跳转拦截 JS"))) }
+}

+ 3 - 9
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.theme
 import android.content.Context
 import android.graphics.Typeface
 import android.net.Uri
-import android.os.Build
 import android.util.LruCache
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.MaterialExpressiveTheme
@@ -83,6 +82,8 @@ fun MiuixThemeWrapper(
     // AppTheme has already resolved system mode to an explicit light/dark value.
     // Do not pass System/MonetSystem to Miuix here: MainActivity handles uiMode
     // changes without recreation, so Miuix must not read a second, stale system mode.
+    // Miuix only applies keyColor in Monet modes. Keep its built-in Light/Dark
+    // palette until the user explicitly enables useMiuixMonet.
     val miuixColorSchemeMode = remember(darkTheme, useMiuixMonet) {
         when {
             useMiuixMonet && darkTheme -> ColorSchemeMode.MonetDark
@@ -98,14 +99,7 @@ fun MiuixThemeWrapper(
         ThemeResolver.resolveMiuixColorSpec(materialVersion, paletteStyleValue)
     }
 
-    val keyColor = if (useMiuixMonet &&
-        themeColors.useDynamicColor &&
-        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-    ) {
-        Color(0xFF6750A4) // 默认颜色,因为 colorResource 只能在 Composable 中
-    } else {
-        themeColors.seedColor
-    }
+    val keyColor = themeColors.seedColor
 
     val controller = remember(
         miuixColorSchemeMode,

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt

@@ -62,7 +62,7 @@ fun AppFloatingActionButton(
     tooltipText: String? = null,
     icon: ImageVector? = null,
     containerColor: Color = LegadoTheme.colorScheme.primaryContainer,
-    contentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer,
+    contentColor: Color = LegadoTheme.colorScheme.primary,
     content: (@Composable () -> Unit)? = null
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)

+ 3 - 3
app/src/main/java/io/legado/app/ui/widget/components/SelectionBottomBar.kt

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.ExpandMore
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.SelectAll
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,6 +34,7 @@ import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
@@ -110,7 +110,7 @@ fun SelectionBottomBar(
                     Box {
                         MiuixIconButton(onClick = { showMenu = true }) {
                             MiuixIcon(
-                                imageVector = Icons.Default.MoreVert,
+                                imageVector = AppIcons.MoreVert,
                                 contentDescription = moreActionsDescription
                             )
                         }
@@ -155,7 +155,7 @@ fun SelectionBottomBar(
                     Box {
                         IconButton(onClick = { showMenu = true }) {
                             AppIcon(
-                                imageVector = Icons.Default.MoreVert,
+                                imageVector = AppIcons.MoreVert,
                                 contentDescription = moreActionsDescription
                             )
                         }

+ 39 - 28
app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt

@@ -1,12 +1,16 @@
 package io.legado.app.ui.widget.components.button
 
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material3.FilterChip
@@ -16,6 +20,7 @@ import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
@@ -26,6 +31,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.card.NormalCard
 import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
 import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
 import top.yukonga.miuix.kmp.basic.Text as MiuixText
@@ -40,47 +46,52 @@ fun ToggleChip(
     checkedContentDescription: String = "已选择",
     uncheckedContentDescription: String = "未选择"
 ) {
-    val isSelected = selected
     if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
+        NormalCard (
+            modifier = modifier
+                .padding(vertical = 2.dp)
+                .semantics {
+                    toggleableState = if (selected) {
+                        ToggleableState.On
+                    } else {
+                        ToggleableState.Off
+                    }
+                    stateDescription = if (selected) {
+                        checkedContentDescription
+                    } else {
+                        uncheckedContentDescription
+                    }
+                },
+            cornerRadius = 12.dp,
             onClick = onToggle,
-            modifier = modifier.semantics {
-                role = Role.Checkbox
-                toggleableState = if (isSelected) {
-                    ToggleableState.On
-                } else {
-                    ToggleableState.Off
-                }
-                stateDescription = if (isSelected) {
-                    checkedContentDescription
-                } else {
-                    uncheckedContentDescription
-                }
-            },
-            backgroundColor = if (selected) {
-                MiuixTheme.colorScheme.primaryContainer
+            containerColor = if (selected) {
+                MiuixTheme.colorScheme.secondaryContainer
             } else {
                 MiuixTheme.colorScheme.surfaceContainer
             }
         ) {
             Row(
+                modifier = Modifier
+                    .padding(horizontal = 12.dp),
                 verticalAlignment = Alignment.CenterVertically,
                 horizontalArrangement = Arrangement.Center,
-                modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
             ) {
-                AnimatedVisibility(visible = selected) {
-                    Row(verticalAlignment = Alignment.CenterVertically) {
-                        MiuixIcon(
-                            imageVector = Icons.Default.Check,
-                            contentDescription = checkedContentDescription,
-                            modifier = Modifier.size(18.dp),
-                            tint = if (selected) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurface
-                        )
-                        Spacer(Modifier.width(6.dp))
-                    }
+                AnimatedVisibility(
+                    visible = selected
+                ) {
+                    MiuixIcon(
+                        imageVector = Icons.Default.Check,
+                        contentDescription = checkedContentDescription,
+                        modifier = Modifier
+                            .padding(end = 8.dp)
+                            .size(16.dp),
+                        tint = if (selected) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.onSurface
+                    )
                 }
 
                 MiuixText(
+                    modifier = Modifier
+                        .padding(vertical = 8.dp),
                     text = label,
                     style = LegadoTheme.typography.labelMediumEmphasized,
                     maxLines = 1,

+ 6 - 4
app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.IconButtonDefaults
 import androidx.compose.material3.minimumInteractiveComponentSize
@@ -30,6 +31,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.selected
@@ -39,8 +41,6 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.text.AppText
 
@@ -49,6 +49,7 @@ internal val SeriesIconSize: Dp
     get() = IconButtonDefaults.mediumIconSize
 internal val MediumSeriesIconButtonSize = DpSize(40.dp, 40.dp)
 internal val MediumSeriesIconSize = SeriesIconSize
+internal val SmallButtonShape = RoundedCornerShape(50)
 
 internal enum class SeriesIconButtonStyle {
     Plain,
@@ -65,6 +66,8 @@ internal fun SeriesButton(
     selected: Boolean = false,
     onLongClick: (() -> Unit)? = null,
     size: DpSize? = null,
+    enforceMinimumInteractiveSize: Boolean = true,
+    shape: Shape = IconButtonDefaults.extraSmallRoundShape,
     style: SeriesIconButtonStyle = SeriesIconButtonStyle.Plain,
     contentColor: Color = LegadoTheme.colorScheme.onSurfaceVariant,
     containerColor: Color? = null,
@@ -101,13 +104,12 @@ internal fun SeriesButton(
         animationSpec = animSpec,
         label = "SeriesIconContentColor"
     )
-    val shape = IconButtonDefaults.extraSmallRoundShape
     val border = borderStroke(style, enabled)
     val interactionSource = remember { MutableInteractionSource() }
 
     Box(
         modifier = Modifier
-            .minimumInteractiveComponentSize()
+            .then(if (enforceMinimumInteractiveSize) Modifier.minimumInteractiveComponentSize() else Modifier)
             .then(modifier)
             .then(if (size != null) Modifier.size(size) else Modifier)
             .clip(shape)

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt

@@ -2,7 +2,6 @@ package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.height
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -17,7 +16,6 @@ import io.legado.app.ui.theme.LegadoTheme
 import kotlinx.coroutines.delay
 import kotlin.time.Duration.Companion.milliseconds
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SmallAnimatedButton(
     checked: Boolean,
@@ -54,6 +52,8 @@ fun SmallAnimatedButton(
         selected = checked,
         onLongClick = onLongClick,
         size = if (text == null) smallContainerSize() else null,
+        enforceMinimumInteractiveSize = false,
+        shape = SmallButtonShape,
         style = SeriesIconButtonStyle.Tonal,
         contentColor = contentColor,
         containerColor = containerColor,

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt

@@ -1,14 +1,12 @@
 package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SmallOutlinedButton(
     onClick: () -> Unit,
@@ -27,6 +25,8 @@ fun SmallOutlinedButton(
             selected = selected,
             onLongClick = onLongClick,
             size = if (text == null) smallContainerSize() else null,
+        enforceMinimumInteractiveSize = false,
+        shape = SmallButtonShape,
             style = SeriesIconButtonStyle.Outlined
         ) { contentColor ->
             SeriesButtonContent(

+ 5 - 14
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt

@@ -1,8 +1,6 @@
 package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.IconButtonDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -10,19 +8,10 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 
-internal val SmallMiuixButtonSize = 32.dp
+internal fun smallContainerSize() = squareSize(SmallButtonSize)
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-internal fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize(
-    IconButtonDefaults.IconButtonWidthOption.Uniform
-)
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-internal val smallIconSize: Dp
-    get() = IconButtonDefaults.extraSmallIconSize
-
-internal val SmallMiuixIconSize: Dp
-    get() = smallIconSize
+internal val smallIconSize: Dp = 18.dp
+internal val SmallButtonSize = 32.dp
 
 @Composable
 fun SmallPlainButton(
@@ -42,6 +31,8 @@ fun SmallPlainButton(
         selected = selected,
         onLongClick = onLongClick,
         size = if (text == null) smallContainerSize() else null,
+        enforceMinimumInteractiveSize = false,
+        shape = SmallButtonShape,
         contentColor = LegadoTheme.colorScheme.onSurfaceVariant
     ) { contentColor ->
         SeriesButtonContent(

+ 37 - 118
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt

@@ -2,31 +2,16 @@ package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.material3.TonalToggleButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
-import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
-import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
 
 enum class ToggleStyle { Outlined, Tonal }
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SmallToggleButton(
     checked: Boolean,
@@ -40,109 +25,43 @@ fun SmallToggleButton(
     text: String? = null,
     contentDescription: String? = null
 ) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        val containerColor by animateColorAsState(
-            targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
-            animationSpec = tween(150),
-            label = "MiuixToggleContainerColor"
+    val containerColor by animateColorAsState(
+        targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
+        animationSpec = tween(150),
+        label = "SmallToggleContainerColor"
+    )
+    val iconTint by animateColorAsState(
+        targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
+        animationSpec = tween(150),
+        label = "SmallToggleIconTint"
+    )
+    SeriesButton(
+        onClick = { onCheckedChange(!checked) },
+        modifier = modifier,
+        enabled = enabled,
+        selected = checked,
+        onLongClick = onLongClick,
+        size = if (text == null) smallContainerSize() else null,
+        enforceMinimumInteractiveSize = false,
+        shape = SmallButtonShape,
+        style = when (style) {
+            ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined
+            ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal
+        },
+        containerColor = containerColor,
+        selectedContainerColor = containerColor,
+        contentColor = iconTint,
+        selectedContentColor = iconTint
+    ) { contentColor ->
+        SeriesButtonContent(
+            icon = if (checked) (iconChecked ?: icon)!! else icon!!,
+            text = text,
+            contentDescription = contentDescription,
+            iconSize = smallIconSize,
+            textStyle = LegadoTheme.typography.labelSmall,
+            contentColor = contentColor,
+            padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
+            spacing = 6.dp
         )
-
-        val iconTint by animateColorAsState(
-            targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
-            animationSpec = tween(150),
-            label = "MiuixToggleIconTint"
-        )
-
-        if (text != null) {
-            MiuixIconButton(
-                onClick = { onCheckedChange(!checked) },
-                modifier = modifier,
-                enabled = enabled,
-                backgroundColor = containerColor
-            ) {
-                Row(
-                    verticalAlignment = Alignment.CenterVertically,
-                    horizontalArrangement = Arrangement.Center,
-                    modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
-                ) {
-                    MiuixIcon(
-                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
-                        contentDescription = null,
-                        modifier = Modifier.size(16.dp),
-                        tint = iconTint
-                    )
-                    MiuixText(
-                        text = text,
-                        style = LegadoTheme.typography.labelSmall,
-                        color = iconTint,
-                        modifier = Modifier.padding(start = 6.dp),
-                        maxLines = 1,
-                        softWrap = false
-                    )
-                }
-            }
-        } else {
-            SeriesIconButton(
-                icon = if (checked) (iconChecked ?: icon)!! else icon!!,
-                contentDescription = contentDescription,
-                onClick = { onCheckedChange(!checked) },
-                modifier = modifier,
-                enabled = enabled,
-                selected = checked,
-                onLongClick = onLongClick,
-                size = squareSize(SmallMiuixButtonSize),
-                iconSize = SmallMiuixIconSize,
-                style = when (style) {
-                    ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined
-                    ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal
-                },
-                selectedContainerColor = containerColor,
-                selectedContentColor = iconTint
-            )
-        }
-    } else {
-        if (text != null) {
-            TonalToggleButton(
-                checked = checked,
-                onCheckedChange = onCheckedChange,
-                modifier = modifier,
-                enabled = enabled,
-                contentPadding = PaddingValues(horizontal = 8.dp)
-            ) {
-                Row(
-                    verticalAlignment = Alignment.CenterVertically,
-                    horizontalArrangement = Arrangement.Center
-                ) {
-                    Icon(
-                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
-                        contentDescription = null,
-                        modifier = Modifier.size(16.dp)
-                    )
-                    Text(
-                        text = text,
-                        style = LegadoTheme.typography.labelSmall,
-                        modifier = Modifier.padding(start = 6.dp),
-                        maxLines = 1,
-                        softWrap = false
-                    )
-                }
-            }
-        } else {
-            SeriesIconButton(
-                icon = if (checked) (iconChecked ?: icon)!! else icon!!,
-                contentDescription = contentDescription,
-                onClick = { onCheckedChange(!checked) },
-                modifier = modifier,
-                enabled = enabled,
-                selected = checked,
-                onLongClick = onLongClick,
-                size = smallContainerSize(),
-                iconSize = smallIconSize,
-                style = when (style) {
-                    ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined
-                    ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal
-                }
-            )
-        }
     }
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -9,7 +8,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun SmallTonalButton(
     onClick: () -> Unit,
@@ -32,6 +30,8 @@ fun SmallTonalButton(
         selected = selected,
         onLongClick = onLongClick,
         size = if (text == null) smallContainerSize() else null,
+        enforceMinimumInteractiveSize = false,
+        shape = SmallButtonShape,
         style = SeriesIconButtonStyle.Tonal,
         contentColor = contentColor,
         containerColor = containerColor,

+ 8 - 2
app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt

@@ -26,13 +26,13 @@ import androidx.compose.material.icons.outlined.Explore
 import androidx.compose.material.icons.outlined.Home
 import androidx.compose.material.icons.outlined.Person
 import androidx.compose.material.icons.outlined.RssFeed
+import androidx.compose.material.icons.outlined.Sell
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.graphics.vector.ImageVector
 import io.legado.app.ui.main.MainDestination
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import top.yukonga.miuix.kmp.icon.MiuixIcons
-import top.yukonga.miuix.kmp.icon.basic.Search
 import top.yukonga.miuix.kmp.icon.extended.Album
 import top.yukonga.miuix.kmp.icon.extended.Back
 import top.yukonga.miuix.kmp.icon.extended.Close
@@ -46,6 +46,7 @@ import top.yukonga.miuix.kmp.icon.extended.MoreCircle
 import top.yukonga.miuix.kmp.icon.extended.Notes
 import top.yukonga.miuix.kmp.icon.extended.Pin
 import top.yukonga.miuix.kmp.icon.extended.Refresh
+import top.yukonga.miuix.kmp.icon.extended.Search
 import top.yukonga.miuix.kmp.icon.extended.Settings
 import top.yukonga.miuix.kmp.icon.extended.Unpin
 import top.yukonga.miuix.kmp.icon.extended.WorldClock
@@ -58,7 +59,7 @@ object AppIcons {
 
     val Search: ImageVector
         @Composable
-        get() = if (isMiuix) MiuixIcons.Basic.Search else Icons.Default.Search
+        get() = if (isMiuix) MiuixIcons.Search else Icons.Default.Search
 
     val MoreVert: ImageVector
         @Composable
@@ -116,6 +117,11 @@ object AppIcons {
         @Composable
         get() = Icons.Default.Check
 
+    val Group: ImageVector
+        @Composable
+        get() = if (isMiuix) MiuixIcons.Filter else Icons.Outlined.Sell
+
+
 
     @Composable
     fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt

@@ -34,11 +34,11 @@ import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 
@@ -297,7 +297,7 @@ fun <T> RuleEditSheet(
                         onClick = { runTest() },
                         tooltipText = stringResource(R.string.test),
                         icon = Icons.Default.RunningWithErrors,
-                        containerColor = LegadoTheme.colorScheme.secondaryContainer,
+                        containerColor = LegadoTheme.colorScheme.onSurfaceVariant,
                     )
                 }
                 AppFloatingActionButton(

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

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.runtime.Composable
@@ -96,7 +95,7 @@ fun <T> DynamicTopAppBar(
                     Box {
                         TopBarActionButton(
                             onClick = { showMenu = true },
-                            imageVector = Icons.Default.MoreVert,
+                            imageVector = AppIcons.MoreVert,
                             contentDescription = stringResource(R.string.more_menu)
                         )
                         RoundDropdownMenu(

+ 12 - 24
app/src/main/java/io/legado/app/web/socket/BookSourceDebugWebSocket.kt

@@ -13,10 +13,9 @@ import splitties.init.appCtx
  * web端书源调试
  */
 class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
-    CoroutineScope by session,
-    Debug.Callback {
+    CoroutineScope by session {
 
-    private val notPrintState = arrayOf(10, 20, 30, 40)
+    private var debugSession: Debug.Session? = null
 
     suspend fun handle() {
         try {
@@ -38,8 +37,14 @@ class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSessio
                             break
                         }
                         appDb.bookSourceDao.getBookSource(tag)?.let {
-                            Debug.callback = this@BookSourceDebugWebSocket
-                            Debug.startDebug(this, it, key)
+                            val current = Debug.startDebug(this, it, key)
+                            debugSession = current
+                            current.events.collect { event ->
+                                if (!event.kind.isSourcePayload) session.send(event.message)
+                                if (event.kind.isTerminal) {
+                                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                                }
+                            }
                         }
                     } else {
                         session.send("数据必须为Json格式")
@@ -51,25 +56,8 @@ class BookSourceDebugWebSocket(private val session: DefaultWebSocketServerSessio
         } catch (e: Exception) {
             e.printOnDebug()
         } finally {
-            Debug.cancelDebug(true)
+            debugSession?.cancel()
+            debugSession = null
         }
     }
-
-    override fun printLog(state: Int, msg: String) {
-        if (state in notPrintState) {
-            return
-        }
-        launch(Dispatchers.IO) {
-            runCatching {
-                session.send(msg)
-                if (state == -1 || state == 1000) {
-                    Debug.cancelDebug(true)
-                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
-                }
-            }.onFailure {
-                it.printOnDebug()
-            }
-        }
-    }
-
 }

+ 12 - 23
app/src/main/java/io/legado/app/web/socket/RssSourceDebugWebSocket.kt

@@ -13,10 +13,9 @@ import splitties.init.appCtx
  * web端订阅源调试
  */
 class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession) :
-    CoroutineScope by session,
-    Debug.Callback {
+    CoroutineScope by session {
 
-    private val notPrintState = arrayOf(10, 20, 30, 40)
+    private var debugSession: Debug.Session? = null
 
     suspend fun handle() {
         try {
@@ -37,8 +36,14 @@ class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession
                             break
                         }
                         appDb.rssSourceDao.getByKey(tag)?.let {
-                            Debug.callback = this@RssSourceDebugWebSocket
-                            Debug.startDebug(this, it)
+                            val current = Debug.startDebug(this, it)
+                            debugSession = current
+                            current.events.collect { event ->
+                                if (!event.kind.isSourcePayload) session.send(event.message)
+                                if (event.kind.isTerminal) {
+                                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
+                                }
+                            }
                         }
                     }
                 }
@@ -46,24 +51,8 @@ class RssSourceDebugWebSocket(private val session: DefaultWebSocketServerSession
         } catch (e: Exception) {
             e.printOnDebug()
         } finally {
-            Debug.cancelDebug(true)
-        }
-    }
-
-    override fun printLog(state: Int, msg: String) {
-        if (state in notPrintState) {
-            return
-        }
-        launch(Dispatchers.IO) {
-            runCatching {
-                session.send(msg)
-                if (state == -1 || state == 1000) {
-                    Debug.cancelDebug(true)
-                    session.close(CloseReason(CloseReason.Codes.NORMAL, "调试结束"))
-                }
-            }.onFailure {
-                it.printOnDebug()
-            }
+            debugSession?.cancel()
+            debugSession = null
         }
     }
 }

+ 0 - 127
app/src/main/res/layout/activity_book_source_edit.xml

@@ -1,127 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:title="@string/edit_book_source" />
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none"
-        android:paddingHorizontal="12dp">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:gravity="center_vertical"
-            android:orientation="horizontal"
-            tools:ignore="VisualLintBounds">
-
-            <TextView
-                android:id="@+id/tv_book_type"
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/sp_type"
-                android:text="@string/book_type"
-                tools:ignore="RtlHardcoded" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/sp_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:contentDescription="@string/book_type"
-                android:entries="@array/book_type"
-                android:prompt="@string/book_type" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/is_enable" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_explore"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/discovery" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_cookie"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="true"
-                android:text="@string/auto_save_cookie" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_review"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/review"
-                android:visibility="gone" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none"
-        android:paddingHorizontal="12dp">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:gravity="center_vertical"
-            android:orientation="horizontal"
-            tools:ignore="VisualLintBounds">
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_event_listener"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/is_event_listener" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_custom_button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checked="false"
-                android:text="@string/custom_button" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <com.google.android.material.tabs.TabLayout
-        android:id="@+id/tab_layout"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingStart="12dp"
-        tools:ignore="SpeakableTextPresentCheck" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:paddingHorizontal="16dp"
-        android:clipToPadding="false"
-        tools:listitem="@layout/item_source_edit" />
-
-</LinearLayout>

+ 0 - 129
app/src/main/res/layout/activity_rss_source_edit.xml

@@ -1,129 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:contentInsetStartWithNavigation="0dp"
-        app:displayHomeAsUp="true"
-        app:title="@string/rss_source_edit" />
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:orientation="horizontal"
-            android:paddingHorizontal="12dp"
-            tools:ignore="VisualLintBounds">
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="true"
-                android:text="@string/is_enable" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_single_url"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="false"
-                android:text="@string/single_url" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_cookie"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="true"
-                android:text="@string/auto_save_cookie" />
-
-            <io.legado.app.lib.theme.view.ThemeCheckBox
-                android:id="@+id/cb_is_enable_preload"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:checked="false"
-                android:text="@string/enable_preload" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <HorizontalScrollView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:scrollbars="none">
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center_vertical"
-            android:orientation="horizontal"
-            android:paddingHorizontal="12dp"
-            tools:ignore="VisualLintBounds">
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/sp_type"
-                android:text="@string/book_type"
-                tools:ignore="RtlHardcoded" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/sp_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:entries="@array/rss_type"
-                android:theme="@style/Spinner" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:labelFor="@id/ly_type"
-                android:text="@string/layout_type"
-                tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-            <androidx.appcompat.widget.AppCompatSpinner
-                android:id="@+id/ly_type"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minHeight="48dp"
-                android:entries="@array/layout_type"
-                android:theme="@style/Spinner" />
-
-        </LinearLayout>
-
-    </HorizontalScrollView>
-
-    <com.google.android.material.tabs.TabLayout
-        android:id="@+id/tab_layout"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingHorizontal="12dp"
-        tools:ignore="SpeakableTextPresentCheck" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:clipToPadding="false"
-        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
-        tools:listitem="@layout/item_source_edit" />
-
-</LinearLayout>

+ 0 - 196
app/src/main/res/layout/activity_source_debug.xml

@@ -1,196 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/ll_content"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:contentInsetStartWithNavigation="0dp"
-        app:contentLayout="@layout/view_search"
-        app:layout_constraintTop_toTopOf="parent"
-        app:title="@string/debug_source" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:clipToPadding="false"
-        app:layoutManager="io.legado.app.ui.widget.recycler.NoChildScrollLinearLayoutManager"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar" />
-
-    <com.google.android.material.loadingindicator.LoadingIndicator
-        android:id="@+id/rotate_loading"
-        android:layout_width="36dp"
-        android:layout_height="36dp"
-        android:layout_margin="6dp"
-        android:contentDescription="@string/loading"
-        android:visibility="gone"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar"
-        app:loading_width="2dp" />
-
-    <io.legado.app.ui.widget.NoChildScrollNestedScrollView
-        android:id="@+id/help"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:visibility="gone"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:padding="16dp">
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试搜索 - 输入关键字,如:"
-                tools:ignore="HardcodedText" />
-
-            <LinearLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:orientation="horizontal">
-
-                <TextView
-                    android:id="@+id/text_my"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_margin="3dp"
-                    android:background="@drawable/selector_fillet_btn_bg"
-                    android:ellipsize="end"
-                    android:gravity="center"
-                    android:maxLines="1"
-                    android:paddingLeft="12dp"
-                    android:paddingTop="4dp"
-                    android:paddingRight="12dp"
-                    android:paddingBottom="4dp"
-                    android:text="我的"
-                    android:textSize="14sp"
-                    tools:ignore="HardcodedText,UnusedAttribute" />
-
-                <TextView
-                    android:id="@+id/text_xt"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_margin="3dp"
-                    android:background="@drawable/selector_fillet_btn_bg"
-                    android:ellipsize="end"
-                    android:gravity="center"
-                    android:maxLines="1"
-                    android:paddingLeft="12dp"
-                    android:paddingTop="4dp"
-                    android:paddingRight="12dp"
-                    android:paddingBottom="4dp"
-                    android:text="系统"
-                    android:textSize="14sp"
-                    tools:ignore="HardcodedText,UnusedAttribute" />
-            </LinearLayout>
-
-            <TextView
-                android:id="@+id/text_fx_title"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试发现 - 输入发现URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_fx"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="系统::http://xxx"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试详情页 - 输入详情页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_info"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="https://m.qidian.com/book/1015609210"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试目录页 - 输入目录页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_toc"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="++https://www.zhaishuyuan.com/read/30394"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="调试正文页 - 输入正文页URL,如:"
-                tools:ignore="HardcodedText" />
-
-            <TextView
-                android:id="@+id/text_content"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_margin="3dp"
-                android:background="@drawable/selector_fillet_btn_bg"
-                android:ellipsize="end"
-                android:gravity="center"
-                android:maxLines="1"
-                android:paddingLeft="12dp"
-                android:paddingTop="4dp"
-                android:paddingRight="12dp"
-                android:paddingBottom="4dp"
-                android:text="--https://www.zhaishuyuan.com/chapter/30394/20940996"
-                android:textSize="14sp"
-                tools:ignore="HardcodedText,UnusedAttribute" />
-
-        </LinearLayout>
-
-    </io.legado.app.ui.widget.NoChildScrollNestedScrollView>
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 43
app/src/main/res/menu/book_source_debug.xml

@@ -1,43 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_scan"
-        android:icon="@drawable/ic_scan"
-        android:title="@string/scan_qr_code"
-        app:showAsAction="always" />
-
-    <item
-        android:id="@+id/menu_search_src"
-        android:title="@string/search_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_book_src"
-        android:title="@string/boo_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_toc_src"
-        android:title="@string/toc_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_content_src"
-        android:title="@string/content_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_refresh_explore"
-        android:title="@string/refresh_explore"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_help"
-        android:title="@string/help"
-        app:showAsAction="never" />
-
-</menu>

+ 0 - 17
app/src/main/res/menu/rss_source_debug.xml

@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_list_src"
-        android:title="@string/list_src"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_content_src"
-        android:title="@string/content_src"
-        app:showAsAction="never" />
-
-</menu>

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

@@ -670,6 +670,8 @@
     <string name="group_manage">分组管理</string>
     <string name="group_add">新建分组</string>
     <string name="group_select">分组选择</string>
+    <string name="book_source_default_groups">默认分组</string>
+    <string name="book_source_other_groups">其他分组</string>
     <string name="group_edit">编辑分组</string>
     <string name="move_to_group">移入分组</string>
     <string name="add_group">添加分组</string>
@@ -710,7 +712,7 @@
     <string name="refresh_cover">刷新封面</string>
     <string name="change_cover_source">封面换源</string>
     <string name="select_local_image">选择本地图片</string>
-    <string name="book_type">类型:</string>
+    <string name="book_type">"类型 "</string>
     <string name="to_backstage">后台</string>
     <string name="importing">正在导入</string>
     <string name="exporting">正在导出</string>
@@ -787,6 +789,12 @@
     <string name="bg_fit_tile">平铺</string>
     <string name="bg_fit_stretch">拉伸填充</string>
     <string name="bg_fit_crop">居中裁剪</string>
+    <string name="bg_fit_nine_patch">九宫格</string>
+    <string name="nine_patch_editor">九宫格切图</string>
+    <string name="nine_patch_split_left">左切分线</string>
+    <string name="nine_patch_split_right">右切分线</string>
+    <string name="nine_patch_split_top">上切分线</string>
+    <string name="nine_patch_split_bottom">下切分线</string>
     <string name="preview_effect">预览效果</string>
     <string name="sample_text">示例文本</string>
     <string name="sure_delete">确定要删除吗?</string>
@@ -1408,6 +1416,7 @@
     <string name="record_heap_dump_s">当应用发生OOM崩溃时保存堆转储</string>
     <string name="record_heap_dump_t">记录堆转储</string>
     <string name="font_weight_text">字重</string>
+    <string name="font_weight">字重</string>
     <string name="keep_swipe_tip">继续滑动以加载下一章…</string>
     <string name="enable_optimize_render">启用绘制优化</string>
     <string name="ignore_battery_permission_rationale">阅读需要请求后台权限以保持服务正常运行,拒绝后不再弹出。如有需要可至高级设置中再次申请。</string>

+ 10 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -597,6 +597,8 @@
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
+    <string name="book_source_default_groups">預設分組</string>
+    <string name="book_source_other_groups">其他分組</string>
     <string name="group_edit">編輯分組</string>
     <string name="move_to_group">移入分組</string>
     <string name="add_group">添加分組</string>
@@ -635,7 +637,7 @@
     <string name="refresh_cover">刷新封面</string>
     <string name="change_cover_source">封面換源</string>
     <string name="select_local_image">選擇本地圖片</string>
-    <string name="book_type">類型: </string>
+    <string name="book_type">類型</string>
     <string name="to_backstage">後台</string>
     <string name="importing">正在導入</string>
     <string name="exporting">正在導出</string>
@@ -1225,6 +1227,7 @@
     <string name="record_heap_dump_s">当应用发生OOM崩溃时保存堆转储</string>
     <string name="record_heap_dump_t">记录堆转储</string>
     <string name="font_weight_text">字重</string>
+    <string name="font_weight">字重</string>
     <string name="keep_swipe_tip">继续滑动以加载下一章…</string>
     <string name="enable_optimize_render">启用绘制优化</string>
     <string name="ignore_battery_permission_rationale">阅读需要请求后台权限以保持服务正常运行</string>
@@ -1578,6 +1581,12 @@
     <string name="backup_error">備份失敗</string>
     <string name="be_swiped">頁面仍可滑動</string>
     <string name="bg_fit_crop">中央裁剪</string>
+    <string name="bg_fit_nine_patch">九宮格</string>
+    <string name="nine_patch_editor">九宮格切圖</string>
+    <string name="nine_patch_split_left">左切分線</string>
+    <string name="nine_patch_split_right">右切分線</string>
+    <string name="nine_patch_split_top">上切分線</string>
+    <string name="nine_patch_split_bottom">下切分線</string>
     <string name="bg_fit_stretch">拉伸</string>
     <string name="bg_fit_tile">平鋪</string>
     <string name="bg_image_fit">背景適配</string>

+ 10 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -606,6 +606,8 @@
     <string name="select_image">選擇圖片</string>
     <string name="group_manage">分組管理</string>
     <string name="group_select">分組選擇</string>
+    <string name="book_source_default_groups">預設分組</string>
+    <string name="book_source_other_groups">其他分組</string>
     <string name="group_edit">編輯分組</string>
     <string name="move_to_group">移入分組</string>
     <string name="add_group">新增分組</string>
@@ -644,7 +646,7 @@
     <string name="refresh_cover">重新整理封面</string>
     <string name="change_cover_source">封面換源</string>
     <string name="select_local_image">選擇本機圖片</string>
-    <string name="book_type">類型:</string>
+    <string name="book_type">類型</string>
     <string name="to_backstage">後台</string>
     <string name="importing">正在匯入</string>
     <string name="exporting">正在匯出</string>
@@ -1229,6 +1231,7 @@
     <string name="record_heap_dump_s">当应用发生OOM崩溃时保存堆转储</string>
     <string name="record_heap_dump_t">记录堆转储</string>
     <string name="font_weight_text">字重</string>
+    <string name="font_weight">字重</string>
     <string name="keep_swipe_tip">继续滑动以加载下一章…</string>
     <string name="enable_optimize_render">启用绘制优化</string>
     <string name="ignore_battery_permission_rationale">阅读需要请求后台权限以保持服务正常运行</string>
@@ -1513,6 +1516,12 @@
     <string name="bg_fit_tile">並排</string>
     <string name="bg_fit_stretch">延伸</string>
     <string name="bg_fit_crop">置中裁切</string>
+    <string name="bg_fit_nine_patch">九宮格</string>
+    <string name="nine_patch_editor">九宮格切圖</string>
+    <string name="nine_patch_split_left">左切分線</string>
+    <string name="nine_patch_split_right">右切分線</string>
+    <string name="nine_patch_split_top">上切分線</string>
+    <string name="nine_patch_split_bottom">下切分線</string>
     <string name="preview_effect">預覽</string>
     <string name="sample_text">範例文字</string>
     <string name="sure_delete">確定要刪除嗎?</string>

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

@@ -701,6 +701,8 @@
     <string name="group_manage">Group Management</string>
     <string name="group_add">New Group</string>
     <string name="group_select">Group selection</string>
+    <string name="book_source_default_groups">Default groups</string>
+    <string name="book_source_other_groups">Other groups</string>
     <string name="group_edit">Group editing</string>
     <string name="move_to_group">Move to group</string>
     <string name="add_group">Add to Groups</string>
@@ -741,7 +743,7 @@
     <string name="refresh_cover">Refresh cover</string>
     <string name="change_cover_source">Change origin</string>
     <string name="select_local_image">Local image</string>
-    <string name="book_type">Type:</string>
+    <string name="book_type">Type</string>
     <string name="to_backstage">Background</string>
     <string name="importing">Importing</string>
     <string name="exporting">Exporting</string>
@@ -818,6 +820,12 @@
     <string name="bg_fit_tile">Tile</string>
     <string name="bg_fit_stretch">Stretch</string>
     <string name="bg_fit_crop">Center Crop</string>
+    <string name="bg_fit_nine_patch">9-Slice</string>
+    <string name="nine_patch_editor">9-Slice Editor</string>
+    <string name="nine_patch_split_left">Left Split</string>
+    <string name="nine_patch_split_right">Right Split</string>
+    <string name="nine_patch_split_top">Top Split</string>
+    <string name="nine_patch_split_bottom">Bottom Split</string>
     <string name="preview_effect">Preview</string>
     <string name="sample_text">Sample Text</string>
     <string name="sure_delete">Are you sure you want to delete?</string>
@@ -1350,6 +1358,7 @@
     <string name="clear_webview_data">Clear WebView Data</string>
     <string name="clear_webview_data_summary">Clear all data in WebView</string>
     <string name="source_tab_list">List</string>
+    <string name="source_tab_web_view">Web view</string>
     <string name="source_tab_start">Startup</string>
     <string name="r_startHtml">Startup page HTML text</string>
     <string name="r_startStyle">Startup page style text</string>
@@ -1468,6 +1477,7 @@
     <string name="record_heap_dump_s">Saving a heap dump when the application crashes by OOM</string>
     <string name="record_heap_dump_t">Capture heap dump</string>
     <string name="font_weight_text">N/B/L</string>
+    <string name="font_weight">Font Weight</string>
     <string name="keep_swipe_tip">Keep swiping to load the next chapter…</string>
     <string name="enable_optimize_render">Enable Render Optimization</string>
     <string name="ignore_battery_permission_rationale">Reading requires requesting background permission to keep the service running properly</string>