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

merge: resolve main updates for multi-speaker TTS

HapeLee 3 месяцев назад
Родитель
Сommit
a8a1fc4f8f
100 измененных файлов с 4030 добавлено и 1264 удалено
  1. 48 20
      app/src/main/AndroidManifest.xml
  2. 27 21
      app/src/main/java/io/legado/app/App.kt
  3. 1 76
      app/src/main/java/io/legado/app/base/AppContextWrapper.kt
  4. 165 14
      app/src/main/java/io/legado/app/base/BaseActivity.kt
  5. 73 9
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  6. 2 2
      app/src/main/java/io/legado/app/base/BaseService.kt
  7. 18 0
      app/src/main/java/io/legado/app/base/LegacyUiConfiguration.kt
  8. 0 1
      app/src/main/java/io/legado/app/constant/EventBus.kt
  9. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  10. 1 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  11. 0 40
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt
  12. 108 0
      app/src/main/java/io/legado/app/data/repository/AppLocaleRepository.kt
  13. 42 0
      app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt
  14. 129 0
      app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt
  15. 1 2
      app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt
  16. 512 0
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  17. 1 2
      app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt
  18. 57 68
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  19. 1 2
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  20. 153 217
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  21. 15 0
      app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt
  22. 14 0
      app/src/main/java/io/legado/app/data/repository/RuleSubscriptionRepository.kt
  23. 35 4
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  24. 55 99
      app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt
  25. 63 5
      app/src/main/java/io/legado/app/di/appModule.kt
  26. 14 0
      app/src/main/java/io/legado/app/domain/gateway/AppLocaleGateway.kt
  27. 51 0
      app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt
  28. 9 0
      app/src/main/java/io/legado/app/domain/gateway/AppUiConfigurationGateway.kt
  29. 23 0
      app/src/main/java/io/legado/app/domain/gateway/BackupSettingsGateway.kt
  30. 66 0
      app/src/main/java/io/legado/app/domain/gateway/BookshelfSettingsGateway.kt
  31. 23 0
      app/src/main/java/io/legado/app/domain/gateway/CoverSettingsGateway.kt
  32. 19 0
      app/src/main/java/io/legado/app/domain/gateway/DownloadCacheSettingsGateway.kt
  33. 15 0
      app/src/main/java/io/legado/app/domain/gateway/LabSettingsGateway.kt
  34. 30 0
      app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt
  35. 25 0
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt
  36. 50 0
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  37. 98 0
      app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt
  38. 15 0
      app/src/main/java/io/legado/app/domain/gateway/TranslationSettingsGateway.kt
  39. 27 0
      app/src/main/java/io/legado/app/domain/model/settings/AppShellSettings.kt
  40. 41 0
      app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt
  41. 15 0
      app/src/main/java/io/legado/app/domain/model/settings/BackupSettings.kt
  42. 51 0
      app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt
  43. 21 0
      app/src/main/java/io/legado/app/domain/model/settings/CoverSettings.kt
  44. 11 0
      app/src/main/java/io/legado/app/domain/model/settings/DownloadCacheSettings.kt
  45. 7 0
      app/src/main/java/io/legado/app/domain/model/settings/LabSettings.kt
  46. 24 0
      app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt
  47. 22 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  48. 104 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  49. 91 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  50. 9 0
      app/src/main/java/io/legado/app/domain/model/settings/TranslationSettings.kt
  51. 18 19
      app/src/main/java/io/legado/app/help/WebDavManager.kt
  52. 25 154
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  53. 209 0
      app/src/main/java/io/legado/app/help/config/AppConfigStore.kt
  54. 67 0
      app/src/main/java/io/legado/app/help/config/CustomTipPlaceholder.kt
  55. 0 42
      app/src/main/java/io/legado/app/help/config/DsSync.kt
  56. 13 5
      app/src/main/java/io/legado/app/help/config/LocalConfig.kt
  57. 108 0
      app/src/main/java/io/legado/app/help/config/PreferencesDsCompat.kt
  58. 44 1
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  59. 64 0
      app/src/main/java/io/legado/app/help/config/SettingsWriter.kt
  60. 2 4
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  61. 2 4
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  62. 17 15
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  63. 17 67
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  64. 46 0
      app/src/main/java/io/legado/app/help/storage/RestoreConfigNormalizer.kt
  65. 0 4
      app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt
  66. 9 7
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  67. 18 3
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  68. 1 1
      app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt
  70. 57 28
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  71. 45 18
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt
  72. 3 4
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt
  73. 1 0
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt
  74. 46 20
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  75. 12 4
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  76. 79 54
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  77. 23 11
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  78. 50 32
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
  79. 9 4
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  80. 10 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  81. 13 5
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  82. 27 10
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  83. 73 26
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  84. 2 3
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  85. 16 1
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  86. 79 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  87. 0 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  88. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  89. 9 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  90. 97 14
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  91. 102 0
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  92. 71 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/CustomTipTarget.kt
  93. 9 19
      app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt
  94. 67 30
      app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt
  95. 3 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt
  96. 1 2
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt
  97. 17 15
      app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt
  98. 7 6
      app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt
  99. 44 37
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt
  100. 10 9
      app/src/main/java/io/legado/app/ui/book/read/sheet/UnderlineConfigSheet.kt

+ 48 - 20
app/src/main/AndroidManifest.xml

@@ -39,7 +39,7 @@
         <activity
             android:name=".ui.main.LauncherW"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcherw">
             <intent-filter>
@@ -55,7 +55,7 @@
         <activity
             android:name=".ui.main.Launcher0"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher0">
             <intent-filter>
@@ -72,7 +72,7 @@
         <activity
             android:name=".ui.main.Launcher1"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher1">
             <intent-filter>
@@ -89,7 +89,7 @@
         <activity
             android:name=".ui.main.Launcher2"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher2">
             <intent-filter>
@@ -106,7 +106,7 @@
         <activity
             android:name=".ui.main.Launcher3"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher3">
             <intent-filter>
@@ -123,7 +123,7 @@
         <activity
             android:name=".ui.main.Launcher4"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher4">
             <intent-filter>
@@ -140,7 +140,7 @@
         <activity
             android:name=".ui.main.Launcher5"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher5">
             <intent-filter>
@@ -157,7 +157,7 @@
         <activity
             android:name=".ui.main.Launcher6"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:icon="@mipmap/launcher6">
             <intent-filter>
@@ -174,7 +174,7 @@
         <activity
             android:name=".ui.main.MainActivity"
             android:alwaysRetainTaskState="true"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:exported="true">
@@ -190,12 +190,14 @@
         </activity>
         <activity
             android:name=".ui.welcome.WelcomeActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:exported="false"
             android:windowSoftInputMode="adjustPan|stateHidden" />
         <!-- 阅读漫画界面 -->
         <activity
             android:name=".ui.book.manga.ReadMangaActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="false"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask">
@@ -210,17 +212,20 @@
         <!-- 书籍详情页 -->
         <activity
             android:name=".ui.book.info.BookInfoActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- 书籍信息编辑 -->
         <activity
             android:name=".ui.book.info.edit.BookInfoEditActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 音频播放界面 -->
         <activity
             android:name="io.legado.app.ui.book.audio.AudioPlayActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask" />
         <!-- 授权界面 -->
@@ -231,113 +236,125 @@
         <!-- 二维码扫描 -->
         <activity
             android:name=".ui.qrcode.QrCodeActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask" />
         <!-- 书源编辑 -->
         <activity
             android:name=".ui.book.source.edit.BookSourceEditActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 订阅源编辑 -->
         <activity
             android:name=".ui.rss.source.edit.RssSourceEditActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
         <!-- 搜索界面 -->
         <activity
             android:name=".ui.book.search.SearchActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="standard" />
         <activity
             android:name=".ui.about.CrashReportActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:excludeFromRecents="true"
             android:launchMode="singleTask" />
         <!-- 书源管理 -->
         <activity
             android:name=".ui.book.source.manage.BookSourceActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="false"
             android:launchMode="singleTop" />
         <!-- 订阅源管理 -->
         <activity
             android:name=".ui.rss.source.manage.RssSourceActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- txt目录规则管理 -->
         <activity
             android:name=".ui.book.toc.rule.TxtTocRuleActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- txt目录规则预览(阅读界面) -->
         <activity
             android:name=".ui.book.toc.rule.preview.TxtTocRulePreviewActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- 替换规则界面 -->
         <activity
             android:name=".ui.replace.ReplaceRuleActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:launchMode="singleTop" />
         <!-- 书源调试 -->
         <activity
             android:name=".ui.book.source.debug.BookSourceDebugActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- 目录 -->
         <activity
             android:name=".ui.book.toc.TocActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- 订阅源调试 -->
         <activity
             android:name=".ui.rss.source.debug.RssSourceDebugActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- 书签 -->
         <activity
             android:name=".ui.book.bookmark.AllBookmarkActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
         <!-- WebView界面 -->
         <activity
             android:name=".ui.browser.WebViewActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:launchMode="standard" />
         <!-- 书源登录 -->
         <activity
             android:name="io.legado.app.ui.login.SourceLoginActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true"
             android:theme="@style/AppTheme.Transparent" />
         <!-- 字典管理 -->
         <activity
             android:name=".ui.dict.rule.DictRuleActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
         <activity
             android:name=".ui.dict.DictActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
         <!-- 文件管理 -->
         <activity
             android:name=".ui.file.FileManageActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true" />
         <!-- 选择文件 -->
         <activity
             android:name=".ui.file.HandleFileActivity"
             android:enableOnBackInvokedCallback="true"
-            android:configChanges="orientation|screenSize"
+            android:configChanges="locale|layoutDirection|uiMode|orientation|screenSize|screenLayout"
             android:hardwareAccelerated="true"
             android:theme="@style/AppTheme.Transparent" />
         <!-- 文字处理 -->
@@ -364,7 +381,7 @@
         <!-- 一键导入 -->
         <activity
             android:name=".ui.association.OnLineImportActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:exported="true"
             android:theme="@style/AppTheme.Transparent">
             <intent-filter>
@@ -380,16 +397,17 @@
         <!-- 验证码输入 -->
         <activity
             android:name=".ui.association.VerificationCodeActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:theme="@style/AppTheme.Transparent" />
         <!-- 跳转确认 -->
         <activity
             android:name=".ui.association.OpenUrlConfirmActivity"
-            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
             android:theme="@style/AppTheme.Transparent" />
         <!-- 打开文件 -->
         <activity
             android:name=".ui.association.FileAssociationActivity"
+            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:exported="true"
             android:launchMode="singleTop"
             android:theme="@style/AppTheme.Transparent">
@@ -515,6 +533,16 @@
             android:name=".service.DownloadService"
             android:foregroundServiceType="dataSync" />
 
+        <service
+            android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
+            android:enabled="false"
+            android:exported="false"
+            tools:ignore="ExportedService">
+            <meta-data
+                android:name="autoStoreLocales"
+                android:value="true" />
+        </service>
+
         <receiver
             android:name=".receiver.MediaButtonReceiver"
             android:exported="true">

+ 27 - 21
app/src/main/java/io/legado/app/App.kt

@@ -6,7 +6,6 @@ import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.content.Context
 import android.content.pm.ApplicationInfo
-import android.content.res.Configuration
 import android.graphics.BitmapFactory
 import android.os.Build
 import androidx.core.graphics.scale
@@ -34,9 +33,10 @@ 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.repository.SettingsRepository
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appModule
+import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
@@ -46,6 +46,8 @@ import io.legado.app.help.LifecycleHelp
 import io.legado.app.help.RuleBigDataHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore.applyDayNightInit
@@ -59,14 +61,16 @@ import io.legado.app.help.storage.Backup
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.model.BookCover
 import io.legado.app.ui.book.read.page.entities.TextLine
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.LogUtils
-import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isDebuggable
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import org.chromium.base.ThreadUtils
 import org.koin.android.ext.android.get
 import org.koin.android.ext.koin.androidContext
@@ -80,17 +84,29 @@ import java.util.logging.Level
 
 class App : Application(), ImageLoaderFactory {
 
-    private lateinit var oldConfig: Configuration
-
     override fun newImageLoader(): ImageLoader {
         return get()
     }
 
     override fun onCreate() {
+        // 首行初始化设置快照层:同步预加载 DataStore(触发 SP 迁移),
+        // 之后所有 getPref* 门面读取均为纯内存查找,须先于一切主题/配置读取
+        AppConfigStore.init(this)
+        // 一次性迁移:把旧版语言偏好写入 AppCompat per-app locales,之后交由
+        // autoStoreLocales 持久化。不能每次启动都执行——API 33+ 上会覆盖用户在
+        // 系统设置里选择的应用语言,API <33 上此时 AppCompat 存储尚未加载、
+        // getApplicationLocales() 恒为空,isEmpty 守卫会形同虚设
+        val legacyLanguage = if (!LocalConfig.appLocaleMigrated) {
+            LocalConfig.appLocaleMigrated = true
+            OtherConfig.language
+        } else null
         startKoin {
             androidContext(this@App)
             modules(appDatabaseModule, appModule)
         }
+        if (legacyLanguage != null) {
+            get<AppLocaleGateway>().migrateLegacyLanguage(legacyLanguage)
+        }
         applyDayNightInit(this)
         if (getPrefString("app_theme", "0") == "12") {
             if (AppConfig.customMode == "accent")
@@ -130,20 +146,19 @@ class App : Application(), ImageLoaderFactory {
         if (isDebuggable) {
             ThreadUtils.setThreadAssertsDisabledForTesting(true)
         }
-        oldConfig = Configuration(resources.configuration)
         registerActivityLifecycleCallbacks(LifecycleHelp)
-        defaultSharedPreferences.registerOnSharedPreferenceChangeListener(AppConfig)
         Coroutine.async {
-            AppWebDav.upConfig()
+            get<BackupSettingsGateway>().settings
+                .map {
+                    listOf(it.webDavUrl, it.webDavDir, it.webDavAccount, it.webDavPassword)
+                }
+                .distinctUntilChanged()
+                .collect { AppWebDav.upConfig() }
         }
         Coroutine.async {
             LogUtils.init(this@App)
             LogUtils.d("App", "onCreate")
             LogUtils.logDeviceInfo()
-            // 确保 DataStore 迁移后 SP 中的主题配置值未丢失
-            kotlin.runCatching {
-                get<SettingsRepository>().postMigrationSync()
-            }
             //预下载Cronet so
             Cronet.preDownload()
             createNotificationChannels()
@@ -190,15 +205,6 @@ class App : Application(), ImageLoaderFactory {
         }
     }
 
-//    override fun onConfigurationChanged(newConfig: Configuration) {
-//        super.onConfigurationChanged(newConfig)
-//        val diff = newConfig.diff(oldConfig)
-//        if ((diff and ActivityInfo.CONFIG_UI_MODE) != 0) {
-//            applyDayNight(this)
-//        }
-//        oldConfig = Configuration(newConfig)
-//    }
-
     override fun onTrimMemory(level: Int) {
         super.onTrimMemory(level)
         TextLine.trimCaches(level)

+ 1 - 76
app/src/main/java/io/legado/app/base/AppContextWrapper.kt

@@ -1,45 +1,26 @@
 package io.legado.app.base
 
-import android.annotation.SuppressLint
 import android.app.Activity
 import android.content.Context
 import android.content.res.Configuration
-import android.os.Build
-import android.os.LocaleList
-import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.sysConfiguration
-import java.util.Locale
 
 
 @Suppress("unused")
 object AppContextWrapper {
 
-    fun wrap(context: Context): Context {
-        val newConfig = Configuration(context.resources.configuration)
-        val targetLocale = getSetLocale()
-        newConfig.setLocale(targetLocale)
-        newConfig.setLocales(LocaleList(targetLocale))
-        newConfig.fontScale = getFontScale(context)
-        return context.createConfigurationContext(newConfig)
-    }
-
-    fun applyLocaleAndFont(activity: Activity) {
+    fun applyFont(activity: Activity) {
         val config = activity.resources.configuration
-        val locale = getSetLocale()
         val fontScale = getFontScale(activity)
 
         val newConfig = Configuration(config)
-        newConfig.setLocale(locale)
-        newConfig.setLocales(LocaleList(locale))
         newConfig.fontScale = fontScale
 
         @Suppress("DEPRECATION")
         activity.resources.updateConfiguration(newConfig, activity.resources.displayMetrics)
     }
 
-
-
     fun getFontScale(context: Context): Float {
         var fontScale = ThemeConfig.fontScale / 10f
         if (fontScale !in 0.8f..1.6f) {
@@ -48,60 +29,4 @@ object AppContextWrapper {
         return fontScale
     }
 
-    /**
-     * 当前系统语言
-     */
-    @SuppressLint("ObsoleteSdkInt")
-    private fun getSystemLocale(): Locale {
-        val locale: Locale
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0有多语言设置获取顶部的语言
-            locale = sysConfiguration.locales.get(0)
-        } else {
-            @Suppress("DEPRECATION")
-            locale = sysConfiguration.locale
-        }
-        return locale
-    }
-
-    /**
-     * 当前App语言
-     */
-    @SuppressLint("ObsoleteSdkInt")
-    private fun getAppLocale(context: Context): Locale {
-        val locale: Locale
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-            locale = context.resources.configuration.locales[0]
-        } else {
-            @Suppress("DEPRECATION")
-            locale = context.resources.configuration.locale
-        }
-        return locale
-
-    }
-
-    /**
-     * 当前设置语言
-     */
-    private fun getSetLocale(): Locale {
-        return when (OtherConfig.language) {
-            "zh" -> Locale.SIMPLIFIED_CHINESE
-            "tw" -> Locale.TRADITIONAL_CHINESE
-            "en" -> Locale.ENGLISH
-            else -> getSystemLocale()
-        }
-    }
-
-    /**
-     * 判断App语言和设置语言是否相同
-     */
-    fun isSameWithSetting(context: Context): Boolean {
-        val locale = getAppLocale(context)
-        val language = locale.language
-        val country = locale.country
-        val pfLocale = getSetLocale()
-        val pfLanguage = pfLocale.language
-        val pfCountry = pfLocale.country
-        return language == pfLanguage && country == pfCountry
-    }
-
 }

+ 165 - 14
app/src/main/java/io/legado/app/base/BaseActivity.kt

@@ -14,20 +14,29 @@ import android.view.View
 import android.window.OnBackInvokedDispatcher
 import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
+import androidx.core.os.LocaleListCompat
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.scale
 import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.viewbinding.ViewBinding
 import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColorsOptions
 import io.legado.app.R
+import io.legado.app.BuildConfig
 import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Theme
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.model.settings.AppUiConfigurationDiff
+import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.utils.applyOpenTint
 import io.legado.app.utils.applyTint
@@ -35,12 +44,14 @@ import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hideSoftInput
-import io.legado.app.utils.observeEvent
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.windowSize
 import java.io.File
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
 
 
 abstract class BaseActivity<VB : ViewBinding>(
@@ -50,6 +61,17 @@ abstract class BaseActivity<VB : ViewBinding>(
     private val imageBg: Boolean = true
 ) : AppCompatActivity() {
 
+    protected open val legacyUiConfigurationPolicy =
+        LegacyUiConfigurationPolicy.ControlledRecreate
+
+    private val appLocaleGateway by inject<AppLocaleGateway>()
+    private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private var lastUiConfiguration: AppUiConfiguration? = null
+    private var lastPlatformConfiguration: Configuration? = null
+    private var pendingControlledRecreate = false
+    private var recreatePosted = false
+    private var hasWindowBackgroundImage = false
+
     protected abstract val binding: VB
 
     val isInMultiWindow: Boolean
@@ -79,7 +101,7 @@ abstract class BaseActivity<VB : ViewBinding>(
     override fun onCreate(savedInstanceState: Bundle?) {
         initTheme()
         window.decorView.disableAutoFill()
-        AppContextWrapper.applyLocaleAndFont(this)
+        AppContextWrapper.applyFont(this)
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
             val enable = !AppConfig.isPredictiveBackEnabled
             if (enable) {
@@ -94,6 +116,8 @@ abstract class BaseActivity<VB : ViewBinding>(
         }
 
         super.onCreate(savedInstanceState)
+        lastPlatformConfiguration = Configuration(resources.configuration)
+        lastUiConfiguration = appUiConfigurationGateway.currentConfiguration
         WindowCompat.setDecorFitsSystemWindows(window, false)
         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
             enableEdgeToEdge()
@@ -108,6 +132,8 @@ abstract class BaseActivity<VB : ViewBinding>(
 
 
         observeLiveBus()
+        observeAppUiConfiguration()
+        traceConfiguration("onCreate")
         //onActivityCreated(savedInstanceState)
     }
 
@@ -132,13 +158,43 @@ abstract class BaseActivity<VB : ViewBinding>(
         WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !isDarkTheme
     }
 
-//    override fun onConfigurationChanged(newConfig: Configuration) {
-//        super.onConfigurationChanged(newConfig)
-//        //findViewById<TitleBar>(R.id.title_bar)
-//        //Log.d("Config", "uiMode = ${newConfig.uiMode}")
-//            //?.onMultiWindowModeChanged(isInMultiWindow, fullScreen)
-//        //setupSystemBar()
-//    }
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        val previous = lastPlatformConfiguration
+        super.onConfigurationChanged(newConfig)
+        lastPlatformConfiguration = Configuration(newConfig)
+        appLocaleGateway.synchronizeFromPlatform()
+
+        val platformDiff = AppUiConfigurationDiff(
+            localeChanged = previous != null && previous.locales != newConfig.locales,
+            themeChanged = hasPlatformNightModeChanged(
+                themeMode = appUiConfigurationGateway.currentConfiguration.appShell.themeMode,
+                previousUiMode = previous?.uiMode,
+                newUiMode = newConfig.uiMode,
+            ),
+        )
+        if (platformDiff.hasChanges) {
+            handleAppUiConfiguration(
+                appUiConfigurationGateway.currentConfiguration,
+                platformDiff,
+            )
+        }
+        traceConfiguration("onConfigurationChanged", platformDiff)
+    }
+
+    override fun onLocalesChanged(locales: LocaleListCompat) {
+        super.onLocalesChanged(locales)
+        appLocaleGateway.synchronizeFromPlatform()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        postControlledRecreateIfNeeded()
+    }
+
+    override fun onDestroy() {
+        traceConfiguration("onDestroy")
+        super.onDestroy()
+    }
 
 
 
@@ -230,8 +286,15 @@ abstract class BaseActivity<VB : ViewBinding>(
     open fun upBackgroundImage() {
         if (imageBg) {
             try {
-                ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
-                    window.decorView.background = it.toDrawable(resources)
+                val background = ThemeConfigStore.getBgImage(this, windowManager.windowSize)
+                if (background != null) {
+                    hasWindowBackgroundImage = true
+                    window.decorView.background = background.toDrawable(resources)
+                } else if (hasWindowBackgroundImage) {
+                    hasWindowBackgroundImage = false
+                    window.decorView.background = themeColor(
+                        com.google.android.material.R.attr.colorSurface
+                    ).toDrawable()
                 }
             } catch (e: OutOfMemoryError) {
                 toastOnUi("背景图片太大,内存溢出")
@@ -242,11 +305,99 @@ abstract class BaseActivity<VB : ViewBinding>(
     }
 
     open fun observeLiveBus() {
-        observeEvent<String>(EventBus.RECREATE) {
-            recreate()
+        // Legacy feature events are registered by subclasses. App-wide UI configuration
+        // is collected separately through observeAppUiConfiguration().
+    }
+
+    protected open fun applyLegacyUiConfiguration(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ) = Unit
+
+    protected open fun rebindLegacyViewTree(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ): Boolean = false
+
+    private fun observeAppUiConfiguration() {
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                appUiConfigurationGateway.configuration.collect { configuration ->
+                    val previous = lastUiConfiguration
+                    lastUiConfiguration = configuration
+                    if (previous == null) return@collect
+                    val diff = configuration.diffFrom(previous)
+                    if (diff.hasChanges) {
+                        handleAppUiConfiguration(configuration, diff)
+                    }
+                }
+            }
         }
     }
 
+    private fun handleAppUiConfiguration(
+        configuration: AppUiConfiguration,
+        diff: AppUiConfigurationDiff,
+    ) {
+        traceConfiguration("apply", diff)
+        if (diff.themeChanged || diff.windowChanged) {
+            setupSystemBar()
+            upBackgroundImage()
+        }
+        if (!diff.requiresLegacyContentRefresh) return
+        when (legacyUiConfigurationPolicy) {
+            LegacyUiConfigurationPolicy.HotApply -> {
+                if (diff.fontScaleChanged) {
+                    AppContextWrapper.applyFont(this)
+                }
+                applyLegacyUiConfiguration(configuration, diff)
+            }
+            LegacyUiConfigurationPolicy.RebindViewTree -> {
+                if (diff.fontScaleChanged) {
+                    AppContextWrapper.applyFont(this)
+                }
+                if (!rebindLegacyViewTree(configuration, diff)) {
+                    requestControlledRecreate()
+                }
+            }
+            LegacyUiConfigurationPolicy.ControlledRecreate -> requestControlledRecreate()
+            LegacyUiConfigurationPolicy.ApplyOnNextOpen -> Unit
+        }
+    }
+
+    private fun requestControlledRecreate() {
+        pendingControlledRecreate = true
+        postControlledRecreateIfNeeded()
+    }
+
+    private fun postControlledRecreateIfNeeded() {
+        if (!pendingControlledRecreate || recreatePosted || isFinishing || isDestroyed) return
+        if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
+        recreatePosted = true
+        window.decorView.post {
+            recreatePosted = false
+            if (pendingControlledRecreate && !isFinishing && !isDestroyed &&
+                lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
+            ) {
+                pendingControlledRecreate = false
+                traceConfiguration("controlledRecreate")
+                recreate()
+            }
+        }
+    }
+
+    private fun traceConfiguration(
+        event: String,
+        diff: AppUiConfigurationDiff? = null,
+    ) {
+        if (!BuildConfig.DEBUG) return
+        LogUtils.d(
+            "UiConfiguration",
+            "${javaClass.simpleName}@${System.identityHashCode(this)} $event" +
+                (diff?.let { " diff=$it" } ?: "")
+        )
+    }
+
     override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
         return try {
             super.dispatchTouchEvent(ev)

+ 73 - 9
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -11,21 +11,33 @@ import androidx.compose.runtime.SideEffect
 import androidx.compose.ui.graphics.toArgb
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.WindowCompat
+import androidx.core.os.LocaleListCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import io.legado.app.BuildConfig
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.Theme
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.domain.model.settings.diffFrom
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.windowSize
 import top.yukonga.miuix.kmp.theme.MiuixTheme
+import org.koin.android.ext.android.inject
+import kotlinx.coroutines.launch
 
 abstract class BaseComposeActivity(
     val fullScreen: Boolean = true,
@@ -34,14 +46,19 @@ abstract class BaseComposeActivity(
     private val imageBg: Boolean = true
 ) : AppCompatActivity() {
 
+    private val appLocaleGateway by inject<AppLocaleGateway>()
+    private val appUiConfigurationGateway by inject<AppUiConfigurationGateway>()
+    private var lastUiConfiguration: AppUiConfiguration? = null
+
     @Composable
     protected abstract fun Content()
 
     override fun onCreate(savedInstanceState: Bundle?) {
         window.decorView.disableAutoFill()
-        AppContextWrapper.applyLocaleAndFont(this)
+        AppContextWrapper.applyFont(this)
 
         super.onCreate(savedInstanceState)
+        lastUiConfiguration = appUiConfigurationGateway.currentConfiguration
         enableEdgeToEdge()
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
             window.isNavigationBarContrastEnforced = false
@@ -61,18 +78,28 @@ abstract class BaseComposeActivity(
         }
 
         observeLiveBus()
+        observeAppUiConfiguration()
+        traceConfiguration("onCreate")
     }
 
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
-        // manifest 声明 uiMode configChanges 后,切换深浅色不再 recreate,
-        // 而 AppCompat 手动回调本方法时不会走 View 树分发,需要自己同步给
-        // Compose(LocalConfiguration)并刷新系统栏与背景图
+        // manifest 声明 locale/layoutDirection/screenLayout/uiMode configChanges 后,Compose 主壳
+        // 可直接响应语言与深浅色变化,无需销毁 Activity。AppCompat 手动回调本方法时
+        // 不会走 View 树分发,需要自己同步给 Compose(LocalConfiguration),并刷新
+        // 系统栏与背景图。
         window.decorView.dispatchConfigurationChanged(newConfig)
+        appLocaleGateway.synchronizeFromPlatform()
         setupSystemBar()
         if (imageBg) {
             upBackgroundImage()
         }
+        traceConfiguration("onConfigurationChanged")
+    }
+
+    override fun onLocalesChanged(locales: LocaleListCompat) {
+        super.onLocalesChanged(locales)
+        appLocaleGateway.synchronizeFromPlatform()
     }
 
     open fun setupSystemBar() {
@@ -91,9 +118,16 @@ abstract class BaseComposeActivity(
 
     open fun upBackgroundImage() {
         try {
-            ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
+            val background = ThemeConfigStore.getBgImage(this, windowManager.windowSize)
+            if (background != null) {
                 hasWindowBgImage = true
-                window.setBackgroundDrawable(it.toDrawable(resources))
+                window.setBackgroundDrawable(background.toDrawable(resources))
+            } else if (hasWindowBgImage) {
+                hasWindowBgImage = false
+                lastWindowBgColor = null
+                window.setBackgroundDrawable(
+                    themeColor(com.google.android.material.R.attr.colorSurface).toDrawable()
+                )
             }
         } catch (_: Exception) {}
     }
@@ -126,12 +160,42 @@ abstract class BaseComposeActivity(
     }
 
     open fun observeLiveBus() {
-        observeEvent<String>(EventBus.RECREATE) {
-            recreate()
-        }
         observeEvent<Boolean>(EventBus.NOTIFY_MAIN) {
             setupSystemBar()
         }
     }
 
+    private fun observeAppUiConfiguration() {
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                appUiConfigurationGateway.configuration.collect { configuration ->
+                    val previous = lastUiConfiguration
+                    lastUiConfiguration = configuration
+                    if (previous == null) return@collect
+                    val diff = configuration.diffFrom(previous)
+                    if (diff.windowChanged) {
+                        setupSystemBar()
+                        if (imageBg) upBackgroundImage()
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onDestroy() {
+        traceConfiguration(
+            "onDestroy changingConfigurations=0x${changingConfigurations.toString(16)} " +
+                "isChangingConfigurations=$isChangingConfigurations isFinishing=$isFinishing"
+        )
+        super.onDestroy()
+    }
+
+    private fun traceConfiguration(event: String) {
+        if (!BuildConfig.DEBUG) return
+        LogUtils.d(
+            "UiConfiguration",
+            "${javaClass.simpleName}@${System.identityHashCode(this)} $event",
+        )
+    }
+
 }

+ 2 - 2
app/src/main/java/io/legado/app/base/BaseService.kt

@@ -8,7 +8,7 @@ import androidx.lifecycle.LifecycleService
 import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.help.LifecycleHelp
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.permission.Permissions
@@ -44,7 +44,7 @@ abstract class BaseService : LifecycleService() {
         startForegroundNotification()
         isForeground = true
         LifecycleHelp.onServiceCreate(this)
-        val localPreferencesRepository: LocalPreferencesRepository = get(LocalPreferencesRepository::class.java)
+        val localPreferencesRepository: SettingsRepository = get(SettingsRepository::class.java)
         val checked: Boolean = runBlocking {
             localPreferencesRepository.getPreference(
                 LocalPreferencesKeys.PERMISSION_CHECKED, false

+ 18 - 0
app/src/main/java/io/legado/app/base/LegacyUiConfiguration.kt

@@ -0,0 +1,18 @@
+package io.legado.app.base
+
+import android.content.res.Configuration
+
+enum class LegacyUiConfigurationPolicy {
+    HotApply,
+    RebindViewTree,
+    ControlledRecreate,
+    ApplyOnNextOpen,
+}
+
+internal fun hasPlatformNightModeChanged(
+    themeMode: String,
+    previousUiMode: Int?,
+    newUiMode: Int,
+): Boolean = themeMode == "0" && previousUiMode != null &&
+    (previousUiMode and Configuration.UI_MODE_NIGHT_MASK) !=
+    (newUiMode and Configuration.UI_MODE_NIGHT_MASK)

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

@@ -2,7 +2,6 @@ package io.legado.app.constant
 
 object EventBus {
     const val MEDIA_BUTTON = "mediaButton"
-    const val RECREATE = "RECREATE"
     const val UP_BOOKSHELF = "upBookToc"
     const val BOOKSHELF_REFRESH = "bookshelfRefresh"
     const val ALOUD_STATE = "aloud_state"

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

@@ -49,6 +49,7 @@ object PreferKey {
     const val enableReview = "enableReview"
     const val showRss = "showRss"
     const val mainNavigationOrder = "mainNavigationOrder"
+    const val navExtended = "navExtended"
     const val showStatusBar = "showStatusBar"
     const val swipeAnimation = "swipeAnimation"
     const val bookshelfLayout = "bookshelfLayout"
@@ -65,6 +66,7 @@ object PreferKey {
     const val processText = "process_text"
     const val saveTabPosition = "saveTabPosition"
     const val fontFolder = "fontFolder"
+    const val fontSort = "fontSort"
     const val backupPath = "backupUri"
     const val restoreIgnore = "restoreIgnore"
     const val threadCount = "threadCount"

+ 1 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -13,6 +13,7 @@ import androidx.datastore.preferences.preferencesDataStore
 val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name = "local_ui_status")
 
 object LocalPreferencesKeys {
+    val MIGRATED_TO_SETTINGS = booleanPreferencesKey("__local_ui_status_migrated_to_settings")
     val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
     val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode")
     val SEARCH_SCOPE = stringPreferencesKey("search_scope")

+ 0 - 40
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt

@@ -1,40 +0,0 @@
-package io.legado.app.data.local.preferences
-
-import android.content.Context
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.MutablePreferences
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.emptyPreferences
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.map
-import java.io.IOException
-
-class LocalPreferencesRepository(private val context: Context) {
-
-    private val dataStore = context.localDataStore
-
-    fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
-        return dataStore.data
-            .catch { exception ->
-                if (exception is IOException) {
-                    emit(emptyPreferences())
-                } else {
-                    throw exception
-                }
-            }
-            .map { preferences ->
-                preferences[key] ?: defaultValue
-            }
-    }
-
-    suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
-        dataStore.edit { preferences ->
-            preferences[key] = value
-        }
-    }
-
-    suspend fun updatePreferences(transform: MutablePreferences.() -> Unit) {
-        dataStore.edit(transform)
-    }
-}

+ 108 - 0
app/src/main/java/io/legado/app/data/repository/AppLocaleRepository.kt

@@ -0,0 +1,108 @@
+package io.legado.app.data.repository
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.os.LocaleListCompat
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.Locale
+
+internal interface AppLocalePlatform {
+    fun getApplicationLocales(): LocaleListCompat
+    fun setApplicationLocales(locales: LocaleListCompat)
+}
+
+private object AppCompatLocalePlatform : AppLocalePlatform {
+    override fun getApplicationLocales(): LocaleListCompat =
+        AppCompatDelegate.getApplicationLocales()
+
+    override fun setApplicationLocales(locales: LocaleListCompat) {
+        AppCompatDelegate.setApplicationLocales(locales)
+    }
+}
+
+class AppLocaleRepository internal constructor(
+    private val platform: AppLocalePlatform = AppCompatLocalePlatform,
+    private val persistLanguage: (String) -> Unit = {
+        AppConfigStore.putString(PreferKey.language, it)
+    },
+    readPersistedLanguage: () -> String? = {
+        AppConfigStore.getString(PreferKey.language)
+    },
+) : AppLocaleGateway {
+
+    private var mirroredLanguage = normalizeLanguage(readPersistedLanguage())
+    private val initialPlatformLocales = platform.getApplicationLocales()
+    private val _language = MutableStateFlow(
+        if (initialPlatformLocales.isEmpty) {
+            mirroredLanguage
+        } else {
+            languageForLocaleList(initialPlatformLocales)
+        }
+    )
+    override val language = _language.asStateFlow()
+    override val currentLanguage: String
+        get() = _language.value
+
+    override fun setLanguage(language: String) {
+        val normalized = normalizeLanguage(language)
+        publish(normalized)
+        val locales = localeListForLanguage(normalized)
+        if (platform.getApplicationLocales() != locales) {
+            platform.setApplicationLocales(locales)
+        }
+    }
+
+    override fun synchronizeFromPlatform() {
+        publish(languageForLocaleList(platform.getApplicationLocales()))
+    }
+
+    override fun migrateLegacyLanguage(language: String) {
+        val platformLocales = platform.getApplicationLocales()
+        if (platformLocales.isEmpty && normalizeLanguage(language) != "auto") {
+            setLanguage(language)
+        } else {
+            synchronizeFromPlatform()
+        }
+    }
+
+    private fun publish(language: String) {
+        val normalized = normalizeLanguage(language)
+        if (_language.value != normalized) {
+            _language.value = normalized
+        }
+        if (mirroredLanguage != normalized) {
+            mirroredLanguage = normalized
+            persistLanguage(normalized)
+        }
+    }
+}
+
+internal fun normalizeLanguage(language: String?): String = when (language) {
+    "zh", "tw", "en" -> language
+    else -> "auto"
+}
+
+internal fun localeListForLanguage(language: String?): LocaleListCompat =
+    when (normalizeLanguage(language)) {
+        "zh" -> LocaleListCompat.create(Locale.SIMPLIFIED_CHINESE)
+        "tw" -> LocaleListCompat.create(Locale.TRADITIONAL_CHINESE)
+        "en" -> LocaleListCompat.create(Locale.ENGLISH)
+        else -> LocaleListCompat.getEmptyLocaleList()
+    }
+
+internal fun languageForLocaleList(locales: LocaleListCompat): String {
+    if (locales.isEmpty) return "auto"
+    val locale = locales[0] ?: return "auto"
+    return when {
+        locale.language == Locale.CHINESE.language &&
+            (locale.country.equals("TW", ignoreCase = true) ||
+                locale.country.equals("HK", ignoreCase = true) ||
+                locale.script.equals("Hant", ignoreCase = true)) -> "tw"
+        locale.language == Locale.CHINESE.language -> "zh"
+        locale.language == Locale.ENGLISH.language -> "en"
+        else -> "auto"
+    }
+}

+ 42 - 0
app/src/main/java/io/legado/app/data/repository/AppUiConfigurationRepository.kt

@@ -0,0 +1,42 @@
+package io.legado.app.data.repository
+
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+
+class AppUiConfigurationRepository(
+    private val appLocaleGateway: AppLocaleGateway,
+    private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
+) : AppUiConfigurationGateway {
+
+    private val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+    private val initialConfiguration = AppUiConfiguration(
+        language = appLocaleGateway.currentLanguage,
+        appShell = appShellSettingsGateway.currentSettings,
+        theme = themeSettingsGateway.currentSettings,
+    )
+
+    override val currentConfiguration: AppUiConfiguration
+        get() = configuration.value
+
+    override val configuration: StateFlow<AppUiConfiguration> = combine(
+        appLocaleGateway.language,
+        appShellSettingsGateway.settings,
+        themeSettingsGateway.settings,
+        ::AppUiConfiguration,
+    ).stateIn(
+        scope = processScope,
+        started = SharingStarted.Eagerly,
+        initialValue = initialConfiguration,
+    )
+}

+ 129 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt

@@ -0,0 +1,129 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.gateway.BookshelfBooleanSetting
+import io.legado.app.domain.gateway.BookshelfIntSetting
+import io.legado.app.domain.gateway.BookshelfSettingsGateway
+import io.legado.app.domain.gateway.BookshelfSettingsUpdate
+import io.legado.app.domain.model.settings.BookshelfSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsLong
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class BookshelfSettingsRepository : BookshelfSettingsGateway {
+    override val currentSettings: BookshelfSettings
+        get() = AppConfigStore.preferences.toBookshelfSettings()
+
+    override val settings: Flow<BookshelfSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toBookshelfSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(update: BookshelfSettingsUpdate) {
+        val (key, value) = when (update) {
+            is BookshelfSettingsUpdate.SaveTabPosition -> PreferKey.saveTabPosition to update.value
+            is BookshelfSettingsUpdate.BooleanValue -> when (update.setting) {
+                BookshelfBooleanSetting.ShowUnread -> PreferKey.showUnread
+                BookshelfBooleanSetting.ShowUnreadNew -> PreferKey.showUnreadNew
+                BookshelfBooleanSetting.ShowTip -> PreferKey.showTip
+                BookshelfBooleanSetting.ShowBookCount -> PreferKey.showBookCount
+                BookshelfBooleanSetting.ShowLastUpdateTime -> PreferKey.showLastUpdateTime
+                BookshelfBooleanSetting.ShowBookIntro -> PreferKey.showBookIntro
+                BookshelfBooleanSetting.ShowIntro -> PreferKey.bookshelfShowIntro
+                BookshelfBooleanSetting.ShowTag -> PreferKey.bookshelfShowTag
+                BookshelfBooleanSetting.ShowLatestChapter -> PreferKey.bookshelfShowLatestChapter
+                BookshelfBooleanSetting.ShowWaitUpCount -> PreferKey.showWaitUpCount
+                BookshelfBooleanSetting.ShowFastScroller -> PreferKey.showBookshelfFastScroller
+                BookshelfBooleanSetting.ShowExpandButton -> PreferKey.shouldShowExpandButton
+                BookshelfBooleanSetting.LayoutCompact -> PreferKey.bookshelfLayoutCompact
+                BookshelfBooleanSetting.ShowDivider -> PreferKey.bookshelfShowDivider
+                BookshelfBooleanSetting.TitleSmallFont -> PreferKey.bookshelfTitleSmallFont
+                BookshelfBooleanSetting.TitleCenter -> PreferKey.bookshelfTitleCenter
+                BookshelfBooleanSetting.CoverShadow -> PreferKey.bookshelfCoverShadow
+                BookshelfBooleanSetting.SearchActionDirectToSearch -> PreferKey.bookshelfSearchActionDirectToSearch
+                BookshelfBooleanSetting.AutoRefresh -> PreferKey.autoRefresh
+            } to update.value
+            is BookshelfSettingsUpdate.IntValue -> when (update.setting) {
+                BookshelfIntSetting.BookGroupStyle -> PreferKey.bookGroupStyle
+                BookshelfIntSetting.Sort -> PreferKey.bookshelfSort
+                BookshelfIntSetting.SortOrder -> PreferKey.bookshelfSortOrder
+                BookshelfIntSetting.IntroMaxLines -> PreferKey.bookshelfIntroMaxLines
+                BookshelfIntSetting.RefreshingLimit -> PreferKey.bookshelfRefreshingLimit
+                BookshelfIntSetting.LayoutModePortrait -> PreferKey.bookshelfLayoutModePortrait
+                BookshelfIntSetting.LayoutGridPortrait -> PreferKey.bookshelfLayoutGridPortrait
+                BookshelfIntSetting.LayoutModeLandscape -> PreferKey.bookshelfLayoutModeLandscape
+                BookshelfIntSetting.LayoutGridLandscape -> PreferKey.bookshelfLayoutGridLandscape
+                BookshelfIntSetting.LayoutListPortrait -> PreferKey.bookshelfLayoutListPortrait
+                BookshelfIntSetting.LayoutListLandscape -> PreferKey.bookshelfLayoutListLandscape
+                BookshelfIntSetting.FolderLayoutModePortrait -> PreferKey.bookshelfFolderLayoutModePortrait
+                BookshelfIntSetting.FolderLayoutGridPortrait -> PreferKey.bookshelfFolderLayoutGridPortrait
+                BookshelfIntSetting.FolderLayoutModeLandscape -> PreferKey.bookshelfFolderLayoutModeLandscape
+                BookshelfIntSetting.FolderLayoutGridLandscape -> PreferKey.bookshelfFolderLayoutGridLandscape
+                BookshelfIntSetting.FolderLayoutListPortrait -> PreferKey.bookshelfFolderLayoutListPortrait
+                BookshelfIntSetting.FolderLayoutListLandscape -> PreferKey.bookshelfFolderLayoutListLandscape
+                BookshelfIntSetting.GridLayout -> PreferKey.bookshelfGridLayout
+                BookshelfIntSetting.TitleMaxLines -> PreferKey.bookshelfTitleMaxLines
+                BookshelfIntSetting.CardColor -> PreferKey.bookshelfCardColor
+                BookshelfIntSetting.CardColorDark -> PreferKey.bookshelfCardColorDark
+                BookshelfIntSetting.GroupListStyle -> PreferKey.bookshelfGroupListStyle
+                BookshelfIntSetting.GroupCoverCount -> PreferKey.bookshelfGroupCoverCount
+                BookshelfIntSetting.ListCoverWidth -> PreferKey.bookshelfListCoverWidth
+                BookshelfIntSetting.GridCoverWidth -> PreferKey.bookshelfGridCoverWidth
+            } to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+private fun Preferences.toBookshelfSettings() = BookshelfSettings(
+    bookGroupStyle = compatDsInt(PreferKey.bookGroupStyle) ?: 0,
+    bookshelfSort = compatDsInt(PreferKey.bookshelfSort) ?: 0,
+    bookshelfSortOrder = compatDsInt(PreferKey.bookshelfSortOrder) ?: 1,
+    showUnread = compatDsBoolean(PreferKey.showUnread) ?: true,
+    showUnreadNew = compatDsBoolean(PreferKey.showUnreadNew) ?: true,
+    showTip = compatDsBoolean(PreferKey.showTip) ?: false,
+    showBookCount = compatDsBoolean(PreferKey.showBookCount) ?: true,
+    showLastUpdateTime = compatDsBoolean(PreferKey.showLastUpdateTime) ?: false,
+    showBookIntro = compatDsBoolean(PreferKey.showBookIntro) ?: false,
+    bookshelfShowIntro = compatDsBoolean(PreferKey.bookshelfShowIntro) ?: true,
+    bookshelfShowTag = compatDsBoolean(PreferKey.bookshelfShowTag) ?: true,
+    bookshelfShowLatestChapter = compatDsBoolean(PreferKey.bookshelfShowLatestChapter) ?: true,
+    bookshelfIntroMaxLines = compatDsInt(PreferKey.bookshelfIntroMaxLines) ?: 0,
+    showWaitUpCount = compatDsBoolean(PreferKey.showWaitUpCount) ?: false,
+    showBookshelfFastScroller = compatDsBoolean(PreferKey.showBookshelfFastScroller) ?: false,
+    shouldShowExpandButton = compatDsBoolean(PreferKey.shouldShowExpandButton) ?: false,
+    bookshelfRefreshingLimit = compatDsInt(PreferKey.bookshelfRefreshingLimit) ?: 0,
+    bookshelfLayoutModePortrait = compatDsInt(PreferKey.bookshelfLayoutModePortrait) ?: 1,
+    bookshelfLayoutGridPortrait = compatDsInt(PreferKey.bookshelfLayoutGridPortrait) ?: 3,
+    bookshelfLayoutModeLandscape = compatDsInt(PreferKey.bookshelfLayoutModeLandscape) ?: 1,
+    bookshelfLayoutGridLandscape = compatDsInt(PreferKey.bookshelfLayoutGridLandscape) ?: 7,
+    bookshelfLayoutListPortrait = compatDsInt(PreferKey.bookshelfLayoutListPortrait) ?: 1,
+    bookshelfLayoutListLandscape = compatDsInt(PreferKey.bookshelfLayoutListLandscape) ?: 1,
+    bookshelfFolderLayoutModePortrait = compatDsInt(PreferKey.bookshelfFolderLayoutModePortrait) ?: 1,
+    bookshelfFolderLayoutGridPortrait = compatDsInt(PreferKey.bookshelfFolderLayoutGridPortrait) ?: 3,
+    bookshelfFolderLayoutModeLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutModeLandscape) ?: 1,
+    bookshelfFolderLayoutGridLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutGridLandscape) ?: 7,
+    bookshelfFolderLayoutListPortrait = compatDsInt(PreferKey.bookshelfFolderLayoutListPortrait) ?: 1,
+    bookshelfFolderLayoutListLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutListLandscape) ?: 1,
+    bookshelfGridLayout = compatDsInt(PreferKey.bookshelfGridLayout) ?: 0,
+    bookshelfLayoutCompact = compatDsBoolean(PreferKey.bookshelfLayoutCompact) ?: false,
+    bookshelfShowDivider = compatDsBoolean(PreferKey.bookshelfShowDivider) ?: true,
+    bookshelfTitleSmallFont = compatDsBoolean(PreferKey.bookshelfTitleSmallFont) ?: false,
+    bookshelfTitleCenter = compatDsBoolean(PreferKey.bookshelfTitleCenter) ?: true,
+    bookshelfTitleMaxLines = compatDsInt(PreferKey.bookshelfTitleMaxLines) ?: 2,
+    bookshelfCoverShadow = compatDsBoolean(PreferKey.bookshelfCoverShadow) ?: false,
+    bookshelfCardColor = compatDsInt(PreferKey.bookshelfCardColor) ?: 0,
+    bookshelfCardColorDark = compatDsInt(PreferKey.bookshelfCardColorDark) ?: 0,
+    bookshelfGroupListStyle = compatDsInt(PreferKey.bookshelfGroupListStyle) ?: 0,
+    bookshelfGroupCoverCount = compatDsInt(PreferKey.bookshelfGroupCoverCount) ?: 4,
+    bookshelfListCoverWidth = compatDsInt(PreferKey.bookshelfListCoverWidth) ?: 84,
+    bookshelfGridCoverWidth = compatDsInt(PreferKey.bookshelfGridCoverWidth) ?: 120,
+    bookshelfSearchActionDirectToSearch = compatDsBoolean(PreferKey.bookshelfSearchActionDirectToSearch) ?: true,
+    autoRefreshBook = compatDsBoolean(PreferKey.autoRefresh) ?: false,
+    saveTabPosition = compatDsLong(PreferKey.saveTabPosition) ?: BookGroup.IdAll,
+)

+ 1 - 2
app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt

@@ -6,7 +6,6 @@ import com.google.gson.annotations.SerializedName
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.domain.gateway.CoverAlbumGateway
 import io.legado.app.domain.model.CoverAlbum
 import io.legado.app.domain.model.CoverAlbumImage
@@ -35,7 +34,7 @@ import java.util.UUID
 
 class CoverAlbumRepository(
     private val context: Context,
-    private val preferences: LocalPreferencesRepository,
+    private val preferences: SettingsRepository,
 ) : CoverAlbumGateway {
 
     companion object {

+ 512 - 0
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -0,0 +1,512 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.BuildConfig
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppShellBooleanSetting
+import io.legado.app.domain.gateway.AppShellStringSetting
+import io.legado.app.domain.gateway.AppShellSettingsUpdate
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.gateway.ThemeBooleanSetting
+import io.legado.app.domain.gateway.ThemeColorSlot
+import io.legado.app.domain.gateway.ThemeFloatSetting
+import io.legado.app.domain.gateway.ThemeIntSetting
+import io.legado.app.domain.gateway.ThemeStringSetting
+import io.legado.app.domain.gateway.ThemeSettingsUpdate
+import io.legado.app.domain.model.settings.AppShellSettings
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsUpdate
+import io.legado.app.domain.model.settings.DownloadCacheSettings
+import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.gateway.CoverSettingsUpdate
+import io.legado.app.domain.model.settings.CoverSettings
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.gateway.LabSettingsGateway
+import io.legado.app.domain.gateway.LabSettingsUpdate
+import io.legado.app.domain.gateway.TranslationSettingsGateway
+import io.legado.app.domain.gateway.TranslationSettingsUpdate
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.domain.model.settings.LabSettings
+import io.legado.app.domain.model.settings.TranslationSettings
+import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.BackupSettingsUpdate
+import io.legado.app.domain.model.settings.BackupSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsFloat
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsString
+import androidx.datastore.preferences.core.Preferences
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class AppShellSettingsRepository : AppShellSettingsGateway {
+    override val currentSettings: AppShellSettings
+        get() = AppConfigStore.preferences.toAppShellSettings()
+
+    override val settings: Flow<AppShellSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toAppShellSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(update: AppShellSettingsUpdate) = updateAll(listOf(update))
+
+    override suspend fun updateAll(updates: List<AppShellSettingsUpdate>) {
+        val values = updates.associate { update ->
+            val (key, value) = when (update) {
+                is AppShellSettingsUpdate.ThemeMode -> PreferKey.themeMode to update.value
+                is AppShellSettingsUpdate.FontScale -> PreferKey.fontScale to update.value
+                is AppShellSettingsUpdate.ComposeEngine -> PreferKey.composeEngine to update.value
+                is AppShellSettingsUpdate.MainNavigationOrder ->
+                    PreferKey.mainNavigationOrder to update.value
+                is AppShellSettingsUpdate.BooleanValue -> when (update.setting) {
+                    AppShellBooleanSetting.ShowHome -> PreferKey.showHome to update.value
+                    AppShellBooleanSetting.ShowDiscovery -> PreferKey.showDiscovery to update.value
+                    AppShellBooleanSetting.ShowRss -> PreferKey.showRss to update.value
+                    AppShellBooleanSetting.ShowStatusBar -> PreferKey.showStatusBar to update.value
+                    AppShellBooleanSetting.SwipeAnimation -> PreferKey.swipeAnimation to update.value
+                    AppShellBooleanSetting.PredictiveBack ->
+                        PreferKey.isPredictiveBackEnabled to update.value
+                    AppShellBooleanSetting.ShowBottomView -> PreferKey.showBottomView to update.value
+                    AppShellBooleanSetting.UseFloatingBottomBar ->
+                        PreferKey.useFloatingBottomBar to update.value
+                    AppShellBooleanSetting.UseFloatingBottomBarLiquidGlass ->
+                        PreferKey.useFloatingBottomBarLiquidGlass to update.value
+                    AppShellBooleanSetting.NavExtended -> PreferKey.navExtended to update.value
+                }
+                is AppShellSettingsUpdate.StringValue -> when (update.setting) {
+                    AppShellStringSetting.TabletInterface ->
+                        PreferKey.tabletInterface to update.value
+                    AppShellStringSetting.LabelVisibilityMode ->
+                        PreferKey.labelVisibilityMode to update.value
+                    AppShellStringSetting.DefaultHomePage ->
+                        PreferKey.defaultHomePage to update.value
+                    AppShellStringSetting.NavIconHome -> PreferKey.navIconHome to update.value
+                    AppShellStringSetting.NavIconBookshelf -> PreferKey.navIconBookshelf to update.value
+                    AppShellStringSetting.NavIconExplore -> PreferKey.navIconExplore to update.value
+                    AppShellStringSetting.NavIconRss -> PreferKey.navIconRss to update.value
+                    AppShellStringSetting.NavIconMy -> PreferKey.navIconMy to update.value
+                    AppShellStringSetting.LauncherIcon -> PreferKey.launcherIcon to update.value
+                }
+            }
+            key to value
+        }
+        AppConfigStore.putAll(values)
+    }
+}
+
+class ThemeSettingsRepository : ThemeSettingsGateway {
+    override val currentSettings: ThemeSettings
+        get() = AppConfigStore.preferences.toThemeSettings()
+
+    override val settings: Flow<ThemeSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toThemeSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(update: ThemeSettingsUpdate) = updateAll(listOf(update))
+
+    override suspend fun updateAll(updates: List<ThemeSettingsUpdate>) {
+        val values = updates.associate { update ->
+            val (key, value) = when (update) {
+            is ThemeSettingsUpdate.AppTheme -> PreferKey.appTheme to update.value
+            is ThemeSettingsUpdate.PureBlack -> PreferKey.pureBlack to update.value
+            is ThemeSettingsUpdate.PaletteStyle -> PreferKey.paletteStyle to update.value
+            is ThemeSettingsUpdate.MaterialVersion -> PreferKey.materialVersion to update.value
+            is ThemeSettingsUpdate.CustomContrast -> PreferKey.customContrast to update.value
+            is ThemeSettingsUpdate.DeepPersonalization ->
+                PreferKey.enableDeepPersonalization to update.value
+            is ThemeSettingsUpdate.CustomColor -> when (update.slot) {
+                ThemeColorSlot.Primary -> PreferKey.themeColor to update.value
+                ThemeColorSlot.Secondary -> PreferKey.secondaryThemeColor to update.value
+                ThemeColorSlot.PrimaryText -> PreferKey.primaryTextColor to update.value
+                ThemeColorSlot.SecondaryText -> PreferKey.secondaryTextColor to update.value
+                ThemeColorSlot.Background -> PreferKey.themeBackgroundColor to update.value
+                ThemeColorSlot.LabelContainer -> PreferKey.labelContainerColor to update.value
+                ThemeColorSlot.PrimaryNight -> PreferKey.themeColorNight to update.value
+                ThemeColorSlot.SecondaryNight -> PreferKey.secondaryThemeColorNight to update.value
+                ThemeColorSlot.PrimaryTextNight -> PreferKey.primaryTextColorNight to update.value
+                ThemeColorSlot.SecondaryTextNight -> PreferKey.secondaryTextColorNight to update.value
+                ThemeColorSlot.BackgroundNight -> PreferKey.themeBackgroundColorNight to update.value
+                ThemeColorSlot.LabelContainerNight -> PreferKey.labelContainerColorNight to update.value
+            }
+            is ThemeSettingsUpdate.AppFontPath -> PreferKey.appFontPath to update.value
+            is ThemeSettingsUpdate.CustomPrimary -> PreferKey.cPrimary to update.value
+            is ThemeSettingsUpdate.CustomNightPrimary -> PreferKey.cNPrimary to update.value
+            is ThemeSettingsUpdate.BooleanValue -> when (update.setting) {
+                ThemeBooleanSetting.UseMiuixMonet -> PreferKey.useMiuixMonet to update.value
+                ThemeBooleanSetting.EnableBlur -> PreferKey.enableBlur to update.value
+                ThemeBooleanSetting.EnableProgressiveBlur -> PreferKey.enableProgressiveBlur to update.value
+                ThemeBooleanSetting.UseFlexibleTopAppBar -> PreferKey.useFlexibleTopAppBar to update.value
+                ThemeBooleanSetting.BookInfoFollowCoverColor -> PreferKey.bookInfoFollowCoverColor to update.value
+                ThemeBooleanSetting.EnableItemDivider -> PreferKey.enableItemDivider to update.value
+                ThemeBooleanSetting.EyeProtectionEnabled -> PreferKey.eyeProtectionEnabled to update.value
+                ThemeBooleanSetting.EyeProtectionSchedule -> PreferKey.eyeProtectionSchedule to update.value
+                ThemeBooleanSetting.ShowRefactorTip ->
+                    io.legado.app.data.local.preferences.LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP.name to update.value
+                ThemeBooleanSetting.EnableCustomTagColors -> PreferKey.enableCustomTagColors to update.value
+            }
+            is ThemeSettingsUpdate.IntValue -> when (update.setting) {
+                ThemeIntSetting.ContainerOpacity -> PreferKey.containerOpacity to update.value
+                ThemeIntSetting.TopBarOpacity -> PreferKey.topBarOpacity to update.value
+                ThemeIntSetting.BottomBarOpacity -> PreferKey.bottomBarOpacity to update.value
+                ThemeIntSetting.TopBarBlurRadius -> PreferKey.topBarBlurRadius to update.value
+                ThemeIntSetting.BottomBarBlurRadius -> PreferKey.bottomBarBlurRadius to update.value
+                ThemeIntSetting.TopBarBlurAlpha -> PreferKey.topBarBlurAlpha to update.value
+                ThemeIntSetting.BottomBarBlurAlpha -> PreferKey.bottomBarBlurAlpha to update.value
+                ThemeIntSetting.BackgroundImageBlurring -> PreferKey.bgImageBlurring to update.value
+                ThemeIntSetting.BackgroundImageDarkBlurring -> PreferKey.bgImageNBlurring to update.value
+                ThemeIntSetting.ItemDividerColor -> PreferKey.itemDividerColor to update.value
+                ThemeIntSetting.ColorTemperature -> PreferKey.colorTemperature to update.value
+            }
+            is ThemeSettingsUpdate.FloatValue -> when (update.setting) {
+                ThemeFloatSetting.BottomBarLensRadius -> PreferKey.bottomBarLensRadius to update.value
+                ThemeFloatSetting.ItemDividerWidth -> PreferKey.itemDividerWidth to update.value
+                ThemeFloatSetting.ItemDividerLength -> PreferKey.itemDividerLength to update.value
+            }
+            is ThemeSettingsUpdate.StringValue -> when (update.setting) {
+                ThemeStringSetting.BookInfoNetworkCoverBackground ->
+                    PreferKey.bookInfoNetworkCoverBackground to update.value
+                ThemeStringSetting.BookInfoDefaultCoverBackground ->
+                    PreferKey.bookInfoDefaultCoverBackground to update.value
+                ThemeStringSetting.BackgroundImageLight -> PreferKey.bgImage to update.value
+                ThemeStringSetting.BackgroundImageDark -> PreferKey.bgImageN to update.value
+                ThemeStringSetting.EyeProtectionStartTime -> PreferKey.eyeProtectionStartTime to update.value
+                ThemeStringSetting.EyeProtectionEndTime -> PreferKey.eyeProtectionEndTime to update.value
+                ThemeStringSetting.CustomTagColorsJson -> PreferKey.customTagColors to update.value
+            }
+            }
+            key to value
+        }
+        AppConfigStore.putAll(values)
+    }
+}
+
+class DownloadCacheSettingsRepository : DownloadCacheSettingsGateway {
+    override val settings: Flow<DownloadCacheSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            DownloadCacheSettings(
+                bitmapCacheSize = preferences.compatDsInt(PreferKey.bitmapCacheSize) ?: 50,
+                imageRetainNum = preferences.compatDsInt(PreferKey.imageRetainNum) ?: 0,
+                preDownloadNum = preferences.compatDsInt(PreferKey.preDownloadNum) ?: 10,
+                threadCount = preferences.compatDsInt(PreferKey.threadCount) ?: 16,
+                cacheBookThreadCount =
+                    preferences.compatDsInt(PreferKey.cacheBookThreadCount) ?: 16,
+                userAgent = preferences.compatDsString(PreferKey.userAgent).orEmpty().ifBlank {
+                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+                        "AppleWebKit/537.36 (KHTML, like Gecko) " +
+                        "Chrome/${BuildConfig.Cronet_Main_Version} Safari/537.36"
+                },
+                cronetEnabled = preferences.compatDsBoolean(PreferKey.cronet) ?: false,
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: DownloadCacheSettingsUpdate) {
+        val (key, value) = when (update) {
+            is DownloadCacheSettingsUpdate.BitmapCacheSize -> PreferKey.bitmapCacheSize to update.value
+            is DownloadCacheSettingsUpdate.ImageRetainNum -> PreferKey.imageRetainNum to update.value
+            is DownloadCacheSettingsUpdate.PreDownloadNum -> PreferKey.preDownloadNum to update.value
+            is DownloadCacheSettingsUpdate.ThreadCount -> PreferKey.threadCount to update.value
+            is DownloadCacheSettingsUpdate.CacheBookThreadCount ->
+                PreferKey.cacheBookThreadCount to update.value
+            is DownloadCacheSettingsUpdate.UserAgent -> PreferKey.userAgent to update.value
+            is DownloadCacheSettingsUpdate.CronetEnabled -> PreferKey.cronet to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+class CoverSettingsRepository : CoverSettingsGateway {
+    override val settings: Flow<CoverSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            CoverSettings(
+                loadOnlyOnWifi = preferences.compatDsBoolean(PreferKey.loadCoverOnlyWifi) ?: false,
+                useDefaultCover = preferences.compatDsBoolean(PreferKey.useDefaultCover) ?: false,
+                showShadow = preferences.compatDsBoolean(PreferKey.coverShowShadow) ?: false,
+                showStroke = preferences.compatDsBoolean(PreferKey.coverShowStroke) ?: true,
+                useDefaultColor = preferences.compatDsBoolean(PreferKey.coverDefaultColor) ?: true,
+                textColor = preferences.compatDsInt(PreferKey.coverTextColor) ?: -16777216,
+                shadowColor = preferences.compatDsInt(PreferKey.coverShadowColor) ?: -16777216,
+                showName = preferences.compatDsBoolean(PreferKey.coverShowName) ?: true,
+                showAuthor = preferences.compatDsBoolean(PreferKey.coverShowAuthor) ?: true,
+                textColorDark = preferences.compatDsInt(PreferKey.coverTextColorN) ?: -1,
+                shadowColorDark = preferences.compatDsInt(PreferKey.coverShadowColorN) ?: -1,
+                showNameDark = preferences.compatDsBoolean(PreferKey.coverShowNameN) ?: true,
+                showAuthorDark = preferences.compatDsBoolean(PreferKey.coverShowAuthorN) ?: true,
+                infoOrientation = preferences.compatDsString(PreferKey.coverInfoOrientation) ?: "0",
+                exploreFilterState = preferences.compatDsInt(PreferKey.exploreFilterState) ?: 0,
+                defaultCover = preferences.compatDsString(PreferKey.defaultCover).orEmpty(),
+                defaultCoverDark = preferences.compatDsString(PreferKey.defaultCoverDark).orEmpty(),
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: CoverSettingsUpdate) {
+        val (key, value) = when (update) {
+            is CoverSettingsUpdate.LoadOnlyOnWifi -> PreferKey.loadCoverOnlyWifi to update.value
+            is CoverSettingsUpdate.UseDefaultCover -> PreferKey.useDefaultCover to update.value
+            is CoverSettingsUpdate.ShowShadow -> PreferKey.coverShowShadow to update.value
+            is CoverSettingsUpdate.ShowStroke -> PreferKey.coverShowStroke to update.value
+            is CoverSettingsUpdate.UseDefaultColor -> PreferKey.coverDefaultColor to update.value
+            is CoverSettingsUpdate.TextColor ->
+                (if (update.dark) PreferKey.coverTextColorN else PreferKey.coverTextColor) to update.value
+            is CoverSettingsUpdate.ShadowColor ->
+                (if (update.dark) PreferKey.coverShadowColorN else PreferKey.coverShadowColor) to update.value
+            is CoverSettingsUpdate.ShowName ->
+                (if (update.dark) PreferKey.coverShowNameN else PreferKey.coverShowName) to update.value
+            is CoverSettingsUpdate.ShowAuthor ->
+                (if (update.dark) PreferKey.coverShowAuthorN else PreferKey.coverShowAuthor) to update.value
+            is CoverSettingsUpdate.InfoOrientation -> PreferKey.coverInfoOrientation to update.value
+            is CoverSettingsUpdate.ExploreFilterState -> PreferKey.exploreFilterState to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+class LabSettingsRepository : LabSettingsGateway {
+    override val settings: Flow<LabSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            LabSettings(
+                enabled = preferences.compatDsBoolean(PreferKey.labEnabled) ?: false,
+                eInkDisplay = preferences.compatDsBoolean(PreferKey.labEInkDisplay) ?: false,
+                eyeProtection = preferences.compatDsBoolean(PreferKey.labEyeProtection) ?: false,
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: LabSettingsUpdate) {
+        val (key, value) = when (update) {
+            is LabSettingsUpdate.Enabled -> PreferKey.labEnabled to update.value
+            is LabSettingsUpdate.EInkDisplay -> PreferKey.labEInkDisplay to update.value
+            is LabSettingsUpdate.EyeProtection -> PreferKey.labEyeProtection to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+class TranslationSettingsRepository : TranslationSettingsGateway {
+    override val settings: Flow<TranslationSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            val storedProvider = preferences.compatDsString(PreferKey.llmProvider)
+                ?: TranslationConstants.PROVIDER_GOOGLE
+            TranslationSettings(
+                provider = if (storedProvider == TranslationConstants.PROVIDER_OPENAI) {
+                    TranslationConstants.PROVIDER_APP_AI
+                } else {
+                    storedProvider
+                },
+                targetLanguage = preferences.compatDsString(PreferKey.llmTargetLanguage) ?: "zh",
+                maxCharsPerChunk = preferences.compatDsInt(PreferKey.llmMaxCharsPerChunk) ?: 10000,
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: TranslationSettingsUpdate) {
+        val (key, value) = when (update) {
+            is TranslationSettingsUpdate.Provider -> PreferKey.llmProvider to update.value
+            is TranslationSettingsUpdate.TargetLanguage -> PreferKey.llmTargetLanguage to update.value
+            is TranslationSettingsUpdate.MaxCharsPerChunk -> PreferKey.llmMaxCharsPerChunk to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+class BackupSettingsRepository : BackupSettingsGateway {
+    override val settings: Flow<BackupSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            BackupSettings(
+                webDavUrl = preferences.compatDsString(PreferKey.webDavUrl).orEmpty(),
+                webDavAccount = preferences.compatDsString(PreferKey.webDavAccount).orEmpty(),
+                webDavPassword = preferences.compatDsString(PreferKey.webDavPassword).orEmpty(),
+                webDavDir = preferences.compatDsString(PreferKey.webDavDir) ?: "legado",
+                webDavDeviceName = preferences.compatDsString(PreferKey.webDavDeviceName).orEmpty(),
+                syncBookProgress = preferences.compatDsBoolean(PreferKey.syncBookProgress) ?: true,
+                syncBookProgressPlus =
+                    preferences.compatDsBoolean(PreferKey.syncBookProgressPlus) ?: false,
+                autoCheckNewBackup =
+                    preferences.compatDsBoolean(PreferKey.autoCheckNewBackup) ?: true,
+                onlyLatestBackup = preferences.compatDsBoolean(PreferKey.onlyLatestBackup) ?: true,
+                backupSyncMode = preferences.compatDsString(PreferKey.backupSyncMode) ?: "both",
+                backupPath = preferences.compatDsString(PreferKey.backupPath),
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: BackupSettingsUpdate) = updateAll(listOf(update))
+
+    override suspend fun updateAll(updates: List<BackupSettingsUpdate>) {
+        val values = buildMap {
+            updates.forEach { update ->
+                when (update) {
+                    is BackupSettingsUpdate.WebDavUrl -> put(PreferKey.webDavUrl, update.value)
+                    is BackupSettingsUpdate.WebDavCredentials -> {
+                        put(PreferKey.webDavAccount, update.account)
+                        put(PreferKey.webDavPassword, update.password)
+                    }
+                    is BackupSettingsUpdate.WebDavDir -> put(PreferKey.webDavDir, update.value)
+                    is BackupSettingsUpdate.WebDavDeviceName ->
+                        put(PreferKey.webDavDeviceName, update.value)
+                    is BackupSettingsUpdate.SyncBookProgress ->
+                        put(PreferKey.syncBookProgress, update.value)
+                    is BackupSettingsUpdate.SyncBookProgressPlus ->
+                        put(PreferKey.syncBookProgressPlus, update.value)
+                    is BackupSettingsUpdate.AutoCheckNewBackup ->
+                        put(PreferKey.autoCheckNewBackup, update.value)
+                    is BackupSettingsUpdate.OnlyLatestBackup ->
+                        put(PreferKey.onlyLatestBackup, update.value)
+                    is BackupSettingsUpdate.BackupSyncMode ->
+                        put(PreferKey.backupSyncMode, update.value)
+                    is BackupSettingsUpdate.BackupPath -> put(PreferKey.backupPath, update.value)
+                }
+            }
+        }
+        AppConfigStore.putAll(values)
+    }
+}
+
+private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
+    themeMode = compatDsString(PreferKey.themeMode) ?: "0",
+    fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
+    composeEngine = compatDsString(PreferKey.composeEngine) ?: "material",
+    showHome = compatDsBoolean(PreferKey.showHome) ?: true,
+    showDiscovery = compatDsBoolean(PreferKey.showDiscovery) ?: true,
+    showRss = compatDsBoolean(PreferKey.showRss) ?: true,
+    showStatusBar = compatDsBoolean(PreferKey.showStatusBar) ?: true,
+    swipeAnimation = compatDsBoolean(PreferKey.swipeAnimation) ?: true,
+    predictiveBackEnabled = compatDsBoolean(PreferKey.isPredictiveBackEnabled) ?: true,
+    showBottomView = compatDsBoolean(PreferKey.showBottomView) ?: true,
+    useFloatingBottomBar = compatDsBoolean(PreferKey.useFloatingBottomBar) ?: false,
+    useFloatingBottomBarLiquidGlass =
+        compatDsBoolean(PreferKey.useFloatingBottomBarLiquidGlass) ?: false,
+    tabletInterface = compatDsString(PreferKey.tabletInterface) ?: "auto",
+    labelVisibilityMode = compatDsString(PreferKey.labelVisibilityMode) ?: "auto",
+    defaultHomePage = compatDsString(PreferKey.defaultHomePage) ?: "bookshelf",
+    mainNavigationOrder = compatDsString(PreferKey.mainNavigationOrder)
+        ?: "home,bookshelf,explore,rss,my",
+    navExtended = compatDsBoolean(PreferKey.navExtended) ?: false,
+    navIconHome = compatDsString(PreferKey.navIconHome) ?: "",
+    navIconBookshelf = compatDsString(PreferKey.navIconBookshelf) ?: "",
+    navIconExplore = compatDsString(PreferKey.navIconExplore) ?: "",
+    navIconRss = compatDsString(PreferKey.navIconRss) ?: "",
+    navIconMy = compatDsString(PreferKey.navIconMy) ?: "",
+    launcherIcon = compatDsString(PreferKey.launcherIcon) ?: "ic_launcher",
+)
+
+private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
+    appTheme = compatDsString(PreferKey.appTheme) ?: "0",
+    useMiuixMonet = compatDsBoolean(PreferKey.useMiuixMonet) ?: false,
+    isPureBlack = compatDsBoolean(PreferKey.pureBlack) ?: false,
+    paletteStyle = compatDsString(PreferKey.paletteStyle) ?: "tonalSpot",
+    materialVersion = compatDsString(PreferKey.materialVersion) ?: "material3",
+    customContrast = compatDsString(PreferKey.customContrast) ?: "Default",
+    customMode = compatDsString(PreferKey.customMode) ?: "tonalSpot",
+    appFontPath = compatDsString(PreferKey.appFontPath),
+    customPrimary = compatDsInt(PreferKey.cPrimary) ?: 0,
+    customNightPrimary = compatDsInt(PreferKey.cNPrimary) ?: 0,
+    enableDeepPersonalization = compatDsBoolean(PreferKey.enableDeepPersonalization) ?: false,
+    themeColor = compatDsInt(PreferKey.themeColor) ?: 0,
+    secondaryThemeColor = compatDsInt(PreferKey.secondaryThemeColor) ?: 0,
+    primaryTextColor = compatDsInt(PreferKey.primaryTextColor) ?: 0,
+    secondaryTextColor = compatDsInt(PreferKey.secondaryTextColor) ?: 0,
+    themeBackgroundColor = compatDsInt(PreferKey.themeBackgroundColor) ?: 0,
+    labelContainerColor = compatDsInt(PreferKey.labelContainerColor) ?: 0,
+    themeColorNight = compatDsInt(PreferKey.themeColorNight) ?: 0,
+    secondaryThemeColorNight = compatDsInt(PreferKey.secondaryThemeColorNight) ?: 0,
+    primaryTextColorNight = compatDsInt(PreferKey.primaryTextColorNight) ?: 0,
+    secondaryTextColorNight = compatDsInt(PreferKey.secondaryTextColorNight) ?: 0,
+    themeBackgroundColorNight = compatDsInt(PreferKey.themeBackgroundColorNight) ?: 0,
+    labelContainerColorNight = compatDsInt(PreferKey.labelContainerColorNight) ?: 0,
+    containerOpacity = compatDsInt(PreferKey.containerOpacity) ?: 100,
+    topBarOpacity = compatDsInt(PreferKey.topBarOpacity) ?: 100,
+    bottomBarOpacity = compatDsInt(PreferKey.bottomBarOpacity) ?: 100,
+    enableBlur = compatDsBoolean(PreferKey.enableBlur) ?: false,
+    enableProgressiveBlur = compatDsBoolean(PreferKey.enableProgressiveBlur) ?: false,
+    topBarBlurRadius = compatDsInt(PreferKey.topBarBlurRadius) ?: 24,
+    bottomBarBlurRadius = compatDsInt(PreferKey.bottomBarBlurRadius) ?: 8,
+    topBarBlurAlpha = compatDsInt(PreferKey.topBarBlurAlpha) ?: 73,
+    bottomBarBlurAlpha = compatDsInt(PreferKey.bottomBarBlurAlpha) ?: 40,
+    bottomBarLensRadius = compatDsFloat(PreferKey.bottomBarLensRadius) ?: 24f,
+    useFlexibleTopAppBar = compatDsBoolean(PreferKey.useFlexibleTopAppBar) ?: true,
+    bookInfoFollowCoverColor = compatDsBoolean(PreferKey.bookInfoFollowCoverColor) ?: true,
+    bookInfoNetworkCoverBackground =
+        compatDsString(PreferKey.bookInfoNetworkCoverBackground) ?: "on",
+    bookInfoDefaultCoverBackground =
+        compatDsString(PreferKey.bookInfoDefaultCoverBackground) ?: "on",
+    backgroundImageLight = compatDsString(PreferKey.bgImage),
+    backgroundImageDark = compatDsString(PreferKey.bgImageN),
+    backgroundImageBlurring = compatDsInt(PreferKey.bgImageBlurring) ?: 0,
+    backgroundImageDarkBlurring = compatDsInt(PreferKey.bgImageNBlurring) ?: 0,
+    enableItemDivider = compatDsBoolean(PreferKey.enableItemDivider) ?: false,
+    itemDividerWidth = compatDsFloat(PreferKey.itemDividerWidth) ?: 1f,
+    itemDividerLength = compatDsFloat(PreferKey.itemDividerLength) ?: 80f,
+    itemDividerColor = compatDsInt(PreferKey.itemDividerColor) ?: 0,
+    eyeProtectionEnabled = compatDsBoolean(PreferKey.eyeProtectionEnabled) ?: false,
+    colorTemperature = compatDsInt(PreferKey.colorTemperature) ?: 50,
+    eyeProtectionSchedule = compatDsBoolean(PreferKey.eyeProtectionSchedule) ?: false,
+    eyeProtectionStartTime = compatDsString(PreferKey.eyeProtectionStartTime) ?: "22:00",
+    eyeProtectionEndTime = compatDsString(PreferKey.eyeProtectionEndTime) ?: "07:00",
+    showRefactorTip = compatDsBoolean(
+        io.legado.app.data.local.preferences.LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP.name
+    ) ?: true,
+    enableCustomTagColors = compatDsBoolean(PreferKey.enableCustomTagColors) ?: false,
+    customTagColorsJson = compatDsString(PreferKey.customTagColors),
+)
+
+class OtherSettingsRepository : OtherSettingsGateway {
+    override val settings: Flow<OtherSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            val rawSourceEditMaxLine = preferences.compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
+            OtherSettings(
+                updateToVariant = preferences.compatDsString(PreferKey.updateToVariant) ?: "official_version",
+                autoCheckUpdateOnStart =
+                    preferences.compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
+                webServiceAutoStart = preferences.compatDsBoolean(PreferKey.webServiceAutoStart) ?: false,
+                autoRefresh = preferences.compatDsBoolean(PreferKey.autoRefresh) ?: false,
+                defaultToRead = preferences.compatDsBoolean(PreferKey.defaultToRead) ?: false,
+                notificationsPost = preferences.compatDsBoolean(PreferKey.notificationsPost) ?: true,
+                ignoreBatteryPermission =
+                    preferences.compatDsBoolean(PreferKey.ignoreBatteryPermission) ?: true,
+                firebaseEnable = preferences.compatDsBoolean(PreferKey.firebaseEnable) ?: true,
+                defaultBookTreeUri = preferences.compatDsString(PreferKey.defaultBookTreeUri),
+                antiAlias = preferences.compatDsBoolean(PreferKey.antiAlias) ?: false,
+                replaceEnableDefault = preferences.compatDsBoolean(PreferKey.replaceEnableDefault) ?: true,
+                autoClearExpired = preferences.compatDsBoolean(PreferKey.autoClearExpired) ?: true,
+                showAddToShelfAlert = preferences.compatDsBoolean(PreferKey.showAddToShelfAlert) ?: true,
+                showMangaUi = preferences.compatDsBoolean(PreferKey.showMangaUi) ?: true,
+                webServiceWakeLock = preferences.compatDsBoolean(PreferKey.webServiceWakeLock) ?: false,
+                sourceEditMaxLine = rawSourceEditMaxLine.takeIf { it >= 10 } ?: Int.MAX_VALUE,
+                webPort = preferences.compatDsInt(PreferKey.webPort) ?: 1122,
+                processText = preferences.compatDsBoolean(PreferKey.processText) ?: true,
+                recordLog = preferences.compatDsBoolean(PreferKey.recordLog) ?: false,
+                recordHeapDump = preferences.compatDsBoolean(PreferKey.recordHeapDump) ?: false,
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: OtherSettingsUpdate) {
+        val (key, value) = when (update) {
+            is OtherSettingsUpdate.UpdateToVariant -> PreferKey.updateToVariant to update.value
+            is OtherSettingsUpdate.AutoCheckUpdateOnStart -> PreferKey.autoCheckUpdateOnStart to update.value
+            is OtherSettingsUpdate.WebServiceAutoStart -> PreferKey.webServiceAutoStart to update.value
+            is OtherSettingsUpdate.AutoRefresh -> PreferKey.autoRefresh to update.value
+            is OtherSettingsUpdate.DefaultToRead -> PreferKey.defaultToRead to update.value
+            is OtherSettingsUpdate.FirebaseEnable -> PreferKey.firebaseEnable to update.value
+            is OtherSettingsUpdate.DefaultBookTreeUri -> PreferKey.defaultBookTreeUri to update.value
+            is OtherSettingsUpdate.AntiAlias -> PreferKey.antiAlias to update.value
+            is OtherSettingsUpdate.ReplaceEnableDefault -> PreferKey.replaceEnableDefault to update.value
+            is OtherSettingsUpdate.AutoClearExpired -> PreferKey.autoClearExpired to update.value
+            is OtherSettingsUpdate.ShowAddToShelfAlert -> PreferKey.showAddToShelfAlert to update.value
+            is OtherSettingsUpdate.ShowMangaUi -> PreferKey.showMangaUi to update.value
+            is OtherSettingsUpdate.WebServiceWakeLock -> PreferKey.webServiceWakeLock to update.value
+            is OtherSettingsUpdate.SourceEditMaxLine -> PreferKey.sourceEditMaxLine to update.value
+            is OtherSettingsUpdate.WebPort -> PreferKey.webPort to update.value
+            is OtherSettingsUpdate.ProcessText -> PreferKey.processText to update.value
+            is OtherSettingsUpdate.RecordLog -> PreferKey.recordLog to update.value
+            is OtherSettingsUpdate.RecordHeapDump -> PreferKey.recordHeapDump to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}

+ 1 - 2
app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt

@@ -2,7 +2,6 @@ package io.legado.app.data.repository
 
 import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
 import io.legado.app.domain.model.HomeDashboardSection
@@ -12,7 +11,7 @@ import kotlinx.coroutines.flow.map
 
 class HomeDashboardRepository(
     private val readRecordDao: ReadRecordDao,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
 ) : HomeDashboardGateway {
 
     override fun observeTotalReadBooks(): Flow<Int> =

+ 57 - 68
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -1,64 +1,51 @@
 package io.legado.app.data.repository
 
-import android.content.Context
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
-import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsValue
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.map
-import java.io.IOException
-
-data class ReadAloudPreferences(
-    val ignoreAudioFocus: Boolean = false,
-    val mediaButtonOnExit: Boolean = true,
-    val readAloudByMediaButton: Boolean = false,
-    val pauseReadAloudWhilePhoneCalls: Boolean = false,
-    val readAloudWakeLock: Boolean = false,
-    val showReadAloudCapsule: Boolean = true,
-    val capsuleOffsetX: Float = 0f,
-    val capsuleOffsetY: Float = 0f,
-    val mediaButtonPerNext: Boolean = false,
-    val readAloudByPage: Boolean = false,
-    val systemMediaControlCompatibilityChange: Boolean = true,
-    val streamReadAloudAudio: Boolean = false,
-    val ttsTimer: Int = 0,
-    val ttsFollowSys: Boolean = true,
-    val ttsSpeechRate: Int = 5,
-    val speechAnalysisMode: String = "rule",
-    val useMultiSpeaker: Boolean = true,
-    val defaultInterface: String = ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC,
-)
+
+typealias ReadAloudPreferences = ReadAloudSettings
 
 class ReadAloudSettingsRepository(
-    private val context: Context,
-    private val settingsRepository: SettingsRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository,
-) {
-
-    private val globalPreferences = context.dataStore.data
-        .catch { exception ->
-            if (exception is IOException) {
-                emit(emptyPreferences())
-            } else {
-                throw exception
-            }
-        }
-        .map { preferences -> preferences.toReadAloudPreferences() }
+    private val settingsRepository: SettingsRepository
+) : ReadAloudSettingsGateway {
+
+    override val currentSettings: ReadAloudSettings
+        get() = AppConfigStore.preferences.toReadAloudPreferences()
 
-    val preferences: Flow<ReadAloudPreferences> = combine(
-        globalPreferences,
-        localPreferencesRepository.getPreference(LocalPreferencesKeys.READ_ALOUD_CAPSULE_OFFSET_X, 0f),
-        localPreferencesRepository.getPreference(LocalPreferencesKeys.READ_ALOUD_CAPSULE_OFFSET_Y, 0f),
-    ) { preferences, capsuleOffsetX, capsuleOffsetY ->
-        preferences.copy(capsuleOffsetX = capsuleOffsetX, capsuleOffsetY = capsuleOffsetY)
+    override val settings: Flow<ReadAloudSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            preferences.toReadAloudPreferences()
+        }
+    val preferences: Flow<ReadAloudPreferences> = settings
+
+    override suspend fun update(update: ReadAloudSettingsUpdate) {
+        when (update) {
+            is ReadAloudSettingsUpdate.IgnoreAudioFocus -> setIgnoreAudioFocus(update.value)
+            is ReadAloudSettingsUpdate.MediaButtonOnExit -> setMediaButtonOnExit(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudByMediaButton -> setReadAloudByMediaButton(update.value)
+            is ReadAloudSettingsUpdate.PauseWhilePhoneCalls -> setPauseReadAloudWhilePhoneCalls(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudWakeLock -> setReadAloudWakeLock(update.value)
+            is ReadAloudSettingsUpdate.MediaButtonPerNext -> setMediaButtonPerNext(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudByPage -> setReadAloudByPage(update.value)
+            is ReadAloudSettingsUpdate.SystemMediaControlCompatibility ->
+                setSystemMediaControlCompatibilityChange(update.value)
+            is ReadAloudSettingsUpdate.StreamAudio -> setStreamReadAloudAudio(update.value)
+            is ReadAloudSettingsUpdate.Timer -> setTtsTimer(update.value)
+            is ReadAloudSettingsUpdate.FollowSystem -> setTtsFollowSys(update.value)
+            is ReadAloudSettingsUpdate.SpeechRate -> setTtsSpeechRate(update.value)
+        }
     }
 
     suspend fun setIgnoreAudioFocus(value: Boolean) {
@@ -86,10 +73,8 @@ class ReadAloudSettingsRepository(
     }
 
     suspend fun setCapsulePosition(x: Float, y: Float) {
-        localPreferencesRepository.updatePreferences {
-            this[LocalPreferencesKeys.READ_ALOUD_CAPSULE_OFFSET_X] = x
-            this[LocalPreferencesKeys.READ_ALOUD_CAPSULE_OFFSET_Y] = y
-        }
+        settingsRepository.putFloat(Keys.CapsuleOffsetX.name, x)
+        settingsRepository.putFloat(Keys.CapsuleOffsetY.name, y)
     }
 
     suspend fun resetCapsulePosition() = setCapsulePosition(0f, 0f)
@@ -140,23 +125,25 @@ class ReadAloudSettingsRepository(
 
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
-            ignoreAudioFocus = this[Keys.IgnoreAudioFocus] ?: false,
-            mediaButtonOnExit = this[Keys.MediaButtonOnExit] ?: true,
-            readAloudByMediaButton = this[Keys.ReadAloudByMediaButton] ?: false,
-            pauseReadAloudWhilePhoneCalls = this[Keys.PauseReadAloudWhilePhoneCalls] ?: false,
-            readAloudWakeLock = this[Keys.ReadAloudWakeLock] ?: false,
-            showReadAloudCapsule = this[Keys.ShowReadAloudCapsule] ?: true,
-            mediaButtonPerNext = this[Keys.MediaButtonPerNext] ?: false,
-            readAloudByPage = this[Keys.ReadAloudByPage] ?: false,
+            ignoreAudioFocus = compatDsValue(Keys.IgnoreAudioFocus, false),
+            mediaButtonOnExit = compatDsValue(Keys.MediaButtonOnExit, true),
+            readAloudByMediaButton = compatDsValue(Keys.ReadAloudByMediaButton, false),
+            pauseReadAloudWhilePhoneCalls = compatDsValue(Keys.PauseReadAloudWhilePhoneCalls, false),
+            readAloudWakeLock = compatDsValue(Keys.ReadAloudWakeLock, false),
+            showReadAloudCapsule = compatDsValue(Keys.ShowReadAloudCapsule, true),
+            capsuleOffsetX = compatDsValue(Keys.CapsuleOffsetX, 0f),
+            capsuleOffsetY = compatDsValue(Keys.CapsuleOffsetY, 0f),
+            mediaButtonPerNext = compatDsValue(Keys.MediaButtonPerNext, false),
+            readAloudByPage = compatDsValue(Keys.ReadAloudByPage, false),
             systemMediaControlCompatibilityChange =
-                this[Keys.SystemMediaControlCompatibilityChange] ?: true,
-            streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
-            ttsTimer = PlaybackTimer.normalize(this[Keys.TtsTimer] ?: 0),
-            ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
-            ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
-            speechAnalysisMode = this[Keys.SpeechAnalysisMode] ?: "rule",
-            useMultiSpeaker = this[Keys.UseMultiSpeaker] ?: true,
-            defaultInterface = this[Keys.DefaultInterface] ?: DEFAULT_INTERFACE_CLASSIC,
+                compatDsValue(Keys.SystemMediaControlCompatibilityChange, true),
+            streamReadAloudAudio = compatDsValue(Keys.StreamReadAloudAudio, false),
+            ttsTimer = PlaybackTimer.normalize(compatDsValue(Keys.TtsTimer, 0)),
+            ttsFollowSys = compatDsValue(Keys.TtsFollowSys, true),
+            ttsSpeechRate = compatDsValue(Keys.TtsSpeechRate, 5),
+            speechAnalysisMode = compatDsValue(Keys.SpeechAnalysisMode, "rule"),
+            useMultiSpeaker = compatDsValue(Keys.UseMultiSpeaker, true),
+            defaultInterface = compatDsValue(Keys.DefaultInterface, DEFAULT_INTERFACE_CLASSIC),
         )
     }
 
@@ -168,6 +155,8 @@ class ReadAloudSettingsRepository(
             booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
         val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
         val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
+        val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
+        val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
         val MediaButtonPerNext = booleanPreferencesKey(KEY_MEDIA_BUTTON_PER_NEXT)
         val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
         val SystemMediaControlCompatibilityChange =

+ 1 - 2
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -10,7 +10,6 @@ import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
@@ -21,7 +20,7 @@ import kotlin.math.min
 class ReadRecordRepository(
     private val dao: ReadRecordDao,
     private val database: AppDatabase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
 ) {
     private fun getCurrentDeviceId(): String = ""
 

+ 153 - 217
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -1,136 +1,76 @@
 package io.legado.app.data.repository
 
-import android.content.Context
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurStyle
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsUpdate
+import io.legado.app.domain.model.settings.ReadSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsValue
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.map
-import java.io.IOException
-
-data class ReadPreferences(
-    val screenOrientation: String = "0",
-    val keepLight: String = "0",
-    val hideStatusBar: Boolean = false,
-    val hideNavigationBar: Boolean = false,
-    val paddingDisplayCutouts: Boolean = false,
-    val titleBarMode: String = "1",
-    val menuAlpha: Int = 100,
-    val readBodyToLh: Boolean = true,
-    val defaultSourceChangeAll: Boolean = true,
-    val textFullJustify: Boolean = true,
-    val textBottomJustify: Boolean = true,
-    val adaptSpecialStyle: Boolean = true,
-    val useZhLayout: Boolean = false,
-    val showBrightnessView: String = "1",
-    val brightnessVwPos: String = "1",
-    val readBrightness: Int = 100,
-    val brightnessAuto: Boolean = false,
-    val useUnderline: Boolean = false,
-    val readSliderMode: String = "0",
-    val doubleHorizontalPage: String = "0",
-    val progressBarBehavior: String = "page",
-    val mouseWheelPage: Boolean = true,
-    val volumeKeyPage: Boolean = true,
-    val volumeKeyPageOnPlay: Boolean = true,
-    val keyPageOnLongPress: Boolean = false,
-    val pageTouchSlop: Int = 0,
-    val sliderVibrator: Boolean = false,
-    val selectVibrator: Boolean = false,
-    val autoChangeSource: Boolean = true,
-    val autoSuggestDayNight: Boolean = false,
-    val selectText: Boolean = true,
-    val noAnimScrollPage: Boolean = false,
-    val clickImgWay: String = "2",
-    val optimizeRender: Boolean = false,
-    val disableReturnKey: Boolean = false,
-    val expandTextMenu: Boolean = false,
-    val showSelectMenuIcon: Boolean = true,
-    val showReadTitleAddition: Boolean = true,
-    val autoReadSpeed: Int = 10,
-    val prevKeys: String = "",
-    val nextKeys: String = "",
-    val tocUiUseReplace: Boolean = false,
-    val tocCountWords: Boolean = true,
-    val readStyleSelect: Int = 0,
-    val comicStyleSelect: Int = 0,
-    val shareLayout: Boolean = false,
-    val readBarStyleFollowPage: Boolean = false,
-    val readBarStyle: Int = 0,
-    val clickActionTL: Int = 2,
-    val clickActionTC: Int = 2,
-    val clickActionTR: Int = 1,
-    val clickActionML: Int = 2,
-    val clickActionMC: Int = 0,
-    val clickActionMR: Int = 1,
-    val clickActionBL: Int = 2,
-    val clickActionBC: Int = 1,
-    val clickActionBR: Int = 1,
-    val fontFolder: String = "",
-    val readMenuBgColor: Int = 0,
-    val readMenuAccentColor: Int = 0,
-    val readMenuContainerColor: Int = 0,
-    val readMenuBgColorNight: Int = 0,
-    val readMenuAccentColorNight: Int = 0,
-    val readMenuContainerColorNight: Int = 0,
-    val readMenuTextColor: Int = 0,
-    val readMenuTextColorNight: Int = 0,
-    val readMenuColorMode: Int = 1,
-    val readMenuIconShowText: Boolean = true,
-    val readMenuIconStyle: Int = 0,
-    val titleBarIconStyle: Int = 0,
-    val readMenuIconItemsPerRow: Int = 5,
-    val readMenuIconRowCount: Int = 1,
-    val readMenuBottomCornerRadius: Int = 0,
-    val readMenuFloatingBottomBar: Boolean = false,
-    val readMenuTopBarBlurMode: Int = ReadMenuBlurMode.None,
-    val readMenuBottomBarBlurMode: Int = ReadMenuBlurMode.None,
-    val readMenuTopBarLiquidGlassButtons: Boolean = false,
-    val readMenuTopBarTitleCapsule: Boolean = false,
-    val readMenuBottomBarLiquidGlassButtons: Boolean = false,
-    val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
-    val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
-    val readMenuBlurRadius: Int = 24,
-    val readMenuBlurAlpha: Int = 60,
-    val readMenuBlurColor: Int = 0,
-    val readMenuBlurColorNight: Int = 0,
-    val readMenuPaletteStyle: String = "",
-    val readMenuLensRadius: Float = 24f,
-    val readMenuBorderWidth: Int = 0,
-    val readMenuBorderColor: Int = 0,
-    val readMenuBorderColorNight: Int = 0,
-    val readMenuCustomIcons: String = "",
-    val titleBarCustomIcons: String = "",
-    val titleBarIconPosition: Int = 0,
-    val showTitleBarIcons: Boolean = true,
-    val chineseConverterType: Int = 0,
-    val showMenuIcon: Boolean = true,
-)
+
+typealias ReadPreferences = ReadSettings
 
 class ReadSettingsRepository(
-    private val context: Context,
     private val settingsRepository: SettingsRepository
-) {
-
-    val preferences: Flow<ReadPreferences> = context.dataStore.data
-        .catch { exception ->
-            if (exception is IOException) {
-                emit(emptyPreferences())
-            } else {
-                throw exception
-            }
-        }
+) : ReadSettingsGateway {
+
+    override val settings: Flow<ReadSettings> = AppConfigStore.preferencesFlow
         .map { preferences ->
-            preferences.toReadPreferences()
+            preferences.toReadSettings()
         }
 
+    val preferences: Flow<ReadPreferences> = settings
+
+    override suspend fun update(update: ReadSettingsUpdate) {
+        when (update) {
+            is ReadSettingsUpdate.ScreenOrientation -> setScreenOrientation(update.value)
+            is ReadSettingsUpdate.KeepLight -> setKeepLight(update.value)
+            is ReadSettingsUpdate.HideStatusBar -> setHideStatusBar(update.value)
+            is ReadSettingsUpdate.HideNavigationBar -> setHideNavigationBar(update.value)
+            is ReadSettingsUpdate.PaddingDisplayCutouts -> setPaddingDisplayCutouts(update.value)
+            is ReadSettingsUpdate.TitleBarMode -> setTitleBarMode(update.value)
+            is ReadSettingsUpdate.ReadMenuBlurAlpha -> setReadMenuBlurAlpha(update.value)
+            is ReadSettingsUpdate.ReadBodyToLh -> setReadBodyToLh(update.value)
+            is ReadSettingsUpdate.DefaultSourceChangeAll -> setDefaultSourceChangeAll(update.value)
+            is ReadSettingsUpdate.TextFullJustify -> setTextFullJustify(update.value)
+            is ReadSettingsUpdate.TextBottomJustify -> setTextBottomJustify(update.value)
+            is ReadSettingsUpdate.AdaptSpecialStyle -> setAdaptSpecialStyle(update.value)
+            is ReadSettingsUpdate.UseZhLayout -> setUseZhLayout(update.value)
+            is ReadSettingsUpdate.ShowBrightnessView -> setShowBrightnessView(update.value)
+            is ReadSettingsUpdate.BrightnessVwPos -> setBrightnessVwPos(update.value)
+            is ReadSettingsUpdate.UseUnderline -> setUseUnderline(update.value)
+            is ReadSettingsUpdate.ReadSliderMode -> setReadSliderMode(update.value)
+            is ReadSettingsUpdate.DoubleHorizontalPage -> setDoubleHorizontalPage(update.value)
+            is ReadSettingsUpdate.ProgressBarBehavior -> setProgressBarBehavior(update.value)
+            is ReadSettingsUpdate.MouseWheelPage -> setMouseWheelPage(update.value)
+            is ReadSettingsUpdate.VolumeKeyPage -> setVolumeKeyPage(update.value)
+            is ReadSettingsUpdate.VolumeKeyPageOnPlay -> setVolumeKeyPageOnPlay(update.value)
+            is ReadSettingsUpdate.KeyPageOnLongPress -> setKeyPageOnLongPress(update.value)
+            is ReadSettingsUpdate.PageTouchSlop -> setPageTouchSlop(update.value)
+            is ReadSettingsUpdate.SliderVibrator -> setSliderVibrator(update.value)
+            is ReadSettingsUpdate.SelectVibrator -> setSelectVibrator(update.value)
+            is ReadSettingsUpdate.AutoChangeSource -> setAutoChangeSource(update.value)
+            is ReadSettingsUpdate.AutoSuggestDayNight -> setAutoSuggestDayNight(update.value)
+            is ReadSettingsUpdate.SelectText -> setSelectText(update.value)
+            is ReadSettingsUpdate.NoAnimScrollPage -> setNoAnimScrollPage(update.value)
+            is ReadSettingsUpdate.ClickImgWay -> setClickImgWay(update.value)
+            is ReadSettingsUpdate.OptimizeRender -> setOptimizeRender(update.value)
+            is ReadSettingsUpdate.DisableReturnKey -> setDisableReturnKey(update.value)
+            is ReadSettingsUpdate.ShowReadTitleAddition -> setShowReadTitleAddition(update.value)
+            is ReadSettingsUpdate.ShowMenuIcon -> setShowMenuIcon(update.value)
+            is ReadSettingsUpdate.PageKeys -> setPageKeys(update.previous, update.next)
+            is ReadSettingsUpdate.FontFolder -> setFontFolder(update.value)
+        }
+    }
+
     suspend fun setScreenOrientation(value: String) =
         settingsRepository.putString(PreferKey.screenOrientation, value)
 
@@ -406,109 +346,105 @@ class ReadSettingsRepository(
         }
     }
 
-    private fun Preferences.toReadPreferences(): ReadPreferences {
-        val readStyleSelect = this[Keys.ReadStyleSelect] ?: 0
-        return ReadPreferences(
-            screenOrientation = this[Keys.ScreenOrientation] ?: "0",
-            keepLight = this[Keys.KeepLight] ?: "0",
-            hideStatusBar = this[Keys.HideStatusBar] ?: false,
-            hideNavigationBar = this[Keys.HideNavigationBar] ?: false,
-            paddingDisplayCutouts = this[Keys.PaddingDisplayCutouts] ?: false,
-            titleBarMode = this[Keys.TitleBarMode] ?: "1",
-            menuAlpha = this[Keys.MenuAlpha] ?: 100,
-            readBodyToLh = this[Keys.ReadBodyToLh] ?: true,
-            defaultSourceChangeAll = this[Keys.DefaultSourceChangeAll] ?: true,
-            textFullJustify = this[Keys.TextFullJustify] ?: true,
-            textBottomJustify = this[Keys.TextBottomJustify] ?: true,
-            adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true,
-            useZhLayout = this[Keys.UseZhLayout] ?: false,
-            showBrightnessView = this[Keys.ShowBrightnessView] ?: "1",
-            brightnessVwPos = this[Keys.BrightnessVwPos] ?: "1",
-            readBrightness = this[Keys.ReadBrightness] ?: 100,
-            brightnessAuto = this[Keys.BrightnessAuto] ?: false,
-            useUnderline = this[Keys.UseUnderline] ?: false,
-            readSliderMode = this[Keys.ReadSliderMode] ?: "0",
-            doubleHorizontalPage = this[Keys.DoubleHorizontalPage] ?: "0",
-            progressBarBehavior = this[Keys.ProgressBarBehavior] ?: "page",
-            mouseWheelPage = this[Keys.MouseWheelPage] ?: true,
-            volumeKeyPage = this[Keys.VolumeKeyPage] ?: true,
-            volumeKeyPageOnPlay = this[Keys.VolumeKeyPageOnPlay] ?: true,
-            keyPageOnLongPress = this[Keys.KeyPageOnLongPress] ?: false,
-            pageTouchSlop = this[Keys.PageTouchSlop] ?: 0,
-            sliderVibrator = this[Keys.SliderVibrator] ?: false,
-            selectVibrator = this[Keys.SelectVibrator] ?: false,
-            autoChangeSource = this[Keys.AutoChangeSource] ?: true,
-            autoSuggestDayNight = this[Keys.AutoSuggestDayNight] ?: false,
-            selectText = this[Keys.SelectText] ?: true,
-            noAnimScrollPage = this[Keys.NoAnimScrollPage] ?: false,
-            clickImgWay = this[Keys.ClickImgWay] ?: "2",
-            optimizeRender = this[Keys.OptimizeRender] ?: false,
-            disableReturnKey = this[Keys.DisableReturnKey] ?: false,
-            expandTextMenu = this[Keys.ExpandTextMenu] ?: false,
-            showSelectMenuIcon = this[Keys.ShowSelectMenuIcon] ?: true,
-            showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true,
-            autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10,
-            prevKeys = this[Keys.PrevKeys] ?: "",
-            nextKeys = this[Keys.NextKeys] ?: "",
-            tocUiUseReplace = this[Keys.TocUiUseReplace] ?: false,
-            tocCountWords = this[Keys.TocCountWords] ?: true,
+    private fun Preferences.toReadSettings(): ReadSettings {
+        val readStyleSelect = compatDsValue(Keys.ReadStyleSelect, 0)
+        return ReadSettings(
+            screenOrientation = compatDsValue(Keys.ScreenOrientation, "0"),
+            keepLight = compatDsValue(Keys.KeepLight, "0"),
+            hideStatusBar = compatDsValue(Keys.HideStatusBar, false),
+            hideNavigationBar = compatDsValue(Keys.HideNavigationBar, false),
+            paddingDisplayCutouts = compatDsValue(Keys.PaddingDisplayCutouts, false),
+            titleBarMode = compatDsValue(Keys.TitleBarMode, "1"),
+            menuAlpha = compatDsValue(Keys.MenuAlpha, 100),
+            readBodyToLh = compatDsValue(Keys.ReadBodyToLh, true),
+            defaultSourceChangeAll = compatDsValue(Keys.DefaultSourceChangeAll, true),
+            textFullJustify = compatDsValue(Keys.TextFullJustify, true),
+            textBottomJustify = compatDsValue(Keys.TextBottomJustify, true),
+            adaptSpecialStyle = compatDsValue(Keys.AdaptSpecialStyle, true),
+            useZhLayout = compatDsValue(Keys.UseZhLayout, false),
+            showBrightnessView = compatDsValue(Keys.ShowBrightnessView, "1"),
+            brightnessVwPos = compatDsValue(Keys.BrightnessVwPos, "1"),
+            readBrightness = compatDsValue(Keys.ReadBrightness, 100),
+            brightnessAuto = compatDsValue(Keys.BrightnessAuto, false),
+            useUnderline = compatDsValue(Keys.UseUnderline, false),
+            readSliderMode = compatDsValue(Keys.ReadSliderMode, "0"),
+            doubleHorizontalPage = compatDsValue(Keys.DoubleHorizontalPage, "0"),
+            progressBarBehavior = compatDsValue(Keys.ProgressBarBehavior, "page"),
+            mouseWheelPage = compatDsValue(Keys.MouseWheelPage, true),
+            volumeKeyPage = compatDsValue(Keys.VolumeKeyPage, true),
+            volumeKeyPageOnPlay = compatDsValue(Keys.VolumeKeyPageOnPlay, true),
+            keyPageOnLongPress = compatDsValue(Keys.KeyPageOnLongPress, false),
+            pageTouchSlop = compatDsValue(Keys.PageTouchSlop, 0),
+            sliderVibrator = compatDsValue(Keys.SliderVibrator, false),
+            selectVibrator = compatDsValue(Keys.SelectVibrator, false),
+            autoChangeSource = compatDsValue(Keys.AutoChangeSource, true),
+            autoSuggestDayNight = compatDsValue(Keys.AutoSuggestDayNight, false),
+            selectText = compatDsValue(Keys.SelectText, true),
+            noAnimScrollPage = compatDsValue(Keys.NoAnimScrollPage, false),
+            clickImgWay = compatDsValue(Keys.ClickImgWay, "2"),
+            optimizeRender = compatDsValue(Keys.OptimizeRender, false),
+            disableReturnKey = compatDsValue(Keys.DisableReturnKey, false),
+            expandTextMenu = compatDsValue(Keys.ExpandTextMenu, false),
+            showSelectMenuIcon = compatDsValue(Keys.ShowSelectMenuIcon, true),
+            showReadTitleAddition = compatDsValue(Keys.ShowReadTitleAddition, true),
+            autoReadSpeed = compatDsValue(Keys.AutoReadSpeed, 10),
+            prevKeys = compatDsValue(Keys.PrevKeys, ""),
+            nextKeys = compatDsValue(Keys.NextKeys, ""),
+            tocUiUseReplace = compatDsValue(Keys.TocUiUseReplace, false),
+            tocCountWords = compatDsValue(Keys.TocCountWords, true),
             readStyleSelect = readStyleSelect,
-            comicStyleSelect = this[Keys.ComicStyleSelect] ?: readStyleSelect,
-            shareLayout = this[Keys.ShareLayout] ?: false,
-            readBarStyleFollowPage = this[Keys.ReadBarStyleFollowPage] ?: false,
-            readBarStyle = this[Keys.ReadBarStyle] ?: 0,
-            clickActionTL = this[Keys.ClickActionTL] ?: 2,
-            clickActionTC = this[Keys.ClickActionTC] ?: 2,
-            clickActionTR = this[Keys.ClickActionTR] ?: 1,
-            clickActionML = this[Keys.ClickActionML] ?: 2,
-            clickActionMC = this[Keys.ClickActionMC] ?: 0,
-            clickActionMR = this[Keys.ClickActionMR] ?: 1,
-            clickActionBL = this[Keys.ClickActionBL] ?: 2,
-            clickActionBC = this[Keys.ClickActionBC] ?: 1,
-            clickActionBR = this[Keys.ClickActionBR] ?: 1,
-            fontFolder = this[Keys.FontFolder] ?: "",
-            readMenuBgColor = this[Keys.ReadMenuBgColor] ?: 0,
-            readMenuAccentColor = this[Keys.ReadMenuAccentColor] ?: 0,
-            readMenuContainerColor = this[Keys.ReadMenuContainerColor] ?: 0,
-            readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0,
-            readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0,
-            readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0,
-            readMenuTextColor = this[Keys.ReadMenuTextColor] ?: 0,
-            readMenuTextColorNight = this[Keys.ReadMenuTextColorNight] ?: 0,
-            readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1,
-            readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true,
-            readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0,
-            titleBarIconStyle = this[Keys.TitleBarIconStyle] ?: 0,
-            readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5,
-            readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1,
-            readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0,
-            readMenuFloatingBottomBar = this[Keys.ReadMenuFloatingBottomBar] ?: false,
-            readMenuTopBarBlurMode = this[Keys.ReadMenuTopBarBlurMode] ?: ReadMenuBlurMode.None,
-            readMenuBottomBarBlurMode = this[Keys.ReadMenuBottomBarBlurMode]
-                ?: ReadMenuBlurMode.None,
-            readMenuTopBarLiquidGlassButtons = this[Keys.ReadMenuTopBarLiquidGlassButtons] ?: false,
-            readMenuTopBarTitleCapsule = this[Keys.ReadMenuTopBarTitleCapsule] ?: false,
-            readMenuBottomBarLiquidGlassButtons = this[Keys.ReadMenuBottomBarLiquidGlassButtons]
-                ?: false,
-            readMenuTopBarBlurStyle = this[Keys.ReadMenuTopBarBlurStyle]
-                ?: ReadMenuBlurStyle.Progressive,
-            readMenuBottomBarBlurStyle = this[Keys.ReadMenuBottomBarBlurStyle]
-                ?: ReadMenuBlurStyle.Solid,
-            readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24,
-            readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60,
-            readMenuBlurColor = this[Keys.ReadMenuBlurColor] ?: 0,
-            readMenuBlurColorNight = this[Keys.ReadMenuBlurColorNight] ?: 0,
-            readMenuPaletteStyle = this[Keys.ReadMenuPaletteStyle] ?: "",
-            readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f,
-            readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0,
-            readMenuBorderColor = this[Keys.ReadMenuBorderColor] ?: 0,
-            readMenuBorderColorNight = this[Keys.ReadMenuBorderColorNight] ?: 0,
-            readMenuCustomIcons = this[Keys.ReadMenuCustomIcons] ?: "",
-            titleBarCustomIcons = this[Keys.TitleBarCustomIcons] ?: "",
-            titleBarIconPosition = this[Keys.TitleBarIconPosition] ?: 0,
-            showTitleBarIcons = this[Keys.ShowTitleBarIcons] ?: false,
-            chineseConverterType = this[Keys.ChineseConverterType] ?: 0,
-            showMenuIcon = this[Keys.ShowMenuIcon] ?: true,
+            comicStyleSelect = compatDsValue(Keys.ComicStyleSelect, readStyleSelect),
+            shareLayout = compatDsValue(Keys.ShareLayout, false),
+            readBarStyleFollowPage = compatDsValue(Keys.ReadBarStyleFollowPage, false),
+            readBarStyle = compatDsValue(Keys.ReadBarStyle, 0),
+            clickActionTL = compatDsValue(Keys.ClickActionTL, 2),
+            clickActionTC = compatDsValue(Keys.ClickActionTC, 2),
+            clickActionTR = compatDsValue(Keys.ClickActionTR, 1),
+            clickActionML = compatDsValue(Keys.ClickActionML, 2),
+            clickActionMC = compatDsValue(Keys.ClickActionMC, 0),
+            clickActionMR = compatDsValue(Keys.ClickActionMR, 1),
+            clickActionBL = compatDsValue(Keys.ClickActionBL, 2),
+            clickActionBC = compatDsValue(Keys.ClickActionBC, 1),
+            clickActionBR = compatDsValue(Keys.ClickActionBR, 1),
+            fontFolder = compatDsValue(Keys.FontFolder, ""),
+            readMenuBgColor = compatDsValue(Keys.ReadMenuBgColor, 0),
+            readMenuAccentColor = compatDsValue(Keys.ReadMenuAccentColor, 0),
+            readMenuContainerColor = compatDsValue(Keys.ReadMenuContainerColor, 0),
+            readMenuBgColorNight = compatDsValue(Keys.ReadMenuBgColorNight, 0),
+            readMenuAccentColorNight = compatDsValue(Keys.ReadMenuAccentColorNight, 0),
+            readMenuContainerColorNight = compatDsValue(Keys.ReadMenuContainerColorNight, 0),
+            readMenuTextColor = compatDsValue(Keys.ReadMenuTextColor, 0),
+            readMenuTextColorNight = compatDsValue(Keys.ReadMenuTextColorNight, 0),
+            readMenuColorMode = compatDsValue(Keys.ReadMenuColorMode, 1),
+            readMenuIconShowText = compatDsValue(Keys.ReadMenuIconShowText, true),
+            readMenuIconStyle = compatDsValue(Keys.ReadMenuIconStyle, 0),
+            titleBarIconStyle = compatDsValue(Keys.TitleBarIconStyle, 0),
+            readMenuIconItemsPerRow = compatDsValue(Keys.ReadMenuIconItemsPerRow, 5),
+            readMenuIconRowCount = compatDsValue(Keys.ReadMenuIconRowCount, 1),
+            readMenuBottomCornerRadius = compatDsValue(Keys.ReadMenuBottomCornerRadius, 0),
+            readMenuFloatingBottomBar = compatDsValue(Keys.ReadMenuFloatingBottomBar, false),
+            readMenuTopBarBlurMode = compatDsValue(Keys.ReadMenuTopBarBlurMode, ReadMenuBlurMode.None),
+            readMenuBottomBarBlurMode = compatDsValue(Keys.ReadMenuBottomBarBlurMode, ReadMenuBlurMode.None),
+            readMenuTopBarLiquidGlassButtons = compatDsValue(Keys.ReadMenuTopBarLiquidGlassButtons, false),
+            readMenuTopBarTitleCapsule = compatDsValue(Keys.ReadMenuTopBarTitleCapsule, false),
+            readMenuBottomBarLiquidGlassButtons = compatDsValue(Keys.ReadMenuBottomBarLiquidGlassButtons, false),
+            readMenuTopBarBlurStyle = compatDsValue(Keys.ReadMenuTopBarBlurStyle, ReadMenuBlurStyle.Progressive),
+            readMenuBottomBarBlurStyle = compatDsValue(Keys.ReadMenuBottomBarBlurStyle, ReadMenuBlurStyle.Solid),
+            readMenuBlurRadius = compatDsValue(Keys.ReadMenuBlurRadius, 24),
+            readMenuBlurAlpha = compatDsValue(Keys.ReadMenuBlurAlpha, 60),
+            readMenuBlurColor = compatDsValue(Keys.ReadMenuBlurColor, 0),
+            readMenuBlurColorNight = compatDsValue(Keys.ReadMenuBlurColorNight, 0),
+            readMenuPaletteStyle = compatDsValue(Keys.ReadMenuPaletteStyle, ""),
+            readMenuLensRadius = compatDsValue(Keys.ReadMenuLensRadius, 24f),
+            readMenuBorderWidth = compatDsValue(Keys.ReadMenuBorderWidth, 0),
+            readMenuBorderColor = compatDsValue(Keys.ReadMenuBorderColor, 0),
+            readMenuBorderColorNight = compatDsValue(Keys.ReadMenuBorderColorNight, 0),
+            readMenuCustomIcons = compatDsValue(Keys.ReadMenuCustomIcons, ""),
+            titleBarCustomIcons = compatDsValue(Keys.TitleBarCustomIcons, ""),
+            titleBarIconPosition = compatDsValue(Keys.TitleBarIconPosition, 0),
+            showTitleBarIcons = compatDsValue(Keys.ShowTitleBarIcons, false),
+            chineseConverterType = compatDsValue(Keys.ChineseConverterType, 0),
+            showMenuIcon = compatDsValue(Keys.ShowMenuIcon, true),
         )
     }
 

+ 15 - 0
app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt

@@ -0,0 +1,15 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RssStarDao
+import io.legado.app.data.entities.RssStar
+import kotlinx.coroutines.flow.Flow
+
+class RssFavoriteRepository(private val dao: RssStarDao) {
+    fun observeGroups(): Flow<List<String>> = dao.flowGroups()
+    fun observeAll(): Flow<List<RssStar>> = dao.liveAll()
+    fun observeGroup(group: String): Flow<List<RssStar>> = dao.flowByGroup(group)
+    suspend fun update(star: RssStar) = dao.update(star)
+    suspend fun delete(star: RssStar) = dao.delete(star.origin, star.link)
+    suspend fun deleteGroup(group: String) = dao.deleteByGroup(group)
+    suspend fun deleteAll() = dao.deleteAll()
+}

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

@@ -0,0 +1,14 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RuleSubDao
+import io.legado.app.data.entities.RuleSub
+import kotlinx.coroutines.flow.Flow
+
+class RuleSubscriptionRepository(private val dao: RuleSubDao) {
+    fun observeAll(): Flow<List<RuleSub>> = dao.flowAll()
+    suspend fun findByUrl(url: String): RuleSub? = dao.findByUrl(url)
+    suspend fun insert(rule: RuleSub) = dao.insert(rule)
+    suspend fun delete(rule: RuleSub) = dao.delete(rule)
+    suspend fun all(): List<RuleSub> = dao.all
+    suspend fun update(vararg rules: RuleSub) = dao.update(*rules)
+}

+ 35 - 4
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -3,11 +3,12 @@ package io.legado.app.data.repository
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.SearchContentHistory
+import io.legado.app.data.dao.SearchContentHistoryDao
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.utils.ChineseUtils
 import kotlinx.coroutines.Dispatchers
@@ -17,7 +18,37 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
 
-class SearchContentRepository {
+class SearchContentRepository(
+    private val titleModeProvider: () -> Int = { 0 },
+    private val historyDao: SearchContentHistoryDao? = null,
+) {
+
+    private val effectiveHistoryDao: SearchContentHistoryDao
+        get() = historyDao ?: appDb.searchContentHistoryDao
+
+    fun observeHistory(book: Book?, onlyThisBook: Boolean): Flow<List<SearchContentHistory>> =
+        if (onlyThisBook && book != null) {
+            effectiveHistoryDao.getByBook(book.name, book.author)
+        } else {
+            effectiveHistoryDao.getAll()
+        }
+
+    suspend fun saveHistory(book: Book, query: String) {
+        val history = effectiveHistoryDao.get(book.name, book.author, query)
+            ?: SearchContentHistory(bookName = book.name, bookAuthor = book.author, query = query)
+        history.time = System.currentTimeMillis()
+        effectiveHistoryDao.insert(history)
+    }
+
+    suspend fun deleteHistory(id: Long) = effectiveHistoryDao.delete(id)
+
+    suspend fun clearHistory(book: Book?, onlyThisBook: Boolean) {
+        if (onlyThisBook && book != null) {
+            effectiveHistoryDao.deleteByBook(book.name, book.author)
+        } else {
+            effectiveHistoryDao.deleteAll()
+        }
+    }
 
     private var lastSearchResults: List<SearchResult>? = null
     private var lastQueryKey: String? = null
@@ -126,7 +157,7 @@ class SearchContentRepository {
         replaceEnabled: Boolean,
         regexReplace: Boolean
     ): String {
-        return "$bookUrl-$query-$replaceEnabled-$regexReplace-${ReadBookConfig.titleMode}"
+        return "$bookUrl-$query-$replaceEnabled-$regexReplace-${titleModeProvider()}"
     }
 
     private suspend fun searchChapter(
@@ -153,7 +184,7 @@ class SearchContentRepository {
             includeTitle = false,
             useReplace = replaceEnabled
         )
-        val includeTitle = ReadBookConfig.titleMode != 2 ||
+        val includeTitle = titleModeProvider() != 2 ||
                 chapter.isVolume ||
                 bodyContent.textList.isEmpty()
         val mContent = if (includeTitle) {

+ 55 - 99
app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt

@@ -1,13 +1,11 @@
 package io.legado.app.data.repository
 
 import android.content.Context
-import androidx.core.content.edit
 import androidx.datastore.core.DataMigration
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.SharedPreferencesMigration
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
@@ -16,13 +14,17 @@ import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import io.legado.app.constant.PreferKey
-import io.legado.app.utils.defaultSharedPreferences
-import io.legado.app.utils.removePref
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.localDataStore
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsValue
+import io.legado.app.help.config.rawPrefValue
+import io.legado.app.help.config.setPrefValue
+import java.io.IOException
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
-import java.io.IOException
 
 val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
     name = "settings",
@@ -32,11 +34,41 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
                 context,
                 "${context.packageName}_preferences"
             ),
+            LocalUiStatusMigration(context),
             ShowBrightnessViewMigration,
         )
     }
 )
 
+internal class LocalUiStatusMigration(
+    private val context: Context,
+) : DataMigration<Preferences> {
+
+    override suspend fun shouldMigrate(currentData: Preferences): Boolean =
+        currentData[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] != true
+
+    override suspend fun migrate(currentData: Preferences): Preferences {
+        val localPreferences = context.localDataStore.data
+            .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+            .first()
+        return mergeMissingLocalPreferences(currentData, localPreferences)
+    }
+
+    override suspend fun cleanUp() = Unit
+}
+
+internal fun mergeMissingLocalPreferences(
+    currentData: Preferences,
+    localPreferences: Preferences,
+): Preferences = currentData.toMutablePreferences().apply {
+    localPreferences.asMap().forEach { (key, value) ->
+        if (currentData.rawPrefValue(key.name) == null) {
+            setPrefValue(key.name, value)
+        }
+    }
+    this[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] = true
+}
+
 internal object ShowBrightnessViewMigration : DataMigration<Preferences> {
 
     private val booleanKey = booleanPreferencesKey(PreferKey.showBrightnessView)
@@ -65,30 +97,23 @@ internal object ShowBrightnessViewMigration : DataMigration<Preferences> {
 
 /**
  * 设置仓储
- * 以 DataStore 为唯一写入源,读取以 DataStore 为准。
- * SP 同步由 PrefDelegate 双写保证(阶段 1),此处不再回写 SP。
+ * 以 DataStore 为唯一持久化源,通过 [AppConfigStore] 的有效快照统一读写。
  */
-class SettingsRepository(private val context: Context) {
-
-    private val dataStore = context.dataStore
-
-    fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
-        return dataStore.data
-            .catch { exception ->
-                if (exception is IOException) {
-                    emit(emptyPreferences())
-                } else {
-                    throw exception
-                }
-            }
-            .map { preferences ->
-                preferences[key] ?: defaultValue
+class SettingsRepository {
+
+    fun <T : Any> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> =
+        AppConfigStore.preferencesFlow.map { it.compatDsValue(key, defaultValue) }
+
+    suspend fun <T : Any> updatePreference(key: Preferences.Key<T>, value: T) {
+        when (value) {
+            is String -> AppConfigStore.putString(key.name, value)
+            is Int -> AppConfigStore.putInt(key.name, value)
+            is Boolean -> AppConfigStore.putBoolean(key.name, value)
+            is Long -> AppConfigStore.putLong(key.name, value)
+            is Float -> AppConfigStore.putFloat(key.name, value)
+            is Set<*> -> @Suppress("UNCHECKED_CAST") {
+                AppConfigStore.putStringSet(key.name, value as Set<String>)
             }
-    }
-
-    suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
-        dataStore.edit { preferences ->
-            preferences[key] = value
         }
     }
 
@@ -97,14 +122,10 @@ class SettingsRepository(private val context: Context) {
         getPreference(stringPreferencesKey(key), defaultValue)
 
     suspend fun putString(key: String, value: String) =
-        updatePreference(stringPreferencesKey(key), value)
+        AppConfigStore.putString(key, value)
 
     suspend fun putStrings(values: Map<String, String>) {
-        dataStore.edit { preferences ->
-            values.forEach { (key, value) ->
-                preferences[stringPreferencesKey(key)] = value
-            }
-        }
+        AppConfigStore.putAll(values)
     }
 
     // Int 类型的快捷访问
@@ -142,73 +163,8 @@ class SettingsRepository(private val context: Context) {
     suspend fun putStringSet(key: String, value: Set<String>) =
         updatePreference(stringSetPreferencesKey(key), value)
 
-    // 批量从 Map 恢复到 DataStore (用于兼容 Restore 逻辑)
-    suspend fun batchPutFromMap(map: Map<String, *>) {
-        dataStore.edit { preferences ->
-            map.forEach { (key, value) ->
-                when (value) {
-                    is String -> preferences[stringPreferencesKey(key)] = value
-                    is Int -> preferences[intPreferencesKey(key)] = value
-                    is Boolean -> preferences[booleanPreferencesKey(key)] = value
-                    is Long -> preferences[longPreferencesKey(key)] = value
-                    is Float -> preferences[floatPreferencesKey(key)] = value
-                    is Set<*> -> {
-                        @Suppress("UNCHECKED_CAST")
-                        preferences[stringSetPreferencesKey(key)] = value as Set<String>
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * DataStore 迁移后同步校验:确保 SP 中的主题配置值未被迁移过程丢失。
-     *
-     * SharedPreferencesMigration 在首次访问 DataStore 时运行,将 SP 值复制到 DataStore。
-     * 在某些场景下(进程被杀、ROM 行为等),迁移可能导致 SP 中的值丢失。
-     * 此方法从 DataStore 读取关键主题配置,如果 SP 中缺失则补写回去。
-     */
-    suspend fun postMigrationSync() {
-        val prefs = dataStore.data.first()
-        val sp = context.defaultSharedPreferences
-        val themeKeys = listOf(
-            PreferKey.composeEngine,
-            PreferKey.appTheme,
-            PreferKey.themeMode,
-            PreferKey.paletteStyle,
-            PreferKey.materialVersion,
-            PreferKey.customContrast,
-            PreferKey.customMode,
-        )
-        var needsWrite = false
-        val editor = mutableMapOf<String, String>()
-        for (key in themeKeys) {
-            if (!sp.contains(key)) {
-                val dsValue = runCatching { prefs[stringPreferencesKey(key)] }.getOrNull()
-                if (dsValue != null) {
-                    editor[key] = dsValue
-                    needsWrite = true
-                }
-            }
-        }
-        if (needsWrite) {
-            sp.edit {
-                editor.forEach { (key, value) ->
-                    putString(key, value)
-                }
-            }
-        }
-    }
-
     // 移除配置
     suspend fun remove(key: String) {
-        dataStore.edit { preferences ->
-            preferences.remove(stringPreferencesKey(key))
-            preferences.remove(intPreferencesKey(key))
-            preferences.remove(booleanPreferencesKey(key))
-            preferences.remove(longPreferencesKey(key))
-            preferences.remove(floatPreferencesKey(key))
-        }
-        context.removePref(key)
+        AppConfigStore.remove(key)
     }
 }

+ 63 - 5
app/src/main/java/io/legado/app/di/appModule.kt

@@ -6,7 +6,6 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.decode.SvgDecoder
 import io.legado.app.data.AppDatabase
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiMemoryRepository
@@ -15,7 +14,11 @@ import io.legado.app.data.repository.AiPromptPresetRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.AppStartupRepository
+import io.legado.app.data.repository.AppLocaleRepository
+import io.legado.app.data.repository.AppShellSettingsRepository
+import io.legado.app.data.repository.AppUiConfigurationRepository
 import io.legado.app.data.repository.BackupRestoreRepository
+import io.legado.app.data.repository.BackupSettingsRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookContentProcessRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
@@ -26,18 +29,23 @@ import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookshelfRepository
+import io.legado.app.data.repository.BookshelfSettingsRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
 import io.legado.app.data.repository.CoverAlbumRepository
+import io.legado.app.data.repository.CoverSettingsRepository
 import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DictionaryRepositoryImpl
 import io.legado.app.data.repository.DirectLinkUploadRepository
+import io.legado.app.data.repository.DownloadCacheSettingsRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
+import io.legado.app.data.repository.LabSettingsRepository
 import io.legado.app.data.repository.LocalBookRepository
+import io.legado.app.data.repository.OtherSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.CloudTtsEngineRepository
@@ -49,12 +57,16 @@ import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.data.repository.RssRepository
+import io.legado.app.data.repository.RssFavoriteRepository
+import io.legado.app.data.repository.RuleSubscriptionRepository
 import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.data.repository.ThemeSettingsRepository
 import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.TranslationCacheRepositoryImpl
+import io.legado.app.data.repository.TranslationSettingsRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
@@ -66,7 +78,11 @@ import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.AppStartupGateway
+import io.legado.app.domain.gateway.AppLocaleGateway
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppUiConfigurationGateway
 import io.legado.app.domain.gateway.BackupRestoreGateway
+import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
@@ -74,17 +90,26 @@ import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
+import io.legado.app.domain.gateway.BookshelfSettingsGateway
 import io.legado.app.domain.gateway.CoverAlbumGateway
+import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.gateway.LocalBookGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.ReadAloudVoiceGateway
 import io.legado.app.domain.gateway.CloudTtsEngineGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
+import io.legado.app.domain.gateway.TranslationSettingsGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
@@ -134,6 +159,7 @@ import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.config.ThemePackageManager
+import io.legado.app.ui.config.readConfig.ApplyReadSettingUseCase
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.ui.about.AboutViewModel
@@ -183,9 +209,12 @@ import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
 import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
+import io.legado.app.ui.config.customTheme.CustomThemeViewModel
 import io.legado.app.ui.config.readConfig.ReadConfigViewModel
 import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
 import io.legado.app.ui.config.themeManage.ThemeManageViewModel
+import io.legado.app.ui.config.labConfig.LabConfigViewModel
+import io.legado.app.ui.config.translation.TranslationConfigViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
@@ -220,6 +249,9 @@ val appModule = module {
     single { get<AppDatabase>().bookChapterDao }
     single { get<AppDatabase>().bookGroupDao }
     single { get<AppDatabase>().bookSourceDao }
+    single { get<AppDatabase>().searchContentHistoryDao }
+    single { get<AppDatabase>().rssStarDao }
+    single { get<AppDatabase>().ruleSubDao }
 
     singleOf(::ReadRecordRepository)
     single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
@@ -229,17 +261,37 @@ val appModule = module {
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
     singleOf(::TxtTocRuleRepository)
-    singleOf(::SearchContentRepository)
+    single {
+        SearchContentRepository(
+            titleModeProvider = { io.legado.app.help.config.ReadBookConfig.titleMode },
+            historyDao = get(),
+        )
+    }
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
+    single<AppLocaleGateway> { AppLocaleRepository() }
+    single<AppShellSettingsGateway> { AppShellSettingsRepository() }
+    single<ThemeSettingsGateway> { ThemeSettingsRepository() }
+    single<AppUiConfigurationGateway> {
+        AppUiConfigurationRepository(get(), get(), get())
+    }
+    single<OtherSettingsGateway> { OtherSettingsRepository() }
+    single<DownloadCacheSettingsGateway> { DownloadCacheSettingsRepository() }
+    single<CoverSettingsGateway> { CoverSettingsRepository() }
+    single<BackupSettingsGateway> { BackupSettingsRepository() }
+    single<LabSettingsGateway> { LabSettingsRepository() }
+    single<TranslationSettingsGateway> { TranslationSettingsRepository() }
+    single<BookshelfSettingsGateway> { BookshelfSettingsRepository() }
     singleOf(::ReadSettingsRepository)
+    single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
     single<HttpTtsEngineGateway> { HttpTtsEngineRepository(get()) }
+    single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
+    singleOf(::ApplyReadSettingUseCase)
     singleOf(::HighlightRuleRepository)
     singleOf(::ReadStyleRepository)
     singleOf(::ReadBookStyleConfigRepository)
-    singleOf(::LocalPreferencesRepository)
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)
@@ -305,6 +357,8 @@ val appModule = module {
     single<ExploreRepository> { get<ExploreRepositoryImpl>() }
     single<ExploreBooksGateway> { get<ExploreRepositoryImpl>() }
     singleOf(::RssRepository)
+    singleOf(::RssFavoriteRepository)
+    singleOf(::RuleSubscriptionRepository)
     single {
         SearchRepositoryImpl(get())
     }
@@ -363,7 +417,8 @@ val appModule = module {
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
-    viewModelOf(::OtherConfigViewModel)
+    viewModel { OtherConfigViewModel(get(), get(), get()) }
+    viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)
     viewModelOf(::CoverAlbumManageViewModel)
@@ -371,6 +426,8 @@ val appModule = module {
     viewModelOf(::ThemeConfigViewModel)
     viewModelOf(::ThemeManageViewModel)
     viewModelOf(::BackupConfigViewModel)
+    viewModelOf(::LabConfigViewModel)
+    viewModelOf(::TranslationConfigViewModel)
     viewModelOf(::AiConfigViewModel)
     viewModelOf(::AiSummaryConfigViewModel)
     viewModelOf(::AiPromptConfigViewModel)
@@ -475,6 +532,7 @@ val appModule = module {
             aiProfileGateway = get(),
             syncReadAloudVoicesUseCase = get(),
             readAloudSessionStore = get(),
+            replaceRuleRepository = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)
@@ -489,6 +547,7 @@ val appModule = module {
         BookshelfManageScreenViewModel(
             application = get(),
             bookRepository = get(),
+            bookSourceRepository = get(),
             bookGroupRepository = get(),
             searchRepository = get(),
             bookshelfManageScreenConfig = get(),
@@ -519,4 +578,3 @@ val appModule = module {
         )
     }
 }
-

+ 14 - 0
app/src/main/java/io/legado/app/domain/gateway/AppLocaleGateway.kt

@@ -0,0 +1,14 @@
+package io.legado.app.domain.gateway
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface AppLocaleGateway {
+    val currentLanguage: String
+    val language: StateFlow<String>
+
+    fun setLanguage(language: String)
+
+    fun synchronizeFromPlatform()
+
+    fun migrateLegacyLanguage(language: String)
+}

+ 51 - 0
app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt

@@ -0,0 +1,51 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.AppShellSettings
+import kotlinx.coroutines.flow.Flow
+
+interface AppShellSettingsGateway {
+    val currentSettings: AppShellSettings
+    val settings: Flow<AppShellSettings>
+    suspend fun update(update: AppShellSettingsUpdate)
+    suspend fun updateAll(updates: List<AppShellSettingsUpdate>)
+}
+
+sealed interface AppShellSettingsUpdate {
+    data class ThemeMode(val value: String) : AppShellSettingsUpdate
+    data class FontScale(val value: Int) : AppShellSettingsUpdate
+    data class ComposeEngine(val value: String) : AppShellSettingsUpdate
+    data class MainNavigationOrder(val value: String) : AppShellSettingsUpdate
+    data class BooleanValue(
+        val setting: AppShellBooleanSetting,
+        val value: Boolean,
+    ) : AppShellSettingsUpdate
+    data class StringValue(
+        val setting: AppShellStringSetting,
+        val value: String,
+    ) : AppShellSettingsUpdate
+}
+
+enum class AppShellBooleanSetting {
+    ShowHome,
+    ShowDiscovery,
+    ShowRss,
+    ShowStatusBar,
+    SwipeAnimation,
+    PredictiveBack,
+    ShowBottomView,
+    UseFloatingBottomBar,
+    UseFloatingBottomBarLiquidGlass,
+    NavExtended,
+}
+
+enum class AppShellStringSetting {
+    TabletInterface,
+    LabelVisibilityMode,
+    DefaultHomePage,
+    NavIconHome,
+    NavIconBookshelf,
+    NavIconExplore,
+    NavIconRss,
+    NavIconMy,
+    LauncherIcon,
+}

+ 9 - 0
app/src/main/java/io/legado/app/domain/gateway/AppUiConfigurationGateway.kt

@@ -0,0 +1,9 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.AppUiConfiguration
+import kotlinx.coroutines.flow.StateFlow
+
+interface AppUiConfigurationGateway {
+    val currentConfiguration: AppUiConfiguration
+    val configuration: StateFlow<AppUiConfiguration>
+}

+ 23 - 0
app/src/main/java/io/legado/app/domain/gateway/BackupSettingsGateway.kt

@@ -0,0 +1,23 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.BackupSettings
+import kotlinx.coroutines.flow.Flow
+
+interface BackupSettingsGateway {
+    val settings: Flow<BackupSettings>
+    suspend fun update(update: BackupSettingsUpdate)
+    suspend fun updateAll(updates: List<BackupSettingsUpdate>)
+}
+
+sealed interface BackupSettingsUpdate {
+    data class WebDavUrl(val value: String) : BackupSettingsUpdate
+    data class WebDavCredentials(val account: String, val password: String) : BackupSettingsUpdate
+    data class WebDavDir(val value: String) : BackupSettingsUpdate
+    data class WebDavDeviceName(val value: String) : BackupSettingsUpdate
+    data class SyncBookProgress(val value: Boolean) : BackupSettingsUpdate
+    data class SyncBookProgressPlus(val value: Boolean) : BackupSettingsUpdate
+    data class AutoCheckNewBackup(val value: Boolean) : BackupSettingsUpdate
+    data class OnlyLatestBackup(val value: Boolean) : BackupSettingsUpdate
+    data class BackupSyncMode(val value: String) : BackupSettingsUpdate
+    data class BackupPath(val value: String?) : BackupSettingsUpdate
+}

+ 66 - 0
app/src/main/java/io/legado/app/domain/gateway/BookshelfSettingsGateway.kt

@@ -0,0 +1,66 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.BookshelfSettings
+import kotlinx.coroutines.flow.Flow
+
+interface BookshelfSettingsGateway {
+    val currentSettings: BookshelfSettings
+    val settings: Flow<BookshelfSettings>
+    suspend fun update(update: BookshelfSettingsUpdate)
+}
+
+sealed interface BookshelfSettingsUpdate {
+    data class BooleanValue(val setting: BookshelfBooleanSetting, val value: Boolean) : BookshelfSettingsUpdate
+    data class IntValue(val setting: BookshelfIntSetting, val value: Int) : BookshelfSettingsUpdate
+    data class SaveTabPosition(val value: Long) : BookshelfSettingsUpdate
+}
+
+enum class BookshelfBooleanSetting {
+    ShowUnread,
+    ShowUnreadNew,
+    ShowTip,
+    ShowBookCount,
+    ShowLastUpdateTime,
+    ShowBookIntro,
+    ShowIntro,
+    ShowTag,
+    ShowLatestChapter,
+    ShowWaitUpCount,
+    ShowFastScroller,
+    ShowExpandButton,
+    LayoutCompact,
+    ShowDivider,
+    TitleSmallFont,
+    TitleCenter,
+    CoverShadow,
+    SearchActionDirectToSearch,
+    AutoRefresh,
+}
+
+enum class BookshelfIntSetting {
+    BookGroupStyle,
+    Sort,
+    SortOrder,
+    IntroMaxLines,
+    RefreshingLimit,
+    LayoutModePortrait,
+    LayoutGridPortrait,
+    LayoutModeLandscape,
+    LayoutGridLandscape,
+    LayoutListPortrait,
+    LayoutListLandscape,
+    FolderLayoutModePortrait,
+    FolderLayoutGridPortrait,
+    FolderLayoutModeLandscape,
+    FolderLayoutGridLandscape,
+    FolderLayoutListPortrait,
+    FolderLayoutListLandscape,
+    GridLayout,
+    TitleMaxLines,
+    CardColor,
+    CardColorDark,
+    GroupListStyle,
+    GroupCoverCount,
+    ListCoverWidth,
+    GridCoverWidth,
+}

+ 23 - 0
app/src/main/java/io/legado/app/domain/gateway/CoverSettingsGateway.kt

@@ -0,0 +1,23 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.CoverSettings
+import kotlinx.coroutines.flow.Flow
+
+interface CoverSettingsGateway {
+    val settings: Flow<CoverSettings>
+    suspend fun update(update: CoverSettingsUpdate)
+}
+
+sealed interface CoverSettingsUpdate {
+    data class LoadOnlyOnWifi(val value: Boolean) : CoverSettingsUpdate
+    data class UseDefaultCover(val value: Boolean) : CoverSettingsUpdate
+    data class ShowShadow(val value: Boolean) : CoverSettingsUpdate
+    data class ShowStroke(val value: Boolean) : CoverSettingsUpdate
+    data class UseDefaultColor(val value: Boolean) : CoverSettingsUpdate
+    data class TextColor(val value: Int, val dark: Boolean) : CoverSettingsUpdate
+    data class ShadowColor(val value: Int, val dark: Boolean) : CoverSettingsUpdate
+    data class ShowName(val value: Boolean, val dark: Boolean) : CoverSettingsUpdate
+    data class ShowAuthor(val value: Boolean, val dark: Boolean) : CoverSettingsUpdate
+    data class InfoOrientation(val value: String) : CoverSettingsUpdate
+    data class ExploreFilterState(val value: Int) : CoverSettingsUpdate
+}

+ 19 - 0
app/src/main/java/io/legado/app/domain/gateway/DownloadCacheSettingsGateway.kt

@@ -0,0 +1,19 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.DownloadCacheSettings
+import kotlinx.coroutines.flow.Flow
+
+interface DownloadCacheSettingsGateway {
+    val settings: Flow<DownloadCacheSettings>
+    suspend fun update(update: DownloadCacheSettingsUpdate)
+}
+
+sealed interface DownloadCacheSettingsUpdate {
+    data class BitmapCacheSize(val value: Int) : DownloadCacheSettingsUpdate
+    data class ImageRetainNum(val value: Int) : DownloadCacheSettingsUpdate
+    data class PreDownloadNum(val value: Int) : DownloadCacheSettingsUpdate
+    data class ThreadCount(val value: Int) : DownloadCacheSettingsUpdate
+    data class CacheBookThreadCount(val value: Int) : DownloadCacheSettingsUpdate
+    data class UserAgent(val value: String) : DownloadCacheSettingsUpdate
+    data class CronetEnabled(val value: Boolean) : DownloadCacheSettingsUpdate
+}

+ 15 - 0
app/src/main/java/io/legado/app/domain/gateway/LabSettingsGateway.kt

@@ -0,0 +1,15 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.LabSettings
+import kotlinx.coroutines.flow.Flow
+
+interface LabSettingsGateway {
+    val settings: Flow<LabSettings>
+    suspend fun update(update: LabSettingsUpdate)
+}
+
+sealed interface LabSettingsUpdate {
+    data class Enabled(val value: Boolean) : LabSettingsUpdate
+    data class EInkDisplay(val value: Boolean) : LabSettingsUpdate
+    data class EyeProtection(val value: Boolean) : LabSettingsUpdate
+}

+ 30 - 0
app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt

@@ -0,0 +1,30 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.OtherSettings
+import kotlinx.coroutines.flow.Flow
+
+interface OtherSettingsGateway {
+    val settings: Flow<OtherSettings>
+    suspend fun update(update: OtherSettingsUpdate)
+}
+
+sealed interface OtherSettingsUpdate {
+    data class UpdateToVariant(val value: String) : OtherSettingsUpdate
+    data class AutoCheckUpdateOnStart(val value: Boolean) : OtherSettingsUpdate
+    data class WebServiceAutoStart(val value: Boolean) : OtherSettingsUpdate
+    data class AutoRefresh(val value: Boolean) : OtherSettingsUpdate
+    data class DefaultToRead(val value: Boolean) : OtherSettingsUpdate
+    data class FirebaseEnable(val value: Boolean) : OtherSettingsUpdate
+    data class DefaultBookTreeUri(val value: String?) : OtherSettingsUpdate
+    data class AntiAlias(val value: Boolean) : OtherSettingsUpdate
+    data class ReplaceEnableDefault(val value: Boolean) : OtherSettingsUpdate
+    data class AutoClearExpired(val value: Boolean) : OtherSettingsUpdate
+    data class ShowAddToShelfAlert(val value: Boolean) : OtherSettingsUpdate
+    data class ShowMangaUi(val value: Boolean) : OtherSettingsUpdate
+    data class WebServiceWakeLock(val value: Boolean) : OtherSettingsUpdate
+    data class SourceEditMaxLine(val value: Int) : OtherSettingsUpdate
+    data class WebPort(val value: Int) : OtherSettingsUpdate
+    data class ProcessText(val value: Boolean) : OtherSettingsUpdate
+    data class RecordLog(val value: Boolean) : OtherSettingsUpdate
+    data class RecordHeapDump(val value: Boolean) : OtherSettingsUpdate
+}

+ 25 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt

@@ -0,0 +1,25 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import kotlinx.coroutines.flow.Flow
+
+interface ReadAloudSettingsGateway {
+    val currentSettings: ReadAloudSettings
+    val settings: Flow<ReadAloudSettings>
+    suspend fun update(update: ReadAloudSettingsUpdate)
+}
+
+sealed interface ReadAloudSettingsUpdate {
+    data class IgnoreAudioFocus(val value: Boolean) : ReadAloudSettingsUpdate
+    data class MediaButtonOnExit(val value: Boolean) : ReadAloudSettingsUpdate
+    data class ReadAloudByMediaButton(val value: Boolean) : ReadAloudSettingsUpdate
+    data class PauseWhilePhoneCalls(val value: Boolean) : ReadAloudSettingsUpdate
+    data class ReadAloudWakeLock(val value: Boolean) : ReadAloudSettingsUpdate
+    data class MediaButtonPerNext(val value: Boolean) : ReadAloudSettingsUpdate
+    data class ReadAloudByPage(val value: Boolean) : ReadAloudSettingsUpdate
+    data class SystemMediaControlCompatibility(val value: Boolean) : ReadAloudSettingsUpdate
+    data class StreamAudio(val value: Boolean) : ReadAloudSettingsUpdate
+    data class Timer(val value: Int) : ReadAloudSettingsUpdate
+    data class FollowSystem(val value: Boolean) : ReadAloudSettingsUpdate
+    data class SpeechRate(val value: Int) : ReadAloudSettingsUpdate
+}

+ 50 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt

@@ -0,0 +1,50 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ReadSettings
+import kotlinx.coroutines.flow.Flow
+
+interface ReadSettingsGateway {
+    val settings: Flow<ReadSettings>
+
+    suspend fun update(update: ReadSettingsUpdate)
+}
+
+sealed interface ReadSettingsUpdate {
+    data class ScreenOrientation(val value: String) : ReadSettingsUpdate
+    data class KeepLight(val value: String) : ReadSettingsUpdate
+    data class HideStatusBar(val value: Boolean) : ReadSettingsUpdate
+    data class HideNavigationBar(val value: Boolean) : ReadSettingsUpdate
+    data class PaddingDisplayCutouts(val value: Boolean) : ReadSettingsUpdate
+    data class TitleBarMode(val value: String) : ReadSettingsUpdate
+    data class ReadMenuBlurAlpha(val value: Int) : ReadSettingsUpdate
+    data class ReadBodyToLh(val value: Boolean) : ReadSettingsUpdate
+    data class DefaultSourceChangeAll(val value: Boolean) : ReadSettingsUpdate
+    data class TextFullJustify(val value: Boolean) : ReadSettingsUpdate
+    data class TextBottomJustify(val value: Boolean) : ReadSettingsUpdate
+    data class AdaptSpecialStyle(val value: Boolean) : ReadSettingsUpdate
+    data class UseZhLayout(val value: Boolean) : ReadSettingsUpdate
+    data class ShowBrightnessView(val value: String) : ReadSettingsUpdate
+    data class BrightnessVwPos(val value: String) : ReadSettingsUpdate
+    data class UseUnderline(val value: Boolean) : ReadSettingsUpdate
+    data class ReadSliderMode(val value: String) : ReadSettingsUpdate
+    data class DoubleHorizontalPage(val value: String) : ReadSettingsUpdate
+    data class ProgressBarBehavior(val value: String) : ReadSettingsUpdate
+    data class MouseWheelPage(val value: Boolean) : ReadSettingsUpdate
+    data class VolumeKeyPage(val value: Boolean) : ReadSettingsUpdate
+    data class VolumeKeyPageOnPlay(val value: Boolean) : ReadSettingsUpdate
+    data class KeyPageOnLongPress(val value: Boolean) : ReadSettingsUpdate
+    data class PageTouchSlop(val value: Int) : ReadSettingsUpdate
+    data class SliderVibrator(val value: Boolean) : ReadSettingsUpdate
+    data class SelectVibrator(val value: Boolean) : ReadSettingsUpdate
+    data class AutoChangeSource(val value: Boolean) : ReadSettingsUpdate
+    data class AutoSuggestDayNight(val value: Boolean) : ReadSettingsUpdate
+    data class SelectText(val value: Boolean) : ReadSettingsUpdate
+    data class NoAnimScrollPage(val value: Boolean) : ReadSettingsUpdate
+    data class ClickImgWay(val value: String) : ReadSettingsUpdate
+    data class OptimizeRender(val value: Boolean) : ReadSettingsUpdate
+    data class DisableReturnKey(val value: Boolean) : ReadSettingsUpdate
+    data class ShowReadTitleAddition(val value: Boolean) : ReadSettingsUpdate
+    data class ShowMenuIcon(val value: Boolean) : ReadSettingsUpdate
+    data class PageKeys(val previous: String, val next: String) : ReadSettingsUpdate
+    data class FontFolder(val value: String) : ReadSettingsUpdate
+}

+ 98 - 0
app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt

@@ -0,0 +1,98 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ThemeSettings
+import kotlinx.coroutines.flow.Flow
+
+interface ThemeSettingsGateway {
+    val currentSettings: ThemeSettings
+    val settings: Flow<ThemeSettings>
+    suspend fun update(update: ThemeSettingsUpdate)
+    suspend fun updateAll(updates: List<ThemeSettingsUpdate>)
+}
+
+sealed interface ThemeSettingsUpdate {
+    data class AppTheme(val value: String) : ThemeSettingsUpdate
+    data class PureBlack(val value: Boolean) : ThemeSettingsUpdate
+    data class PaletteStyle(val value: String) : ThemeSettingsUpdate
+    data class MaterialVersion(val value: String) : ThemeSettingsUpdate
+    data class CustomContrast(val value: String) : ThemeSettingsUpdate
+    data class DeepPersonalization(val value: Boolean) : ThemeSettingsUpdate
+    data class CustomColor(val slot: ThemeColorSlot, val value: Int) : ThemeSettingsUpdate
+    data class AppFontPath(val value: String?) : ThemeSettingsUpdate
+    data class CustomPrimary(val value: Int) : ThemeSettingsUpdate
+    data class CustomNightPrimary(val value: Int) : ThemeSettingsUpdate
+    data class BooleanValue(
+        val setting: ThemeBooleanSetting,
+        val value: Boolean,
+    ) : ThemeSettingsUpdate
+    data class IntValue(
+        val setting: ThemeIntSetting,
+        val value: Int,
+    ) : ThemeSettingsUpdate
+    data class FloatValue(
+        val setting: ThemeFloatSetting,
+        val value: Float,
+    ) : ThemeSettingsUpdate
+    data class StringValue(
+        val setting: ThemeStringSetting,
+        val value: String?,
+    ) : ThemeSettingsUpdate
+}
+
+enum class ThemeBooleanSetting {
+    UseMiuixMonet,
+    EnableBlur,
+    EnableProgressiveBlur,
+    UseFlexibleTopAppBar,
+    BookInfoFollowCoverColor,
+    EnableItemDivider,
+    EyeProtectionEnabled,
+    EyeProtectionSchedule,
+    ShowRefactorTip,
+    EnableCustomTagColors,
+}
+
+enum class ThemeIntSetting {
+    ContainerOpacity,
+    TopBarOpacity,
+    BottomBarOpacity,
+    TopBarBlurRadius,
+    BottomBarBlurRadius,
+    TopBarBlurAlpha,
+    BottomBarBlurAlpha,
+    BackgroundImageBlurring,
+    BackgroundImageDarkBlurring,
+    ItemDividerColor,
+    ColorTemperature,
+}
+
+enum class ThemeFloatSetting {
+    BottomBarLensRadius,
+    ItemDividerWidth,
+    ItemDividerLength,
+}
+
+enum class ThemeStringSetting {
+    BookInfoNetworkCoverBackground,
+    BookInfoDefaultCoverBackground,
+    BackgroundImageLight,
+    BackgroundImageDark,
+    EyeProtectionStartTime,
+    EyeProtectionEndTime,
+    CustomTagColorsJson,
+}
+
+enum class ThemeColorSlot {
+    Primary,
+    Secondary,
+    PrimaryText,
+    SecondaryText,
+    Background,
+    LabelContainer,
+    PrimaryNight,
+    SecondaryNight,
+    PrimaryTextNight,
+    SecondaryTextNight,
+    BackgroundNight,
+    LabelContainerNight,
+}

+ 15 - 0
app/src/main/java/io/legado/app/domain/gateway/TranslationSettingsGateway.kt

@@ -0,0 +1,15 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.TranslationSettings
+import kotlinx.coroutines.flow.Flow
+
+interface TranslationSettingsGateway {
+    val settings: Flow<TranslationSettings>
+    suspend fun update(update: TranslationSettingsUpdate)
+}
+
+sealed interface TranslationSettingsUpdate {
+    data class Provider(val value: String) : TranslationSettingsUpdate
+    data class TargetLanguage(val value: String) : TranslationSettingsUpdate
+    data class MaxCharsPerChunk(val value: Int) : TranslationSettingsUpdate
+}

+ 27 - 0
app/src/main/java/io/legado/app/domain/model/settings/AppShellSettings.kt

@@ -0,0 +1,27 @@
+package io.legado.app.domain.model.settings
+
+data class AppShellSettings(
+    val themeMode: String = "0",
+    val fontScale: Int = 10,
+    val composeEngine: String = "material",
+    val showHome: Boolean = true,
+    val showDiscovery: Boolean = true,
+    val showRss: Boolean = true,
+    val showStatusBar: Boolean = true,
+    val swipeAnimation: Boolean = true,
+    val predictiveBackEnabled: Boolean = true,
+    val showBottomView: Boolean = true,
+    val useFloatingBottomBar: Boolean = false,
+    val useFloatingBottomBarLiquidGlass: Boolean = false,
+    val tabletInterface: String = "auto",
+    val labelVisibilityMode: String = "auto",
+    val defaultHomePage: String = "bookshelf",
+    val mainNavigationOrder: String = "home,bookshelf,explore,rss,my",
+    val navExtended: Boolean = false,
+    val navIconHome: String = "",
+    val navIconBookshelf: String = "",
+    val navIconExplore: String = "",
+    val navIconRss: String = "",
+    val navIconMy: String = "",
+    val launcherIcon: String = "ic_launcher",
+)

+ 41 - 0
app/src/main/java/io/legado/app/domain/model/settings/AppUiConfiguration.kt

@@ -0,0 +1,41 @@
+package io.legado.app.domain.model.settings
+
+data class AppUiConfiguration(
+    val language: String = "auto",
+    val appShell: AppShellSettings = AppShellSettings(),
+    val theme: ThemeSettings = ThemeSettings(),
+)
+
+data class AppUiConfigurationDiff(
+    val localeChanged: Boolean = false,
+    val themeChanged: Boolean = false,
+    val fontScaleChanged: Boolean = false,
+    val windowChanged: Boolean = false,
+) {
+    val hasChanges: Boolean
+        get() = localeChanged || themeChanged || fontScaleChanged || windowChanged
+
+    val requiresLegacyContentRefresh: Boolean
+        get() = localeChanged || themeChanged || fontScaleChanged
+}
+
+fun AppUiConfiguration.diffFrom(previous: AppUiConfiguration): AppUiConfigurationDiff =
+    AppUiConfigurationDiff(
+        localeChanged = language != previous.language,
+        themeChanged = appShell.themeMode != previous.appShell.themeMode ||
+            theme.requiresLegacyThemeRefresh(previous.theme),
+        fontScaleChanged = appShell.fontScale != previous.appShell.fontScale,
+        windowChanged = appShell.showStatusBar != previous.appShell.showStatusBar ||
+            theme.backgroundImageLight != previous.theme.backgroundImageLight ||
+            theme.backgroundImageDark != previous.theme.backgroundImageDark ||
+            theme.backgroundImageBlurring != previous.theme.backgroundImageBlurring ||
+            theme.backgroundImageDarkBlurring != previous.theme.backgroundImageDarkBlurring,
+    )
+
+private fun ThemeSettings.requiresLegacyThemeRefresh(previous: ThemeSettings): Boolean =
+    appTheme != previous.appTheme ||
+        isPureBlack != previous.isPureBlack ||
+        customMode != previous.customMode ||
+        appFontPath != previous.appFontPath ||
+        customPrimary != previous.customPrimary ||
+        customNightPrimary != previous.customNightPrimary

+ 15 - 0
app/src/main/java/io/legado/app/domain/model/settings/BackupSettings.kt

@@ -0,0 +1,15 @@
+package io.legado.app.domain.model.settings
+
+data class BackupSettings(
+    val webDavUrl: String = "",
+    val webDavAccount: String = "",
+    val webDavPassword: String = "",
+    val webDavDir: String = "legado",
+    val webDavDeviceName: String = "",
+    val syncBookProgress: Boolean = true,
+    val syncBookProgressPlus: Boolean = false,
+    val autoCheckNewBackup: Boolean = true,
+    val onlyLatestBackup: Boolean = true,
+    val backupSyncMode: String = "both",
+    val backupPath: String? = null,
+)

+ 51 - 0
app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt

@@ -0,0 +1,51 @@
+package io.legado.app.domain.model.settings
+
+import io.legado.app.data.entities.BookGroup
+
+data class BookshelfSettings(
+    val bookGroupStyle: Int = 0,
+    val bookshelfSort: Int = 0,
+    val bookshelfSortOrder: Int = 1,
+    val showUnread: Boolean = true,
+    val showUnreadNew: Boolean = true,
+    val showTip: Boolean = false,
+    val showBookCount: Boolean = true,
+    val showLastUpdateTime: Boolean = false,
+    val showBookIntro: Boolean = false,
+    val bookshelfShowIntro: Boolean = true,
+    val bookshelfShowTag: Boolean = true,
+    val bookshelfShowLatestChapter: Boolean = true,
+    val bookshelfIntroMaxLines: Int = 0,
+    val showWaitUpCount: Boolean = false,
+    val showBookshelfFastScroller: Boolean = false,
+    val shouldShowExpandButton: Boolean = false,
+    val bookshelfRefreshingLimit: Int = 0,
+    val bookshelfLayoutModePortrait: Int = 1,
+    val bookshelfLayoutGridPortrait: Int = 3,
+    val bookshelfLayoutModeLandscape: Int = 1,
+    val bookshelfLayoutGridLandscape: Int = 7,
+    val bookshelfLayoutListPortrait: Int = 1,
+    val bookshelfLayoutListLandscape: Int = 1,
+    val bookshelfFolderLayoutModePortrait: Int = 1,
+    val bookshelfFolderLayoutGridPortrait: Int = 3,
+    val bookshelfFolderLayoutModeLandscape: Int = 1,
+    val bookshelfFolderLayoutGridLandscape: Int = 7,
+    val bookshelfFolderLayoutListPortrait: Int = 1,
+    val bookshelfFolderLayoutListLandscape: Int = 1,
+    val bookshelfGridLayout: Int = 0,
+    val bookshelfLayoutCompact: Boolean = false,
+    val bookshelfShowDivider: Boolean = true,
+    val bookshelfTitleSmallFont: Boolean = false,
+    val bookshelfTitleCenter: Boolean = true,
+    val bookshelfTitleMaxLines: Int = 2,
+    val bookshelfCoverShadow: Boolean = false,
+    val bookshelfCardColor: Int = 0,
+    val bookshelfCardColorDark: Int = 0,
+    val bookshelfGroupListStyle: Int = 0,
+    val bookshelfGroupCoverCount: Int = 4,
+    val bookshelfListCoverWidth: Int = 84,
+    val bookshelfGridCoverWidth: Int = 120,
+    val bookshelfSearchActionDirectToSearch: Boolean = true,
+    val autoRefreshBook: Boolean = false,
+    val saveTabPosition: Long = BookGroup.IdAll,
+)

+ 21 - 0
app/src/main/java/io/legado/app/domain/model/settings/CoverSettings.kt

@@ -0,0 +1,21 @@
+package io.legado.app.domain.model.settings
+
+data class CoverSettings(
+    val loadOnlyOnWifi: Boolean = false,
+    val useDefaultCover: Boolean = false,
+    val showShadow: Boolean = false,
+    val showStroke: Boolean = true,
+    val useDefaultColor: Boolean = true,
+    val textColor: Int = -16777216,
+    val shadowColor: Int = -16777216,
+    val showName: Boolean = true,
+    val showAuthor: Boolean = true,
+    val textColorDark: Int = -1,
+    val shadowColorDark: Int = -1,
+    val showNameDark: Boolean = true,
+    val showAuthorDark: Boolean = true,
+    val infoOrientation: String = "0",
+    val exploreFilterState: Int = 0,
+    val defaultCover: String = "",
+    val defaultCoverDark: String = "",
+)

+ 11 - 0
app/src/main/java/io/legado/app/domain/model/settings/DownloadCacheSettings.kt

@@ -0,0 +1,11 @@
+package io.legado.app.domain.model.settings
+
+data class DownloadCacheSettings(
+    val bitmapCacheSize: Int = 50,
+    val imageRetainNum: Int = 0,
+    val preDownloadNum: Int = 10,
+    val threadCount: Int = 16,
+    val cacheBookThreadCount: Int = 16,
+    val userAgent: String = "",
+    val cronetEnabled: Boolean = false,
+)

+ 7 - 0
app/src/main/java/io/legado/app/domain/model/settings/LabSettings.kt

@@ -0,0 +1,7 @@
+package io.legado.app.domain.model.settings
+
+data class LabSettings(
+    val enabled: Boolean = false,
+    val eInkDisplay: Boolean = false,
+    val eyeProtection: Boolean = false,
+)

+ 24 - 0
app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt

@@ -0,0 +1,24 @@
+package io.legado.app.domain.model.settings
+
+data class OtherSettings(
+    val updateToVariant: String = "official_version",
+    val autoCheckUpdateOnStart: Boolean = false,
+    val webServiceAutoStart: Boolean = false,
+    val autoRefresh: Boolean = false,
+    val defaultToRead: Boolean = false,
+    val notificationsPost: Boolean = true,
+    val ignoreBatteryPermission: Boolean = true,
+    val firebaseEnable: Boolean = true,
+    val defaultBookTreeUri: String? = null,
+    val antiAlias: Boolean = false,
+    val replaceEnableDefault: Boolean = true,
+    val autoClearExpired: Boolean = true,
+    val showAddToShelfAlert: Boolean = true,
+    val showMangaUi: Boolean = true,
+    val webServiceWakeLock: Boolean = false,
+    val sourceEditMaxLine: Int = Int.MAX_VALUE,
+    val webPort: Int = 1122,
+    val processText: Boolean = true,
+    val recordLog: Boolean = false,
+    val recordHeapDump: Boolean = false,
+)

+ 22 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -0,0 +1,22 @@
+package io.legado.app.domain.model.settings
+
+data class ReadAloudSettings(
+    val ignoreAudioFocus: Boolean = false,
+    val mediaButtonOnExit: Boolean = true,
+    val readAloudByMediaButton: Boolean = false,
+    val pauseReadAloudWhilePhoneCalls: Boolean = false,
+    val readAloudWakeLock: Boolean = false,
+    val showReadAloudCapsule: Boolean = true,
+    val capsuleOffsetX: Float = 0f,
+    val capsuleOffsetY: Float = 0f,
+    val mediaButtonPerNext: Boolean = false,
+    val readAloudByPage: Boolean = false,
+    val systemMediaControlCompatibilityChange: Boolean = true,
+    val streamReadAloudAudio: Boolean = false,
+    val ttsTimer: Int = 0,
+    val ttsFollowSys: Boolean = true,
+    val ttsSpeechRate: Int = 5,
+    val speechAnalysisMode: String = "rule",
+    val useMultiSpeaker: Boolean = true,
+    val defaultInterface: String = "classic",
+)

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

@@ -0,0 +1,104 @@
+package io.legado.app.domain.model.settings
+
+import io.legado.app.constant.ReadMenuBlurMode
+import io.legado.app.constant.ReadMenuBlurStyle
+
+data class ReadSettings(
+    val screenOrientation: String = "0",
+    val keepLight: String = "0",
+    val hideStatusBar: Boolean = false,
+    val hideNavigationBar: Boolean = false,
+    val paddingDisplayCutouts: Boolean = false,
+    val titleBarMode: String = "1",
+    val menuAlpha: Int = 100,
+    val readBodyToLh: Boolean = true,
+    val defaultSourceChangeAll: Boolean = true,
+    val textFullJustify: Boolean = true,
+    val textBottomJustify: Boolean = true,
+    val adaptSpecialStyle: Boolean = true,
+    val useZhLayout: Boolean = false,
+    val showBrightnessView: String = "1",
+    val brightnessVwPos: String = "1",
+    val readBrightness: Int = 100,
+    val brightnessAuto: Boolean = false,
+    val useUnderline: Boolean = false,
+    val readSliderMode: String = "0",
+    val doubleHorizontalPage: String = "0",
+    val progressBarBehavior: String = "page",
+    val mouseWheelPage: Boolean = true,
+    val volumeKeyPage: Boolean = true,
+    val volumeKeyPageOnPlay: Boolean = true,
+    val keyPageOnLongPress: Boolean = false,
+    val pageTouchSlop: Int = 0,
+    val sliderVibrator: Boolean = false,
+    val selectVibrator: Boolean = false,
+    val autoChangeSource: Boolean = true,
+    val autoSuggestDayNight: Boolean = false,
+    val selectText: Boolean = true,
+    val noAnimScrollPage: Boolean = false,
+    val clickImgWay: String = "2",
+    val optimizeRender: Boolean = false,
+    val disableReturnKey: Boolean = false,
+    val expandTextMenu: Boolean = false,
+    val showSelectMenuIcon: Boolean = true,
+    val showReadTitleAddition: Boolean = true,
+    val autoReadSpeed: Int = 10,
+    val prevKeys: String = "",
+    val nextKeys: String = "",
+    val tocUiUseReplace: Boolean = false,
+    val tocCountWords: Boolean = true,
+    val readStyleSelect: Int = 0,
+    val comicStyleSelect: Int = 0,
+    val shareLayout: Boolean = false,
+    val readBarStyleFollowPage: Boolean = false,
+    val readBarStyle: Int = 0,
+    val clickActionTL: Int = 2,
+    val clickActionTC: Int = 2,
+    val clickActionTR: Int = 1,
+    val clickActionML: Int = 2,
+    val clickActionMC: Int = 0,
+    val clickActionMR: Int = 1,
+    val clickActionBL: Int = 2,
+    val clickActionBC: Int = 1,
+    val clickActionBR: Int = 1,
+    val fontFolder: String = "",
+    val readMenuBgColor: Int = 0,
+    val readMenuAccentColor: Int = 0,
+    val readMenuContainerColor: Int = 0,
+    val readMenuBgColorNight: Int = 0,
+    val readMenuAccentColorNight: Int = 0,
+    val readMenuContainerColorNight: Int = 0,
+    val readMenuTextColor: Int = 0,
+    val readMenuTextColorNight: Int = 0,
+    val readMenuColorMode: Int = 1,
+    val readMenuIconShowText: Boolean = true,
+    val readMenuIconStyle: Int = 0,
+    val titleBarIconStyle: Int = 0,
+    val readMenuIconItemsPerRow: Int = 5,
+    val readMenuIconRowCount: Int = 1,
+    val readMenuBottomCornerRadius: Int = 0,
+    val readMenuFloatingBottomBar: Boolean = false,
+    val readMenuTopBarBlurMode: Int = ReadMenuBlurMode.None,
+    val readMenuBottomBarBlurMode: Int = ReadMenuBlurMode.None,
+    val readMenuTopBarLiquidGlassButtons: Boolean = false,
+    val readMenuTopBarTitleCapsule: Boolean = false,
+    val readMenuBottomBarLiquidGlassButtons: Boolean = false,
+    val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
+    val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
+    val readMenuBlurRadius: Int = 24,
+    val readMenuBlurAlpha: Int = 60,
+    val readMenuBlurColor: Int = 0,
+    val readMenuBlurColorNight: Int = 0,
+    val readMenuPaletteStyle: String = "",
+    val readMenuLensRadius: Float = 24f,
+    val readMenuBorderWidth: Int = 0,
+    val readMenuBorderColor: Int = 0,
+    val readMenuBorderColorNight: Int = 0,
+    val readMenuCustomIcons: String = "",
+    val titleBarCustomIcons: String = "",
+    val titleBarIconPosition: Int = 0,
+    val showTitleBarIcons: Boolean = true,
+    val chineseConverterType: Int = 0,
+    val showMenuIcon: Boolean = true,
+)
+

+ 91 - 0
app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt

@@ -0,0 +1,91 @@
+package io.legado.app.domain.model.settings
+
+data class ThemeSettings(
+    val appTheme: String = "0",
+    val useMiuixMonet: Boolean = false,
+    val isPureBlack: Boolean = false,
+    val paletteStyle: String = "tonalSpot",
+    val materialVersion: String = "material3",
+    val customContrast: String = "Default",
+    val customMode: String = "tonalSpot",
+    val appFontPath: String? = null,
+    val customPrimary: Int = 0,
+    val customNightPrimary: Int = 0,
+    val enableDeepPersonalization: Boolean = false,
+    val themeColor: Int = 0,
+    val secondaryThemeColor: Int = 0,
+    val primaryTextColor: Int = 0,
+    val secondaryTextColor: Int = 0,
+    val themeBackgroundColor: Int = 0,
+    val labelContainerColor: Int = 0,
+    val themeColorNight: Int = 0,
+    val secondaryThemeColorNight: Int = 0,
+    val primaryTextColorNight: Int = 0,
+    val secondaryTextColorNight: Int = 0,
+    val themeBackgroundColorNight: Int = 0,
+    val labelContainerColorNight: Int = 0,
+    val containerOpacity: Int = 100,
+    val topBarOpacity: Int = 100,
+    val bottomBarOpacity: Int = 100,
+    val enableBlur: Boolean = false,
+    val enableProgressiveBlur: Boolean = false,
+    val topBarBlurRadius: Int = 24,
+    val bottomBarBlurRadius: Int = 8,
+    val topBarBlurAlpha: Int = 73,
+    val bottomBarBlurAlpha: Int = 40,
+    val bottomBarLensRadius: Float = 24f,
+    val useFlexibleTopAppBar: Boolean = true,
+    val bookInfoFollowCoverColor: Boolean = true,
+    val bookInfoNetworkCoverBackground: String = "on",
+    val bookInfoDefaultCoverBackground: String = "on",
+    val backgroundImageLight: String? = null,
+    val backgroundImageDark: String? = null,
+    val backgroundImageBlurring: Int = 0,
+    val backgroundImageDarkBlurring: Int = 0,
+    val enableItemDivider: Boolean = false,
+    val itemDividerWidth: Float = 1f,
+    val itemDividerLength: Float = 80f,
+    val itemDividerColor: Int = 0,
+    val eyeProtectionEnabled: Boolean = false,
+    val colorTemperature: Int = 50,
+    val eyeProtectionSchedule: Boolean = false,
+    val eyeProtectionStartTime: String = "22:00",
+    val eyeProtectionEndTime: String = "07:00",
+    val showRefactorTip: Boolean = true,
+    val enableCustomTagColors: Boolean = false,
+    val customTagColorsJson: String? = null,
+)
+
+data class ThemeCustomColors(
+    val primary: Int,
+    val secondary: Int,
+    val primaryText: Int,
+    val secondaryText: Int,
+    val background: Int,
+    val labelContainer: Int,
+) {
+    val hasCustomColor: Boolean
+        get() = primary != 0 || secondary != 0 || primaryText != 0 ||
+            secondaryText != 0 || background != 0 || labelContainer != 0
+}
+
+fun ThemeSettings.customColors(isDark: Boolean): ThemeCustomColors =
+    if (isDark) {
+        ThemeCustomColors(
+            primary = themeColorNight.takeIf { it != 0 } ?: themeColor,
+            secondary = secondaryThemeColorNight.takeIf { it != 0 } ?: secondaryThemeColor,
+            primaryText = primaryTextColorNight.takeIf { it != 0 } ?: primaryTextColor,
+            secondaryText = secondaryTextColorNight.takeIf { it != 0 } ?: secondaryTextColor,
+            background = themeBackgroundColorNight.takeIf { it != 0 } ?: themeBackgroundColor,
+            labelContainer = labelContainerColorNight.takeIf { it != 0 } ?: labelContainerColor,
+        )
+    } else {
+        ThemeCustomColors(
+            primary = themeColor,
+            secondary = secondaryThemeColor,
+            primaryText = primaryTextColor,
+            secondaryText = secondaryTextColor,
+            background = themeBackgroundColor,
+            labelContainer = labelContainerColor,
+        )
+    }

+ 9 - 0
app/src/main/java/io/legado/app/domain/model/settings/TranslationSettings.kt

@@ -0,0 +1,9 @@
+package io.legado.app.domain.model.settings
+
+import io.legado.app.domain.model.TranslationConstants
+
+data class TranslationSettings(
+    val provider: String = TranslationConstants.PROVIDER_GOOGLE,
+    val targetLanguage: String = "zh",
+    val maxCharsPerChunk: Int = 10000,
+)

+ 18 - 19
app/src/main/java/io/legado/app/help/WebDavManager.kt

@@ -1,6 +1,5 @@
 package io.legado.app.help
 
-import android.content.SharedPreferences
 import android.net.Uri
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
@@ -8,6 +7,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.storage.Backup
 import io.legado.app.help.storage.BackupRestoreLock
@@ -24,7 +24,6 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.compress.ZipUtils
-import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.isJson
 import io.legado.app.utils.normalizeFileName
@@ -34,12 +33,14 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.init.appCtx
 import java.io.File
 import kotlin.coroutines.cancellation.CancellationException
 
@@ -66,10 +67,10 @@ sealed class WebDavState {
     ) : WebDavState()
 }
 
+@OptIn(kotlinx.coroutines.FlowPreview::class)
 class WebDavManager(
-    private val prefs: SharedPreferences = appCtx.defaultSharedPreferences,
     private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
-) : SharedPreferences.OnSharedPreferenceChangeListener {
+) {
 
     private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
     private val configFlow = MutableStateFlow(readConfig())
@@ -79,12 +80,22 @@ class WebDavManager(
     private var refreshJob: Job? = null
 
     init {
-        prefs.registerOnSharedPreferenceChangeListener(this)
+        scope.launch {
+            merge(
+                AppConfigStore.observeString(PreferKey.webDavUrl).drop(1),
+                AppConfigStore.observeString(PreferKey.webDavAccount).drop(1),
+                AppConfigStore.observeString(PreferKey.webDavPassword).drop(1),
+                AppConfigStore.observeString(PreferKey.webDavDir).drop(1),
+                AppConfigStore.observeString(PreferKey.webDavDeviceName).drop(1),
+            ).debounce(100).collect {
+                configFlow.value = readConfig()
+                refresh()
+            }
+        }
         refresh()
     }
 
     fun close() {
-        prefs.unregisterOnSharedPreferenceChangeListener(this)
         scope.cancel()
     }
 
@@ -273,18 +284,6 @@ class WebDavManager(
         }
     }
 
-    override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
-        if (key == PreferKey.webDavUrl
-            || key == PreferKey.webDavAccount
-            || key == PreferKey.webDavPassword
-            || key == PreferKey.webDavDir
-            || key == PreferKey.webDavDeviceName
-        ) {
-            configFlow.value = readConfig()
-            refresh()
-        }
-    }
-
     private fun readConfig(): WebDavConfig {
         return WebDavConfig(
             url = BackupConfig.webDavUrl,

+ 25 - 154
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -1,11 +1,8 @@
 package io.legado.app.help.config
 
-import android.content.SharedPreferences
 import io.legado.app.BuildConfig
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
-import io.legado.app.data.repository.ReadPreferences
-import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.ui.config.backupConfig.BackupConfig
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
@@ -24,33 +21,26 @@ import io.legado.app.utils.putPrefBoolean
 import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.sysConfiguration
-import io.legado.app.utils.toastOnUi
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-import org.koin.java.KoinJavaComponent.get
 import splitties.init.appCtx
 
 @Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
-object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
-    private val settingsRepository: SettingsRepository by lazy {
-        get(SettingsRepository::class.java)
-    }
+object AppConfig {
     val isCronet get() = DownloadCacheConfig.cronetEnable
     val useAntiAlias get() = OtherConfig.antiAlias
     val userAgent: String get() = DownloadCacheConfig.userAgent
 
-    var isEInkMode = appCtx.getPrefString("app_theme", "0") == "4"
-    var isTransparent = appCtx.getPrefString("app_theme", "0") == "13"
+    val isEInkMode get() = appCtx.getPrefString("app_theme", "0") == "4"
+    val isTransparent get() = appCtx.getPrefString("app_theme", "0") == "13"
     val customMode get() = ThemeConfig.customMode
-    var clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2)
-    var clickActionTC = appCtx.getPrefInt(PreferKey.clickActionTC, 2)
-    var clickActionTR = appCtx.getPrefInt(PreferKey.clickActionTR, 1)
-    var clickActionML = appCtx.getPrefInt(PreferKey.clickActionML, 2)
-    var clickActionMC = appCtx.getPrefInt(PreferKey.clickActionMC, 0)
-    var clickActionMR = appCtx.getPrefInt(PreferKey.clickActionMR, 1)
-    var clickActionBL = appCtx.getPrefInt(PreferKey.clickActionBL, 2)
-    var clickActionBC = appCtx.getPrefInt(PreferKey.clickActionBC, 1)
-    var clickActionBR = appCtx.getPrefInt(PreferKey.clickActionBR, 1)
+    val clickActionTL get() = appCtx.getPrefInt(PreferKey.clickActionTL, 2)
+    val clickActionTC get() = appCtx.getPrefInt(PreferKey.clickActionTC, 2)
+    val clickActionTR get() = appCtx.getPrefInt(PreferKey.clickActionTR, 1)
+    val clickActionML get() = appCtx.getPrefInt(PreferKey.clickActionML, 2)
+    val clickActionMC get() = appCtx.getPrefInt(PreferKey.clickActionMC, 0)
+    val clickActionMR get() = appCtx.getPrefInt(PreferKey.clickActionMR, 1)
+    val clickActionBL get() = appCtx.getPrefInt(PreferKey.clickActionBL, 2)
+    val clickActionBC get() = appCtx.getPrefInt(PreferKey.clickActionBC, 1)
+    val clickActionBR get() = appCtx.getPrefInt(PreferKey.clickActionBR, 1)
 
     //    -1无操作 1下一页 2上一页 0显示菜单
     var mangaClickActionTL
@@ -98,105 +88,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     val adaptSpecialStyle get() = ReadConfig.adaptSpecialStyle
     val useUnderline get() = ReadConfig.useUnderline
 
-    private var readBarStyleFollowPageValue =
-        appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
-    private var readBarStyleValue = appCtx.getPrefInt(PreferKey.readBarStyle, 0)
-
-    fun syncReadPreferences(preferences: ReadPreferences) {
-        ReadConfig.optimizeRender = preferences.optimizeRender
-        ReadConfig.adaptSpecialStyle = preferences.adaptSpecialStyle
-        ReadConfig.useUnderline = preferences.useUnderline
-        ReadConfig.chineseConverterType = preferences.chineseConverterType
-        clickActionTL = preferences.clickActionTL
-        clickActionTC = preferences.clickActionTC
-        clickActionTR = preferences.clickActionTR
-        clickActionML = preferences.clickActionML
-        clickActionMC = preferences.clickActionMC
-        clickActionMR = preferences.clickActionMR
-        clickActionBL = preferences.clickActionBL
-        clickActionBC = preferences.clickActionBC
-        clickActionBR = preferences.clickActionBR
-        ReadConfig.screenOrientation = preferences.screenOrientation
-        ReadConfig.noAnimScrollPage = preferences.noAnimScrollPage
-        ReadConfig.tocUiUseReplace = preferences.tocUiUseReplace
-        ReadConfig.tocCountWords = preferences.tocCountWords
-        ReadConfig.autoChangeSource = preferences.autoChangeSource
-        ReadConfig.clickImgWay = preferences.clickImgWay
-        ReadConfig.doubleHorizontalPage = preferences.doubleHorizontalPage
-        ReadConfig.progressBarBehavior = preferences.progressBarBehavior
-        ReadConfig.keyPageOnLongPress = preferences.keyPageOnLongPress
-        ReadConfig.volumeKeyPage = preferences.volumeKeyPage
-        ReadConfig.volumeKeyPageOnPlay = preferences.volumeKeyPageOnPlay
-        ReadConfig.mouseWheelPage = preferences.mouseWheelPage
-        ReadConfig.paddingDisplayCutouts = preferences.paddingDisplayCutouts
-        ReadConfig.pageTouchSlop = preferences.pageTouchSlop
-        ReadConfig.showReadTitleAddition = preferences.showReadTitleAddition
-        ReadConfig.titleBarMode = preferences.titleBarMode
-        ReadBookConfig.readMenuBlurAlpha = preferences.readMenuBlurAlpha
-        ReadBookConfig.readSliderMode = preferences.readSliderMode
-        readBarStyleFollowPageValue = preferences.readBarStyleFollowPage
-        readBarStyleValue = preferences.readBarStyle
-        ReadConfig.defaultSourceChangeAll = preferences.defaultSourceChangeAll
-        ReadConfig.sliderVibrator = preferences.sliderVibrator
-        ReadConfig.selectVibrator = preferences.selectVibrator
-        ReadBookConfig.brightnessVwPos = preferences.brightnessVwPos
-        ReadBookConfig.readBrightness = preferences.readBrightness
-    }
-
-    fun updateReadBarStyleCache(value: Int) {
-        readBarStyleValue = value.coerceIn(0, 2)
-    }
-
-    private fun syncReadPreferenceFromSharedPreferences(key: String?) {
-        when (key) {
-            PreferKey.readBarStyleFollowPage -> readBarStyleFollowPageValue =
-                appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
-            PreferKey.readBarStyle -> readBarStyleValue =
-                appCtx.getPrefInt(PreferKey.readBarStyle, 0)
-        }
-    }
-
-
-    override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
-        syncReadPreferenceFromSharedPreferences(key)
-        when (key) {
-
-            PreferKey.clickActionTL -> clickActionTL =
-                appCtx.getPrefInt(PreferKey.clickActionTL, 2)
-
-            PreferKey.clickActionTC -> clickActionTC =
-                appCtx.getPrefInt(PreferKey.clickActionTC, 2)
-
-            PreferKey.clickActionTR -> clickActionTR =
-                appCtx.getPrefInt(PreferKey.clickActionTR, 1)
-
-            PreferKey.clickActionML -> clickActionML =
-                appCtx.getPrefInt(PreferKey.clickActionML, 2)
-
-            PreferKey.clickActionMC -> clickActionMC =
-                appCtx.getPrefInt(PreferKey.clickActionMC, 0)
-
-            PreferKey.clickActionMR -> clickActionMR =
-                appCtx.getPrefInt(PreferKey.clickActionMR, 1)
-
-            PreferKey.clickActionBL -> clickActionBL =
-                appCtx.getPrefInt(PreferKey.clickActionBL, 2)
-
-            PreferKey.clickActionBC -> clickActionBC =
-                appCtx.getPrefInt(PreferKey.clickActionBC, 1)
-
-            PreferKey.clickActionBR -> clickActionBR =
-                appCtx.getPrefInt(PreferKey.clickActionBR, 1)
-
-            PreferKey.readBodyToLh -> ReadBookConfig.readBodyToLh =
-                appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
-
-            PreferKey.useZhLayout -> ReadBookConfig.useZhLayout =
-                appCtx.getPrefBoolean(PreferKey.useZhLayout)
-
-        }
-    }
-
     var isNightTheme: Boolean
         get() = when (ThemeConfig.themeMode) {
             "1" -> false
@@ -297,20 +188,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
 
     var bookExportFileName: String?
-        get() = runBlocking {
-            settingsRepository.getString(PreferKey.bookExportFileName).first().ifEmpty { null }
-        }
+        get() = appCtx.getPrefString(PreferKey.bookExportFileName)
         set(value) {
-            runBlocking { DsSync.putString(PreferKey.bookExportFileName, value) }
+            appCtx.putPrefString(PreferKey.bookExportFileName, value)
         }
 
     // 保存 自定义导出章节模式 文件名js表达式
     var episodeExportFileName: String?
-        get() = runBlocking {
-            settingsRepository.getString(PreferKey.episodeExportFileName).first()
-        }
+        get() = appCtx.getPrefString(PreferKey.episodeExportFileName)
         set(value) {
-            runBlocking { DsSync.putString(PreferKey.episodeExportFileName, value) }
+            appCtx.putPrefString(PreferKey.episodeExportFileName, value)
         }
 
     var bookImportFileName: String?
@@ -516,15 +403,15 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
 
     var importKeepName: Boolean
-        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepName).first() }
-        set(value) { runBlocking { DsSync.putBoolean(PreferKey.importKeepName, value) } }
+        get() = appCtx.getPrefBoolean(PreferKey.importKeepName)
+        set(value) { appCtx.putPrefBoolean(PreferKey.importKeepName, value) }
     var importKeepGroup: Boolean
-        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepGroup).first() }
-        set(value) { runBlocking { DsSync.putBoolean(PreferKey.importKeepGroup, value) } }
+        get() = appCtx.getPrefBoolean(PreferKey.importKeepGroup)
+        set(value) { appCtx.putPrefBoolean(PreferKey.importKeepGroup, value) }
     var importKeepEnable: Boolean
-        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepEnable).first() }
+        get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable)
         set(value) {
-            runBlocking { DsSync.putBoolean(PreferKey.importKeepEnable, value) }
+            appCtx.putPrefBoolean(PreferKey.importKeepEnable, value)
         }
 
     var previewImageByClick: Boolean
@@ -649,17 +536,15 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
 
     var readBarStyleFollowPage: Boolean
-        get() = readBarStyleFollowPageValue
+        get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
         set(value) {
-            readBarStyleFollowPageValue = value
             appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value)
         }
 
     var readBarStyle: Int
-        get() = readBarStyleValue
+        get() = appCtx.getPrefInt(PreferKey.readBarStyle, 0)
         set(value) {
-            readBarStyleValue = value.coerceIn(0, 2)
-            appCtx.putPrefInt(PreferKey.readBarStyle, readBarStyleValue)
+            appCtx.putPrefInt(PreferKey.readBarStyle, value.coerceIn(0, 2))
         }
 
     var sourceEditMaxLine: Int
@@ -680,20 +565,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefBoolean(PreferKey.brightnessVwPos, value)
         }
 
-    fun detectClickArea() {
-        if (clickActionTL * clickActionTC * clickActionTR
-            * clickActionML * clickActionMC * clickActionMR
-            * clickActionBL * clickActionBC * clickActionBR != 0
-        ) {
-            appCtx.putPrefInt(PreferKey.clickActionMC, 0)
-            appCtx.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
-        }
-    }
-
-    fun detectMangaClickArea() {
-        ReadMangaConfig.detectMangaClickArea()
-    }
-
     var firebaseEnable: Boolean
         get() = OtherConfig.firebaseEnable
         set(value) {

+ 209 - 0
app/src/main/java/io/legado/app/help/config/AppConfigStore.kt

@@ -0,0 +1,209 @@
+package io.legado.app.help.config
+
+import android.content.Context
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import io.legado.app.data.repository.dataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.io.IOException
+
+import io.legado.app.utils.LogUtils
+import io.legado.app.utils.stackTraceStr
+
+/**
+ * "settings" DataStore 的进程内内存快照层,设置读取的唯一同步入口。
+ *
+ * 三个职责(不做其他事,防止长成 AppConfig 2.0):
+ * 1. snapshot:App.onCreate 首行同步预加载一次(同时触发 SharedPreferencesMigration),
+ *    之后由常驻 collector 跟随 DataStore 变化回灌,保证外部写入(Restore 批量写等)可见;
+ * 2. pending overlay:写入先进内存立即对读侧生效,异步串行落盘、回灌确认后移除,
+ *    保证"写后立即读"永远读到新值,collector 携带旧状态回灌也不会闪烁;
+ * 3. observe:按 key 订阅变化,替代 SP OnSharedPreferenceChangeListener。
+ *
+ * 约束:读 API 是纯内存查找(主线程零 IO),但 onCreate 的预加载耗时与 settings 文件
+ * 大小成正比——大 value(长 JSON、图片路径列表等)不得写进 "settings" DataStore,
+ * 应走各自的文件级配置。
+ */
+object AppConfigStore {
+
+    @Volatile
+    private var core: PendingOverlayCore? = null
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    /**
+     * 在 App.onCreate 首行调用,先于一切设置读取(主题初始化等)。
+     * runBlocking 首读会触发 DataStore 迁移,与旧版首个 PrefDelegate 构造时的行为一致,
+     * 只是显式提前且全程只做一次。
+     */
+    fun init(context: Context) {
+        if (core != null) return
+        val dataStore = context.applicationContext.dataStore
+        val initial = runBlocking {
+            dataStore.data
+                .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+                .first()
+        }
+        val newCore = PendingOverlayCore(
+            initial = initial,
+            launchWrite = { SettingsWriter.launchWrite(it) },
+            persist = { key, value -> dataStore.edit { it.setPrefValue(key, value) } },
+            persistAll = { values ->
+                dataStore.edit { prefs ->
+                    values.forEach { (key, value) -> prefs.setPrefValue(key, value) }
+                }
+            },
+        )
+        core = newCore
+        scope.launch {
+            dataStore.data
+                .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+                .collect { newCore.onEmission(it) }
+        }
+    }
+
+    private fun requireCore(): PendingOverlayCore =
+        checkNotNull(core) { "AppConfigStore 未初始化,应在 App.onCreate 首行调用 init()" }
+
+    /** 当前生效的设置快照(DataStore 落盘状态 + 未落盘写入叠加) */
+    val preferences: Preferences get() = requireCore().preferencesFlow.value
+
+    val preferencesFlow: StateFlow<Preferences> get() = requireCore().preferencesFlow
+
+    // 读取:key 不存在时返回 null,由调用方(过渡期的门面)决定回退行为
+    fun getString(key: String): String? = preferences.compatDsString(key)
+    fun getInt(key: String): Int? = preferences.compatDsInt(key)
+    fun getBoolean(key: String): Boolean? = preferences.compatDsBoolean(key)
+    fun getLong(key: String): Long? = preferences.compatDsLong(key)
+    fun getFloat(key: String): Float? = preferences.compatDsFloat(key)
+    fun getStringSet(key: String): Set<String>? = preferences.compatDsStringSet(key)
+
+    // 写入:立即对读侧生效,异步串行落盘;value 为 null 表示移除
+    fun putString(key: String, value: String?) = requireCore().put(key, value)
+    fun putInt(key: String, value: Int) = requireCore().put(key, value)
+    fun putBoolean(key: String, value: Boolean) = requireCore().put(key, value)
+    fun putLong(key: String, value: Long) = requireCore().put(key, value)
+    fun putFloat(key: String, value: Float) = requireCore().put(key, value)
+    fun putStringSet(key: String, value: Set<String>) = requireCore().put(key, value)
+    fun remove(key: String) = requireCore().put(key, null)
+
+    /** 批量写入(Restore 恢复备份等):整批立即对读侧生效,单次 edit 落盘 */
+    fun putAll(values: Map<String, Any?>) = requireCore().putAll(values)
+
+    // 订阅:替代 SP 监听器的统一入口,值不存在时发 null
+    fun observeString(key: String): Flow<String?> = observe { it.compatDsString(key) }
+    fun observeInt(key: String): Flow<Int?> = observe { it.compatDsInt(key) }
+    fun observeBoolean(key: String): Flow<Boolean?> = observe { it.compatDsBoolean(key) }
+    fun observeLong(key: String): Flow<Long?> = observe { it.compatDsLong(key) }
+    fun observeFloat(key: String): Flow<Float?> = observe { it.compatDsFloat(key) }
+
+    private inline fun <T> observe(crossinline read: (Preferences) -> T?): Flow<T?> =
+        preferencesFlow.map { read(it) }.distinctUntilChanged()
+}
+
+/**
+ * 快照 + pending overlay 的核心状态机,与 Android/DataStore 解耦以便 JVM 单测。
+ *
+ * 不变式:[preferencesFlow] = snapshot 叠加所有 pending 写入,pending 中的值永远优先。
+ * snapshot 只由 collector 回灌([onEmission])推进,回灌按落盘提交顺序到达,因此单调不回退;
+ * pending 条目要等到回灌中该 key 的值已等于写入目标值时才移除——落盘完成后若先处理到
+ * 一条落盘前的旧回灌,该 key 仍被 overlay 保护,读值不会短暂回滚(回滚会让 observe
+ * 订阅方收到"新→旧→新"的幻影变更,如触发多余的 Activity recreate / WebDav refresh)。
+ * 落盘失败时移除对应 pending 实现回滚,读取回退到已落盘状态。
+ */
+internal class PendingOverlayCore(
+    initial: Preferences,
+    private val launchWrite: (suspend () -> Unit) -> Unit,
+    private val persist: suspend (key: String, value: Any?) -> Preferences,
+    private val persistAll: suspend (values: Map<String, Any?>) -> Preferences,
+) {
+
+    private val lock = Any()
+    private var snapshot: Preferences = initial
+    private val pending = LinkedHashMap<String, PendingWrite>()
+    private val _preferences = MutableStateFlow(initial)
+    val preferencesFlow: StateFlow<Preferences> get() = _preferences
+
+    private class PendingWrite(val value: Any?)
+
+    fun put(key: String, value: Any?) {
+        synchronized(lock) {
+            val write = PendingWrite(value)
+            pending[key] = write
+            rebuild()
+            // 在锁内提交写任务,保证落盘顺序与 pending 覆盖顺序一致
+            launchWrite {
+                // 成功时不动 snapshot/pending:等 collector 回灌到含新值的状态时(onEmission)
+                // 再移除 overlay,避免"落盘完成后才处理到旧回灌"导致读值短暂回滚
+                runCatching { persist(key, value) }.onFailure { e ->
+                    synchronized(lock) {
+                        if (pending[key] === write) {
+                            // 失败回滚:移除 overlay,读取回退到已落盘状态
+                            pending.remove(key)
+                            rebuild()
+                            LogUtils.e("AppConfigStore", "保存设置失败: key=$key, value=$value\n${e.stackTraceStr}")
+                        } else {
+                            // 同 key 已有更新的写入,继续由新条目覆盖
+                            LogUtils.e("AppConfigStore", "保存设置失败且已被新写入覆盖: key=$key, value=$value\n${e.stackTraceStr}")
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    fun putAll(values: Map<String, Any?>) {
+        if (values.isEmpty()) return
+        synchronized(lock) {
+            val writes = values.mapValues { PendingWrite(it.value) }
+            pending.putAll(writes)
+            rebuild()
+            launchWrite {
+                runCatching { persistAll(values) }.onFailure { e ->
+                    synchronized(lock) {
+                        var removed = false
+                        writes.forEach { (key, write) ->
+                            if (pending[key] === write) {
+                                pending.remove(key)
+                                removed = true
+                            }
+                        }
+                        if (removed) rebuild()
+                        LogUtils.e("AppConfigStore", "批量保存设置失败: keys=${values.keys}\n${e.stackTraceStr}")
+                    }
+                }
+            }
+        }
+    }
+
+    fun onEmission(prefs: Preferences) {
+        synchronized(lock) {
+            snapshot = prefs
+            // 回灌已包含某个 pending 写入的目标值时,说明该写入已落盘且对 collector 可见,
+            // 此时移除 overlay 才不会被更早的旧回灌回滚
+            pending.entries.removeAll { (key, write) -> prefs.rawPrefValue(key) == write.value }
+            rebuild()
+        }
+    }
+
+    private fun rebuild() {
+        if (pending.isEmpty()) {
+            _preferences.value = snapshot
+            return
+        }
+        val prefs = snapshot.toMutablePreferences()
+        pending.forEach { (key, write) -> prefs.setPrefValue(key, write.value) }
+        _preferences.value = prefs.toPreferences()
+    }
+}

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

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

+ 0 - 42
app/src/main/java/io/legado/app/help/config/DsSync.kt

@@ -1,42 +0,0 @@
-package io.legado.app.help.config
-
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.floatPreferencesKey
-import androidx.datastore.preferences.core.intPreferencesKey
-import androidx.datastore.preferences.core.longPreferencesKey
-import androidx.datastore.preferences.core.stringPreferencesKey
-import io.legado.app.data.repository.dataStore
-import splitties.init.appCtx
-
-/**
- * DataStore 写入辅助对象。
- *
- * 让 [io.legado.app.ui.config.PrefDelegate] 在写入 SP 的同时同步写入 DataStore,
- * 保证两边数据一致,避免 DataStore 迁移/读取时出现值缺失。
- */
-object DsSync {
-
-    suspend fun putString(key: String, value: String?) {
-        appCtx.dataStore.edit { prefs ->
-            val dsKey = stringPreferencesKey(key)
-            if (value == null) prefs.remove(dsKey) else prefs[dsKey] = value
-        }
-    }
-
-    suspend fun putInt(key: String, value: Int) {
-        appCtx.dataStore.edit { it[intPreferencesKey(key)] = value }
-    }
-
-    suspend fun putBoolean(key: String, value: Boolean) {
-        appCtx.dataStore.edit { it[booleanPreferencesKey(key)] = value }
-    }
-
-    suspend fun putLong(key: String, value: Long) {
-        appCtx.dataStore.edit { it[longPreferencesKey(key)] = value }
-    }
-
-    suspend fun putFloat(key: String, value: Float) {
-        appCtx.dataStore.edit { it[floatPreferencesKey(key)] = value }
-    }
-}

+ 13 - 5
app/src/main/java/io/legado/app/help/config/LocalConfig.kt

@@ -4,7 +4,8 @@ import android.content.Context
 import android.content.SharedPreferences
 import androidx.core.content.edit
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.utils.getBoolean
 import io.legado.app.utils.putBoolean
 import io.legado.app.utils.putLong
@@ -21,8 +22,8 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
 
     private const val versionCodeKey = "appVersionCode"
 
-    private val localPreferencesRepository: LocalPreferencesRepository
-        by lazy { get(LocalPreferencesRepository::class.java) }
+    private val localPreferencesRepository: SettingsRepository
+        by lazy { get(SettingsRepository::class.java) }
 
     /**
      * 本地密码,用来对需要备份的敏感信息加密,如 webdav 配置等
@@ -64,9 +65,16 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
         }
 
     var navExtended: Boolean
-        get() = getBoolean("navExtended")
+        get() = getBoolean(PreferKey.navExtended)
         set(value) {
-            putBoolean("navExtended", value)
+            putBoolean(PreferKey.navExtended, value)
+        }
+
+    /** 旧版语言偏好是否已迁移到 AppCompat per-app locales(App.onCreate 一次性迁移) */
+    var appLocaleMigrated: Boolean
+        get() = getBoolean("appLocaleMigrated")
+        set(value) {
+            putBoolean("appLocaleMigrated", value)
         }
 
     val readHelpVersionIsLast: Boolean

+ 108 - 0
app/src/main/java/io/legado/app/help/config/PreferencesDsCompat.kt

@@ -0,0 +1,108 @@
+package io.legado.app.help.config
+
+import androidx.datastore.preferences.core.MutablePreferences
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+
+/**
+ * DataStore 值的类型兼容读写。
+ *
+ * 历史数据(SP 迁移、旧版本写入)里同一 key 的存储类型可能与读取类型不一致:
+ * int 存成 string("1")、long 存成 int 等。这里统一做"按实际存储类型取值,再转换到目标类型"。
+ *
+ * 注意:依赖 [Preferences.Key] 的 equals/hashCode 只比较 name(datastore 既有行为,
+ * 也是同名不同类型 key 会互相覆盖的原因),因此用任意类型的 key 都能取到该 name 下的原始值。
+ */
+
+/**
+ * 取 key 对应的原始存储值(不做类型断言)。
+ * Key 按 name 判等,用 string 类型的 key 即可取到该 name 下任意类型的原始值;
+ * 返回位置声明为 Any? 使编译器不插入 checkcast,存储类型不匹配也不会抛 ClassCastException。
+ * 不可用 asMap() 实现:datastore 的 asMap() 每次调用都会防御性复制整个 map,读是热路径。
+ */
+fun Preferences.rawPrefValue(key: String): Any? = this[stringPreferencesKey(key)]
+
+fun Preferences.compatDsString(key: String): String? = when (val raw = rawPrefValue(key)) {
+    is String -> raw
+    is Number, is Boolean -> raw.toString()
+    else -> null
+}
+
+fun Preferences.compatDsInt(key: String): Int? = when (val raw = rawPrefValue(key)) {
+    is Number -> raw.toInt()
+    is String -> raw.toIntOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsBoolean(key: String): Boolean? = when (val raw = rawPrefValue(key)) {
+    is Boolean -> raw
+    is String -> raw.toBooleanStrictOrNull() ?: when (raw) {
+        "1" -> true
+        "0" -> false
+        else -> null
+    }
+    is Number -> when (raw.toInt()) {
+        1 -> true
+        0 -> false
+        else -> null
+    }
+    else -> null
+}
+
+fun Preferences.compatDsLong(key: String): Long? = when (val raw = rawPrefValue(key)) {
+    is Number -> raw.toLong()
+    is String -> raw.toLongOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsFloat(key: String): Float? = when (val raw = rawPrefValue(key)) {
+    is Number -> raw.toFloat()
+    is String -> raw.toFloatOrNull()
+    else -> null
+}
+
+fun Preferences.compatDsStringSet(key: String): Set<String>? = when (val raw = rawPrefValue(key)) {
+    is Set<*> -> @Suppress("UNCHECKED_CAST") (raw as Set<String>)
+    else -> null
+}
+
+/**
+ * 按默认值的运行时类型读取设置,并兼容历史版本中同名 key 的存储类型漂移。
+ *
+ * 该入口用于数据层的类型化设置映射,避免每个 repository 重复实现 String/Int/Boolean
+ * 兼容规则。调用方必须传入非空、可识别类型的默认值。
+ */
+@Suppress("UNCHECKED_CAST")
+fun <T : Any> Preferences.compatDsValue(key: Preferences.Key<T>, defaultValue: T): T =
+    when (defaultValue) {
+        is String -> (compatDsString(key.name) ?: defaultValue) as T
+        is Int -> (compatDsInt(key.name) ?: defaultValue) as T
+        is Boolean -> (compatDsBoolean(key.name) ?: defaultValue) as T
+        is Long -> (compatDsLong(key.name) ?: defaultValue) as T
+        is Float -> (compatDsFloat(key.name) ?: defaultValue) as T
+        is Set<*> -> (compatDsStringSet(key.name) ?: defaultValue) as T
+        else -> defaultValue
+    }
+
+/**
+ * 按 value 的运行时类型写入对应类型的 key;value 为 null 表示移除。
+ * 由于 Key 按 name 判等,写入会覆盖同名的旧类型条目,移除一次即可清掉任意类型的条目。
+ */
+fun MutablePreferences.setPrefValue(key: String, value: Any?) {
+    when (value) {
+        null -> remove(stringPreferencesKey(key))
+        is String -> this[stringPreferencesKey(key)] = value
+        is Int -> this[intPreferencesKey(key)] = value
+        is Boolean -> this[booleanPreferencesKey(key)] = value
+        is Long -> this[longPreferencesKey(key)] = value
+        is Float -> this[floatPreferencesKey(key)] = value
+        is Set<*> -> @Suppress("UNCHECKED_CAST") {
+            this[stringSetPreferencesKey(key)] = value as Set<String>
+        }
+    }
+}

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

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

+ 64 - 0
app/src/main/java/io/legado/app/help/config/SettingsWriter.kt

@@ -0,0 +1,64 @@
+package io.legado.app.help.config
+
+import io.legado.app.utils.LogUtils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.measureTime
+
+/**
+ * DataStore 写入序列化队列,所有经 [AppConfigStore] 的异步写入按提交顺序串行落盘。
+ */
+object SettingsWriter {
+
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val writeDispatcher = Dispatchers.IO.limitedParallelism(1)
+
+    private val pendingCount = AtomicInteger(0)
+
+    /** 当前排队的写入任务数(含正在执行的那一个)。*/
+    val pendingWriteCount: Int get() = pendingCount.get()
+
+    /**
+     * 串行写队列:所有经 [AppConfigStore] 的写入按提交顺序落盘。
+     * 写入异常由提交的 block 负责处理;[AppConfigStore] 会移除对应 pending overlay,
+     * 使读取回退到最近一次已落盘快照。
+     */
+    fun launchWrite(block: suspend () -> Unit): Job {
+        pendingCount.incrementAndGet()
+        return scope.launch(writeDispatcher) {
+            try {
+                val elapsed = measureTime { runCatching { block() } }
+                if (elapsed > 500.milliseconds) {
+                    LogUtils.d(
+                        "SettingsWriter",
+                        "单次 edit 耗时 ${elapsed.inWholeMilliseconds}ms, 队列剩余 ${pendingCount.get() - 1}"
+                    )
+                }
+            } finally {
+                pendingCount.decrementAndGet()
+            }
+        }
+    }
+
+    /**
+     * 等待所有排队的写入落盘完成(最长等待 [timeoutMs] 毫秒)。
+     * 用于 restart 等必须确保"写入已持久化"的场景。
+     */
+    suspend fun awaitPendingWrites(timeoutMs: Long = 3000) {
+        withTimeoutOrNull(timeoutMs) {
+            while (pendingCount.get() > 0) {
+                delay(50)
+            }
+        }
+    }
+}

+ 2 - 4
app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt

@@ -45,14 +45,12 @@ object ThemeConfigStore {
 
     fun applyDayNight(context: Context) {
         initNightMode()
-        postEvent(EventBus.RECREATE, "")
         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
     }
 
     /**
-     * 切换深浅色但不发 RECREATE:Compose 界面通过 ThemeConfig.themeMode 快照状态
-     * 自动换色(保留 ToggleButton 等组件的过渡动画),setDefaultNightMode 只会重建
-     * 未在 manifest 声明 uiMode configChanges 的旧 View Activity
+     * Compose 界面通过 ThemeConfig.themeMode 快照状态自动换色;旧 View 界面由
+     * BaseActivity 的兼容策略决定热更新、重新绑定或受控重建。
      */
     fun applyDayNightLive() {
         initNightMode()

+ 2 - 4
app/src/main/java/io/legado/app/help/storage/Backup.kt

@@ -12,6 +12,7 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ReadBookConfig
@@ -22,9 +23,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.compress.ZipUtils
 import io.legado.app.utils.createFolderIfNotExist
-import io.legado.app.data.repository.dataStore
 import io.legado.app.utils.externalFiles
-import kotlinx.coroutines.flow.first
 import io.legado.app.utils.getFile
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.normalizeFileName
@@ -188,8 +187,7 @@ object Backup {
                 .writeText(GSON.toJson(it))
         }
         currentCoroutineContext().ensureActive()
-        val configMap = appCtx.dataStore.data.first()
-            .asMap()
+        val configMap = AppConfigStore.preferences.asMap()
             .mapKeys { it.key.name }
         val xmlBuilder = StringBuilder()
         xmlBuilder.append("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n")

+ 17 - 15
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -2,11 +2,27 @@ package io.legado.app.help.storage
 
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import splitties.init.appCtx
 
+internal val alwaysIgnoredPreferenceKeys = setOf(
+    PreferKey.defaultCover,
+    PreferKey.defaultCoverDark,
+    PreferKey.backupPath,
+    PreferKey.defaultBookTreeUri,
+    PreferKey.webDavDeviceName,
+    PreferKey.launcherIcon,
+    PreferKey.bitmapCacheSize,
+    PreferKey.webServiceWakeLock,
+    PreferKey.readAloudWakeLock,
+    PreferKey.audioPlayWakeLock,
+    LocalPreferencesKeys.PASSWORD.name,
+    LocalPreferencesKeys.MIGRATED_TO_SETTINGS.name,
+)
+
 /**
  * 备份配置
  */
@@ -49,20 +65,6 @@ object BackupConfig {
         appCtx.getString(R.string.local_book)
     )
 
-    //自动忽略keys
-    private val ignorePrefKeys = arrayOf(
-        PreferKey.defaultCover,
-        PreferKey.defaultCoverDark,
-        PreferKey.backupPath,
-        PreferKey.defaultBookTreeUri,
-        PreferKey.webDavDeviceName,
-        PreferKey.launcherIcon,
-        PreferKey.bitmapCacheSize,
-        PreferKey.webServiceWakeLock,
-        PreferKey.readAloudWakeLock,
-        PreferKey.audioPlayWakeLock
-    )
-
     //阅读配置
     private val readPrefKeys = arrayOf(
         PreferKey.readStyleSelect,
@@ -139,7 +141,7 @@ object BackupConfig {
     )
 
     fun keyIsNotIgnore(key: String, isBackup: Boolean = false): Boolean {
-        if (ignorePrefKeys.contains(key)) return false
+        if (key in alwaysIgnoredPreferenceKeys) return false
         if (isBackup) return true
         return when {
             ignoreReadConfig && readPrefKeys.contains(key) -> false

+ 17 - 67
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -3,12 +3,12 @@ package io.legado.app.help.storage
 import android.content.Context
 import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
-import androidx.core.content.edit
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
@@ -32,14 +32,16 @@ import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.upType
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.LocalConfig
+import io.legado.app.help.config.SettingsWriter
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.model.BookCover
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.utils.ACache
@@ -47,7 +49,6 @@ import io.legado.app.utils.FileUtils
 import io.legado.app.utils.GSON
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.compress.ZipUtils
-import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isContentScheme
@@ -58,7 +59,7 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.withContext
 import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
+import org.koin.core.component.get
 import org.xmlpull.v1.XmlPullParser
 import org.xmlpull.v1.XmlPullParserFactory
 import splitties.init.appCtx
@@ -70,7 +71,6 @@ import java.io.FileInputStream
  */
 object Restore : KoinComponent {
 
-    private val settingsRepository: SettingsRepository by inject()
     private const val TAG = "Restore"
 
     suspend fun restore(context: Context, uri: Uri) {
@@ -335,6 +335,7 @@ object Restore : KoinComponent {
         appCtx.toastOnUi(R.string.restore_success)
         withContext(Main) {
             delay(100)
+            get<AppLocaleGateway>().setLanguage(OtherConfig.language)
             if (!BuildConfig.DEBUG) {
                 LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
             }
@@ -395,68 +396,17 @@ object Restore : KoinComponent {
     }
 
     private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
-        val finalMap = mutableMapOf<String, Any>()
-        val legacyBookInfoBackground = map[PreferKey.bookInfoBackgroundBlur] as? String
-        val restoreNetworkCoverBackground =
-            legacyBookInfoBackground != null &&
-                    PreferKey.bookInfoNetworkCoverBackground !in map &&
-                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoBackgroundBlur) &&
-                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoNetworkCoverBackground)
-        val restoreDefaultCoverBackground =
-            legacyBookInfoBackground != null &&
-                    PreferKey.bookInfoDefaultCoverBackground !in map &&
-                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoBackgroundBlur) &&
-                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoDefaultCoverBackground)
-        appCtx.defaultSharedPreferences.edit {
-            map.forEach { (key, value) ->
-                if (BackupConfig.keyIsNotIgnore(key)) {
-                    when (key) {
-                        PreferKey.webDavPassword -> {
-                            val password = kotlin.runCatching {
-                                aes.decryptStr(value.toString())
-                            }.getOrNull() ?: let {
-                                if (appCtx.getPrefString(PreferKey.webDavPassword).isNullOrBlank()) {
-                                    value.toString()
-                                } else null
-                            }
-                            password?.let {
-                                putString(key, it)
-                                finalMap[key] = it
-                            }
-                        }
-
-                        else -> {
-                            if (value != null) {
-                                when (value) {
-                                    is Int -> { putInt(key, value); finalMap[key] = value }
-                                    is Boolean -> { putBoolean(key, value); finalMap[key] = value }
-                                    is Long -> { putLong(key, value); finalMap[key] = value }
-                                    is Double -> { // JSON 数字会被解析为 Double
-                                        val floatValue = value.toFloat()
-                                        putFloat(key, floatValue)
-                                        finalMap[key] = floatValue
-                                    }
-                                    is Float -> { putFloat(key, value); finalMap[key] = value }
-                                    is String -> { putString(key, value); finalMap[key] = value }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-            legacyBookInfoBackground?.let { backgroundMode ->
-                if (restoreNetworkCoverBackground) {
-                    putString(PreferKey.bookInfoNetworkCoverBackground, backgroundMode)
-                    finalMap[PreferKey.bookInfoNetworkCoverBackground] = backgroundMode
-                }
-                if (restoreDefaultCoverBackground) {
-                    putString(PreferKey.bookInfoDefaultCoverBackground, backgroundMode)
-                    finalMap[PreferKey.bookInfoDefaultCoverBackground] = backgroundMode
-                }
-            }
-        }
-        // 同步恢复到 DataStore
-        settingsRepository.batchPutFromMap(finalMap)
+        val finalMap = normalizeConfigMap(
+            map = map,
+            keyIsNotIgnore = { BackupConfig.keyIsNotIgnore(it) },
+            decryptWebDavPassword = { runCatching { aes.decryptStr(it) }.getOrNull() },
+            hasLocalWebDavPassword = !appCtx.getPrefString(PreferKey.webDavPassword)
+                .isNullOrBlank(),
+        )
+        // 经快照层批量恢复:立即对读侧生效(onRestoreFinish 的读取不再依赖回灌时机),单次原子 edit 落盘
+        AppConfigStore.putAll(finalMap)
+        // 恢复完成提示前等待落盘,dataStore.edit 返回即持久化完成
+        SettingsWriter.awaitPendingWrites()
     }
 
     private fun readXmlToMap(file: File): Map<String, Any?> {

+ 46 - 0
app/src/main/java/io/legado/app/help/storage/RestoreConfigNormalizer.kt

@@ -0,0 +1,46 @@
+package io.legado.app.help.storage
+
+import io.legado.app.constant.PreferKey
+
+/**
+ * 把 config.xml 解析出的原始键值规整为可直接批量写入设置存储的 map(纯函数,便于 JVM 单测):
+ * - [PreferKey.webDavPassword]:AES 解密;解密失败时本地无密码则保留原文,否则丢弃;
+ * - Double 转 Float(JSON 数字会被解析为 Double);
+ * - 旧版 [PreferKey.bookInfoBackgroundBlur] 拆分补写两个新背景键(备份中缺新键且未被忽略时);
+ * - 过滤忽略键与 null 值。
+ */
+internal fun normalizeConfigMap(
+    map: Map<String, Any?>,
+    keyIsNotIgnore: (String) -> Boolean,
+    decryptWebDavPassword: (String) -> String?,
+    hasLocalWebDavPassword: Boolean,
+): Map<String, Any> {
+    val finalMap = mutableMapOf<String, Any>()
+    map.forEach { (key, value) ->
+        if (!keyIsNotIgnore(key)) return@forEach
+        when (key) {
+            PreferKey.webDavPassword -> {
+                val password = decryptWebDavPassword(value.toString())
+                    ?: value.toString().takeIf { !hasLocalWebDavPassword }
+                password?.let { finalMap[key] = it }
+            }
+
+            else -> when (value) {
+                is Double -> finalMap[key] = value.toFloat()
+                is Int, is Boolean, is Long, is Float, is String -> finalMap[key] = value
+            }
+        }
+    }
+    val legacyBookInfoBackground = map[PreferKey.bookInfoBackgroundBlur] as? String
+    if (legacyBookInfoBackground != null && keyIsNotIgnore(PreferKey.bookInfoBackgroundBlur)) {
+        listOf(
+            PreferKey.bookInfoNetworkCoverBackground,
+            PreferKey.bookInfoDefaultCoverBackground,
+        ).forEach { newKey ->
+            if (newKey !in map && keyIsNotIgnore(newKey)) {
+                finalMap[newKey] = legacyBookInfoBackground
+            }
+        }
+    }
+    return finalMap
+}

+ 0 - 4
app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt

@@ -16,11 +16,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
-import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.getPrefString
-import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 
@@ -99,7 +97,6 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                     callChangeListener(value)
                     ThemeConfig.appTheme = value
                     notifyDataSetChanged()
-                    postEvent(EventBus.RECREATE, "")
                 }
             }
         }
@@ -157,4 +154,3 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
         val background : MaterialCardView = view.findViewById(R.id.cardView)
     }
 }
-

+ 9 - 7
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -45,6 +45,7 @@ import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
 import io.legado.app.help.readaloud.segment.toCanonicalSpeechParagraphs
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.AppConfigStore
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.glide.ImageLoader
@@ -60,13 +61,14 @@ import io.legado.app.utils.LogUtils
 import io.legado.app.utils.activityPendingIntent
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.observeEvent
-import io.legado.app.utils.observeSharedPreferences
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import org.koin.java.KoinJavaComponent.get
@@ -213,12 +215,12 @@ abstract class BaseReadAloudService : BaseService(),
             val startPos = it.getInt("startPos")
             newReadAloud(play, pageIndex, startPos)
         }
-        observeSharedPreferences { _, key ->
-            when (key) {
-                PreferKey.ignoreAudioFocus,
-                PreferKey.pauseReadAloudWhilePhoneCalls -> {
-                    initPhoneStateListener()
-                }
+        lifecycleScope.launch {
+            merge(
+                AppConfigStore.observeBoolean(PreferKey.ignoreAudioFocus).drop(1),
+                AppConfigStore.observeBoolean(PreferKey.pauseReadAloudWhilePhoneCalls).drop(1),
+            ).collect {
+                initPhoneStateListener()
             }
         }
     }

+ 18 - 3
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -8,6 +8,8 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
@@ -15,11 +17,11 @@ import io.legado.app.domain.model.readaloud.SpeechEngineRoute
 import io.legado.app.domain.model.readaloud.SpeechVoiceRouter
 import io.legado.app.domain.model.readaloud.SystemTtsVoiceConfig
 import io.legado.app.help.MediaHelp
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.fromJsonObject
@@ -27,14 +29,21 @@ import io.legado.app.utils.servicePendingIntent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 
 /**
  * 本地朗读
  */
-class TTSReadAloudService : BaseReadAloudService() {
+class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
 
     override val useSpeechPlaybackQueue: Boolean = true
 
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway by inject()
+    @Volatile
+    private var speechRateSetting: Int = 5
+
     private var textToSpeech: TextToSpeech? = null
     private var ttsInitFinish = false
     private val ttsUtteranceListener = TTSUtteranceListener()
@@ -50,6 +59,12 @@ class TTSReadAloudService : BaseReadAloudService() {
 
     override fun onCreate() {
         super.onCreate()
+        speechRateSetting = readAloudSettingsGateway.currentSettings.ttsSpeechRate
+        lifecycleScope.launch {
+            readAloudSettingsGateway.settings.collect {
+                speechRateSetting = it.ttsSpeechRate
+            }
+        }
         initTts()
     }
 
@@ -287,7 +302,7 @@ class TTSReadAloudService : BaseReadAloudService() {
                 initTts()
             }
         } else {
-            val speechRate = (ReadConfig.ttsSpeechRate + 5) / 10f
+            val speechRate = (speechRateSetting + 5) / 10f
             textToSpeech?.setSpeechRate(speechRate)
             if (reset && !pause) {
                 play()

+ 1 - 1
app/src/main/java/io/legado/app/ui/association/ImportReplaceRuleDialog.kt

@@ -124,7 +124,7 @@ class ImportReplaceRuleDialog() : BaseDialogFragment(R.layout.dialog_recycler_vi
             R.id.menu_new_group -> alertCustomGroup(item)
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepName, item.isChecked)
+                requireContext().putPrefBoolean(PreferKey.importKeepName, item.isChecked)
             }
         }
         return true

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt

@@ -12,7 +12,7 @@ class AllBookmarkActivity : BaseComposeActivity() {
     @Composable
     override fun Content() {
         MaterialTheme {
-            AllBookmarkScreen(
+            AllBookmarkRouteScreen(
                 onBack = { finish() }
             )
         }
@@ -21,4 +21,4 @@ class AllBookmarkActivity : BaseComposeActivity() {
         super.onCreate(savedInstanceState)
 
     }
-}
+}

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

@@ -34,7 +34,7 @@ import androidx.compose.material3.ListItem
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -75,6 +75,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 kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -83,21 +85,58 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
     ExperimentalMaterial3ExpressiveApi::class
 )
 @Composable
-fun AllBookmarkScreen(
+fun AllBookmarkRouteScreen(
     viewModel: AllBookmarkViewModel = koinViewModel(),
     onBack: () -> Unit,
 ) {
     val context = LocalContext.current
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    var pendingExportIsMd by remember { mutableStateOf(false) }
+    val exportLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocumentTree()
+    ) { uri: Uri? ->
+        uri?.let {
+            viewModel.onIntent(AllBookmarkIntent.Export(it, pendingExportIsMd))
+            Toast.makeText(context, context.getString(R.string.export_started), Toast.LENGTH_SHORT).show()
+        }
+    }
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is AllBookmarkEffect.ShowMessage ->
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+            }
+        }
+    }
+
+    AllBookmarkScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        onRequestExport = { isMarkdown ->
+            pendingExportIsMd = isMarkdown
+            exportLauncher.launch(null)
+        },
+        onBack = onBack,
+    )
+}
+
+@Composable
+fun AllBookmarkScreen(
+    state: BookmarkUiState,
+    onIntent: (AllBookmarkIntent) -> Unit,
+    onRequestExport: (Boolean) -> Unit,
+    onBack: () -> Unit,
+) {
 
-    val uiState by viewModel.uiState.collectAsState()
     val contentState = when {
-        uiState.isLoading -> "LOADING"
-        uiState.bookmarks.isEmpty() -> "EMPTY"
+        state.isLoading -> "LOADING"
+        state.bookmarks.isEmpty() -> "EMPTY"
         else -> "CONTENT"
     }
-    val searchText = uiState.searchQuery
-    val collapsedGroups = uiState.collapsedGroups
-    val bookmarksGrouped = uiState.bookmarks
+    val searchText = state.searchQuery
+    val collapsedGroups = state.collapsedGroups
+    val bookmarksGrouped = state.bookmarks
     val bookmarkGroups = remember(bookmarksGrouped) { bookmarksGrouped.entries.toList() }
     val allKeys = bookmarksGrouped.keys
     val isAllCollapsed =
@@ -108,7 +147,6 @@ fun AllBookmarkScreen(
     var showSearch by remember { mutableStateOf(false) }
     var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
     var showBottomSheet by remember { mutableStateOf(false) }
-    var pendingExportIsMd by remember { mutableStateOf(false) }
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val stickyGroup by remember(bookmarkGroups, collapsedGroups, listState) {
@@ -126,15 +164,6 @@ fun AllBookmarkScreen(
         }
     }
 
-    val exportLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
-    ) { uri: Uri? ->
-        uri?.let {
-            viewModel.exportBookmark(it, pendingExportIsMd)
-            Toast.makeText(context, context.getString(R.string.export_started), Toast.LENGTH_SHORT).show()
-        }
-    }
-
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
@@ -148,7 +177,7 @@ fun AllBookmarkScreen(
                     actions = {
                         if (bookmarksGrouped.isNotEmpty()) {
                             TopBarActionButton(
-                                onClick = { viewModel.toggleAllCollapse(allKeys) },
+                                onClick = { onIntent(AllBookmarkIntent.ToggleAllCollapse(allKeys)) },
                                 imageVector = if (isAllCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
                                 contentDescription = stringResource(
                                     if (isAllCollapsed) {
@@ -163,7 +192,7 @@ fun AllBookmarkScreen(
                             onClick = {
                                 showSearch = !showSearch
                                 if (!showSearch) {
-                                    viewModel.onSearchQueryChanged("")
+                                    onIntent(AllBookmarkIntent.SetSearchQuery(""))
                                 }
                             },
                             imageVector = Icons.Default.Search,
@@ -182,16 +211,14 @@ fun AllBookmarkScreen(
                                 text = stringResource(R.string.export_bookmarks_json),
                                 onClick = {
                                     showMenu = false
-                                    pendingExportIsMd = false
-                                    exportLauncher.launch(null)
+                                    onRequestExport(false)
                                 }
                             )
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.export_bookmarks_markdown),
                                 onClick = {
                                     showMenu = false
-                                    pendingExportIsMd = true
-                                    exportLauncher.launch(null)
+                                    onRequestExport(true)
                                 }
                             )
                         }
@@ -206,7 +233,7 @@ fun AllBookmarkScreen(
                 ) {
                     SearchBar(
                         query = searchText,
-                        onQueryChange = { viewModel.onSearchQueryChanged(it) },
+                        onQueryChange = { onIntent(AllBookmarkIntent.SetSearchQuery(it)) },
                         placeholder = stringResource(R.string.search),
                         scrollState = listState,
                         scope = scope
@@ -279,7 +306,9 @@ fun AllBookmarkScreen(
                                             title = headerKey.bookName,
                                             subtitle = headerKey.bookAuthor,
                                             isCollapsed = isCollapsed,
-                                            onToggle = { viewModel.toggleGroupCollapse(headerKey) },
+                                            onToggle = {
+                                                onIntent(AllBookmarkIntent.ToggleGroupCollapse(headerKey))
+                                            },
                                             isMiuix = isMiuix
                                         )
 
@@ -350,11 +379,11 @@ fun AllBookmarkScreen(
                 editingBookmark = null
             },
             onSave = { updatedBookmark ->
-                viewModel.updateBookmark(updatedBookmark)
+                onIntent(AllBookmarkIntent.UpdateBookmark(updatedBookmark))
                 showBottomSheet = false
             },
             onDelete = { bookmarkToDelete ->
-                viewModel.deleteBookmark(bookmarkToDelete)
+                onIntent(AllBookmarkIntent.DeleteBookmark(bookmarkToDelete))
                 showBottomSheet = false
             }
         )

+ 45 - 18
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -2,7 +2,7 @@ package io.legado.app.ui.book.bookmark
 
 import android.app.Application
 import android.net.Uri
-import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.dao.BookmarkDao
@@ -11,19 +11,27 @@ import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
 import io.legado.app.utils.createFileIfNotExist
 import io.legado.app.utils.openOutputStream
-import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.writeToOutputStream
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Locale
@@ -35,7 +43,7 @@ data class BookmarkGroupHeader(
     override fun toString(): String = "$bookName|$bookAuthor"
 }
 
-@Immutable
+@Stable
 data class BookmarkItemUi(
     val id: Long,
     val content: String,
@@ -46,15 +54,28 @@ data class BookmarkItemUi(
     val rawBookmark: Bookmark
 )
 
-@Immutable
+@Stable
 data class BookmarkUiState(
     val isLoading: Boolean = false,
-    val bookmarks: Map<BookmarkGroupHeader, List<BookmarkItemUi>> = emptyMap(),
+    val bookmarks: ImmutableMap<BookmarkGroupHeader, ImmutableList<BookmarkItemUi>> = persistentMapOf(),
     val error: Throwable? = null,
     val searchQuery: String = "",
-    val collapsedGroups: Set<String> = emptySet()
+    val collapsedGroups: ImmutableSet<String> = persistentSetOf()
 )
 
+sealed interface AllBookmarkIntent {
+    data class SetSearchQuery(val query: String) : AllBookmarkIntent
+    data class ToggleGroupCollapse(val group: BookmarkGroupHeader) : AllBookmarkIntent
+    data class ToggleAllCollapse(val groups: Set<BookmarkGroupHeader>) : AllBookmarkIntent
+    data class UpdateBookmark(val bookmark: Bookmark) : AllBookmarkIntent
+    data class DeleteBookmark(val bookmark: Bookmark) : AllBookmarkIntent
+    data class Export(val treeUri: Uri, val isMarkdown: Boolean) : AllBookmarkIntent
+}
+
+sealed interface AllBookmarkEffect {
+    data class ShowMessage(val message: String) : AllBookmarkEffect
+}
+
 
 class AllBookmarkViewModel(
     application: Application,
@@ -63,6 +84,8 @@ class AllBookmarkViewModel(
 
     private val _searchQuery = MutableStateFlow("")
     private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
+    private val _effects = MutableSharedFlow<AllBookmarkEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     @OptIn(ExperimentalCoroutinesApi::class)
     val uiState: StateFlow<BookmarkUiState> = combine(
@@ -96,12 +119,14 @@ class AllBookmarkViewModel(
             .groupBy { item ->
                 BookmarkGroupHeader(item.bookName, item.bookAuthor)
             }
+            .mapValues { (_, items) -> items.toImmutableList() }
+            .toImmutableMap()
 
         BookmarkUiState(
             isLoading = false,
             bookmarks = grouped,
             searchQuery = query,
-            collapsedGroups = collapsed
+            collapsedGroups = collapsed.toImmutableSet()
         )
     }.catch { e ->
         emit(BookmarkUiState(isLoading = false, error = e))
@@ -111,8 +136,15 @@ class AllBookmarkViewModel(
         initialValue = BookmarkUiState(isLoading = true)
     )
 
-    fun onSearchQueryChanged(query: String) {
-        _searchQuery.value = query
+    fun onIntent(intent: AllBookmarkIntent) {
+        when (intent) {
+            is AllBookmarkIntent.SetSearchQuery -> _searchQuery.value = intent.query
+            is AllBookmarkIntent.ToggleGroupCollapse -> toggleGroupCollapse(intent.group)
+            is AllBookmarkIntent.ToggleAllCollapse -> toggleAllCollapse(intent.groups)
+            is AllBookmarkIntent.UpdateBookmark -> updateBookmark(intent.bookmark)
+            is AllBookmarkIntent.DeleteBookmark -> deleteBookmark(intent.bookmark)
+            is AllBookmarkIntent.Export -> exportBookmark(intent.treeUri, intent.isMarkdown)
+        }
     }
 
     fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
@@ -148,7 +180,6 @@ class AllBookmarkViewModel(
     fun exportBookmark(treeUri: Uri, isMarkdown: Boolean) {
         viewModelScope.launch(Dispatchers.IO) {
             try {
-                val context = getApplication<Application>()
                 val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
                 val suffix = if (isMarkdown) "md" else "json"
                 val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
@@ -165,14 +196,10 @@ class AllBookmarkViewModel(
                     }
                 }
 
-                withContext(Dispatchers.Main) {
-                    context.toastOnUi("导出成功: $fileName")
-                }
+                _effects.emit(AllBookmarkEffect.ShowMessage("导出成功: $fileName"))
             } catch (e: Exception) {
                 e.printStackTrace()
-                withContext(Dispatchers.Main) {
-                    getApplication<Application>().toastOnUi("导出失败: ${e.message}")
-                }
+                _effects.emit(AllBookmarkEffect.ShowMessage("导出失败: ${e.message}"))
             }
         }
     }
@@ -194,4 +221,4 @@ class AllBookmarkViewModel(
         }
         outputStream.write(sb.toString().toByteArray())
     }
-}
+}

+ 3 - 4
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt

@@ -5,18 +5,17 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.datastore.preferences.core.Preferences
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.ui.config.prefDelegate
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.launch
-import splitties.init.appCtx
 
 object ChangeSourceConfig {
 
-    private val repo = LocalPreferencesRepository(appCtx)
+    private val repo = SettingsRepository()
     private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
 
     private val _searchScope = mutableStateOf("")
@@ -120,7 +119,7 @@ object ChangeSourceConfig {
         observe(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, false, _loadWordCount)
     }
 
-    private fun <T> observe(
+    private fun <T : Any> observe(
         key: Preferences.Key<T>,
         defaultValue: T,
         state: MutableState<T>,

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt

@@ -20,6 +20,7 @@ data class ExploreShowUiState(
     val isEnd: Boolean = false,
     val errorMsg: String? = null,
     val sheet: ExploreShowSheet = ExploreShowSheet.None,
+    val filterStateId: Int = 0,
 )
 
 @Stable

+ 46 - 20
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -51,7 +51,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.R
 import io.legado.app.domain.model.BookShelfState
-import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.responsiveHazeEffect
@@ -90,7 +89,7 @@ private enum class BookFilterState(val id: Int) {
     ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
 )
 @Composable
-fun ExploreShowScreen(
+fun ExploreShowRouteScreen(
     viewModel: ExploreShowViewModel = koinViewModel(),
     title: String = "",
     onBack: () -> Unit,
@@ -119,10 +118,37 @@ fun ExploreShowScreen(
         }
     }
 
+    ExploreShowScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        title = title,
+        onBack = onBack,
+        onBookClick = onBookClick,
+        sharedTransitionScope = sharedTransitionScope,
+        animatedVisibilityScope = animatedVisibilityScope,
+    )
+}
+
+@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
+@OptIn(
+    ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
+    ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
+)
+@Composable
+fun ExploreShowScreen(
+    state: ExploreShowUiState,
+    onIntent: (ExploreShowIntent) -> Unit,
+    title: String = "",
+    onBack: () -> Unit,
+    onBookClick: (SearchBook, String?) -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
 
-    val filterStateId = CoverConfig.exploreFilterState
+    val filterStateId = state.filterStateId
     val books = remember(state.books, filterStateId) {
         val filter = BookFilterState.fromId(filterStateId)
         when (filter) {
@@ -165,7 +191,7 @@ fun ExploreShowScreen(
         state.books.size,
     ) {
         if (shouldLoadMore && canLoadMore) {
-            viewModel.onIntent(ExploreShowIntent.LoadMore)
+            onIntent(ExploreShowIntent.LoadMore)
         }
     }
 
@@ -173,7 +199,7 @@ fun ExploreShowScreen(
     // but the ViewModel hasn't reached the end of data yet.
     LaunchedEffect(books.isEmpty(), state.isLoading, state.isEnd, state.books.size) {
         if (books.isEmpty() && !state.isLoading && !state.isEnd && state.books.isNotEmpty()) {
-            viewModel.onIntent(ExploreShowIntent.ForceLoadNext)
+            onIntent(ExploreShowIntent.ForceLoadNext)
         }
     }
 
@@ -191,7 +217,7 @@ fun ExploreShowScreen(
 
     AppModalBottomSheet(
         show = state.sheet == ExploreShowSheet.GridCount,
-        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) }
+        onDismissRequest = { onIntent(ExploreShowIntent.DismissSheet) }
     ) {
         Row(
             modifier = Modifier
@@ -218,7 +244,7 @@ fun ExploreShowScreen(
         AppSlider(
             value = state.gridCount.toFloat(),
             onValueChange = {
-                viewModel.onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
+                onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
             },
             valueRange = 1f..10f,
             steps = 8,
@@ -231,11 +257,11 @@ fun ExploreShowScreen(
 
     ExploreKindSelectSheet(
         show = state.sheet == ExploreShowSheet.KindSelect,
-        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) },
+        onDismissRequest = { onIntent(ExploreShowIntent.DismissSheet) },
         sourceUrl = state.sourceUrl,
         onSelected = { selectedKinds ->
             selectedKinds.firstOrNull()?.let { kind ->
-                viewModel.onIntent(ExploreShowIntent.SwitchKind(kind))
+                onIntent(ExploreShowIntent.SwitchKind(kind))
             }
         }
     )
@@ -259,7 +285,7 @@ fun ExploreShowScreen(
                     ) {
                         TopBarActionButton(
                             onClick = {
-                                viewModel.onIntent(
+                                onIntent(
                                     ExploreShowIntent.ShowSheet(
                                         ExploreShowSheet.GridCount
                                     )
@@ -271,13 +297,13 @@ fun ExploreShowScreen(
                     }
 
                     TopBarActionButton(
-                        onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
+                        onClick = { onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
                         imageVector = Icons.Outlined.FilterAlt,
                         contentDescription = stringResource(R.string.select_or_search_category)
                     )
 
                     TopBarActionButton(
-                        onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
+                        onClick = { onIntent(ExploreShowIntent.ToggleLayout) },
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
                         contentDescription = stringResource(R.string.a11y_switch_layout)
                     )
@@ -289,7 +315,7 @@ fun ExploreShowScreen(
         AppPullToRefresh(
             modifier = Modifier.fillMaxSize(),
             isRefreshing = state.isRefreshing,
-            onRefresh = { viewModel.onIntent(ExploreShowIntent.Refresh) },
+            onRefresh = { onIntent(ExploreShowIntent.Refresh) },
             topPadding = paddingValues.calculateTopPadding(),
             scrollBehavior = scrollBehavior
         ) {
@@ -326,7 +352,7 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = {
-                                    viewModel.onIntent(
+                                    onIntent(
                                         ExploreShowIntent.OpenBook(
                                             item.book,
                                             sharedCoverKey
@@ -348,8 +374,8 @@ fun ExploreShowScreen(
                             item(span = { GridItemSpan(maxLineSpan) }) {
                                 ExploreShowLoadMoreFooter(
                                     state = state,
-                                    onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
-                                    onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
+                                    onRetry = { onIntent(ExploreShowIntent.LoadMore) },
+                                    onLoadMore = { onIntent(ExploreShowIntent.ForceLoadNext) },
                                 )
                             }
                         }
@@ -377,7 +403,7 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = {
-                                    viewModel.onIntent(
+                                    onIntent(
                                         ExploreShowIntent.OpenBook(
                                             item.book,
                                             sharedCoverKey
@@ -399,8 +425,8 @@ fun ExploreShowScreen(
                             item {
                                 ExploreShowLoadMoreFooter(
                                     state = state,
-                                    onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
-                                    onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
+                                    onRetry = { onIntent(ExploreShowIntent.LoadMore) },
+                                    onLoadMore = { onIntent(ExploreShowIntent.ForceLoadNext) },
                                 )
                             }
                         }
@@ -424,7 +450,7 @@ fun ExploreShowScreen(
             onBookClick(book, sharedCoverKey)
         },
         onAddToShelf = { book ->
-            viewModel.onIntent(ExploreShowIntent.AddToShelf(book))
+            onIntent(ExploreShowIntent.AddToShelf(book))
         },
     )
 }

+ 12 - 4
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -10,9 +10,10 @@ import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
+import io.legado.app.domain.gateway.CoverSettingsGateway
 import android.content.res.Configuration
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.utils.stackTraceStr
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -51,7 +52,8 @@ class ExploreShowViewModel(
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
+    private val coverSettingsGateway: CoverSettingsGateway,
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
@@ -135,13 +137,18 @@ class ExploreShowViewModel(
 
     private fun combineUiState() {
         viewModelScope.launch {
+            val displayAndCoverSettings = combine(
+                _displayState,
+                coverSettingsGateway.settings,
+            ) { displayState, coverSettings -> displayState to coverSettings }
             combine(
                 _rawBooks,
                 _bookshelf,
                 _loadState,
                 _kindState,
-                _displayState,
-            ) { rawBooks, bookshelf, loadState, kindState, displayState ->
+                displayAndCoverSettings,
+            ) { rawBooks, bookshelf, loadState, kindState, displayAndCover ->
+                val (displayState, coverSettings) = displayAndCover
                 val books = rawBooks.map { item ->
                     ExploreBookItemUi(
                         book = item,
@@ -166,6 +173,7 @@ class ExploreShowViewModel(
                     isEnd = loadState.isEnd,
                     errorMsg = loadState.errorMsg,
                     sheet = displayState.sheet,
+                    filterStateId = coverSettings.exploreFilterState,
                 )
             }.collect { newState ->
                 _uiState.value = newState

+ 79 - 54
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -54,7 +54,6 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.selector
-import io.legado.app.ui.config.importBookConfig.ImportBookConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.AppPullToRefresh
@@ -237,7 +236,7 @@ private fun ImportBookContent(
 }
 
 @Composable
-fun ImportBookScreen(
+fun ImportBookRouteScreen(
     onBackClick: () -> Unit,
     viewModel: ImportBookViewModel = koinViewModel()
 ) {
@@ -246,7 +245,7 @@ fun ImportBookScreen(
     var showFolderPicker by remember { mutableStateOf(false) }
     var showImportFileNameDialog by remember { mutableStateOf(false) }
     var pendingSingleAddBook by remember { mutableStateOf<ImportBook?>(null) }
-    var fileNameJs by remember { mutableStateOf(ImportBookConfig.bookImportFileName.orEmpty()) }
+    var fileNameJs by remember { mutableStateOf("") }
     var pickerTarget by remember { mutableStateOf(ImportFolderPickTarget.IMPORT_FOLDER) }
     val selectDocTree = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocumentTree()
@@ -260,16 +259,12 @@ fun ImportBookScreen(
     }
     val handleBack = {
         when {
-            uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
+            uiState.selectedIds.isNotEmpty() -> viewModel.dispatch(ImportBookIntent.ClearSelection)
             uiState.canGoBack -> viewModel.dispatch(ImportBookIntent.NavigateBack)
             else -> onBackClick()
         }
     }
 
-    BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) {
-        handleBack()
-    }
-
     LaunchedEffect(viewModel) {
         viewModel.dispatch(ImportBookIntent.Initialize)
         viewModel.effects.collect { effect ->
@@ -308,93 +303,123 @@ fun ImportBookScreen(
                 is ImportBookEffect.ShowToastRes -> {
                     context.toastOnUi(effect.resId)
                 }
+
+                is ImportBookEffect.ShowToast -> context.toastOnUi(effect.message)
             }
         }
     }
 
+    ImportBookScreen(
+        state = uiState,
+        onIntent = viewModel::dispatch,
+        onBackClick = handleBack,
+        showFolderPicker = showFolderPicker,
+        onShowFolderPickerChange = { showFolderPicker = it },
+        showImportFileNameDialog = showImportFileNameDialog,
+        onShowImportFileNameDialogChange = { showImportFileNameDialog = it },
+        fileNameJs = fileNameJs,
+        onFileNameJsChange = { fileNameJs = it },
+        pendingSingleAddBook = pendingSingleAddBook,
+        onPendingSingleAddBookChange = { pendingSingleAddBook = it },
+        onSelectBookFiles = {
+            selectBookFiles.launch(
+                arrayOf(
+                    "text/plain", "application/epub+zip", "application/pdf",
+                    "application/x-mobipocket-ebook", "application/vnd.amazon.ebook",
+                    "application/zip", "application/x-rar-compressed",
+                    "application/x-7z-compressed", "application/octet-stream",
+                )
+            )
+        },
+    )
+}
+
+@Composable
+fun ImportBookScreen(
+    state: ImportBookUiState,
+    onIntent: (ImportBookIntent) -> Unit,
+    onBackClick: () -> Unit,
+    showFolderPicker: Boolean,
+    onShowFolderPickerChange: (Boolean) -> Unit,
+    showImportFileNameDialog: Boolean,
+    onShowImportFileNameDialogChange: (Boolean) -> Unit,
+    fileNameJs: String,
+    onFileNameJsChange: (String) -> Unit,
+    pendingSingleAddBook: ImportBook?,
+    onPendingSingleAddBookChange: (ImportBook?) -> Unit,
+    onSelectBookFiles: () -> Unit,
+) {
+    BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) { onBackClick() }
+
     FilePickerSheet(
         show = showFolderPicker,
-        onDismissRequest = { showFolderPicker = false },
+        onDismissRequest = { onShowFolderPickerChange(false) },
         title = stringResource(R.string.select_folder),
         onSelectSysDir = {
-            showFolderPicker = false
-            viewModel.dispatch(ImportBookIntent.SelectFolderClick)
+            onShowFolderPickerChange(false)
+            onIntent(ImportBookIntent.SelectFolderClick)
         }
     )
 
     AppAlertDialog(
         show = showImportFileNameDialog,
-        onDismissRequest = { showImportFileNameDialog = false },
+        onDismissRequest = { onShowImportFileNameDialogChange(false) },
         title = stringResource(R.string.import_file_name),
         content = {
             AppText("Use js to parse file name from src, then assign name/author.")
             OutlinedTextField(
                 value = fileNameJs,
-                onValueChange = { fileNameJs = it },
+                onValueChange = onFileNameJsChange,
                 label = { AppText("js") }
             )
         },
         confirmText = stringResource(android.R.string.ok),
         onConfirm = {
-            ImportBookConfig.bookImportFileName = fileNameJs
-            showImportFileNameDialog = false
+            onIntent(ImportBookIntent.SetFileNameRule(fileNameJs))
+            onShowImportFileNameDialogChange(false)
         },
         dismissText = stringResource(android.R.string.cancel),
-        onDismiss = { showImportFileNameDialog = false }
+        onDismiss = { onShowImportFileNameDialogChange(false) }
     )
 
     pendingSingleAddBook?.let { book ->
         AppAlertDialog(
-            data = book,
-            onDismissRequest = { pendingSingleAddBook = null },
+            show = true,
+            onDismissRequest = { onPendingSingleAddBookChange(null) },
             title = stringResource(R.string.add_to_bookshelf),
             text = stringResource(R.string.check_add_bookshelf, book.name),
             confirmText = stringResource(android.R.string.ok),
             onConfirm = {
-                viewModel.dispatch(ImportBookIntent.AddSingleToBookshelf(book))
-                pendingSingleAddBook = null
+                onIntent(ImportBookIntent.AddSingleToBookshelf(book))
+                onPendingSingleAddBookChange(null)
             },
             dismissText = stringResource(android.R.string.cancel),
-            onDismiss = { pendingSingleAddBook = null }
+            onDismiss = { onPendingSingleAddBookChange(null) }
         )
     }
 
     ImportBookContent(
-        state = uiState,
-        onBackClick = handleBack,
-        onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
-        onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
-        onSelectFolder = { showFolderPicker = true },
-        onSelectBookFiles = {
-            selectBookFiles.launch(
-                arrayOf(
-                    "text/plain",
-                    "application/epub+zip",
-                    "application/pdf",
-                    "application/x-mobipocket-ebook",
-                    "application/vnd.amazon.ebook",
-                    "application/zip",
-                    "application/x-rar-compressed",
-                    "application/x-7z-compressed",
-                    "application/octet-stream"
-                )
-            )
-        },
-        onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
+        state = state,
+        onBackClick = onBackClick,
+        onSearchToggle = { onIntent(ImportBookIntent.SearchToggle(it)) },
+        onSearchQueryChange = { onIntent(ImportBookIntent.SearchQueryChange(it)) },
+        onSelectFolder = { onShowFolderPickerChange(true) },
+        onSelectBookFiles = onSelectBookFiles,
+        onScanFolder = { onIntent(ImportBookIntent.ScanFolder) },
         onImportFileName = {
-            fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
-            showImportFileNameDialog = true
+            onFileNameJsChange(state.fileNameRule)
+            onShowImportFileNameDialogChange(true)
         },
-        onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) },
-        onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) },
-        onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) },
-        onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) },
-        onClearSelection = { viewModel.clearSelection() },
-        onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) },
-        onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) },
-        onItemAddToBookshelf = { pendingSingleAddBook = it },
-        onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) },
-        onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) }
+        onSortChange = { onIntent(ImportBookIntent.SortChange(it)) },
+        onNavigateBack = { onIntent(ImportBookIntent.NavigateBack) },
+        onNavigateToLevel = { onIntent(ImportBookIntent.NavigateToLevel(it)) },
+        onSelectAll = { onIntent(ImportBookIntent.SelectAll) },
+        onClearSelection = { onIntent(ImportBookIntent.ClearSelection) },
+        onSelectInvert = { onIntent(ImportBookIntent.SelectInvert) },
+        onAddToBookshelf = { onIntent(ImportBookIntent.AddToBookshelf) },
+        onItemAddToBookshelf = onPendingSingleAddBookChange,
+        onDeleteSelection = { onIntent(ImportBookIntent.DeleteSelection) },
+        onItemClick = { onIntent(ImportBookIntent.ItemClick(it)) }
     )
 }
 

+ 23 - 11
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -30,7 +30,6 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.list
 import io.legado.app.utils.mapParallel
 import io.legado.app.utils.takePersistablePermissionSafely
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
@@ -62,7 +61,8 @@ data class ImportBookUiState(
     val interaction: InteractionState = InteractionState(),
     val pathNames: List<String> = emptyList(),
     val canGoBack: Boolean = false,
-    val sort: Int = 0
+    val sort: Int = 0,
+    val fileNameRule: String = "",
 ) : ListUiState<ImportBook> {
     override val isSearch: Boolean get() = interaction.isSearchMode
     override val isLoading: Boolean get() = interaction.isLoading
@@ -86,12 +86,14 @@ sealed interface ImportBookIntent {
     data class NavigateToLevel(val level: Int) : ImportBookIntent
     data object SelectAll : ImportBookIntent
     data object SelectInvert : ImportBookIntent
+    data object ClearSelection : ImportBookIntent
     data object AddToBookshelf : ImportBookIntent
     data class AddSingleToBookshelf(val item: ImportBook) : ImportBookIntent
     data object DeleteSelection : ImportBookIntent
     data class ItemClick(val item: ImportBook) : ImportBookIntent
     data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
     data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
+    data class SetFileNameRule(val value: String) : ImportBookIntent
 }
 
 sealed interface ImportBookEffect {
@@ -104,6 +106,7 @@ sealed interface ImportBookEffect {
     data class ShowArchiveEntries(val fileDoc: FileDoc, val fileNames: List<String>) : ImportBookEffect
     data class ShowImportArchiveDialog(val fileDoc: FileDoc, val fileName: String) : ImportBookEffect
     data class ShowToastRes(val resId: Int) : ImportBookEffect
+    data class ShowToast(val message: String) : ImportBookEffect
 }
 
 class ImportBookViewModel(application: Application) : BaseViewModel(application) {
@@ -157,6 +160,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             is ImportBookIntent.NavigateToLevel -> navigateToLevel(intent.level)
             ImportBookIntent.SelectAll -> selectAllCheckable()
             ImportBookIntent.SelectInvert -> invertSelection()
+            ImportBookIntent.ClearSelection -> clearSelection()
             ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
             is ImportBookIntent.AddSingleToBookshelf -> addSingleToBookshelf(intent.item)
             ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
@@ -170,6 +174,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 intent.fileDoc,
                 intent.fileName
             )
+            is ImportBookIntent.SetFileNameRule -> {
+                ImportBookConfig.bookImportFileName = intent.value
+            }
         }
     }
 
@@ -220,7 +227,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             interaction = state.interaction,
             pathNames = pathNames,
             canGoBack = state.subDocs.isNotEmpty(),
-            sort = state.sort
+            sort = state.sort,
+            fileNameRule = ImportBookConfig.bookImportFileName.orEmpty(),
         )
     }.flowOn(Dispatchers.Default)
         .stateIn(
@@ -539,10 +547,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(selectedBooks.map { it.file.uri })
         }.onError {
-            context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请尝试重新选择文件夹"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }.onFinally {
             clearSelection()
         }
@@ -554,10 +562,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(uris)
         }.onError {
-            context.toastOnUi("添加书架失败,请重新选择书籍文件")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请重新选择书籍文件"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }
     }
 
@@ -566,10 +574,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(listOf(item.file.uri))
         }.onError {
-            context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请尝试重新选择文件夹"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }.onFinally {
             _state.update { state ->
                 state.copy(selectedIds = state.selectedIds - item.selectionId)
@@ -675,7 +683,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 }
             }.onFailure {
                 withContext(Main) {
-                    context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
+                    _effects.tryEmit(
+                        ImportBookEffect.ShowToast("扫描文件夹出错\n${it.localizedMessage}")
+                    )
                 }
                 _state.update { state ->
                     state.copy(interaction = state.interaction.copy(isLoading = false))
@@ -714,7 +724,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 )
             }
         }.onError {
-            context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
+            _effects.tryEmit(
+                ImportBookEffect.ShowToast("获取文件列表出错\n${it.localizedMessage}")
+            )
             _state.update { state ->
                 state.copy(interaction = state.interaction.copy(isLoading = false))
             }

+ 50 - 32
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt

@@ -107,7 +107,7 @@ sealed class RemoteBookSheet {
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @Composable
-fun RemoteBookScreen(
+fun RemoteBookRouteScreen(
     viewModel: RemoteBookViewModel = koinViewModel(),
     onBackClick: () -> Unit
 ) {
@@ -188,7 +188,7 @@ fun RemoteBookScreen(
                         showSheet = null
                     },
                     onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) },
-                    onDelete = { viewModel.deleteServer(it) },
+                    onDelete = { viewModel.dispatch(RemoteBookIntent.DeleteServer(it)) },
                     onDefault = {
                         viewModel.dispatch(RemoteBookIntent.SelectServer(AppConst.DEFAULT_WEBDAV_ID))
                         showSheet = null
@@ -200,7 +200,7 @@ fun RemoteBookScreen(
                 ServerConfigSheetContent(
                     server = state.server,
                     onSave = {
-                        viewModel.saveServer(it)
+                        viewModel.dispatch(RemoteBookIntent.SaveServer(it))
                         showSheet = RemoteBookSheet.Servers
                     },
                     onCancel = { showSheet = RemoteBookSheet.Servers }
@@ -211,8 +211,6 @@ fun RemoteBookScreen(
         }
     }
 
-    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-
     LaunchedEffect(viewModel) {
         viewModel.dispatch(RemoteBookIntent.Initialize)
         viewModel.effects.collect { effect ->
@@ -253,17 +251,37 @@ fun RemoteBookScreen(
         }
     }
 
+    RemoteBookScreen(
+        state = uiState,
+        onIntent = viewModel::dispatch,
+        onBackClick = onBackClick,
+        onOpenServers = { showSheet = RemoteBookSheet.Servers },
+        onRequestReimport = { dialogState = RemoteBookDialog.ReImport(it) },
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun RemoteBookScreen(
+    state: RemoteBookUiState,
+    onIntent: (RemoteBookIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onOpenServers: () -> Unit,
+    onRequestReimport: (RemoteBook) -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
     ListScaffold(
         title = "远程书籍",
-        state = uiState,
+        state = state,
         scrollBehavior = scrollBehavior,
         onBackClick = onBackClick,
-        onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) },
-        onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) },
+        onSearchToggle = { onIntent(RemoteBookIntent.SearchToggle(it)) },
+        onSearchQueryChange = { onIntent(RemoteBookIntent.SearchChange(it)) },
         searchPlaceholder = "搜索",
         topBarActions = {
             TopBarActionButton(
-                onClick = { showSheet = RemoteBookSheet.Servers },
+                onClick = onOpenServers,
                 imageVector = Icons.Default.Storage,
                 contentDescription = stringResource(R.string.a11y_server_list)
             )
@@ -272,11 +290,11 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
                 text = "按名称排序",
                 onClick = {
-                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
+                    onIntent(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
                     dismiss()
                 },
                 trailingIcon = {
-                    if (uiState.sortKey == RemoteBookSort.Name) {
+                    if (state.sortKey == RemoteBookSort.Name) {
                         Icon(Icons.Default.Check, null)
                     }
                 }
@@ -284,11 +302,11 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
                 text = "按时间排序",
                 onClick = {
-                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
+                    onIntent(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
                     dismiss()
                 },
                 trailingIcon = {
-                    if (uiState.sortKey == RemoteBookSort.Default) {
+                    if (state.sortKey == RemoteBookSort.Default) {
                         Icon(Icons.Default.Check, null)
                     }
                 }
@@ -296,25 +314,25 @@ fun RemoteBookScreen(
         },
         bottomContent = {
             PathNavigationBar(
-                pathNames = uiState.pathNames,
-                canGoBack = uiState.canGoBack,
-                onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) },
-                onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) }
+                pathNames = state.pathNames,
+                canGoBack = state.canGoBack,
+                onNavigateBack = { onIntent(RemoteBookIntent.NavigateBack) },
+                onNavigateToLevel = { onIntent(RemoteBookIntent.NavigateToLevel(it)) }
             )
         },
         selectionActions = SelectionActions(
-            onClearSelection = { viewModel.clearSelection() },
-            onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) },
-            onSelectInvert = { viewModel.dispatch(RemoteBookIntent.SelectInvert) },
+            onClearSelection = { onIntent(RemoteBookIntent.ClearSelection) },
+            onSelectAll = { onIntent(RemoteBookIntent.SelectAll) },
+            onSelectInvert = { onIntent(RemoteBookIntent.SelectInvert) },
             primaryAction = ActionItem(
                 text = "添加至书架",
                 icon = Icons.Default.CloudDownload,
                 onClick = {
-                    val selectedBooks = uiState.items
-                        .filter { it.id in uiState.selectedIds }
+                    val selectedBooks = state.items
+                        .filter { it.id in state.selectedIds }
                         .map { it.remoteBook }
                         .toSet()
-                    viewModel.dispatch(RemoteBookIntent.AddBooks(selectedBooks))
+                    onIntent(RemoteBookIntent.AddBooks(selectedBooks))
                 }
             ),
             secondaryActions = emptyList()
@@ -325,13 +343,13 @@ fun RemoteBookScreen(
             modifier = Modifier
                 .fillMaxSize()
                 .padding(paddingValues),
-            isRefreshing = uiState.isLoading,
-            onRefresh = { viewModel.dispatch(RemoteBookIntent.Refresh) },
+            isRefreshing = state.isLoading,
+            onRefresh = { onIntent(RemoteBookIntent.Refresh) },
             topPadding = paddingValues.calculateTopPadding(),
             scrollBehavior = scrollBehavior
         ) {
-            if (uiState.items.isEmpty()) {
-                if (uiState.isLoading) {
+            if (state.items.isEmpty()) {
+                if (state.isLoading) {
                     AppCircularProgressIndicator(modifier = Modifier
                         .fillMaxSize()
                         .wrapContentSize(Alignment.Center))
@@ -345,20 +363,20 @@ fun RemoteBookScreen(
                 }
             } else {
                 LazyColumn(modifier = Modifier.fillMaxSize()) {
-                    items(uiState.items, key = { it.id }) { itemUi ->
+                    items(state.items, key = { it.id }) { itemUi ->
                         val book = itemUi.remoteBook
                         RemoteBookItem(
                             modifier = Modifier.animateItem(),
                             book = book,
-                            isSelected = itemUi.id in uiState.selectedIds,
+                            isSelected = itemUi.id in state.selectedIds,
                             onClick = {
-                                viewModel.dispatch(RemoteBookIntent.OpenItem(book))
+                                onIntent(RemoteBookIntent.OpenItem(book))
                             },
                             onAddClick = { remoteBook ->
-                                viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(remoteBook)))
+                                onIntent(RemoteBookIntent.AddBooks(setOf(remoteBook)))
                             },
                             onUpdateClick = { remoteBook ->
-                                dialogState = RemoteBookDialog.ReImport(remoteBook)
+                                onRequestReimport(remoteBook)
                             }
                         )
                     }

+ 9 - 4
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt

@@ -30,7 +30,6 @@ import io.legado.app.utils.FileDoc
 import io.legado.app.utils.find
 import io.legado.app.utils.isUri
 import io.legado.app.utils.takePersistablePermissionSafely
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -85,6 +84,7 @@ sealed interface RemoteBookIntent {
     data class SortToggle(val sort: RemoteBookSort) : RemoteBookIntent
     data object SelectAll : RemoteBookIntent
     data object SelectInvert : RemoteBookIntent
+    data object ClearSelection : RemoteBookIntent
     data class ToggleSelection(val id: String) : RemoteBookIntent
     data class OpenItem(val book: RemoteBook) : RemoteBookIntent
     data object NavigateBack : RemoteBookIntent
@@ -95,6 +95,8 @@ sealed interface RemoteBookIntent {
     data class BookFolderPicked(val uri: android.net.Uri?) : RemoteBookIntent
     data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent
     data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent
+    data class DeleteServer(val server: Server) : RemoteBookIntent
+    data class SaveServer(val server: Server) : RemoteBookIntent
 }
 
 sealed interface RemoteBookEffect {
@@ -139,6 +141,7 @@ class RemoteBookViewModel(
             is RemoteBookIntent.SortToggle -> toggleSort(intent.sort)
             RemoteBookIntent.SelectAll -> selectAllCheckable()
             RemoteBookIntent.SelectInvert -> invertSelection()
+            RemoteBookIntent.ClearSelection -> clearSelection()
             is RemoteBookIntent.ToggleSelection -> toggleSelection(intent.id)
             is RemoteBookIntent.OpenItem -> onOpenItem(intent.book)
             RemoteBookIntent.NavigateBack -> navigateBack()
@@ -156,6 +159,8 @@ class RemoteBookViewModel(
                 intent.fileDoc,
                 intent.fileName
             )
+            is RemoteBookIntent.DeleteServer -> deleteServer(intent.server)
+            is RemoteBookIntent.SaveServer -> saveServer(intent.server)
         }
     }
 
@@ -236,7 +241,7 @@ class RemoteBookViewModel(
                     onSuccess()
                 }
             } catch (e: Exception) {
-                context.toastOnUi("初始化webDav出错:${e.localizedMessage}")
+                _effects.tryEmit(RemoteBookEffect.ShowToast("初始化webDav出错:${e.localizedMessage}"))
                 _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
             }
         }
@@ -254,7 +259,7 @@ class RemoteBookViewModel(
                 _state.update { it.copy(remoteBooks = bookList) }
             } catch (e: Exception) {
                 AppLog.put("获取webDav书籍出错\n${e.localizedMessage}", e)
-                context.toastOnUi("获取webDav书籍出错\n${e.localizedMessage}")
+                _effects.tryEmit(RemoteBookEffect.ShowToast("获取webDav书籍出错\n${e.localizedMessage}"))
             } finally {
                 _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
             }
@@ -286,7 +291,7 @@ class RemoteBookViewModel(
             Result.failure(e)
         } catch (e: Exception) {
             AppLog.put("导入出错\n${e.localizedMessage}", e)
-            context.toastOnUi("导入出错\n${e.localizedMessage}")
+            _effects.tryEmit(RemoteBookEffect.ShowToast("导入出错\n${e.localizedMessage}"))
             Result.failure(e)
         } finally {
             _state.update { it.copy(interaction = it.interaction.copy(isUploading = false)) }

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

@@ -43,6 +43,13 @@ data class BookInfoUiState(
     val showAppLogSheet: Boolean = false,
     val sheet: BookInfoSheet = BookInfoSheet.None,
     val dialog: BookInfoDialog? = null,
+    val bookInfoFollowCoverColor: Boolean = true,
+    val bookInfoNetworkCoverBackground: String = "on",
+    val bookInfoDefaultCoverBackground: String = "on",
+    val loadCoverOnlyOnWifi: Boolean = false,
+    val defaultCover: String = "",
+    val defaultCoverDark: String = "",
+    val showMangaUi: Boolean = true,
 )
 
 @Stable
@@ -203,9 +210,12 @@ sealed interface BookInfoIntent {
     data object CharacterListClick : BookInfoIntent
     data object KnowledgeListClick : BookInfoIntent
     data object EventListClick : BookInfoIntent
+    data class SetDefaultBookTreeUri(val value: String) : BookInfoIntent
 }
 
 sealed interface BookInfoEffect {
+    data class ShowMessage(val message: String) : BookInfoEffect
+
     data class Finish(
         val resultCode: Int? = null,
         val afterTransition: Boolean = false,

+ 13 - 5
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -11,6 +11,8 @@ import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.ui.platform.LocalContext
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
@@ -26,8 +28,6 @@ import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.manga.ReadMangaActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
-import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
@@ -36,7 +36,10 @@ import io.legado.app.utils.openFileUri
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.collections.immutable.persistentListOf
+import io.legado.app.data.entities.BookGroup
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
@@ -66,13 +69,15 @@ fun BookInfoRouteScreen(
     val context = LocalContext.current
     val activity = context as AppCompatActivity
     val lifecycleOwner = LocalLifecycleOwner.current
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val showMangaUi by rememberUpdatedState(uiState.showMangaUi)
 
     val tocActivityResult = rememberLauncherForActivityResult(TocActivityResult()) {
         viewModel.onTocResult(it)
     }
     val localBookTreeSelect = rememberLauncherForActivityResult(HandleFileContract()) {
         it.uri?.let { treeUri ->
-            OtherConfig.defaultBookTreeUri = treeUri.toString()
+            viewModel.onIntent(BookInfoIntent.SetDefaultBookTreeUri(treeUri.toString()))
         }
     }
     val infoEditResult = rememberLauncherForActivityResult(
@@ -127,6 +132,7 @@ fun BookInfoRouteScreen(
     LaunchedEffect(viewModel, activity) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
+                is BookInfoEffect.ShowMessage -> context.toastOnUi(effect.message)
                 is BookInfoEffect.Finish -> {
                     onFinish(effect.resultCode, effect.afterTransition)
                 }
@@ -140,7 +146,7 @@ fun BookInfoRouteScreen(
                 is BookInfoEffect.OpenReader -> {
                     val cls = when {
                         effect.book.isAudio -> AudioPlayActivity::class.java
-                        !effect.book.isLocal && effect.book.isImage && ReadMangaConfig.showMangaUi -> {
+                        !effect.book.isLocal && effect.book.isImage && showMangaUi -> {
                             ReadMangaActivity::class.java
                         }
 
@@ -229,7 +235,9 @@ fun BookInfoRouteScreen(
     }
 
     BookInfoScreen(
-        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        state = uiState,
+        groups = viewModel.allGroups
+            .collectAsStateWithLifecycle(persistentListOf<BookGroup>()).value,
         onIntent = viewModel::onIntent,
         onBack = onBack,
         sharedTransitionScope = sharedTransitionScope,

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

@@ -76,15 +76,13 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import coil.ImageLoader
 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.ui.config.coverConfig.CoverConfig
-import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -134,6 +132,7 @@ import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 @Composable
 fun BookInfoScreen(
     state: BookInfoUiState,
+    groups: ImmutableList<BookGroup>,
     onIntent: (BookInfoIntent) -> Unit,
     onBack: () -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -168,11 +167,22 @@ fun BookInfoScreen(
     ) {
         mutableStateOf(initiallyUsesDefaultCover)
     }
-    val backdropStyle = state.book?.let { resolveBookInfoBackdropStyle(it, usesDefaultCover) }
+    val backdropStyle = state.book?.let {
+        resolveBookInfoBackdropStyle(
+            book = it,
+            usesDefaultCover = usesDefaultCover,
+            defaultCoverBackground = state.bookInfoDefaultCoverBackground,
+            networkCoverBackground = state.bookInfoNetworkCoverBackground,
+        )
+    }
     val bookColorTheme = rememberBookInfoColorTheme(
         book = state.book,
         enabled = backdropStyle?.showCover == true,
         usesDefaultCover = usesDefaultCover,
+        followCoverColor = state.bookInfoFollowCoverColor,
+        defaultCover = state.defaultCover,
+        defaultCoverDark = state.defaultCoverDark,
+        loadCoverOnlyOnWifi = state.loadCoverOnlyOnWifi,
     )
 
     BookInfoColorTheme(theme = bookColorTheme.takeIf {
@@ -180,6 +190,7 @@ fun BookInfoScreen(
     }) {
         BookInfoScreenContent(
             state = state,
+            groups = groups,
             onIntent = onIntent,
             onBack = onBack,
             sharedTransitionScope = sharedTransitionScope,
@@ -202,6 +213,7 @@ fun BookInfoScreen(
 @Composable
 private fun BookInfoScreenContent(
     state: BookInfoUiState,
+    groups: ImmutableList<BookGroup>,
     onIntent: (BookInfoIntent) -> Unit,
     onBack: () -> Unit,
     sharedTransitionScope: SharedTransitionScope?,
@@ -381,7 +393,6 @@ private fun BookInfoScreenContent(
             onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
         )
         BookInfoSheet.GroupPicker -> {
-            val groups by koinInject<io.legado.app.data.repository.BookGroupRepository>().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
             GroupSelectSheet(
                 show = currentSheet == BookInfoSheet.GroupPicker,
                 groups = groups,
@@ -549,11 +560,15 @@ private fun rememberBookInfoColorTheme(
     book: BookInfoBookUi?,
     enabled: Boolean,
     usesDefaultCover: Boolean,
+    followCoverColor: Boolean,
+    defaultCover: String,
+    defaultCoverDark: String,
+    loadCoverOnlyOnWifi: Boolean,
 ): ThemeOverrideState? {
     if (
         book == null ||
         !enabled ||
-        !ThemeConfig.bookInfoFollowCoverColor
+        !followCoverColor
     ) {
         return null
     }
@@ -561,7 +576,7 @@ private fun rememberBookInfoColorTheme(
     val imageLoader = koinInject<ImageLoader>()
     val isNight = LegadoTheme.isDark
     val defaultCoverPaths =
-        if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
+        if (isNight) defaultCoverDark else defaultCover
     val coverPath = remember(
         book.name,
         book.author,
@@ -580,7 +595,7 @@ private fun rememberBookInfoColorTheme(
         }
     } ?: return null
     val sourceOrigin = if (usesDefaultCover) null else book.origin
-    val loadOnlyWifi = !usesDefaultCover && CoverConfig.loadCoverOnlyWifi
+    val loadOnlyWifi = !usesDefaultCover && loadCoverOnlyOnWifi
     val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
         listOf(coverPath, sourceOrigin, loadOnlyWifi)
     }
@@ -600,11 +615,13 @@ private fun rememberBookInfoColorTheme(
 private fun resolveBookInfoBackdropStyle(
     book: BookInfoBookUi,
     usesDefaultCover: Boolean,
+    defaultCoverBackground: String,
+    networkCoverBackground: String,
 ): BookInfoBackdropStyle {
     val backgroundMode = if (usesDefaultCover) {
-        ThemeConfig.bookInfoDefaultCoverBackground
+        defaultCoverBackground
     } else {
-        ThemeConfig.bookInfoNetworkCoverBackground
+        networkCoverBackground
     }
     return resolveBookInfoBackdropStyle(backgroundMode)
 }

+ 73 - 26
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -26,6 +26,10 @@ import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.exception.NoBooksDirException
@@ -62,7 +66,6 @@ import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
@@ -81,6 +84,7 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -95,9 +99,12 @@ class BookInfoViewModel(
     private val bookGroupRepository: BookGroupRepository,
     private val imageLoader: ImageLoader,
     private val bookKnowledgeGateway: BookKnowledgeGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
+    private val coverSettingsGateway: CoverSettingsGateway,
+    private val otherSettingsGateway: OtherSettingsGateway,
 ) : BaseViewModel(application) {
 
-    val allGroups = bookGroupRepository.flowAll()
+    val allGroups = bookGroupRepository.flowSelect().map { it.toImmutableList() }
 
     private val _uiState = MutableStateFlow(BookInfoUiState())
     val uiState = _uiState.asStateFlow()
@@ -107,6 +114,37 @@ class BookInfoViewModel(
 
     init {
         collectEventBus()
+        collectAppearanceSettings()
+        collectOtherSettings()
+    }
+
+    private fun collectAppearanceSettings() {
+        viewModelScope.launch {
+            combine(
+                themeSettingsGateway.settings,
+                coverSettingsGateway.settings,
+            ) { theme, cover -> theme to cover }
+                .collectLatest { (theme, cover) ->
+                    _uiState.update {
+                        it.copy(
+                            bookInfoFollowCoverColor = theme.bookInfoFollowCoverColor,
+                            bookInfoNetworkCoverBackground = theme.bookInfoNetworkCoverBackground,
+                            bookInfoDefaultCoverBackground = theme.bookInfoDefaultCoverBackground,
+                            loadCoverOnlyOnWifi = cover.loadOnlyOnWifi,
+                            defaultCover = cover.defaultCover,
+                            defaultCoverDark = cover.defaultCoverDark,
+                        )
+                    }
+                }
+        }
+    }
+
+    private fun collectOtherSettings() {
+        viewModelScope.launch {
+            otherSettingsGateway.settings.collectLatest { settings ->
+                _uiState.update { it.copy(showMangaUi = settings.showMangaUi) }
+            }
+        }
     }
 
     private fun collectEventBus() {
@@ -236,7 +274,7 @@ class BookInfoViewModel(
             }
             upBook(book, source)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "未找到书籍")
+            showMessage(it.localizedMessage ?: "未找到书籍")
             emitEffect(BookInfoEffect.Finish(afterTransition = true))
         }
     }
@@ -295,7 +333,7 @@ class BookInfoViewModel(
 
             is BookInfoIntent.AddSourceAsNewBook -> {
                 addToBookshelf(intent.book, intent.toc) {
-                    context.toastOnUi("已添加到书架")
+                    showMessage("已添加到书架")
                 }
             }
 
@@ -339,6 +377,9 @@ class BookInfoViewModel(
             BookInfoIntent.CharacterListClick -> openCharacterList()
             BookInfoIntent.KnowledgeListClick -> openKnowledgeList()
             BookInfoIntent.EventListClick -> openEventList()
+            is BookInfoIntent.SetDefaultBookTreeUri -> viewModelScope.launch {
+                otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(intent.value))
+            }
         }
     }
 
@@ -454,7 +495,7 @@ class BookInfoViewModel(
             syncUiState(isTocLoading = true)
             loadBookInfo(book, canReName = false)
             if (!book.getSplitLongChapter()) {
-                context.toastOnUi(context.getString(R.string.need_more_time_load_content))
+                showMessage(R.string.need_more_time_load_content)
             }
         }
     }
@@ -478,7 +519,7 @@ class BookInfoViewModel(
         }.onSuccess {
             emitEffect(it)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+            showMessage(it.localizedMessage ?: "书源不存在")
         }
     }
 
@@ -499,7 +540,7 @@ class BookInfoViewModel(
         }.onSuccess {
             emitEffect(it)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+            showMessage(it.localizedMessage ?: "书源不存在")
         }
     }
 
@@ -544,11 +585,11 @@ class BookInfoViewModel(
             inBookshelf = true
             syncUiState(isTocLoading = true)
             loadChapter(newBook)
-            context.toastOnUi("同步完成")
+            showMessage("同步完成")
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi(it.localizedMessage)
+            showMessage(it.localizedMessage ?: "同步失败")
         }
     }
 
@@ -563,7 +604,7 @@ class BookInfoViewModel(
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi(it.localizedMessage)
+            showMessage(it.localizedMessage ?: "操作失败")
         }
     }
 
@@ -578,9 +619,9 @@ class BookInfoViewModel(
                     ReadManga.clearMangaChapter()
                 }
             }.onSuccess {
-                context.toastOnUi(R.string.clear_cache_success)
+                showMessage(R.string.clear_cache_success)
             }.onError {
-                context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
+                showMessage("清理缓存出错\n${it.localizedMessage}")
             }
         }
     }
@@ -609,14 +650,14 @@ class BookInfoViewModel(
             }
         }.onSuccess { success ->
             if (success) {
-                context.toastOnUi("保存成功")
+                showMessage("保存成功")
             } else {
-                context.toastOnUi("保存失败")
+                showMessage("保存失败")
             }
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi("保存出错: ${it.localizedMessage}")
+            showMessage("保存出错: ${it.localizedMessage}")
         }
     }
 
@@ -717,7 +758,7 @@ class BookInfoViewModel(
             success?.invoke()
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            showMessage("添加书籍失败")
         }
     }
 
@@ -779,7 +820,7 @@ class BookInfoViewModel(
             val source = bookSource ?: run {
                 currentChapterList = emptyList()
                 syncUiState(isTocLoading = false)
-                context.toastOnUi(R.string.error_no_source)
+                showMessage(R.string.error_no_source)
                 return
             }
             WebBook.getBookInfo(scope, source, book, canReName = canReName)
@@ -805,7 +846,7 @@ class BookInfoViewModel(
                     scheduleRelatedBooksLoad(loadedBook, source)
                 }.onError {
                     AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
-                    context.toastOnUi(R.string.error_get_book_info)
+                    showMessage(R.string.error_get_book_info)
                     syncUiState(isTocLoading = false)
                 }
         }
@@ -959,7 +1000,7 @@ class BookInfoViewModel(
             val source = bookSource ?: run {
                 currentChapterList = emptyList()
                 syncUiState(isTocLoading = false)
-                context.toastOnUi(R.string.error_no_source)
+                showMessage(R.string.error_no_source)
                 return
             }
             val oldBook = book.copy()
@@ -1004,7 +1045,7 @@ class BookInfoViewModel(
             syncUiState(isTocLoading = false)
         }.onError {
             currentWebFiles = emptyList()
-            context.toastOnUi("LoadWebFileError\n${it.localizedMessage}")
+            showMessage("LoadWebFileError\n${it.localizedMessage}")
             syncUiState(isTocLoading = false)
         }
     }
@@ -1032,7 +1073,7 @@ class BookInfoViewModel(
     private fun onTocClick() {
         val book = currentBook ?: return
         if (currentChapterList.isEmpty()) {
-            context.toastOnUi(R.string.chapter_list_empty)
+            showMessage(R.string.chapter_list_empty)
             return
         }
         if (!inBookshelf) {
@@ -1170,7 +1211,7 @@ class BookInfoViewModel(
             }
 
             BookInfoMenuAction.Upload -> uploadBook {
-                context.toastOnUi("上传成功")
+                showMessage("上传成功")
             }
             BookInfoMenuAction.SyncRemote -> syncFromRemote()
             BookInfoMenuAction.Refresh -> refreshCurrentBook()
@@ -1244,7 +1285,7 @@ class BookInfoViewModel(
         val book = currentBook ?: return
         if (book.isLocal) return
         if (!appDb.bookSourceDao.has(book.origin)) {
-            context.toastOnUi(R.string.error_no_source)
+            showMessage(R.string.error_no_source)
             return
         }
         emitEffect(BookInfoEffect.OpenBookSourceEdit(book.origin))
@@ -1257,7 +1298,7 @@ class BookInfoViewModel(
             }
         }.onError {
             AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
+            showMessage("getArchiveEntriesName Error:\n${it.localizedMessage}")
         }.onSuccess {
             onSuccess.invoke(it)
         }
@@ -1281,7 +1322,7 @@ class BookInfoViewModel(
             success?.invoke(book)
         }.onError {
             AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
+            showMessage("importArchiveBook Error:\n${it.localizedMessage}")
         }
     }
 
@@ -1312,7 +1353,7 @@ class BookInfoViewModel(
                 is NoBooksDirException -> emitEffect(BookInfoEffect.OpenSelectBooksDir)
                 else -> {
                     AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
-                    context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
+                    showMessage("ImportWebFileError\n${it.localizedMessage}")
                 }
             }
         }.onFinally {
@@ -1657,6 +1698,12 @@ class BookInfoViewModel(
         return WebBook.exploreBookWithResolvedUrl(source, url, 1, book)
     }
 
+    private fun showMessage(resId: Int) = showMessage(context.getString(resId))
+
+    private fun showMessage(message: String) {
+        emitEffect(BookInfoEffect.ShowMessage(message))
+    }
+
     private fun emitEffect(effect: BookInfoEffect) {
         _effects.tryEmit(effect)
     }

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -60,7 +60,6 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.constant.IntentAction
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
@@ -865,6 +864,7 @@ private fun BookshelfManageScreen(
     BookSourcePickerSheet(
         show = showBatchSourcePickerSheet,
         title = "选择目标书源",
+        sources = state.bookSources,
         onDismissRequest = { showBatchSourcePickerSheet = false },
         onConfirm = { sources ->
             pendingBatchSources = sources
@@ -1288,11 +1288,10 @@ private fun BookshelfManageScreen(
 private fun BookSourcePickerSheet(
     show: Boolean,
     title: String,
+    sources: List<BookSourcePart>,
     onDismissRequest: () -> Unit,
     onConfirm: (List<BookSource>) -> Unit,
 ) {
-    val sources by appDb.bookSourceDao.flowEnabled()
-        .collectAsStateWithLifecycle(initialValue = emptyList())
     var searchKey by rememberSaveable(show) { mutableStateOf("") }
     var selectedSources by remember(show) { mutableStateOf<List<BookSourcePart>>(emptyList()) }
     val selectedUrls = remember(selectedSources) {

+ 16 - 1
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -8,8 +8,10 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BatchChangeSourceCandidate
@@ -43,6 +45,9 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import java.util.concurrent.ConcurrentHashMap
@@ -81,7 +86,8 @@ data class BookshelfManageScreenUiState(
     val batchChangeOptions: ChangeSourceMigrationOptions = ChangeSourceMigrationOptions(),
     val cacheVersion: Long = 0,
     val deleteBookOriginal: Boolean = LocalConfig.deleteBookOriginal,
-    val exportConfig: BookshelfManageScreenExportConfig = BookshelfManageScreenExportConfig()
+    val exportConfig: BookshelfManageScreenExportConfig = BookshelfManageScreenExportConfig(),
+    val bookSources: ImmutableList<BookSourcePart> = persistentListOf(),
 )
 
 sealed interface BookshelfManageScreenIntent {
@@ -148,6 +154,7 @@ sealed interface BookshelfManageScreenEffect {
 class BookshelfManageScreenViewModel(
     application: Application,
     private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository,
     private val bookGroupRepository: BookGroupRepository,
     private val searchRepository: SearchRepository,
     val bookshelfManageScreenConfig: BookshelfManageScreenConfig,
@@ -178,6 +185,14 @@ class BookshelfManageScreenViewModel(
     private var observersStarted = false
     private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
     private val pendingCacheCountRefreshBookUrls = ConcurrentHashMap.newKeySet<String>()
+
+    init {
+        viewModelScope.launch {
+            bookSourceRepository.flowEnabled().collect { sources ->
+                _uiState.update { it.copy(bookSources = sources.toImmutableList()) }
+            }
+        }
+    }
     @Volatile
     private var pendingDownloadRunningRefresh = false
 

+ 79 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HttpTTS
@@ -98,6 +99,60 @@ data class ReadBookStyleConfig(
     val isNightBgImage: Boolean get() = bgTypeNight != 0
 }
 
+@Stable
+data class ReadSheetConfigUiState(
+    val letterSpacing: Float = 0f,
+    val lineSpacing: Int = 0,
+    val paragraphSpacing: Int = 0,
+    val paragraphIndentCount: Int = 2,
+    val textItalic: Boolean = false,
+    val textBold: Int = 0,
+    val chineseConverterType: Int = 0,
+    val textColor: Int = 0,
+    val textAccentColor: Int = 0,
+    val titleMode: Int = 0,
+    val titleBold: Int = 0,
+    val titleSegType: Int = 0,
+    val titleSegDistance: Int = 0,
+    val titleSegFlag: String = "",
+    val titleSegScaling: Float = 1f,
+    val titleLineSpacingExtra: Int = 0,
+    val titleLineSpacingSub: Int = 0,
+    val titleSize: Int = 0,
+    val titleTopSpacing: Int = 0,
+    val titleBottomSpacing: Int = 0,
+    val titleColor: Int = 0,
+    val titleColorNight: Int = 0,
+    val textColorDay: Int = 0,
+    val textColorNight: Int = 0,
+    val textShadow: Boolean = false,
+    val textShadowColor: Int = 0,
+    val shadowRadius: Float = 0f,
+    val shadowDx: Float = 0f,
+    val shadowDy: Float = 0f,
+    val underline: Boolean = false,
+    val dottedLine: Boolean = false,
+    val underlineExtend: Boolean = false,
+    val underlineColor: Int = 0,
+    val underlineHeight: Int = 0,
+    val underlinePadding: Int = 0,
+    val dottedBase: Float = 0f,
+    val dottedRatio: Float = 0f,
+    val paddingTop: Int = 0,
+    val paddingBottom: Int = 0,
+    val paddingLeft: Int = 0,
+    val paddingRight: Int = 0,
+    val headerPaddingTop: Int = 0,
+    val headerPaddingBottom: Int = 0,
+    val headerPaddingLeft: Int = 0,
+    val headerPaddingRight: Int = 0,
+    val footerPaddingTop: Int = 0,
+    val footerPaddingBottom: Int = 0,
+    val footerPaddingLeft: Int = 0,
+    val footerPaddingRight: Int = 0,
+    val configNames: ImmutableList<String> = persistentListOf(),
+)
+
 @Stable
 data class ChapterSummaryUiState(
     val bookUrl: String = "",
@@ -206,6 +261,8 @@ data class ReadBookUiState(
     val replaceRuleEnabled: Boolean = false,
     val effectiveReplaceCount: Int = 0,
     val effectiveContentProcessCount: Int = 0,
+    val effectiveReplaceRules: ImmutableList<ReplaceRule> = persistentListOf(),
+    val chineseConverterActive: Boolean = false,
     // Translation
     val translationMode: Boolean = false,
     // Chapter info
@@ -260,6 +317,7 @@ data class ReadBookUiState(
     val readAloudParagraphInterval: Int = 0,
     // Style config (reactive state for ReadBookConfig)
     val styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
+    val sheetConfig: ReadSheetConfigUiState = ReadSheetConfigUiState(),
     // Menu config (from ReadBookConfig via repository)
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
     val highlightRuleConfig: HighlightRuleConfigUiState = HighlightRuleConfigUiState(),
@@ -527,6 +585,9 @@ sealed interface ReadBookIntent {
     // Replace editor (needs Activity context for ActivityResult)
     data class OpenReplaceEditor(val id: Long, val pattern: String?) : ReadBookIntent
     data object ReplaceRuleChanged : ReadBookIntent
+    data class DisableEffectiveReplace(val rule: ReplaceRule) : ReadBookIntent
+    data object DisableChineseConverter : ReadBookIntent
+    data object DisableReSegment : ReadBookIntent
 
     // Font folder picker (needs Activity context for ActivityResult)
     data object OpenFontFolderPicker : ReadBookIntent
@@ -737,7 +798,6 @@ sealed interface ReadBookEffect {
 
     // Navigation / lifecycle
     data object Finish : ReadBookEffect
-    data object Recreate : ReadBookEffect
 
     // ReadView operations (require Activity/View reference)
     data class UpdateReadViewConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect
@@ -1054,6 +1114,24 @@ sealed interface ConfigUpdate {
     data class TipFooterRight(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
     }
+    data class CustomTipHeaderLeft(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipHeaderMiddle(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipHeaderRight(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterLeft(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterMiddle(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
+    data class CustomTipFooterRight(val value: String) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+    }
     data class HeaderFont(val path: String) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }

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

@@ -805,7 +805,6 @@ class ReadBookController(
         when (effect) {
             // ── Already migrated (View-layer) ──
             is ReadBookEffect.Finish -> closeReadBook()
-            is ReadBookEffect.Recreate -> activity.recreate()
             is ReadBookEffect.UpdateReadViewConfig -> {
                 val r = refs ?: return
                 effect.actions.forEach { action ->

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

@@ -733,6 +733,7 @@ private fun ReadBookMenuSurface(
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
                             PaddingConfigContent(
+                                config = state.sheetConfig,
                                 onIntent = onIntent,
                                 modifier = Modifier.padding(horizontal = 16.dp),
                             )
@@ -763,6 +764,7 @@ private fun ReadBookMenuSurface(
                             onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
                         ) {
                             ReadStyleTextTitleContent(
+                                config = state.sheetConfig,
                                 onOpenShadowSet = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet))
                                 },

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

@@ -170,11 +170,15 @@ fun ReadBookScreen(
 
     ShadowSetSheet(
         show = state.activeSheet is ReadBookSheet.ShadowSet,
+        config = state.sheetConfig,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
     )
     EffectiveReplacesSheet(
         show = state.activeSheet is ReadBookSheet.EffectiveReplaces,
+        effectiveRules = state.effectiveReplaceRules,
+        chineseConvertActive = state.chineseConverterActive,
+        reSegmentActive = state.reSegment,
         onDismissRequest = dismissSheet,
         onOpenReplaceEditor = { id, pattern ->
             onIntent(ReadBookIntent.OpenReplaceEditor(id, pattern))
@@ -187,6 +191,9 @@ fun ReadBookScreen(
         onOpenContentProcesses = {
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentProcesses))
         },
+        onDisableRule = { onIntent(ReadBookIntent.DisableEffectiveReplace(it)) },
+        onDisableChineseConverter = { onIntent(ReadBookIntent.DisableChineseConverter) },
+        onDisableReSegment = { onIntent(ReadBookIntent.DisableReSegment) },
     )
     ContentProcessesSheet(
         show = state.activeSheet is ReadBookSheet.ContentProcesses,
@@ -196,6 +203,7 @@ fun ReadBookScreen(
     )
     UnderlineConfigSheet(
         show = state.activeSheet is ReadBookSheet.UnderlineConfig,
+        config = state.sheetConfig,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
     )
@@ -242,6 +250,7 @@ fun ReadBookScreen(
     HighlightRuleConfigSheet(
         show = state.activeSheet is ReadBookSheet.HighlightRuleConfig,
         state = state.highlightRuleConfig,
+        allConfigNames = state.sheetConfig.configNames,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
     )

+ 97 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -30,10 +30,11 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
+import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.UploadRepository
@@ -117,7 +118,6 @@ import io.legado.app.utils.openUrl
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toStringArray
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.CancellationException
@@ -167,7 +167,7 @@ class ReadBookViewModel(
     private val readSettingsRepository: ReadSettingsRepository,
     private val readBookStyleConfigRepository: ReadBookStyleConfigRepository,
     private val readAloudSettingsRepository: ReadAloudSettingsRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
     private val highlightRuleRepository: HighlightRuleRepository,
     private val uploadRepository: UploadRepository,
     private val changeBookSourceUseCase: ChangeBookSourceUseCase,
@@ -181,6 +181,7 @@ class ReadBookViewModel(
     private val aiProfileGateway: AiProfileGateway,
     private val syncReadAloudVoicesUseCase: SyncReadAloudVoicesUseCase,
     private val readAloudSessionStore: ReadAloudSessionStore,
+    private val replaceRuleRepository: ReplaceRuleRepository,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
     // --- MVI State ---
@@ -384,6 +385,17 @@ class ReadBookViewModel(
             is ReadBookIntent.RefreshAllChapters -> refreshAllChapters()
             is ReadBookIntent.RefreshContentAfter -> refreshContentAfter()
             is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled)
+            is ReadBookIntent.DisableEffectiveReplace -> viewModelScope.launch {
+                replaceRuleRepository.insert(intent.rule.copy(isEnabled = false))
+            }
+            ReadBookIntent.DisableChineseConverter -> {
+                handleConfigUpdate(ConfigUpdate.ChineseConverterType(0))
+            }
+            ReadBookIntent.DisableReSegment -> {
+                ReadBook.book?.setReSegment(false)
+                ReadBook.loadContent(false)
+                _uiState.update { it.copy(reSegment = false) }
+            }
             is ReadBookIntent.ToggleTranslation -> toggleTranslation()
             is ReadBookIntent.OpenChapterSummary -> openChapterSummary()
             is ReadBookIntent.OpenAiCurrentChapterRewrite -> openAiCurrentChapterRewrite()
@@ -635,7 +647,11 @@ class ReadBookViewModel(
 
             is ReadBookIntent.MenuCoverProgress -> {
                 ReadBook.book?.let {
-                    ReadBook.uploadProgress(true) { context.toastOnUi(R.string.upload_book_success) }
+                    ReadBook.uploadProgress(true) {
+                        _effects.tryEmit(
+                            ReadBookEffect.ShowToast(context.getString(R.string.upload_book_success))
+                        )
+                    }
                 }
             }
 
@@ -649,7 +665,7 @@ class ReadBookViewModel(
                             textChapter.chapter.getFileName("nr")
                         )
                     ) {
-                        context.toastOnUi("未找到可移除的重复标题")
+                        _effects.tryEmit(ReadBookEffect.ShowToast("未找到可移除的重复标题"))
                     }
                 }
                 reverseRemoveSameTitle()
@@ -2198,6 +2214,61 @@ class ReadBookViewModel(
         )
     }
 
+    private fun buildSheetConfig(): ReadSheetConfigUiState = ReadSheetConfigUiState(
+        letterSpacing = ReadBookConfig.letterSpacing,
+        lineSpacing = ReadBookConfig.lineSpacingExtra,
+        paragraphSpacing = ReadBookConfig.paragraphSpacing,
+        paragraphIndentCount = ReadBookConfig.paragraphIndent.length,
+        textItalic = ReadBookConfig.textItalic,
+        textBold = ReadBookConfig.textBold,
+        chineseConverterType = ReadConfig.chineseConverterType,
+        textColor = ReadBookConfig.durConfig.curTextColor(),
+        textAccentColor = ReadBookConfig.durConfig.curTextAccentColor(),
+        titleMode = ReadBookConfig.titleMode,
+        titleBold = ReadBookConfig.titleBold,
+        titleSegType = ReadBookConfig.titleSegType,
+        titleSegDistance = ReadBookConfig.titleSegDistance,
+        titleSegFlag = ReadBookConfig.titleSegFlag,
+        titleSegScaling = ReadBookConfig.titleSegScaling,
+        titleLineSpacingExtra = ReadBookConfig.titleLineSpacingExtra,
+        titleLineSpacingSub = ReadBookConfig.titleLineSpacingSub,
+        titleSize = ReadBookConfig.titleSize,
+        titleTopSpacing = ReadBookConfig.titleTopSpacing,
+        titleBottomSpacing = ReadBookConfig.titleBottomSpacing,
+        titleColor = ReadBookConfig.titleColor,
+        titleColorNight = ReadBookConfig.titleColorNight,
+        textColorDay = ReadBookConfig.textColor,
+        textColorNight = ReadBookConfig.textColorNight,
+        textShadow = ReadBookConfig.textShadow,
+        textShadowColor = ReadBookConfig.durConfig.curTextShadowColor(),
+        shadowRadius = ReadBookConfig.shadowRadius,
+        shadowDx = ReadBookConfig.shadowDx,
+        shadowDy = ReadBookConfig.shadowDy,
+        underline = ReadBookConfig.underline,
+        dottedLine = ReadBookConfig.dottedLine,
+        underlineExtend = ReadBookConfig.underlineExtend,
+        underlineColor = ReadBookConfig.durConfig.curUnderlineColor(),
+        underlineHeight = ReadBookConfig.underlineHeight,
+        underlinePadding = ReadBookConfig.underlinePadding,
+        dottedBase = ReadBookConfig.durConfig.dottedBase,
+        dottedRatio = ReadBookConfig.durConfig.dottedRatio,
+        paddingTop = ReadBookConfig.paddingTop,
+        paddingBottom = ReadBookConfig.paddingBottom,
+        paddingLeft = ReadBookConfig.paddingLeft,
+        paddingRight = ReadBookConfig.paddingRight,
+        headerPaddingTop = ReadBookConfig.headerPaddingTop,
+        headerPaddingBottom = ReadBookConfig.headerPaddingBottom,
+        headerPaddingLeft = ReadBookConfig.headerPaddingLeft,
+        headerPaddingRight = ReadBookConfig.headerPaddingRight,
+        footerPaddingTop = ReadBookConfig.footerPaddingTop,
+        footerPaddingBottom = ReadBookConfig.footerPaddingBottom,
+        footerPaddingLeft = ReadBookConfig.footerPaddingLeft,
+        footerPaddingRight = ReadBookConfig.footerPaddingRight,
+        configNames = ReadBookConfig.configList.map { it.name }
+            .filter { it.isNotBlank() }
+            .toImmutableList(),
+    )
+
     private fun syncFromReadBook(current: ReadBookUiState): ReadBookUiState {
         val book = ReadBook.book
         val textChapter = ReadBook.curTextChapter
@@ -2219,6 +2290,8 @@ class ReadBookViewModel(
             replaceRuleEnabled = book?.getUseReplaceRule() ?: false,
             effectiveReplaceCount = textChapter?.effectiveReplaceRules?.size ?: 0,
             effectiveContentProcessCount = textChapter?.effectiveContentProcesses?.size ?: 0,
+            effectiveReplaceRules = textChapter?.effectiveReplaceRules.orEmpty().toImmutableList(),
+            chineseConverterActive = ReadConfig.chineseConverterType > 0,
             translationMode = book?.getTranslationMode() ?: false,
             isLocalTxt = book?.isLocalTxt == true,
             isEpub = book?.isEpub == true,
@@ -2229,6 +2302,7 @@ class ReadBookViewModel(
             sameTitleRemoved = textChapter?.sameTitleRemoved ?: false,
             isReadingProgressSyncConfigured = isReadingProgressSyncConfigured(),
             styleConfig = buildStyleConfig(),
+            sheetConfig = buildSheetConfig(),
             menuConfig = ReadMenuConfig(
                 titleBarIconPosition = ReadBookConfig.titleBarIconPosition,
                 showTitleBarIcons = ReadBookConfig.showTitleBarIcons,
@@ -2719,7 +2793,7 @@ class ReadBookViewModel(
                 ReadBook.upMsg(null)
             }.catch {
                 AppLog.put("自动换源失败\n${it.localizedMessage}", it)
-                context.toastOnUi("自动换源失败\n${it.localizedMessage}")
+                _effects.tryEmit(ReadBookEffect.ShowToast("自动换源失败\n${it.localizedMessage}"))
             }.collect()
         }
     }
@@ -2781,7 +2855,7 @@ class ReadBookViewModel(
             _effects.tryEmit(ReadBookEffect.Finish)
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            _effects.tryEmit(ReadBookEffect.ShowToast("添加书籍失败"))
         }
     }
 
@@ -4443,9 +4517,9 @@ class ReadBookViewModel(
             )
             if (!success) throw NoStackTraceException("保存到相册失败")
         }.onError {
-            context.toastOnUi("保存图片失败: ${it.localizedMessage}")
+            _effects.tryEmit(ReadBookEffect.ShowToast("保存图片失败: ${it.localizedMessage}"))
         }.onSuccess {
-            context.toastOnUi("已保存到相册")
+            _effects.tryEmit(ReadBookEffect.ShowToast("已保存到相册"))
         }
     }
 
@@ -4659,6 +4733,12 @@ class ReadBookViewModel(
             is ConfigUpdate.TipFooterLeft -> ReadBookConfig.tipFooterLeft = update.value
             is ConfigUpdate.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = update.value
             is ConfigUpdate.TipFooterRight -> ReadBookConfig.tipFooterRight = update.value
+            is ConfigUpdate.CustomTipHeaderLeft -> ReadBookConfig.customTipHeaderLeft = update.value
+            is ConfigUpdate.CustomTipHeaderMiddle -> ReadBookConfig.customTipHeaderMiddle = update.value
+            is ConfigUpdate.CustomTipHeaderRight -> ReadBookConfig.customTipHeaderRight = update.value
+            is ConfigUpdate.CustomTipFooterLeft -> ReadBookConfig.customTipFooterLeft = update.value
+            is ConfigUpdate.CustomTipFooterMiddle -> ReadBookConfig.customTipFooterMiddle = update.value
+            is ConfigUpdate.CustomTipFooterRight -> ReadBookConfig.customTipFooterRight = update.value
             is ConfigUpdate.HeaderFont -> ReadBookConfig.headerFont = update.path
             is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value
             is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color
@@ -5691,7 +5771,6 @@ class ReadBookViewModel(
                 ConfigUpdateAction.UpdateSystemUi
             )
         ))
-        postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
     }
 
     private fun applyReadStyleBackgroundImage(uri: Uri) {
@@ -5980,8 +6059,12 @@ class ReadBookViewModel(
             localPreferencesRepository.updatePreference(
                 LocalPreferencesKeys.READ_URL_IN_BROWSER, newValue
             )
-            context.toastOnUi(
-                if (newValue) R.string.open_by_browser else R.string.open_by_webview
+            _effects.tryEmit(
+                ReadBookEffect.ShowToast(
+                    context.getString(
+                        if (newValue) R.string.open_by_browser else R.string.open_by_webview
+                    )
+                )
             )
         }
     }
@@ -5991,7 +6074,7 @@ class ReadBookViewModel(
         if (book.isLocal) return
         val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
         if (chapter == null) {
-            context.toastOnUi(R.string.no_chapter)
+            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.no_chapter)))
             return
         }
         _uiState.update { it.copy(activeDialog = ReadBookDialog.ConfirmChapterPay(chapter.title)) }
@@ -6097,7 +6180,7 @@ class ReadBookViewModel(
             success?.invoke()
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            _effects.tryEmit(ReadBookEffect.ShowToast("添加书籍失败"))
         }
     }
 

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

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

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

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

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

@@ -17,7 +17,6 @@ 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.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -25,17 +24,13 @@ 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.data.appDb
 import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.model.ReadBook
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
-import kotlinx.coroutines.launch
 
 private const val CHINESE_CONVERT_ID = -1L
 private const val RE_SEGMENT_ID = -2L
@@ -43,22 +38,21 @@ private const val RE_SEGMENT_ID = -2L
 @Composable
 fun EffectiveReplacesSheet(
     show: Boolean,
+    effectiveRules: List<ReplaceRule>,
+    chineseConvertActive: Boolean,
+    reSegmentActive: Boolean,
     onDismissRequest: () -> Unit,
     onOpenReplaceEditor: (id: Long, pattern: String?) -> Unit,
     onReplaceRuleChanged: () -> Unit,
     onNavigateToTextEffects: () -> Unit,
     onOpenContentProcesses: () -> Unit,
+    onDisableRule: (ReplaceRule) -> Unit,
+    onDisableChineseConverter: () -> Unit,
+    onDisableReSegment: () -> Unit,
 ) {
-    val scope = rememberCoroutineScope()
-    val chineseConvertActive = ReadConfig.chineseConverterType > 0
     val chineseConvertItem = remember { ReplaceRule(CHINESE_CONVERT_ID, "繁简转换") }
-    val reSegmentActive = ReadBook.book?.getReSegment() == true
     val reSegmentItem = remember { ReplaceRule(RE_SEGMENT_ID, "") }
 
-    val effectiveRules = remember(show) {
-        ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList()
-    }
-
     val items = remember(show, effectiveRules, chineseConvertActive, reSegmentActive) {
         buildList {
             addAll(effectiveRules)
@@ -155,15 +149,11 @@ fun EffectiveReplacesSheet(
                                     isEdited = true
                                     when (rule.id) {
                                         RE_SEGMENT_ID -> {
-                                            ReadBook.book?.setReSegment(false)
-                                            ReadBook.loadContent(false)
+                                            onDisableReSegment()
                                         }
 
-                                        CHINESE_CONVERT_ID -> ReadConfig.chineseConverterType = 0
-                                        else -> scope.launch {
-                                            rule.isEnabled = false
-                                            appDb.replaceRuleDao.insert(rule)
-                                        }
+                                        CHINESE_CONVERT_ID -> onDisableChineseConverter()
+                                        else -> onDisableRule(rule)
                                     }
                                 },
                 icon = Icons.Default.Close,

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

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

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

@@ -51,6 +51,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
 fun HighlightRuleConfigSheet(
     show: Boolean,
     state: HighlightRuleConfigUiState,
+    allConfigNames: List<String>,
     onDismissRequest: () -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
@@ -209,6 +210,7 @@ fun HighlightRuleConfigSheet(
     HighlightRuleEditSheet(
         show = show && editingRuleValue != null,
         rule = editingRuleValue,
+        allConfigNames = allConfigNames,
         onDismissRequest = { onIntent(ReadBookIntent.DismissHighlightRuleEdit) },
         onSave = { updated ->
             onIntent(ReadBookIntent.SaveHighlightRule(updated))
@@ -218,6 +220,7 @@ fun HighlightRuleConfigSheet(
     HighlightRuleEditSheet(
         show = show && state.showNewRule,
         rule = null,
+        allConfigNames = allConfigNames,
         onDismissRequest = { onIntent(ReadBookIntent.DismissHighlightRuleEdit) },
         onSave = { newRule ->
             onIntent(ReadBookIntent.SaveHighlightRule(newRule))

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

@@ -48,7 +48,6 @@ import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.configNames
 import io.legado.app.data.repository.toJsonArray
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FontFolderState
@@ -74,6 +73,7 @@ import java.io.File
 fun HighlightRuleEditSheet(
     show: Boolean,
     rule: HighlightRule?,
+    allConfigNames: List<String>,
     onDismissRequest: () -> Unit,
     onSave: (HighlightRule) -> Unit,
 ) {
@@ -123,7 +123,6 @@ fun HighlightRuleEditSheet(
     var hasBgImage by remember(show, rule) { mutableStateOf(initial.bgImage?.isNotBlank() == true) }
 
     // Config binding state — empty set = global (applies to all configs)
-    val allConfigNames = remember { ReadBookConfig.configList.map { it.name }.filter { it.isNotBlank() } }
     var configNames by remember(show, rule) {
         mutableStateOf(initial.configName.orEmpty().configNames().toSet())
     }

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

@@ -26,7 +26,7 @@ import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.ReadSheetConfigUiState
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -39,12 +39,12 @@ import kotlinx.coroutines.launch
 
 @Composable
 fun PaddingConfigSheet(
-    show: Boolean,
+    config: ReadSheetConfigUiState,
     onDismissRequest: () -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
     AppModalBottomSheet(
-        show = show,
+        show = true,
         onDismissRequest = {
             onIntent(ReadBookIntent.SaveReadStyleConfig)
             onDismissRequest()
@@ -52,6 +52,7 @@ fun PaddingConfigSheet(
         title = stringResource(R.string.padding),
     ) {
         PaddingConfigContent(
+            config = config,
             onIntent = onIntent,
             modifier = Modifier
                 .padding(bottom = 16.dp),
@@ -61,6 +62,7 @@ fun PaddingConfigSheet(
 
 @Composable
 fun PaddingConfigContent(
+    config: ReadSheetConfigUiState,
     onIntent: (ReadBookIntent) -> Unit,
     modifier: Modifier = Modifier,
 ) {
@@ -80,20 +82,20 @@ fun PaddingConfigContent(
     val pageHeights = remember { mutableStateMapOf<Int, Int>() }
     val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
 
-    var paddingTop by remember { mutableFloatStateOf(ReadBookConfig.paddingTop.toFloat()) }
-    var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) }
-    var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) }
-    var paddingRight by remember { mutableFloatStateOf(ReadBookConfig.paddingRight.toFloat()) }
+    var paddingTop by remember(config.paddingTop) { mutableFloatStateOf(config.paddingTop.toFloat()) }
+    var paddingBottom by remember(config.paddingBottom) { mutableFloatStateOf(config.paddingBottom.toFloat()) }
+    var paddingLeft by remember(config.paddingLeft) { mutableFloatStateOf(config.paddingLeft.toFloat()) }
+    var paddingRight by remember(config.paddingRight) { mutableFloatStateOf(config.paddingRight.toFloat()) }
 
-    var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
-    var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
-    var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
-    var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
+    var headerPaddingTop by remember(config.headerPaddingTop) { mutableFloatStateOf(config.headerPaddingTop.toFloat()) }
+    var headerPaddingBottom by remember(config.headerPaddingBottom) { mutableFloatStateOf(config.headerPaddingBottom.toFloat()) }
+    var headerPaddingLeft by remember(config.headerPaddingLeft) { mutableFloatStateOf(config.headerPaddingLeft.toFloat()) }
+    var headerPaddingRight by remember(config.headerPaddingRight) { mutableFloatStateOf(config.headerPaddingRight.toFloat()) }
 
-    var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
-    var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
-    var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
-    var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
+    var footerPaddingTop by remember(config.footerPaddingTop) { mutableFloatStateOf(config.footerPaddingTop.toFloat()) }
+    var footerPaddingBottom by remember(config.footerPaddingBottom) { mutableFloatStateOf(config.footerPaddingBottom.toFloat()) }
+    var footerPaddingLeft by remember(config.footerPaddingLeft) { mutableFloatStateOf(config.footerPaddingLeft.toFloat()) }
+    var footerPaddingRight by remember(config.footerPaddingRight) { mutableFloatStateOf(config.footerPaddingRight.toFloat()) }
 
     LaunchedEffect(pagerState) {
         snapshotFlow { pagerState.currentPage }.collect { page ->

+ 7 - 6
app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt

@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.ReadSheetConfigUiState
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
@@ -28,14 +28,15 @@ import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 @Composable
 fun ShadowSetSheet(
     show: Boolean,
+    config: ReadSheetConfigUiState,
     onDismissRequest: () -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
-    var textShadow by remember { mutableStateOf(ReadBookConfig.textShadow) }
-    var shadowColor by remember { mutableIntStateOf(ReadBookConfig.durConfig.curTextShadowColor()) }
-    var shadowRadius by remember { mutableFloatStateOf(ReadBookConfig.shadowRadius) }
-    var shadowDx by remember { mutableFloatStateOf(ReadBookConfig.shadowDx) }
-    var shadowDy by remember { mutableFloatStateOf(ReadBookConfig.shadowDy) }
+    var textShadow by remember(show, config.textShadow) { mutableStateOf(config.textShadow) }
+    var shadowColor by remember(show, config.textShadowColor) { mutableIntStateOf(config.textShadowColor) }
+    var shadowRadius by remember(show, config.shadowRadius) { mutableFloatStateOf(config.shadowRadius) }
+    var shadowDx by remember(show, config.shadowDx) { mutableFloatStateOf(config.shadowDx) }
+    var shadowDy by remember(show, config.shadowDy) { mutableFloatStateOf(config.shadowDy) }
     var showColorPicker by remember { mutableStateOf(false) }
 
     AppModalBottomSheet(

+ 44 - 37
app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt

@@ -45,10 +45,9 @@ 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.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
-import io.legado.app.ui.config.readConfig.ReadConfig
+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.AppTextField
@@ -78,6 +77,7 @@ private const val COLOR_TITLE_NIGHT = 5
 
 @Composable
 fun ReadStyleTextTitleContent(
+    config: ReadSheetConfigUiState,
     onOpenShadowSet: () -> Unit,
     onOpenUnderlineConfig: () -> Unit,
     onOpenHighlightRule: () -> Unit,
@@ -105,6 +105,7 @@ fun ReadStyleTextTitleContent(
     }
 
     ReadStyleTextTitleContent(
+        config = config,
         tabTitles = tabTitles,
         selectedTab = selectedTab,
         pagerState = pagerState,
@@ -134,6 +135,7 @@ fun ReadStyleTextTitleContent(
 
 @Composable
 internal fun ReadStyleTextTitleContent(
+    config: ReadSheetConfigUiState,
     tabTitles: List<String>,
     selectedTab: Int,
     pagerState: PagerState,
@@ -183,6 +185,7 @@ internal fun ReadStyleTextTitleContent(
             ) {
                 when (page) {
                     0 -> TextEffectsPage(
+                        config = config,
                         onOpenShadowSet = onOpenShadowSet,
                         onOpenUnderlineConfig = onOpenUnderlineConfig,
                         onOpenHighlightRule = onOpenHighlightRule,
@@ -190,8 +193,9 @@ internal fun ReadStyleTextTitleContent(
                         onIntent = onIntent,
                     )
 
-                    1 -> LayoutSpacingPage(onIntent = onIntent)
+                    1 -> LayoutSpacingPage(config = config, onIntent = onIntent)
                     2 -> TitleSettingsPage(
+                        config = config,
                         onOpenTitleFontSelect = onOpenTitleFontSelect,
                         onIntent = onIntent,
                     )
@@ -205,13 +209,14 @@ internal fun ReadStyleTextTitleContent(
 
 @Composable
 internal fun LayoutSpacingPage(
+    config: ReadSheetConfigUiState,
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
-    var letterSpacing by remember { mutableFloatStateOf(ReadBookConfig.letterSpacing) }
-    var lineSpacing by remember { mutableFloatStateOf(ReadBookConfig.lineSpacingExtra.toFloat()) }
-    var paragraphSpacing by remember { mutableFloatStateOf(ReadBookConfig.paragraphSpacing.toFloat()) }
-    var indentCount by remember { mutableIntStateOf(ReadBookConfig.paragraphIndent.length) }
+    var letterSpacing by remember { mutableFloatStateOf(config.letterSpacing) }
+    var lineSpacing by remember { mutableFloatStateOf(config.lineSpacing.toFloat()) }
+    var paragraphSpacing by remember { mutableFloatStateOf(config.paragraphSpacing.toFloat()) }
+    var indentCount by remember { mutableIntStateOf(config.paragraphIndentCount) }
 
     Column(
         modifier = modifier
@@ -270,6 +275,7 @@ internal fun LayoutSpacingPage(
 
 @Composable
 internal fun TextEffectsPage(
+    config: ReadSheetConfigUiState,
     onOpenShadowSet: () -> Unit,
     onOpenUnderlineConfig: () -> Unit,
     onOpenHighlightRule: () -> Unit,
@@ -277,8 +283,8 @@ internal fun TextEffectsPage(
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
-    var textItalic by remember { mutableStateOf(ReadBookConfig.textItalic) }
-    var textBold by remember { mutableIntStateOf(ReadBookConfig.textBold) }
+    var textItalic by remember { mutableStateOf(config.textItalic) }
+    var textBold by remember { mutableIntStateOf(config.textBold) }
 
     var showColorPicker by remember { mutableStateOf(false) }
     var colorPickerId by remember { mutableIntStateOf(0) }
@@ -318,7 +324,7 @@ internal fun TextEffectsPage(
         val chineseConvertValues = remember { arrayOf("0", "1", "2") }
         TinyDropdownSettingItem(
             title = stringResource(R.string.chinese_converter),
-            selectedValue = ReadConfig.chineseConverterType.toString(),
+            selectedValue = config.chineseConverterType.toString(),
             displayEntries = chineseConvertEntries,
             entryValues = chineseConvertValues,
             onValueChange = {
@@ -330,19 +336,19 @@ internal fun TextEffectsPage(
         SectionTitle(stringResource(R.string.read_color))
         TinyColorSettingItem(
             title = stringResource(R.string.text_color),
-            colorValue = ReadBookConfig.durConfig.curTextColor(),
+            colorValue = config.textColor,
             onClick = {
                 colorPickerId = COLOR_TEXT
-                colorPickerInitial = ReadBookConfig.durConfig.curTextColor()
+                colorPickerInitial = config.textColor
                 showColorPicker = true
             },
         )
         TinyColorSettingItem(
             title = stringResource(R.string.text_accent_color),
-            colorValue = ReadBookConfig.durConfig.curTextAccentColor(),
+            colorValue = config.textAccentColor,
             onClick = {
                 colorPickerId = COLOR_ACCENT
-                colorPickerInitial = ReadBookConfig.durConfig.curTextAccentColor()
+                colorPickerInitial = config.textAccentColor
                 showColorPicker = true
             },
         )
@@ -394,21 +400,22 @@ internal fun TextEffectsPage(
 
 @Composable
 internal fun TitleSettingsPage(
+    config: ReadSheetConfigUiState,
     onOpenTitleFontSelect: () -> Unit,
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
-    var titleMode by remember(ReadBookConfig.titleMode) { mutableIntStateOf(ReadBookConfig.titleMode) }
-    var titleBold by remember(ReadBookConfig.titleBold) { mutableIntStateOf(ReadBookConfig.titleBold) }
-    var titleSegType by remember(ReadBookConfig.titleSegType) { mutableIntStateOf(ReadBookConfig.titleSegType) }
-    var titleSegDistance by remember(ReadBookConfig.titleSegDistance) { mutableIntStateOf(ReadBookConfig.titleSegDistance) }
-    var titleSegFlag by remember(ReadBookConfig.titleSegFlag) { mutableStateOf(ReadBookConfig.titleSegFlag) }
-    var titleSegScaling by remember(ReadBookConfig.titleSegScaling) { mutableFloatStateOf(ReadBookConfig.titleSegScaling) }
-    var titleLineSpacingExtra by remember(ReadBookConfig.titleLineSpacingExtra) { mutableIntStateOf(ReadBookConfig.titleLineSpacingExtra) }
-    var titleLineSpacingSub by remember(ReadBookConfig.titleLineSpacingSub) { mutableIntStateOf(ReadBookConfig.titleLineSpacingSub) }
-    var titleSize by remember(ReadBookConfig.titleSize) { mutableIntStateOf(ReadBookConfig.titleSize) }
-    var titleTopSpacing by remember(ReadBookConfig.titleTopSpacing) { mutableIntStateOf(ReadBookConfig.titleTopSpacing) }
-    var titleBottomSpacing by remember(ReadBookConfig.titleBottomSpacing) { mutableIntStateOf(ReadBookConfig.titleBottomSpacing) }
+    var titleMode by remember(config.titleMode) { mutableIntStateOf(config.titleMode) }
+    var titleBold by remember(config.titleBold) { mutableIntStateOf(config.titleBold) }
+    var titleSegType by remember(config.titleSegType) { mutableIntStateOf(config.titleSegType) }
+    var titleSegDistance by remember(config.titleSegDistance) { mutableIntStateOf(config.titleSegDistance) }
+    var titleSegFlag by remember(config.titleSegFlag) { mutableStateOf(config.titleSegFlag) }
+    var titleSegScaling by remember(config.titleSegScaling) { mutableFloatStateOf(config.titleSegScaling) }
+    var titleLineSpacingExtra by remember(config.titleLineSpacingExtra) { mutableIntStateOf(config.titleLineSpacingExtra) }
+    var titleLineSpacingSub by remember(config.titleLineSpacingSub) { mutableIntStateOf(config.titleLineSpacingSub) }
+    var titleSize by remember(config.titleSize) { mutableIntStateOf(config.titleSize) }
+    var titleTopSpacing by remember(config.titleTopSpacing) { mutableIntStateOf(config.titleTopSpacing) }
+    var titleBottomSpacing by remember(config.titleBottomSpacing) { mutableIntStateOf(config.titleBottomSpacing) }
 
     var showColorPicker by remember { mutableStateOf(false) }
     var colorPickerId by remember { mutableIntStateOf(0) }
@@ -445,30 +452,30 @@ internal fun TitleSettingsPage(
 
         TinyColorModeSettingItem(
             title = stringResource(R.string.title_color),
-            dayColor = if (ReadBookConfig.titleColor != 0) {
-                ReadBookConfig.titleColor
+            dayColor = if (config.titleColor != 0) {
+                config.titleColor
             } else {
-                ReadBookConfig.textColor
+                config.textColorDay
             },
-            nightColor = if (ReadBookConfig.titleColorNight != 0) {
-                ReadBookConfig.titleColorNight
+            nightColor = if (config.titleColorNight != 0) {
+                config.titleColorNight
             } else {
-                ReadBookConfig.textColorNight
+                config.textColorNight
             },
             onClickColor = { isNight ->
                 if (isNight) {
                     colorPickerId = COLOR_TITLE_NIGHT
-                    colorPickerInitial = if (ReadBookConfig.titleColorNight != 0) {
-                        ReadBookConfig.titleColorNight
+                    colorPickerInitial = if (config.titleColorNight != 0) {
+                        config.titleColorNight
                     } else {
-                        ReadBookConfig.textColorNight
+                        config.textColorNight
                     }
                 } else {
                     colorPickerId = COLOR_TITLE
-                    colorPickerInitial = if (ReadBookConfig.titleColor != 0) {
-                        ReadBookConfig.titleColor
+                    colorPickerInitial = if (config.titleColor != 0) {
+                        config.titleColor
                     } else {
-                        ReadBookConfig.textColor
+                        config.textColorDay
                     }
                 }
                 showColorPicker = true

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

@@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.ReadSheetConfigUiState
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
@@ -33,17 +33,18 @@ import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 @Composable
 fun UnderlineConfigSheet(
     show: Boolean,
+    config: ReadSheetConfigUiState,
     onDismissRequest: () -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
-    var underline by remember { mutableStateOf(ReadBookConfig.underline) }
-    var dottedLine by remember { mutableStateOf(ReadBookConfig.dottedLine) }
-    var underlineExtend by remember { mutableStateOf(ReadBookConfig.underlineExtend) }
-    var underlineColor by remember { mutableStateOf(ReadBookConfig.durConfig.curUnderlineColor()) }
-    var underlineHeight by remember { mutableFloatStateOf(ReadBookConfig.underlineHeight.toFloat()) }
-    var underlinePadding by remember { mutableFloatStateOf(ReadBookConfig.underlinePadding.toFloat()) }
-    var dottedBase by remember { mutableFloatStateOf(ReadBookConfig.durConfig.dottedBase) }
-    var dottedRatio by remember { mutableFloatStateOf(ReadBookConfig.durConfig.dottedRatio) }
+    var underline by remember(show, config.underline) { mutableStateOf(config.underline) }
+    var dottedLine by remember(show, config.dottedLine) { mutableStateOf(config.dottedLine) }
+    var underlineExtend by remember(show, config.underlineExtend) { mutableStateOf(config.underlineExtend) }
+    var underlineColor by remember(show, config.underlineColor) { mutableStateOf(config.underlineColor) }
+    var underlineHeight by remember(show, config.underlineHeight) { mutableFloatStateOf(config.underlineHeight.toFloat()) }
+    var underlinePadding by remember(show, config.underlinePadding) { mutableFloatStateOf(config.underlinePadding.toFloat()) }
+    var dottedBase by remember(show, config.dottedBase) { mutableFloatStateOf(config.dottedBase) }
+    var dottedRatio by remember(show, config.dottedRatio) { mutableFloatStateOf(config.dottedRatio) }
     var showColorPicker by remember { mutableStateOf(false) }
 
     AppModalBottomSheet(

Некоторые файлы не были показаны из-за большого количества измененных файлов