Browse Source

Merge branch 'pr1-settings-gateway' into HEAD

# Conflicts:
#	app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
#	app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
#	app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
#	app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt
#	app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
#	app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
#	app/src/test/java/io/legado/app/data/repository/LabSettingsMappingTest.kt
HapeLee 3 months ago
parent
commit
77c5e20fea
75 changed files with 2786 additions and 1816 deletions
  1. 8 0
      AGENTS.md
  2. 4 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 5 1
      app/src/main/java/io/legado/app/data/repository/BookExportSettingsRepository.kt
  4. 56 58
      app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt
  5. 5 1
      app/src/main/java/io/legado/app/data/repository/ChangeSourceSettingsRepository.kt
  6. 185 199
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  7. 5 1
      app/src/main/java/io/legado/app/data/repository/ImportBookSettingsRepository.kt
  8. 7 61
      app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt
  9. 102 204
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  10. 58 66
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  11. 0 9
      app/src/main/java/io/legado/app/data/repository/SettingsMapping.kt
  12. 1 42
      app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt
  13. 1 15
      app/src/main/java/io/legado/app/domain/gateway/BackupSettingsGateway.kt
  14. 1 58
      app/src/main/java/io/legado/app/domain/gateway/BookshelfSettingsGateway.kt
  15. 1 26
      app/src/main/java/io/legado/app/domain/gateway/MangaSettingsGateway.kt
  16. 1 27
      app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt
  17. 1 23
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt
  18. 6 51
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  19. 6 91
      app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt
  20. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/LabSettings.kt
  21. 5 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  22. 90 18
      app/src/main/java/io/legado/app/help/config/AppConfigStore.kt
  23. 9 13
      app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt
  24. 1 2
      app/src/main/java/io/legado/app/service/WebService.kt
  25. 3 2
      app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
  26. 6 6
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt
  27. 6 6
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt
  28. 8 11
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  29. 8 8
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  30. 1 2
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  31. 1 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  32. 18 20
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  33. 3 3
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  34. 13 29
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
  35. 1 2
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt
  36. 2 3
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt
  37. 1 2
      app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
  38. 107 76
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  39. 1 2
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  40. 19 18
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt
  41. 16 2
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeContract.kt
  42. 20 21
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt
  43. 31 11
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeViewModel.kt
  44. 34 33
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  45. 16 17
      app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt
  46. 54 59
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
  47. 28 4
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt
  48. 3 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigRouteScreen.kt
  49. 140 169
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  50. 148 115
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  51. 1 8
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  52. 145 101
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt
  53. 3 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  54. 4 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt
  55. 9 11
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  56. 1 2
      app/src/main/java/io/legado/app/ui/main/home/HomeViewModel.kt
  57. 1 2
      app/src/main/java/io/legado/app/ui/welcome/BookFolderFragment.kt
  58. 6 6
      app/src/main/java/io/legado/app/ui/welcome/WebDavFragment.kt
  59. 1 2
      app/src/main/java/io/legado/app/ui/widget/components/FontSelectGrid.kt
  60. 6 0
      app/src/main/res/values/strings.xml
  61. 100 0
      app/src/test/java/io/legado/app/data/repository/AppShellSettingsMappingTest.kt
  62. 39 0
      app/src/test/java/io/legado/app/data/repository/AtomicSettingsTestUtils.kt
  63. 71 0
      app/src/test/java/io/legado/app/data/repository/BackupSettingsMappingTest.kt
  64. 156 0
      app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt
  65. 23 26
      app/src/test/java/io/legado/app/data/repository/LabSettingsMappingTest.kt
  66. 196 47
      app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt
  67. 90 0
      app/src/test/java/io/legado/app/data/repository/OtherSettingsMappingTest.kt
  68. 5 7
      app/src/test/java/io/legado/app/data/repository/Phase2SettingsMappingTest.kt
  69. 113 0
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  70. 150 0
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  71. 276 0
      app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt
  72. 119 1
      app/src/test/java/io/legado/app/help/config/PendingOverlayCoreTest.kt
  73. 3 2
      app/src/test/java/io/legado/app/ui/about/AboutViewModelTest.kt
  74. 7 6
      app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt
  75. 14 1
      build.gradle.kts

+ 8 - 0
AGENTS.md

@@ -30,6 +30,14 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
 - Remove imports/variables/functions that YOUR changes made unused.
 - Don't remove pre-existing dead code unless asked.
 
+### Settings Gateway Conventions
+
+- Ordinary settings gateways mutate state through `update { current -> current.copy(...) }`.
+- Do not introduce `*SettingsUpdate` dispatch types or `updateAll` on settings gateways.
+- Submit related multi-field changes in one `copy(...)` transform so the SSOT can apply them atomically.
+- Keep specialized APIs such as `ReadStyleMutation`, `ThemePackageSettingsGateway.applyAndAwait`,
+  `ThemeStateTransaction`, and `AppUiConfigurationGateway` in their dedicated shapes.
+
 ### Goal-Driven Execution
 
 Transform tasks into verifiable goals:

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

@@ -406,11 +406,15 @@ object PreferKey {
     // Lab
     const val labEnabled = "labEnabled"
     const val labEInkDisplay = "labEInkDisplay"
+    const val labEyeProtection = "labEyeProtection"
 
     // Eye Protection
     const val eyeProtectionEnabled = "eyeProtectionEnabled"
     const val colorTemperature = "colorTemperature"
     const val eyeProtectionAutoNight = "eyeProtectionAutoNight"
+    const val eyeProtectionSchedule = "eyeProtectionSchedule"
+    const val eyeProtectionStartTime = "eyeProtectionStartTime"
+    const val eyeProtectionEndTime = "eyeProtectionEndTime"
 }
 
 object ReadMenuBlurMode {

+ 5 - 1
app/src/main/java/io/legado/app/data/repository/BookExportSettingsRepository.kt

@@ -21,7 +21,11 @@ class BookExportSettingsRepository : BookExportSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (BookExportSettings) -> BookExportSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, BookExportSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toBookExportSettings,
+            toPrefMap = BookExportSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 

+ 56 - 58
app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt

@@ -3,10 +3,7 @@ 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
@@ -24,64 +21,16 @@ class BookshelfSettingsRepository : BookshelfSettingsGateway {
         .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
-                BookshelfBooleanSetting.HideEmptyGroups -> PreferKey.hideEmptyGroups
-            } 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))
+    override suspend fun update(transform: (BookshelfSettings) -> BookshelfSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toBookshelfSettings,
+            toPrefMap = BookshelfSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
-private fun Preferences.toBookshelfSettings() = BookshelfSettings(
+internal fun Preferences.toBookshelfSettings() = BookshelfSettings(
     bookGroupStyle = compatDsInt(PreferKey.bookGroupStyle) ?: 0,
     hideEmptyGroups = compatDsBoolean(PreferKey.hideEmptyGroups) ?: true,
     bookshelfSort = compatDsInt(PreferKey.bookshelfSort) ?: 0,
@@ -129,3 +78,52 @@ private fun Preferences.toBookshelfSettings() = BookshelfSettings(
     autoRefreshBook = compatDsBoolean(PreferKey.autoRefresh) ?: false,
     saveTabPosition = compatDsLong(PreferKey.saveTabPosition) ?: BookGroup.IdAll,
 )
+
+internal fun BookshelfSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.bookGroupStyle to bookGroupStyle,
+    PreferKey.hideEmptyGroups to hideEmptyGroups,
+    PreferKey.bookshelfSort to bookshelfSort,
+    PreferKey.bookshelfSortOrder to bookshelfSortOrder,
+    PreferKey.showUnread to showUnread,
+    PreferKey.showUnreadNew to showUnreadNew,
+    PreferKey.showTip to showTip,
+    PreferKey.showBookCount to showBookCount,
+    PreferKey.showLastUpdateTime to showLastUpdateTime,
+    PreferKey.showBookIntro to showBookIntro,
+    PreferKey.bookshelfShowIntro to bookshelfShowIntro,
+    PreferKey.bookshelfShowTag to bookshelfShowTag,
+    PreferKey.bookshelfShowLatestChapter to bookshelfShowLatestChapter,
+    PreferKey.bookshelfIntroMaxLines to bookshelfIntroMaxLines,
+    PreferKey.showWaitUpCount to showWaitUpCount,
+    PreferKey.showBookshelfFastScroller to showBookshelfFastScroller,
+    PreferKey.shouldShowExpandButton to shouldShowExpandButton,
+    PreferKey.bookshelfRefreshingLimit to bookshelfRefreshingLimit,
+    PreferKey.bookshelfLayoutModePortrait to bookshelfLayoutModePortrait,
+    PreferKey.bookshelfLayoutGridPortrait to bookshelfLayoutGridPortrait,
+    PreferKey.bookshelfLayoutModeLandscape to bookshelfLayoutModeLandscape,
+    PreferKey.bookshelfLayoutGridLandscape to bookshelfLayoutGridLandscape,
+    PreferKey.bookshelfLayoutListPortrait to bookshelfLayoutListPortrait,
+    PreferKey.bookshelfLayoutListLandscape to bookshelfLayoutListLandscape,
+    PreferKey.bookshelfFolderLayoutModePortrait to bookshelfFolderLayoutModePortrait,
+    PreferKey.bookshelfFolderLayoutGridPortrait to bookshelfFolderLayoutGridPortrait,
+    PreferKey.bookshelfFolderLayoutModeLandscape to bookshelfFolderLayoutModeLandscape,
+    PreferKey.bookshelfFolderLayoutGridLandscape to bookshelfFolderLayoutGridLandscape,
+    PreferKey.bookshelfFolderLayoutListPortrait to bookshelfFolderLayoutListPortrait,
+    PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
+    PreferKey.bookshelfGridLayout to bookshelfGridLayout,
+    PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfShowDivider to bookshelfShowDivider,
+    PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
+    PreferKey.bookshelfTitleCenter to bookshelfTitleCenter,
+    PreferKey.bookshelfTitleMaxLines to bookshelfTitleMaxLines,
+    PreferKey.bookshelfCoverShadow to bookshelfCoverShadow,
+    PreferKey.bookshelfCardColor to bookshelfCardColor,
+    PreferKey.bookshelfCardColorDark to bookshelfCardColorDark,
+    PreferKey.bookshelfGroupListStyle to bookshelfGroupListStyle,
+    PreferKey.bookshelfGroupCoverCount to bookshelfGroupCoverCount,
+    PreferKey.bookshelfListCoverWidth to bookshelfListCoverWidth,
+    PreferKey.bookshelfGridCoverWidth to bookshelfGridCoverWidth,
+    PreferKey.bookshelfSearchActionDirectToSearch to bookshelfSearchActionDirectToSearch,
+    PreferKey.autoRefresh to autoRefreshBook,
+    PreferKey.saveTabPosition to saveTabPosition,
+)

+ 5 - 1
app/src/main/java/io/legado/app/data/repository/ChangeSourceSettingsRepository.kt

@@ -21,7 +21,11 @@ class ChangeSourceSettingsRepository : ChangeSourceSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (ChangeSourceSettings) -> ChangeSourceSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, ChangeSourceSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toChangeSourceSettings,
+            toPrefMap = ChangeSourceSettings::toPrefMap,
+            transform = transform,
+        )
     }
 
     override suspend fun setMigrationOptions(options: ChangeSourceMigrationOptions) {

+ 185 - 199
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -3,24 +3,13 @@ package io.legado.app.data.repository
 import androidx.datastore.preferences.core.Preferences
 import io.legado.app.BuildConfig
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.AppShellBooleanSetting
 import io.legado.app.domain.gateway.AppShellSettingsGateway
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
-import io.legado.app.domain.gateway.AppShellStringSetting
 import io.legado.app.domain.gateway.BackupSettingsGateway
-import io.legado.app.domain.gateway.BackupSettingsUpdate
 import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
-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.ThemeSettingsGateway
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
-import io.legado.app.domain.gateway.ThemeStringSetting
 import io.legado.app.domain.gateway.TranslationSettingsGateway
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.domain.model.settings.AppShellSettings
@@ -48,49 +37,12 @@ class AppShellSettingsRepository : AppShellSettingsGateway {
         .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)
+    override suspend fun update(transform: (AppShellSettings) -> AppShellSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toAppShellSettings,
+            toPrefMap = AppShellSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -102,90 +54,12 @@ class ThemeSettingsRepository : ThemeSettingsGateway {
         .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.OverrideBaseCardCornerRadius ->
-                    PreferKey.overrideBaseCardCornerRadius to update.value
-
-                ThemeBooleanSetting.OverrideBaseCardBorder ->
-                    PreferKey.overrideBaseCardBorder to update.value
-
-                ThemeBooleanSetting.DisableSplicedColumnGroupCornerRadius ->
-                    PreferKey.disableSplicedColumnGroupCornerRadius 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.BaseCardBorderColor -> PreferKey.baseCardBorderColor to update.value
-                ThemeIntSetting.BaseCardBorderColorNight ->
-                    PreferKey.baseCardBorderColorNight 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
-                ThemeFloatSetting.BaseCardCornerRadius -> PreferKey.baseCardCornerRadius to update.value
-                ThemeFloatSetting.BaseCardBorderWidth -> PreferKey.baseCardBorderWidth 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.CustomTagColorsJson -> PreferKey.customTagColors to update.value
-            }
-            }
-            key to value
-        }
-        AppConfigStore.putAll(values)
+    override suspend fun update(transform: (ThemeSettings) -> ThemeSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toThemeSettings,
+            toPrefMap = ThemeSettings::toGatewayPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -198,8 +72,10 @@ class DownloadCacheSettingsRepository : DownloadCacheSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (DownloadCacheSettings) -> DownloadCacheSettings) {
-        AppConfigStore.putAllAndAwait(
-            currentSettings.diffPrefMap(transform, DownloadCacheSettings::toPrefMap)
+        AppConfigStore.atomicUpdateAndAwait(
+            read = Preferences::toDownloadCacheSettings,
+            toPrefMap = DownloadCacheSettings::toPrefMap,
+            transform = transform,
         )
     }
 }
@@ -213,7 +89,11 @@ class CoverSettingsRepository : CoverSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (CoverSettings) -> CoverSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, CoverSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toCoverSettings,
+            toPrefMap = CoverSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -226,7 +106,11 @@ class LabSettingsRepository : LabSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (LabSettings) -> LabSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, LabSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toLabSettings,
+            toPrefMap = LabSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -239,7 +123,11 @@ class TranslationSettingsRepository : TranslationSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (TranslationSettings) -> TranslationSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, TranslationSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toTranslationSettings,
+            toPrefMap = TranslationSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -251,35 +139,12 @@ class BackupSettingsRepository : BackupSettingsGateway {
         .map { it.toBackupSettings() }
         .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)
+    override suspend fun update(transform: (BackupSettings) -> BackupSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toBackupSettings,
+            toPrefMap = BackupSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
@@ -351,11 +216,13 @@ internal fun CoverSettings.toPrefMap(): Map<String, Any?> = mapOf(
 internal fun Preferences.toLabSettings(): LabSettings = LabSettings(
     enabled = compatDsBoolean(PreferKey.labEnabled) ?: false,
     eInkDisplay = compatDsBoolean(PreferKey.labEInkDisplay) ?: false,
+    eyeProtection = compatDsBoolean(PreferKey.labEyeProtection) ?: false,
 )
 
 internal fun LabSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.labEnabled to enabled,
     PreferKey.labEInkDisplay to eInkDisplay,
+    PreferKey.labEyeProtection to eyeProtection,
 )
 
 internal fun Preferences.toTranslationSettings(): TranslationSettings {
@@ -396,6 +263,20 @@ internal fun Preferences.toBackupSettings(): BackupSettings = BackupSettings(
     backupPath = compatDsString(PreferKey.backupPath),
 )
 
+internal fun BackupSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.webDavUrl to webDavUrl,
+    PreferKey.webDavAccount to webDavAccount,
+    PreferKey.webDavPassword to webDavPassword,
+    PreferKey.webDavDir to webDavDir,
+    PreferKey.webDavDeviceName to webDavDeviceName,
+    PreferKey.syncBookProgress to syncBookProgress,
+    PreferKey.syncBookProgressPlus to syncBookProgressPlus,
+    PreferKey.autoCheckNewBackup to autoCheckNewBackup,
+    PreferKey.onlyLatestBackup to onlyLatestBackup,
+    PreferKey.backupSyncMode to backupSyncMode,
+    PreferKey.backupPath to backupPath,
+)
+
 internal fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
     themeMode = compatDsString(PreferKey.themeMode) ?: "0",
     fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
@@ -424,6 +305,32 @@ internal fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettin
     launcherIcon = compatDsString(PreferKey.launcherIcon) ?: "ic_launcher",
 )
 
+internal fun AppShellSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.themeMode to themeMode,
+    PreferKey.fontScale to fontScale,
+    PreferKey.composeEngine to composeEngine,
+    PreferKey.showHome to showHome,
+    PreferKey.showDiscovery to showDiscovery,
+    PreferKey.showRss to showRss,
+    PreferKey.showStatusBar to showStatusBar,
+    PreferKey.swipeAnimation to swipeAnimation,
+    PreferKey.isPredictiveBackEnabled to predictiveBackEnabled,
+    PreferKey.showBottomView to showBottomView,
+    PreferKey.useFloatingBottomBar to useFloatingBottomBar,
+    PreferKey.useFloatingBottomBarLiquidGlass to useFloatingBottomBarLiquidGlass,
+    PreferKey.tabletInterface to tabletInterface,
+    PreferKey.labelVisibilityMode to labelVisibilityMode,
+    PreferKey.defaultHomePage to defaultHomePage,
+    PreferKey.mainNavigationOrder to mainNavigationOrder,
+    PreferKey.navExtended to navExtended,
+    PreferKey.navIconHome to navIconHome,
+    PreferKey.navIconBookshelf to navIconBookshelf,
+    PreferKey.navIconExplore to navIconExplore,
+    PreferKey.navIconRss to navIconRss,
+    PreferKey.navIconMy to navIconMy,
+    PreferKey.launcherIcon to launcherIcon,
+)
+
 internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     appTheme = compatDsString(PreferKey.appTheme) ?: "0",
     useMiuixMonet = compatDsBoolean(PreferKey.useMiuixMonet) ?: false,
@@ -482,6 +389,11 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     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,
@@ -489,6 +401,73 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     customTagColorsJson = compatDsString(PreferKey.customTagColors),
 )
 
+/**
+ * Theme gateway 的 59 键写入边界。主题包专用的 [ThemeSettings.customMode] 与
+ * [ThemeSettings.bookInfoInputColor] 由 ThemePackageSettingsGateway 的事务路径持久化。
+ */
+internal fun ThemeSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.appTheme to appTheme,
+    PreferKey.useMiuixMonet to useMiuixMonet,
+    PreferKey.pureBlack to isPureBlack,
+    PreferKey.paletteStyle to paletteStyle,
+    PreferKey.materialVersion to materialVersion,
+    PreferKey.customContrast to customContrast,
+    PreferKey.appFontPath to appFontPath,
+    PreferKey.cPrimary to customPrimary,
+    PreferKey.cNPrimary to customNightPrimary,
+    PreferKey.enableDeepPersonalization to enableDeepPersonalization,
+    PreferKey.themeColor to themeColor,
+    PreferKey.secondaryThemeColor to secondaryThemeColor,
+    PreferKey.primaryTextColor to primaryTextColor,
+    PreferKey.secondaryTextColor to secondaryTextColor,
+    PreferKey.themeBackgroundColor to themeBackgroundColor,
+    PreferKey.labelContainerColor to labelContainerColor,
+    PreferKey.themeColorNight to themeColorNight,
+    PreferKey.secondaryThemeColorNight to secondaryThemeColorNight,
+    PreferKey.primaryTextColorNight to primaryTextColorNight,
+    PreferKey.secondaryTextColorNight to secondaryTextColorNight,
+    PreferKey.themeBackgroundColorNight to themeBackgroundColorNight,
+    PreferKey.labelContainerColorNight to labelContainerColorNight,
+    PreferKey.containerOpacity to containerOpacity,
+    PreferKey.overrideBaseCardCornerRadius to overrideBaseCardCornerRadius,
+    PreferKey.baseCardCornerRadius to baseCardCornerRadius,
+    PreferKey.overrideBaseCardBorder to overrideBaseCardBorder,
+    PreferKey.baseCardBorderWidth to baseCardBorderWidth,
+    PreferKey.baseCardBorderColor to baseCardBorderColor,
+    PreferKey.baseCardBorderColorNight to baseCardBorderColorNight,
+    PreferKey.disableSplicedColumnGroupCornerRadius to disableSplicedColumnGroupCornerRadius,
+    PreferKey.topBarOpacity to topBarOpacity,
+    PreferKey.bottomBarOpacity to bottomBarOpacity,
+    PreferKey.enableBlur to enableBlur,
+    PreferKey.enableProgressiveBlur to enableProgressiveBlur,
+    PreferKey.topBarBlurRadius to topBarBlurRadius,
+    PreferKey.bottomBarBlurRadius to bottomBarBlurRadius,
+    PreferKey.topBarBlurAlpha to topBarBlurAlpha,
+    PreferKey.bottomBarBlurAlpha to bottomBarBlurAlpha,
+    PreferKey.bottomBarLensRadius to bottomBarLensRadius,
+    PreferKey.useFlexibleTopAppBar to useFlexibleTopAppBar,
+    PreferKey.bookInfoFollowCoverColor to bookInfoFollowCoverColor,
+    PreferKey.bookInfoNetworkCoverBackground to bookInfoNetworkCoverBackground,
+    PreferKey.bookInfoDefaultCoverBackground to bookInfoDefaultCoverBackground,
+    PreferKey.bgImage to backgroundImageLight,
+    PreferKey.bgImageN to backgroundImageDark,
+    PreferKey.bgImageBlurring to backgroundImageBlurring,
+    PreferKey.bgImageNBlurring to backgroundImageDarkBlurring,
+    PreferKey.enableItemDivider to enableItemDivider,
+    PreferKey.itemDividerWidth to itemDividerWidth,
+    PreferKey.itemDividerLength to itemDividerLength,
+    PreferKey.itemDividerColor to itemDividerColor,
+    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
+    PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
+    PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
+    PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,
+    io.legado.app.data.local.preferences.LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP.name to
+        showRefactorTip,
+    PreferKey.enableCustomTagColors to enableCustomTagColors,
+    PreferKey.customTagColors to customTagColorsJson,
+)
+
 internal fun Preferences.toOtherSettings(): OtherSettings {
     val rawSourceEditMaxLine = compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
     return OtherSettings(
@@ -520,6 +499,34 @@ internal fun Preferences.toOtherSettings(): OtherSettings {
     )
 }
 
+internal fun OtherSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.updateToVariant to updateToVariant,
+    PreferKey.autoCheckUpdateOnStart to autoCheckUpdateOnStart,
+    PreferKey.webServiceAutoStart to webServiceAutoStart,
+    PreferKey.autoRefresh to autoRefresh,
+    PreferKey.defaultToRead to defaultToRead,
+    PreferKey.notificationsPost to notificationsPost,
+    PreferKey.ignoreBatteryPermission to ignoreBatteryPermission,
+    PreferKey.firebaseEnable to firebaseEnable,
+    PreferKey.defaultBookTreeUri to defaultBookTreeUri,
+    PreferKey.antiAlias to antiAlias,
+    PreferKey.replaceEnableDefault to replaceEnableDefault,
+    PreferKey.autoClearExpired to autoClearExpired,
+    PreferKey.showAddToShelfAlert to showAddToShelfAlert,
+    PreferKey.showMangaUi to showMangaUi,
+    PreferKey.webServiceWakeLock to webServiceWakeLock,
+    PreferKey.sourceEditMaxLine to sourceEditMaxLine,
+    PreferKey.webPort to webPort,
+    PreferKey.processText to processText,
+    PreferKey.recordLog to recordLog,
+    PreferKey.recordHeapDump to recordHeapDump,
+    PreferKey.audioPlayWakeLock to audioPlayUseWakeLock,
+    PreferKey.importKeepName to importKeepName,
+    PreferKey.importKeepGroup to importKeepGroup,
+    PreferKey.importKeepEnable to importKeepEnable,
+    PreferKey.fontSort to fontSort,
+)
+
 class OtherSettingsRepository : OtherSettingsGateway {
     override val currentSettings: OtherSettings
         get() = AppConfigStore.preferences.toOtherSettings()
@@ -528,32 +535,11 @@ class OtherSettingsRepository : OtherSettingsGateway {
         .map { it.toOtherSettings() }
         .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
-            is OtherSettingsUpdate.AudioPlayUseWakeLock -> PreferKey.audioPlayWakeLock to update.value
-            is OtherSettingsUpdate.ImportKeepName -> PreferKey.importKeepName to update.value
-            is OtherSettingsUpdate.ImportKeepGroup -> PreferKey.importKeepGroup to update.value
-            is OtherSettingsUpdate.ImportKeepEnable -> PreferKey.importKeepEnable to update.value
-            is OtherSettingsUpdate.FontSort -> PreferKey.fontSort to update.value
-        }
-        AppConfigStore.putAllAndAwait(mapOf(key to value))
+    override suspend fun update(transform: (OtherSettings) -> OtherSettings) {
+        AppConfigStore.atomicUpdateAndAwait(
+            read = Preferences::toOtherSettings,
+            toPrefMap = OtherSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }

+ 5 - 1
app/src/main/java/io/legado/app/data/repository/ImportBookSettingsRepository.kt

@@ -21,7 +21,11 @@ class ImportBookSettingsRepository : ImportBookSettingsGateway {
         .distinctUntilChanged()
 
     override suspend fun update(transform: (ImportBookSettings) -> ImportBookSettings) {
-        AppConfigStore.putAll(currentSettings.diffPrefMap(transform, ImportBookSettings::toPrefMap))
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toImportBookSettings,
+            toPrefMap = ImportBookSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 

+ 7 - 61
app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt

@@ -3,8 +3,6 @@ package io.legado.app.data.repository
 import androidx.datastore.preferences.core.Preferences
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaClickArea
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsBoolean
@@ -15,6 +13,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
 
 class MangaSettingsRepository : MangaSettingsGateway {
+
     override val currentSettings: MangaSettings
         get() = AppConfigStore.preferences.toMangaSettings()
 
@@ -22,68 +21,15 @@ class MangaSettingsRepository : MangaSettingsGateway {
         .map { it.toMangaSettings() }
         .distinctUntilChanged()
 
-    override suspend fun update(update: MangaSettingsUpdate) = updateAll(listOf(update))
-
-    override suspend fun updateAll(updates: List<MangaSettingsUpdate>) {
-        AppConfigStore.putAll(updates.toMangaPreferenceValues())
+    override suspend fun update(transform: (MangaSettings) -> MangaSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toMangaSettings,
+            toPrefMap = MangaSettings::toPrefMap,
+            transform = transform,
+        )
     }
 }
 
-internal fun List<MangaSettingsUpdate>.toMangaPreferenceValues(): Map<String, Any?> =
-    buildMap {
-        this@toMangaPreferenceValues.forEach { update ->
-            when (update) {
-                    is MangaSettingsUpdate.ShowMangaUi -> put(PreferKey.showMangaUi, update.value)
-                    is MangaSettingsUpdate.DisableScale -> put(PreferKey.disableMangaScale, update.value)
-                    is MangaSettingsUpdate.DisableScrollAnimation ->
-                        put(PreferKey.disableMangaScrollAnimation, update.value)
-                    is MangaSettingsUpdate.DisableCrossFade ->
-                        put(PreferKey.disableMangaCrossFade, update.value)
-                    is MangaSettingsUpdate.ScrollMode -> put(PreferKey.mangaScrollMode, update.value)
-                    is MangaSettingsUpdate.PreDownloadNum ->
-                        put(PreferKey.mangaPreDownloadNum, update.value)
-                    is MangaSettingsUpdate.AutoPageSpeed ->
-                        put(PreferKey.mangaAutoPageSpeed, update.value)
-                    is MangaSettingsUpdate.FooterConfig -> put(PreferKey.mangaFooterConfig, update.value)
-                    is MangaSettingsUpdate.DisableClickScroll ->
-                        put(PreferKey.disableClickScroll, update.value)
-                    is MangaSettingsUpdate.LongClick -> put(PreferKey.mangaLongClick, update.value)
-                    is MangaSettingsUpdate.Background -> put(PreferKey.mangaBackground, update.value)
-                    is MangaSettingsUpdate.ColorFilter -> put(PreferKey.mangaColorFilter, update.value)
-                    is MangaSettingsUpdate.HideTitle -> put(PreferKey.hideMangaTitle, update.value)
-                    is MangaSettingsUpdate.EInk -> {
-                        put(PreferKey.enableMangaEInk, update.enabled)
-                        put(PreferKey.enableMangaGray, false)
-                        put(PreferKey.mangaEInkThreshold, update.threshold)
-                    }
-                    is MangaSettingsUpdate.Gray -> {
-                        put(PreferKey.enableMangaGray, update.enabled)
-                        put(PreferKey.enableMangaEInk, false)
-                    }
-                    is MangaSettingsUpdate.WebtoonSidePadding ->
-                        put(PreferKey.webtoonSidePaddingDp, update.value)
-                    is MangaSettingsUpdate.VolumeKeyPage ->
-                        put(PreferKey.mangaVolumeKeyPage, update.value)
-                    is MangaSettingsUpdate.ReverseVolumeKeyPage ->
-                        put(PreferKey.reverseVolumeKeyPage, update.value)
-                    is MangaSettingsUpdate.ClickAction -> put(
-                        when (update.area) {
-                            MangaClickArea.TL -> PreferKey.mangaClickActionTL
-                            MangaClickArea.TC -> PreferKey.mangaClickActionTC
-                            MangaClickArea.TR -> PreferKey.mangaClickActionTR
-                            MangaClickArea.ML -> PreferKey.mangaClickActionML
-                            MangaClickArea.MC -> PreferKey.mangaClickActionMC
-                            MangaClickArea.MR -> PreferKey.mangaClickActionMR
-                            MangaClickArea.BL -> PreferKey.mangaClickActionBL
-                            MangaClickArea.BC -> PreferKey.mangaClickActionBC
-                            MangaClickArea.BR -> PreferKey.mangaClickActionBR
-                        },
-                        update.value,
-                    )
-            }
-        }
-    }
-
 internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.showMangaUi to showMangaUi,
     PreferKey.disableMangaScale to disableMangaScale,

+ 102 - 204
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -3,10 +3,10 @@ package io.legado.app.data.repository
 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.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.help.config.AppConfigStore
@@ -15,222 +15,120 @@ import io.legado.app.help.config.compatDsValue
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
-typealias ReadAloudPreferences = ReadAloudSettings
-
-class ReadAloudSettingsRepository(
-    private val settingsRepository: SettingsRepository
-) : ReadAloudSettingsGateway {
+class ReadAloudSettingsRepository : ReadAloudSettingsGateway {
 
     override val currentSettings: ReadAloudSettings
-        get() = AppConfigStore.preferences.toReadAloudPreferences()
+        get() = AppConfigStore.preferences.toReadAloudSettings()
 
     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.TtsEngine -> setTtsEngine(update.value)
-            is ReadAloudSettingsUpdate.ParagraphInterval -> setTtsParagraphInterval(update.value)
-            is ReadAloudSettingsUpdate.AudioCacheCleanTime -> setAudioCacheCleanTime(update.value)
-            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)
-            is ReadAloudSettingsUpdate.ContentSelectSpeakMode ->
-                settingsRepository.putInt(PreferKey.contentSelectSpeakMod, update.value)
-            is ReadAloudSettingsUpdate.AudioPreDownloadNum ->
-                settingsRepository.putInt(PreferKey.audioPreDownloadNum, update.value)
-            is ReadAloudSettingsUpdate.PreSynthesisConcurrency ->
-                settingsRepository.putInt(PreferKey.ttsPreSynthesisConcurrency, update.value)
-
-            is ReadAloudSettingsUpdate.CapsuleAutoCollapse ->
-                settingsRepository.putBoolean(PreferKey.capsuleAutoCollapse, update.value)
+            preferences.toReadAloudSettings()
         }
-    }
-
-    suspend fun setTtsEngine(value: String?) {
-        AppConfigStore.putString(PreferKey.ttsEngine, value)
-    }
-
-    suspend fun setTtsParagraphInterval(value: Int) {
-        settingsRepository.putInt(PreferKey.ttsParagraphInterval, value)
-    }
-
-    suspend fun setAudioCacheCleanTime(value: Int) {
-        settingsRepository.putInt(PreferKey.audioCacheCleanTime, value)
-    }
-
-    suspend fun setIgnoreAudioFocus(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.ignoreAudioFocus, value)
-    }
-
-    suspend fun setMediaButtonOnExit(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.mediaButtonOnExit, value)
-    }
+    val preferences: Flow<ReadAloudSettings> = settings
 
-    suspend fun setReadAloudByMediaButton(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.readAloudByMediaButton, value)
-    }
-
-    suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
-    }
-
-    suspend fun setReadAloudWakeLock(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.readAloudWakeLock, value)
-    }
-
-    suspend fun setShowReadAloudCapsule(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.showReadAloudCapsule, value)
-    }
-
-    suspend fun setCapsulePosition(x: Float, y: Float) {
-        settingsRepository.putFloat(Keys.CapsuleOffsetX.name, x)
-        settingsRepository.putFloat(Keys.CapsuleOffsetY.name, y)
-    }
-
-    suspend fun resetCapsulePosition() = setCapsulePosition(0f, 0f)
-
-    suspend fun setMediaButtonPerNext(value: Boolean) {
-        settingsRepository.putBoolean(KEY_MEDIA_BUTTON_PER_NEXT, value)
-    }
-
-    suspend fun setReadAloudByPage(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.readAloudByPage, value)
-    }
-
-    suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
-    }
-
-    suspend fun setStreamReadAloudAudio(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.streamReadAloudAudio, value)
-    }
-
-    suspend fun setTtsTimer(value: Int) {
-        val timer = PlaybackTimer.normalize(value)
-        settingsRepository.putInt(PreferKey.ttsTimer, timer)
-    }
-
-    suspend fun setTtsFollowSys(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.ttsFollowSys, value)
-    }
-
-    suspend fun setTtsSpeechRate(value: Int) {
-        settingsRepository.putInt(PreferKey.ttsSpeechRate, value.coerceIn(0, 80))
-    }
-
-    suspend fun setSpeechAnalysisMode(value: String) {
-        settingsRepository.putString(PreferKey.speechAnalysisMode, value)
-    }
-
-    suspend fun setUseMultiSpeaker(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.useMultiSpeaker, value)
-    }
-
-    suspend fun setDefaultInterface(value: String) {
-        settingsRepository.putString(
-            PreferKey.defaultReadAloudInterface,
-            value.takeIf { it in AVAILABLE_INTERFACES } ?: DEFAULT_INTERFACE_CLASSIC,
-        )
-    }
-
-    suspend fun setPreSynthesisConcurrency(value: Int) {
-        settingsRepository.putInt(PreferKey.ttsPreSynthesisConcurrency, value.coerceIn(1, 8))
-    }
-
-    suspend fun setCapsuleAutoCollapse(value: Boolean) {
-        settingsRepository.putBoolean(PreferKey.capsuleAutoCollapse, value)
-    }
-
-    private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
-        return ReadAloudPreferences(
-            ttsEngine = compatDsString(PreferKey.ttsEngine),
-            ttsParagraphInterval = compatDsValue(Keys.TtsParagraphInterval, 0),
-            audioCacheCleanTime = compatDsValue(Keys.AudioCacheCleanTime, 10),
-            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),
-            capsuleAutoCollapse = compatDsValue(Keys.CapsuleAutoCollapse, true),
-            capsuleOffsetX = compatDsValue(Keys.CapsuleOffsetX, 0f),
-            capsuleOffsetY = compatDsValue(Keys.CapsuleOffsetY, 0f),
-            mediaButtonPerNext = compatDsValue(Keys.MediaButtonPerNext, false),
-            readAloudByPage = compatDsValue(Keys.ReadAloudByPage, false),
-            systemMediaControlCompatibilityChange =
-                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),
-            contentSelectSpeakMode = compatDsValue(Keys.ContentSelectSpeakMode, 0),
-            audioPreDownloadNum = compatDsValue(Keys.AudioPreDownloadNum, 10),
-            ttsPreSynthesisConcurrency = compatDsValue(Keys.PreSynthesisConcurrency, 3),
-        )
-    }
-
-    private object Keys {
-        val TtsParagraphInterval = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.ttsParagraphInterval
-        )
-        val AudioCacheCleanTime = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.audioCacheCleanTime
-        )
-        val IgnoreAudioFocus = booleanPreferencesKey(PreferKey.ignoreAudioFocus)
-        val MediaButtonOnExit = booleanPreferencesKey(PreferKey.mediaButtonOnExit)
-        val ReadAloudByMediaButton = booleanPreferencesKey(PreferKey.readAloudByMediaButton)
-        val PauseReadAloudWhilePhoneCalls =
-            booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
-        val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
-        val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
-        val CapsuleAutoCollapse = booleanPreferencesKey(PreferKey.capsuleAutoCollapse)
-        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 =
-            booleanPreferencesKey(PreferKey.systemMediaControlCompatibilityChange)
-        val StreamReadAloudAudio = booleanPreferencesKey(PreferKey.streamReadAloudAudio)
-        val TtsTimer = androidx.datastore.preferences.core.intPreferencesKey(PreferKey.ttsTimer)
-        val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
-        val TtsSpeechRate = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.ttsSpeechRate
-        )
-        val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)
-        val UseMultiSpeaker = booleanPreferencesKey(PreferKey.useMultiSpeaker)
-        val DefaultInterface = stringPreferencesKey(PreferKey.defaultReadAloudInterface)
-        val ContentSelectSpeakMode = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.contentSelectSpeakMod
-        )
-        val AudioPreDownloadNum = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.audioPreDownloadNum
-        )
-        val PreSynthesisConcurrency = androidx.datastore.preferences.core.intPreferencesKey(
-            PreferKey.ttsPreSynthesisConcurrency
+    override suspend fun update(transform: (ReadAloudSettings) -> ReadAloudSettings) {
+        AppConfigStore.atomicUpdate(
+            read = Preferences::toReadAloudSettings,
+            toPrefMap = ReadAloudSettings::toPrefMap,
+            transform = transform,
         )
     }
 
     companion object {
-        const val KEY_MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
         const val DEFAULT_INTERFACE_CLASSIC = "classic"
         const val DEFAULT_INTERFACE_PLAYER = "player"
         val AVAILABLE_INTERFACES = setOf(DEFAULT_INTERFACE_CLASSIC, DEFAULT_INTERFACE_PLAYER)
     }
 }
+
+internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSettings(
+    ttsEngine = compatDsString(PreferKey.ttsEngine),
+    ttsParagraphInterval = compatDsValue(ReadAloudKeys.TtsParagraphInterval, 0),
+    audioCacheCleanTime = compatDsValue(ReadAloudKeys.AudioCacheCleanTime, 10),
+    ignoreAudioFocus = compatDsValue(ReadAloudKeys.IgnoreAudioFocus, false),
+    mediaButtonOnExit = compatDsValue(ReadAloudKeys.MediaButtonOnExit, true),
+    readAloudByMediaButton = compatDsValue(ReadAloudKeys.ReadAloudByMediaButton, false),
+    pauseReadAloudWhilePhoneCalls =
+        compatDsValue(ReadAloudKeys.PauseReadAloudWhilePhoneCalls, false),
+    readAloudWakeLock = compatDsValue(ReadAloudKeys.ReadAloudWakeLock, false),
+    showReadAloudCapsule = compatDsValue(ReadAloudKeys.ShowReadAloudCapsule, true),
+    capsuleAutoCollapse = compatDsValue(ReadAloudKeys.CapsuleAutoCollapse, true),
+    capsuleOffsetX = compatDsValue(ReadAloudKeys.CapsuleOffsetX, 0f),
+    capsuleOffsetY = compatDsValue(ReadAloudKeys.CapsuleOffsetY, 0f),
+    mediaButtonPerNext = compatDsValue(ReadAloudKeys.MediaButtonPerNext, false),
+    readAloudByPage = compatDsValue(ReadAloudKeys.ReadAloudByPage, false),
+    systemMediaControlCompatibilityChange =
+        compatDsValue(ReadAloudKeys.SystemMediaControlCompatibilityChange, true),
+    streamReadAloudAudio = compatDsValue(ReadAloudKeys.StreamReadAloudAudio, false),
+    ttsTimer = PlaybackTimer.normalize(compatDsValue(ReadAloudKeys.TtsTimer, 0)),
+    ttsFollowSys = compatDsValue(ReadAloudKeys.TtsFollowSys, true),
+    ttsSpeechRate = compatDsValue(ReadAloudKeys.TtsSpeechRate, 5),
+    speechAnalysisMode = compatDsValue(ReadAloudKeys.SpeechAnalysisMode, "rule"),
+    useMultiSpeaker = compatDsValue(ReadAloudKeys.UseMultiSpeaker, true),
+    defaultInterface = compatDsValue(
+        ReadAloudKeys.DefaultInterface,
+        ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC,
+    ),
+    contentSelectSpeakMode = compatDsValue(ReadAloudKeys.ContentSelectSpeakMode, 0),
+    audioPreDownloadNum = compatDsValue(ReadAloudKeys.AudioPreDownloadNum, 10),
+    ttsPreSynthesisConcurrency = compatDsValue(ReadAloudKeys.PreSynthesisConcurrency, 3),
+)
+
+internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.ttsEngine to ttsEngine,
+    PreferKey.ttsParagraphInterval to ttsParagraphInterval,
+    PreferKey.audioCacheCleanTime to audioCacheCleanTime,
+    PreferKey.ignoreAudioFocus to ignoreAudioFocus,
+    PreferKey.mediaButtonOnExit to mediaButtonOnExit,
+    PreferKey.readAloudByMediaButton to readAloudByMediaButton,
+    PreferKey.pauseReadAloudWhilePhoneCalls to pauseReadAloudWhilePhoneCalls,
+    PreferKey.readAloudWakeLock to readAloudWakeLock,
+    PreferKey.showReadAloudCapsule to showReadAloudCapsule,
+    PreferKey.capsuleAutoCollapse to capsuleAutoCollapse,
+    ReadAloudKeys.CapsuleOffsetX.name to capsuleOffsetX,
+    ReadAloudKeys.CapsuleOffsetY.name to capsuleOffsetY,
+    MEDIA_BUTTON_PER_NEXT to mediaButtonPerNext,
+    PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
+    PreferKey.streamReadAloudAudio to streamReadAloudAudio,
+    PreferKey.ttsTimer to ttsTimer,
+    PreferKey.ttsFollowSys to ttsFollowSys,
+    PreferKey.ttsSpeechRate to ttsSpeechRate,
+    PreferKey.speechAnalysisMode to speechAnalysisMode,
+    PreferKey.useMultiSpeaker to useMultiSpeaker,
+    PreferKey.defaultReadAloudInterface to defaultInterface,
+    PreferKey.contentSelectSpeakMod to contentSelectSpeakMode,
+    PreferKey.audioPreDownloadNum to audioPreDownloadNum,
+    PreferKey.ttsPreSynthesisConcurrency to ttsPreSynthesisConcurrency,
+)
+
+private const val MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
+
+private object ReadAloudKeys {
+    val TtsParagraphInterval = intPreferencesKey(PreferKey.ttsParagraphInterval)
+    val AudioCacheCleanTime = intPreferencesKey(PreferKey.audioCacheCleanTime)
+    val IgnoreAudioFocus = booleanPreferencesKey(PreferKey.ignoreAudioFocus)
+    val MediaButtonOnExit = booleanPreferencesKey(PreferKey.mediaButtonOnExit)
+    val ReadAloudByMediaButton = booleanPreferencesKey(PreferKey.readAloudByMediaButton)
+    val PauseReadAloudWhilePhoneCalls =
+        booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
+    val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
+    val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
+    val CapsuleAutoCollapse = booleanPreferencesKey(PreferKey.capsuleAutoCollapse)
+    val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
+    val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
+    val MediaButtonPerNext = booleanPreferencesKey(MEDIA_BUTTON_PER_NEXT)
+    val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
+    val SystemMediaControlCompatibilityChange =
+        booleanPreferencesKey(PreferKey.systemMediaControlCompatibilityChange)
+    val StreamReadAloudAudio = booleanPreferencesKey(PreferKey.streamReadAloudAudio)
+    val TtsTimer = intPreferencesKey(PreferKey.ttsTimer)
+    val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
+    val TtsSpeechRate = intPreferencesKey(PreferKey.ttsSpeechRate)
+    val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)
+    val UseMultiSpeaker = booleanPreferencesKey(PreferKey.useMultiSpeaker)
+    val DefaultInterface = stringPreferencesKey(PreferKey.defaultReadAloudInterface)
+    val ContentSelectSpeakMode = intPreferencesKey(PreferKey.contentSelectSpeakMod)
+    val AudioPreDownloadNum = intPreferencesKey(PreferKey.audioPreDownloadNum)
+    val PreSynthesisConcurrency = intPreferencesKey(PreferKey.ttsPreSynthesisConcurrency)
+}

+ 58 - 66
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -9,7 +9,6 @@ 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
@@ -34,56 +33,12 @@ class ReadSettingsRepository(
 
     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.EyeProtectionEnabled -> setEyeProtectionEnabled(update.value)
-            is ReadSettingsUpdate.EyeProtectionIntensity -> setEyeProtectionIntensity(update.value)
-            is ReadSettingsUpdate.EyeProtectionAutoNight -> setEyeProtectionAutoNight(update.value)
-            is ReadSettingsUpdate.ShowBrightnessView -> setShowBrightnessView(update.value)
-            is ReadSettingsUpdate.BrightnessVwPos -> setBrightnessVwPos(update.value)
-            is ReadSettingsUpdate.Brightness -> setReadBrightness(update.value)
-            is ReadSettingsUpdate.BrightnessAuto -> setBrightnessAuto(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.TextSelectMenuConfig -> settingsRepository.putString(PreferKey.textSelectMenuConfig, update.value)
-            is ReadSettingsUpdate.ReadUrlInBrowser -> settingsRepository.putBoolean(PreferKey.readUrlOpenInBrowser, update.value)
-            is ReadSettingsUpdate.ShowMenuIcon -> setShowMenuIcon(update.value)
-            is ReadSettingsUpdate.TitleBarCompact -> setTitleBarCompact(update.value)
-            is ReadSettingsUpdate.PageKeys -> setPageKeys(update.previous, update.next)
-            is ReadSettingsUpdate.FontFolder -> setFontFolder(update.value)
-            is ReadSettingsUpdate.SystemTypefaces -> setSystemTypefaces(update.value)
-            is ReadSettingsUpdate.PreDownloadNum -> setPreDownloadNum(update.value)
-        }
+    override suspend fun update(transform: (ReadSettings) -> ReadSettings) {
+        AppConfigStore.atomicUpdate(
+            read = { it.toReadSettings() },
+            toPrefMap = ReadSettings::toGatewayPrefMap,
+            transform = transform,
+        )
     }
 
     suspend fun setScreenOrientation(value: String) =
@@ -104,15 +59,6 @@ class ReadSettingsRepository(
     suspend fun setTitleBarMode(value: String) =
         settingsRepository.putString(PreferKey.titleBarMode, value)
 
-    suspend fun setEyeProtectionEnabled(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.eyeProtectionEnabled, value)
-
-    suspend fun setEyeProtectionIntensity(value: Int) =
-        settingsRepository.putInt(PreferKey.colorTemperature, value.coerceIn(0, 100))
-
-    suspend fun setEyeProtectionAutoNight(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.eyeProtectionAutoNight, value)
-
     suspend fun setMenuAlpha(value: Int) =
         settingsRepository.putInt(PreferKey.menuAlpha, value)
 
@@ -395,9 +341,6 @@ class ReadSettingsRepository(
             textBottomJustify = compatDsValue(Keys.TextBottomJustify, true),
             adaptSpecialStyle = compatDsValue(Keys.AdaptSpecialStyle, true),
             useZhLayout = compatDsValue(Keys.UseZhLayout, false),
-            eyeProtectionEnabled = compatDsValue(Keys.EyeProtectionEnabled, false),
-            eyeProtectionIntensity = compatDsValue(Keys.EyeProtectionIntensity, 50).coerceIn(0, 100),
-            eyeProtectionAutoNight = compatDsValue(Keys.EyeProtectionAutoNight, false),
             showBrightnessView = compatDsValue(Keys.ShowBrightnessView, "0"),
             brightnessVwPos = compatDsValue(Keys.BrightnessVwPos, "1"),
             readBrightness = compatDsValue(Keys.ReadBrightness, 100),
@@ -506,9 +449,6 @@ class ReadSettingsRepository(
         val TextBottomJustify = booleanPreferencesKey(PreferKey.textBottomJustify)
         val AdaptSpecialStyle = booleanPreferencesKey(PreferKey.adaptSpecialStyle)
         val UseZhLayout = booleanPreferencesKey(PreferKey.useZhLayout)
-        val EyeProtectionEnabled = booleanPreferencesKey(PreferKey.eyeProtectionEnabled)
-        val EyeProtectionIntensity = intPreferencesKey(PreferKey.colorTemperature)
-        val EyeProtectionAutoNight = booleanPreferencesKey(PreferKey.eyeProtectionAutoNight)
         val ShowBrightnessView = stringPreferencesKey(PreferKey.showBrightnessView)
         val BrightnessVwPos = stringPreferencesKey(PreferKey.brightnessVwPos)
         val ReadBrightness = intPreferencesKey(PreferKey.brightness)
@@ -602,3 +542,55 @@ class ReadSettingsRepository(
         val TitleBarCompact = booleanPreferencesKey(PreferKey.titleBarCompact)
     }
 }
+
+/**
+ * ReadSettings 是 101 字段的读取超集;gateway update 当前只承诺持久化以下 45 个键。
+ * 扩展此边界时必须同步更新 ReadSettingsMappingTest 的显式键集契约。
+ */
+internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.screenOrientation to screenOrientation,
+    PreferKey.keepLight to keepLight,
+    PreferKey.hideStatusBar to hideStatusBar,
+    PreferKey.hideNavigationBar to hideNavigationBar,
+    PreferKey.paddingDisplayCutouts to paddingDisplayCutouts,
+    PreferKey.titleBarMode to titleBarMode,
+    PreferKey.readMenuBlurAlpha to readMenuBlurAlpha,
+    PreferKey.readBodyToLh to readBodyToLh,
+    PreferKey.defaultSourceChangeAll to defaultSourceChangeAll,
+    PreferKey.textFullJustify to textFullJustify,
+    PreferKey.textBottomJustify to textBottomJustify,
+    PreferKey.adaptSpecialStyle to adaptSpecialStyle,
+    PreferKey.useZhLayout to useZhLayout,
+    PreferKey.showBrightnessView to showBrightnessView,
+    PreferKey.brightnessVwPos to brightnessVwPos,
+    PreferKey.brightness to readBrightness,
+    PreferKey.brightnessAuto to brightnessAuto,
+    PreferKey.useUnderline to useUnderline,
+    PreferKey.readSliderMode to readSliderMode,
+    PreferKey.doublePageHorizontal to doubleHorizontalPage,
+    PreferKey.progressBarBehavior to progressBarBehavior,
+    PreferKey.mouseWheelPage to mouseWheelPage,
+    PreferKey.volumeKeyPage to volumeKeyPage,
+    PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
+    PreferKey.keyPageOnLongPress to keyPageOnLongPress,
+    PreferKey.pageTouchSlop to pageTouchSlop,
+    PreferKey.sliderVibrator to sliderVibrator,
+    PreferKey.selectVibrator to selectVibrator,
+    PreferKey.autoChangeSource to autoChangeSource,
+    PreferKey.autoSuggestDayNight to autoSuggestDayNight,
+    PreferKey.selectText to selectText,
+    PreferKey.noAnimScrollPage to noAnimScrollPage,
+    PreferKey.clickImgWay to clickImgWay,
+    PreferKey.optimizeRender to optimizeRender,
+    PreferKey.disableReturnKey to disableReturnKey,
+    PreferKey.showReadTitleAddition to showReadTitleAddition,
+    PreferKey.textSelectMenuConfig to textSelectMenuConfig,
+    PreferKey.readUrlOpenInBrowser to readUrlInBrowser,
+    PreferKey.showMenuIcon to showMenuIcon,
+    PreferKey.titleBarCompact to titleBarCompact,
+    PreferKey.prevKeys to prevKeys,
+    PreferKey.nextKeys to nextKeys,
+    PreferKey.fontFolder to fontFolder,
+    PreferKey.systemTypefaces to systemTypefaces,
+    PreferKey.preDownloadNum to preDownloadNum,
+)

+ 0 - 9
app/src/main/java/io/legado/app/data/repository/SettingsMapping.kt

@@ -1,9 +0,0 @@
-package io.legado.app.data.repository
-
-internal inline fun <T> T.diffPrefMap(
-    transform: (T) -> T,
-    toPrefMap: (T) -> Map<String, Any?>,
-): Map<String, Any?> {
-    val previous = toPrefMap(this)
-    return toPrefMap(transform(this)).filter { (key, value) -> previous[key] != value }
-}

+ 1 - 42
app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt

@@ -6,46 +6,5 @@ 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,
+    suspend fun update(transform: (AppShellSettings) -> AppShellSettings)
 }

+ 1 - 15
app/src/main/java/io/legado/app/domain/gateway/BackupSettingsGateway.kt

@@ -6,19 +6,5 @@ import kotlinx.coroutines.flow.Flow
 interface BackupSettingsGateway {
     val currentSettings: BackupSettings
     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
+    suspend fun update(transform: (BackupSettings) -> BackupSettings)
 }

+ 1 - 58
app/src/main/java/io/legado/app/domain/gateway/BookshelfSettingsGateway.kt

@@ -6,62 +6,5 @@ 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,
-    HideEmptyGroups,
-}
-
-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,
+    suspend fun update(transform: (BookshelfSettings) -> BookshelfSettings)
 }

+ 1 - 26
app/src/main/java/io/legado/app/domain/gateway/MangaSettingsGateway.kt

@@ -6,30 +6,5 @@ import kotlinx.coroutines.flow.Flow
 interface MangaSettingsGateway {
     val currentSettings: MangaSettings
     val settings: Flow<MangaSettings>
-    suspend fun update(update: MangaSettingsUpdate)
-    suspend fun updateAll(updates: List<MangaSettingsUpdate>)
+    suspend fun update(transform: (MangaSettings) -> MangaSettings)
 }
-
-sealed interface MangaSettingsUpdate {
-    data class ShowMangaUi(val value: Boolean) : MangaSettingsUpdate
-    data class DisableScale(val value: Boolean) : MangaSettingsUpdate
-    data class DisableScrollAnimation(val value: Boolean) : MangaSettingsUpdate
-    data class DisableCrossFade(val value: Boolean) : MangaSettingsUpdate
-    data class ScrollMode(val value: Int) : MangaSettingsUpdate
-    data class PreDownloadNum(val value: Int) : MangaSettingsUpdate
-    data class AutoPageSpeed(val value: Int) : MangaSettingsUpdate
-    data class FooterConfig(val value: String) : MangaSettingsUpdate
-    data class DisableClickScroll(val value: Boolean) : MangaSettingsUpdate
-    data class LongClick(val value: Boolean) : MangaSettingsUpdate
-    data class Background(val value: Int) : MangaSettingsUpdate
-    data class ColorFilter(val value: String) : MangaSettingsUpdate
-    data class HideTitle(val value: Boolean) : MangaSettingsUpdate
-    data class EInk(val enabled: Boolean, val threshold: Int) : MangaSettingsUpdate
-    data class Gray(val enabled: Boolean) : MangaSettingsUpdate
-    data class WebtoonSidePadding(val value: Int) : MangaSettingsUpdate
-    data class VolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
-    data class ReverseVolumeKeyPage(val value: Boolean) : MangaSettingsUpdate
-    data class ClickAction(val area: MangaClickArea, val value: Int) : MangaSettingsUpdate
-}
-
-enum class MangaClickArea { TL, TC, TR, ML, MC, MR, BL, BC, BR }

+ 1 - 27
app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt

@@ -6,31 +6,5 @@ import kotlinx.coroutines.flow.Flow
 interface OtherSettingsGateway {
     val currentSettings: OtherSettings
     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
-    data class AudioPlayUseWakeLock(val value: Boolean) : OtherSettingsUpdate
-    data class ImportKeepName(val value: Boolean) : OtherSettingsUpdate
-    data class ImportKeepGroup(val value: Boolean) : OtherSettingsUpdate
-    data class ImportKeepEnable(val value: Boolean) : OtherSettingsUpdate
-    data class FontSort(val value: Int) : OtherSettingsUpdate
+    suspend fun update(transform: (OtherSettings) -> OtherSettings)
 }

+ 1 - 23
app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt

@@ -6,27 +6,5 @@ import kotlinx.coroutines.flow.Flow
 interface ReadAloudSettingsGateway {
     val currentSettings: ReadAloudSettings
     val settings: Flow<ReadAloudSettings>
-    suspend fun update(update: ReadAloudSettingsUpdate)
-}
-
-sealed interface ReadAloudSettingsUpdate {
-    data class TtsEngine(val value: String?) : ReadAloudSettingsUpdate
-    data class ParagraphInterval(val value: Int) : ReadAloudSettingsUpdate
-    data class AudioCacheCleanTime(val value: Int) : 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
-    data class ContentSelectSpeakMode(val value: Int) : ReadAloudSettingsUpdate
-    data class AudioPreDownloadNum(val value: Int) : ReadAloudSettingsUpdate
-    data class PreSynthesisConcurrency(val value: Int) : ReadAloudSettingsUpdate
-    data class CapsuleAutoCollapse(val value: Boolean) : ReadAloudSettingsUpdate
+    suspend fun update(transform: (ReadAloudSettings) -> ReadAloudSettings)
 }

+ 6 - 51
app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt

@@ -7,55 +7,10 @@ interface ReadSettingsGateway {
     val currentSettings: ReadSettings
     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 EyeProtectionEnabled(val value: Boolean) : ReadSettingsUpdate
-    data class EyeProtectionIntensity(val value: Int) : ReadSettingsUpdate
-    data class EyeProtectionAutoNight(val value: Boolean) : ReadSettingsUpdate
-    data class ShowBrightnessView(val value: String) : ReadSettingsUpdate
-    data class BrightnessVwPos(val value: String) : ReadSettingsUpdate
-    data class Brightness(val value: Int) : ReadSettingsUpdate
-    data class BrightnessAuto(val value: Boolean) : 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 TextSelectMenuConfig(val value: String) : ReadSettingsUpdate
-    data class ReadUrlInBrowser(val value: Boolean) : ReadSettingsUpdate
-    data class ShowMenuIcon(val value: Boolean) : ReadSettingsUpdate
-    data class TitleBarCompact(val value: Boolean) : ReadSettingsUpdate
-    data class PageKeys(val previous: String, val next: String) : ReadSettingsUpdate
-    data class FontFolder(val value: String) : ReadSettingsUpdate
-    data class SystemTypefaces(val value: Int) : ReadSettingsUpdate
-    data class PreDownloadNum(val value: Int) : ReadSettingsUpdate
+    /**
+     * 仅持久化 gateway 实现映射声明的 45 个配置键。
+     * [ReadSettings] 是包含遗留阅读菜单/样式字段的读模型超集;未纳入 gateway 映射的字段
+     * 仍须通过对应的遗留 Repository setter 写入,不能在此处 copy 后期待自动落盘。
+     */
+    suspend fun update(transform: (ReadSettings) -> ReadSettings)
 }

+ 6 - 91
app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt

@@ -6,96 +6,11 @@ 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,
-    OverrideBaseCardCornerRadius,
-    OverrideBaseCardBorder,
-    DisableSplicedColumnGroupCornerRadius,
-    ShowRefactorTip,
-    EnableCustomTagColors,
-}
-
-enum class ThemeIntSetting {
-    ContainerOpacity,
-    TopBarOpacity,
-    BottomBarOpacity,
-    TopBarBlurRadius,
-    BottomBarBlurRadius,
-    TopBarBlurAlpha,
-    BottomBarBlurAlpha,
-    BackgroundImageBlurring,
-    BackgroundImageDarkBlurring,
-    ItemDividerColor,
-    BaseCardBorderColor,
-    BaseCardBorderColorNight,
-    ColorTemperature,
-}
-
-enum class ThemeFloatSetting {
-    BottomBarLensRadius,
-    ItemDividerWidth,
-    ItemDividerLength,
-    BaseCardCornerRadius,
-    BaseCardBorderWidth,
-}
-
-enum class ThemeStringSetting {
-    BookInfoNetworkCoverBackground,
-    BookInfoDefaultCoverBackground,
-    BackgroundImageLight,
-    BackgroundImageDark,
-    CustomTagColorsJson,
-}
 
-enum class ThemeColorSlot {
-    Primary,
-    Secondary,
-    PrimaryText,
-    SecondaryText,
-    Background,
-    LabelContainer,
-    PrimaryNight,
-    SecondaryNight,
-    PrimaryTextNight,
-    SecondaryTextNight,
-    BackgroundNight,
-    LabelContainerNight,
+    /**
+     * 仅持久化 gateway 映射声明的 59 个配置键。
+     * [ThemeSettings] 是包含主题包专用字段的 61 字段读模型;[ThemeSettings.customMode] 与
+     * [ThemeSettings.bookInfoInputColor] 仍须由主题包事务写入,不能在此处 copy 后期待自动落盘。
+     */
+    suspend fun update(transform: (ThemeSettings) -> ThemeSettings)
 }

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

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

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

@@ -54,6 +54,11 @@ data class ThemeSettings(
     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,

+ 90 - 18
app/src/main/java/io/legado/app/help/config/AppConfigStore.kt

@@ -106,6 +106,26 @@ object AppConfigStore {
     suspend fun putAllAndAwait(values: Map<String, Any?>) =
         requireCore().putAllAndAwait(values)
 
+    /**
+     * 基于当前生效快照原子更新设置。
+     * [transform] 必须是纯计算、快速且不挂起,不得在其中执行 IO 或回调外部状态。
+     */
+    fun <T> atomicUpdate(
+        read: (Preferences) -> T,
+        toPrefMap: (T) -> Map<String, Any?>,
+        transform: (T) -> T,
+    ) = requireCore().atomicUpdate(read, toPrefMap, transform)
+
+    /**
+     * 原子更新设置并等待 DataStore edit 完成。
+     * [transform] 必须是纯计算、快速且不挂起。
+     */
+    suspend fun <T> atomicUpdateAndAwait(
+        read: (Preferences) -> T,
+        toPrefMap: (T) -> Map<String, Any?>,
+        transform: (T) -> T,
+    ) = requireCore().atomicUpdateAndAwait(read, toPrefMap, transform)
+
     // 订阅:替代 SP 监听器的统一入口,值不存在时发 null
     fun observeString(key: String): Flow<String?> = observe { it.compatDsString(key) }
     fun observeInt(key: String): Flow<Int?> = observe { it.compatDsInt(key) }
@@ -177,6 +197,50 @@ internal class PendingOverlayCore(
         completion.await().getOrThrow()
     }
 
+    /**
+     * 读取 overlay、计算 transform 与差量入队均在同一临界区内完成。
+     * transform 必须是纯计算、快速且不挂起。
+     */
+    fun <T> atomicUpdate(
+        read: (Preferences) -> T,
+        toPrefMap: (T) -> Map<String, Any?>,
+        transform: (T) -> T,
+    ) {
+        synchronized(lock) {
+            val values = atomicDiffLocked(read, toPrefMap, transform)
+            if (values.isNotEmpty()) enqueueAllLocked(values, completion = null)
+        }
+    }
+
+    /** [transform] 必须是纯计算、快速且不挂起。 */
+    suspend fun <T> atomicUpdateAndAwait(
+        read: (Preferences) -> T,
+        toPrefMap: (T) -> Map<String, Any?>,
+        transform: (T) -> T,
+    ) {
+        val completion = synchronized(lock) {
+            val values = atomicDiffLocked(read, toPrefMap, transform)
+            if (values.isEmpty()) {
+                null
+            } else {
+                CompletableDeferred<Result<Unit>>().also {
+                    enqueueAllLocked(values, completion = it)
+                }
+            }
+        }
+        completion?.await()?.getOrThrow()
+    }
+
+    private fun <T> atomicDiffLocked(
+        read: (Preferences) -> T,
+        toPrefMap: (T) -> Map<String, Any?>,
+        transform: (T) -> T,
+    ): Map<String, Any?> {
+        val current = read(_preferences.value)
+        val previous = toPrefMap(current)
+        return toPrefMap(transform(current)).filter { (key, value) -> previous[key] != value }
+    }
+
     private fun enqueueAll(
         values: Map<String, Any?>,
         completion: CompletableDeferred<Result<Unit>>?,
@@ -186,27 +250,35 @@ internal class PendingOverlayCore(
             return
         }
         synchronized(lock) {
-            val writes = values.mapValues { PendingWrite(it.value) }
-            pending.putAll(writes)
-            rebuild()
-            launchWrite {
-                runCatching { persistAll(values) }
-                    .onSuccess { completion?.complete(Result.success(Unit)) }
-                    .onFailure { e ->
-                        synchronized(lock) {
-                            var removed = false
-                            writes.forEach { (key, write) ->
-                                if (pending[key] === write) {
-                                    pending.remove(key)
-                                    removed = true
-                                }
+            enqueueAllLocked(values, completion)
+        }
+    }
+
+    /** 调用方必须已持有 [lock]。 */
+    private fun enqueueAllLocked(
+        values: Map<String, Any?>,
+        completion: CompletableDeferred<Result<Unit>>?,
+    ) {
+        val writes = values.mapValues { PendingWrite(it.value) }
+        pending.putAll(writes)
+        rebuild()
+        launchWrite {
+            runCatching { persistAll(values) }
+                .onSuccess { completion?.complete(Result.success(Unit)) }
+                .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}")
                         }
-                        completion?.complete(Result.failure(e))
+                        if (removed) rebuild()
+                        LogUtils.e("AppConfigStore", "批量保存设置失败: keys=${values.keys}\n${e.stackTraceStr}")
                     }
-            }
+                    completion?.complete(Result.failure(e))
+                }
         }
     }
 

+ 9 - 13
app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt

@@ -18,9 +18,7 @@ import androidx.lifecycle.lifecycleScope
 import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.ThemeIntSetting
 import io.legado.app.domain.gateway.ThemeSettingsGateway
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.toastOnUi
@@ -105,18 +103,16 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                     currentValue = value
                     callChangeListener(value)
                     holder.itemView.activity?.lifecycleScope?.launch {
-                        val updates = buildList {
-                            add(ThemeSettingsUpdate.AppTheme(value))
-                            if (value == "13") {
-                                add(
-                                    ThemeSettingsUpdate.IntValue(
-                                        ThemeIntSetting.ContainerOpacity,
-                                        0,
-                                    )
-                                )
-                            }
+                        themeSettingsGateway.update {
+                            it.copy(
+                                appTheme = value,
+                                containerOpacity = if (value == "13") {
+                                    0
+                                } else {
+                                    it.containerOpacity
+                                },
+                            )
                         }
-                        themeSettingsGateway.updateAll(updates)
                     }
                     notifyDataSetChanged()
                 }

+ 1 - 2
app/src/main/java/io/legado/app/service/WebService.kt

@@ -17,7 +17,6 @@ import io.legado.app.constant.NotificationId
 import io.legado.app.constant.PreferKey
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.eventBus.FlowEventBus
 import io.legado.app.utils.getPrefBoolean
@@ -126,7 +125,7 @@ class WebService : BaseService() {
             IntentAction.stop -> {
                 // ——————【修改开始】通知栏点击停止时,也记录关闭状态——————
                 lifecycleScope.launch {
-                    otherSettingsGateway.update(OtherSettingsUpdate.WebServiceAutoStart(false))
+                    otherSettingsGateway.update { it.copy(webServiceAutoStart = false) }
                 }
                 stopSelf()
                 // ——————【修改结束】——————

+ 3 - 2
app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt

@@ -11,7 +11,6 @@ import io.legado.app.base.VMBaseActivity
 import io.legado.app.constant.AppLog
 import io.legado.app.databinding.ActivityTranslucenceBinding
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.exception.InvalidBooksDirException
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.permission.Permissions
@@ -48,7 +47,9 @@ class FileAssociationActivity :
         intent.data?.let { uri ->
             it.uri?.let { treeUri ->
                 lifecycleScope.launch {
-                    otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(treeUri.toString()))
+                    otherSettingsGateway.update {
+                        it.copy(defaultBookTreeUri = treeUri.toString())
+                    }
                 }
                 importBook(treeUri, uri)
             } ?: let {

+ 6 - 6
app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt

@@ -22,7 +22,7 @@ import io.legado.app.databinding.DialogRecyclerViewBinding
 import io.legado.app.databinding.ItemSourceImportBinding
 import io.legado.app.help.config.AppConfig
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.lib.dialogs.alert
 //import io.legado.app.lib.theme.primaryColor
 import io.legado.app.ui.widget.dialog.CodeDialog
@@ -178,24 +178,24 @@ class ImportBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_r
 
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepName(item.isChecked))
+                updateImportSetting { it.copy(importKeepName = item.isChecked) }
             }
 
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepGroup(item.isChecked))
+                updateImportSetting { it.copy(importKeepGroup = item.isChecked) }
             }
 
             R.id.menu_keep_enable -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepEnable(item.isChecked))
+                updateImportSetting { it.copy(importKeepEnable = item.isChecked) }
             }
         }
         return false
     }
 
-    private fun updateImportSetting(update: OtherSettingsUpdate) {
-        lifecycleScope.launch { otherSettingsGateway.update(update) }
+    private fun updateImportSetting(transform: (OtherSettings) -> OtherSettings) {
+        lifecycleScope.launch { otherSettingsGateway.update(transform) }
     }
 
     private fun alertCustomGroup(item: MenuItem) {

+ 6 - 6
app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt

@@ -22,7 +22,7 @@ import io.legado.app.databinding.DialogRecyclerViewBinding
 import io.legado.app.databinding.ItemSourceImportBinding
 import io.legado.app.help.config.AppConfig
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.lib.dialogs.alert
 //import io.legado.app.lib.theme.primaryColor
 import io.legado.app.ui.widget.dialog.CodeDialog
@@ -158,24 +158,24 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
             R.id.menu_new_group -> alertCustomGroup(item)
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepName(item.isChecked))
+                updateImportSetting { it.copy(importKeepName = item.isChecked) }
             }
 
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepGroup(item.isChecked))
+                updateImportSetting { it.copy(importKeepGroup = item.isChecked) }
             }
 
             R.id.menu_keep_enable -> {
                 item.isChecked = !item.isChecked
-                updateImportSetting(OtherSettingsUpdate.ImportKeepEnable(item.isChecked))
+                updateImportSetting { it.copy(importKeepEnable = item.isChecked) }
             }
         }
         return false
     }
 
-    private fun updateImportSetting(update: OtherSettingsUpdate) {
-        lifecycleScope.launch { otherSettingsGateway.update(update) }
+    private fun updateImportSetting(transform: (OtherSettings) -> OtherSettings) {
+        lifecycleScope.launch { otherSettingsGateway.update(transform) }
     }
 
     private fun alertCustomGroup(item: MenuItem) {

+ 8 - 11
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt

@@ -45,9 +45,7 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.ActivityAudioPlayBinding
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
@@ -208,18 +206,17 @@ class AudioPlayActivity :
                 }
             }
             R.id.menu_media_control -> lifecycleScope.launch {
-                readAloudSettingsGateway.update(
-                    ReadAloudSettingsUpdate.SystemMediaControlCompatibility(
-                        !readAloudSettingsGateway.currentSettings.systemMediaControlCompatibilityChange
+                readAloudSettingsGateway.update {
+                    it.copy(
+                        systemMediaControlCompatibilityChange =
+                            !it.systemMediaControlCompatibilityChange
                     )
-                )
+                }
             }
             R.id.menu_wake_lock -> lifecycleScope.launch {
-                otherSettingsGateway.update(
-                    OtherSettingsUpdate.AudioPlayUseWakeLock(
-                        !otherSettingsGateway.currentSettings.audioPlayUseWakeLock
-                    )
-                )
+                otherSettingsGateway.update {
+                    it.copy(audioPlayUseWakeLock = !it.audioPlayUseWakeLock)
+                }
             }
             R.id.menu_copy_audio_url -> {
                 AudioPlay.book?.let {

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

@@ -17,8 +17,8 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.model.settings.ImportBookSettings
+import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.canSafelyRebindTo
 import io.legado.app.model.localBook.LocalBook
@@ -259,7 +259,7 @@ class ImportBookViewModel(
             return
         }
         if (otherSettingsGateway.currentSettings.defaultBookTreeUri != effectiveDefaultPath) {
-            updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(effectiveDefaultPath))
+            updateOtherSetting { it.copy(defaultBookTreeUri = effectiveDefaultPath) }
         }
         val importPath = importBookSettingsGateway.currentSettings.importBookPath
         if (importPath.isNullOrBlank() || !importPath.isUri()) {
@@ -274,7 +274,7 @@ class ImportBookViewModel(
         val pickedUri = persistFolderPermission(uri)
         when (target) {
             ImportFolderPickTarget.DEFAULT_BOOK -> {
-                updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(pickedUri.toString()))
+                updateOtherSetting { it.copy(defaultBookTreeUri = pickedUri.toString()) }
                 if (importBookSettingsGateway.currentSettings.importBookPath.isNullOrBlank()) {
                     updateImportBookSetting { it.copy(importBookPath = pickedUri.toString()) }
                 }
@@ -283,7 +283,7 @@ class ImportBookViewModel(
             ImportFolderPickTarget.IMPORT_FOLDER -> {
                 updateImportBookSetting { it.copy(importBookPath = pickedUri.toString()) }
                 if (otherSettingsGateway.currentSettings.defaultBookTreeUri.isNullOrBlank()) {
-                    updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(pickedUri.toString()))
+                    updateOtherSetting { it.copy(defaultBookTreeUri = pickedUri.toString()) }
                 }
             }
         }
@@ -328,7 +328,7 @@ class ImportBookViewModel(
         if (candidates.isEmpty()) {
             firstPersistedTreeUri()?.toString()?.let { persistedPath ->
                 if (trySetRootDoc(persistedPath)) {
-                    updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(persistedPath))
+                    updateOtherSetting { it.copy(defaultBookTreeUri = persistedPath) }
                     updateImportBookSetting { it.copy(importBookPath = persistedPath) }
                     return
                 }
@@ -360,7 +360,7 @@ class ImportBookViewModel(
 
         firstPersistedTreeUri()?.toString()?.let { persistedPath ->
             if (trySetRootDoc(persistedPath)) {
-                updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(persistedPath))
+                updateOtherSetting { it.copy(defaultBookTreeUri = persistedPath) }
                 updateImportBookSetting { it.copy(importBookPath = persistedPath) }
                 return
             }
@@ -844,9 +844,9 @@ class ImportBookViewModel(
         }
     }
 
-    private fun updateOtherSetting(update: OtherSettingsUpdate) {
+    private fun updateOtherSetting(transform: (OtherSettings) -> OtherSettings) {
         viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-            otherSettingsGateway.update(update)
+            otherSettingsGateway.update(transform)
         }
     }
 

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

@@ -15,7 +15,6 @@ import io.legado.app.data.entities.Server
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.model.analyzeRule.CustomUrl
 import io.legado.app.model.localBook.LocalBook
@@ -175,7 +174,7 @@ class RemoteBookViewModel(
         uri ?: return
         uri.takePersistablePermissionSafely(context)
         viewModelScope.launch {
-            otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(uri.toString()))
+            otherSettingsGateway.update { it.copy(defaultBookTreeUri = uri.toString()) }
         }
     }
 

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -29,7 +29,6 @@ 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.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ThemeSettings
@@ -359,7 +358,7 @@ class BookInfoViewModel(
             BookInfoIntent.KnowledgeListClick -> openKnowledgeList()
             BookInfoIntent.EventListClick -> openEventList()
             is BookInfoIntent.SetDefaultBookTreeUri -> viewModelScope.launch {
-                otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(intent.value))
+                otherSettingsGateway.update { it.copy(defaultBookTreeUri = intent.value) }
             }
         }
     }

+ 18 - 20
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -37,7 +37,7 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.ActivityMangaBinding
 import io.legado.app.databinding.ViewLoadMoreBinding
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isImage
@@ -544,20 +544,18 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     override fun onColorSelected(dialogId: Int, color: Int){
         if (dialogId == MANGA_B)
         {
-            updateMangaSetting(MangaSettingsUpdate.Background(color)) {
-                setBackground()
-            }
+            updateMangaSetting(afterUpdate = ::setBackground) { it.copy(background = color) }
         }
     }
 
     override fun onDialogDismissed(dialogId: Int) = Unit
 
     private fun updateMangaSetting(
-        update: MangaSettingsUpdate,
         afterUpdate: () -> Unit = {},
+        transform: (MangaSettings) -> MangaSettings,
     ) {
         lifecycleScope.launch {
-            viewModel.updateMangaSettings(update)
+            viewModel.updateMangaSettings(transform)
             afterUpdate()
         }
     }
@@ -760,7 +758,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
                     getString(R.string.pre_download),
                     viewModel.mangaSettings.value.preDownloadNum
                 ) {
-                    updateMangaSetting(MangaSettingsUpdate.PreDownloadNum(it))
+                    updateMangaSetting { settings -> settings.copy(preDownloadNum = it) }
                     item.title = getString(R.string.pre_download_m, it)
                     setRecyclerViewPreloader(it)
                 }
@@ -814,20 +812,20 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     //点击滑动
     override fun onClickScrollDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableClickScroll(disabled))
+        updateMangaSetting { it.copy(disableClickScroll = disabled) }
     }
 
     override fun onScrollAniDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableScrollAnimation(disabled))
+        updateMangaSetting { it.copy(disableMangaScrollAnimation = disabled) }
     }
 
     override fun onCrossFadeDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableCrossFade(disabled))
+        updateMangaSetting { it.copy(disableMangaCrossFade = disabled) }
     }
 
     //双击缩放
     override fun onMangaScaleDisabledChanged(disabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.DisableScale(disabled))
+        updateMangaSetting { it.copy(disableMangaScale = disabled) }
         setDisableMangaScale(disabled)
     }
 
@@ -841,13 +839,15 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     //墨水屏
     override fun updateEpaperMode(enabled: Boolean, threshold: Int) {
-        updateMangaSetting(MangaSettingsUpdate.EInk(enabled, threshold))
+        updateMangaSetting {
+            it.copy(enableEInk = enabled, enableGray = false, eInkThreshold = threshold)
+        }
         mAdapter.enableMangaEInk(enabled, threshold)
     }
 
     //灰度
     override fun updateGrayMode(enabled: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.Gray(enabled))
+        updateMangaSetting { it.copy(enableEInk = false, enableGray = enabled) }
         mAdapter.enableGray(enabled)
     }
 
@@ -865,27 +865,25 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     //自动翻页速度
     override fun onAutoPageSpeedChanged(speed: Int) {
         setAutoReadEnabled(false)
-        updateMangaSetting(MangaSettingsUpdate.AutoPageSpeed(speed))
+        updateMangaSetting { it.copy(autoPageSpeed = speed) }
         mScrollTimer.setSpeed(speed)
         setAutoReadEnabled(enableScroll)
     }
 
     override fun onMangaLongClickChanged(checked: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.LongClick(checked))
+        updateMangaSetting { it.copy(longClick = checked) }
     }
 
     override fun onVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.VolumeKeyPage(enable))
+        updateMangaSetting { it.copy(volumeKeyPage = enable) }
     }
 
     override fun onReverseVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.ReverseVolumeKeyPage(enable))
+        updateMangaSetting { it.copy(reverseVolumeKeyPage = enable) }
     }
 
     override fun onHideMangaTitleChanged(hide: Boolean) {
-        updateMangaSetting(MangaSettingsUpdate.HideTitle(hide)) {
-            ReadManga.loadContent()
-        }
+        updateMangaSetting(afterUpdate = ReadManga::loadContent) { it.copy(hideTitle = hide) }
     }
 
     override fun openBookInfoActivity() {

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -19,7 +19,7 @@ import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
@@ -72,8 +72,8 @@ class ReadMangaViewModel(
         }
     }
 
-    suspend fun updateMangaSettings(update: MangaSettingsUpdate) {
-        mangaSettingsGateway.update(update)
+    suspend fun updateMangaSettings(transform: (MangaSettings) -> MangaSettings) {
+        mangaSettingsGateway.update(transform)
     }
 
     /**

+ 13 - 29
app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt

@@ -8,11 +8,9 @@ import android.widget.TextView
 import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.base.BaseOverlayDialogFragment
-import io.legado.app.constant.PreferKey
 import io.legado.app.databinding.DialogClickActionConfigBinding
-import io.legado.app.domain.gateway.MangaClickArea
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.utils.getCompatColor
 import io.legado.app.utils.toastOnUi
@@ -73,83 +71,71 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
 
         binding.tvTopLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTL, action)
+                setClickAction { it.copy(clickActionTL = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvTopCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTC, action)
+                setClickAction { it.copy(clickActionTC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvTopRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionTR, action)
+                setClickAction { it.copy(clickActionTR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionML, action)
+                setClickAction { it.copy(clickActionML = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionMC, action)
+                setClickAction { it.copy(clickActionMC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvMiddleRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionMR, action)
+                setClickAction { it.copy(clickActionMR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomLeft.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBL, action)
+                setClickAction { it.copy(clickActionBL = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomCenter.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBC, action)
+                setClickAction { it.copy(clickActionBC = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
 
         binding.tvBottomRight.setOnClickListener {
             selectAction { action ->
-                setClickAction(PreferKey.mangaClickActionBR, action)
+                setClickAction { it.copy(clickActionBR = action) }
                 (it as? TextView)?.text = actions[action]
             }
         }
     }
 
-    private fun setClickAction(key: String, action: Int) {
-        val area = when (key) {
-            PreferKey.mangaClickActionTL -> MangaClickArea.TL
-            PreferKey.mangaClickActionTC -> MangaClickArea.TC
-            PreferKey.mangaClickActionTR -> MangaClickArea.TR
-            PreferKey.mangaClickActionML -> MangaClickArea.ML
-            PreferKey.mangaClickActionMC -> MangaClickArea.MC
-            PreferKey.mangaClickActionMR -> MangaClickArea.MR
-            PreferKey.mangaClickActionBL -> MangaClickArea.BL
-            PreferKey.mangaClickActionBC -> MangaClickArea.BC
-            PreferKey.mangaClickActionBR -> MangaClickArea.BR
-            else -> return
-        }
+    private fun setClickAction(transform: (MangaSettings) -> MangaSettings) {
         lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-            mangaSettingsGateway.update(MangaSettingsUpdate.ClickAction(area, action))
+            mangaSettingsGateway.update(transform)
         }
     }
 
@@ -165,9 +151,7 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
     override fun onDestroy() {
         if (!hasMenuClickArea()) {
             lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                mangaSettingsGateway.update(
-                    MangaSettingsUpdate.ClickAction(MangaClickArea.MC, 0)
-                )
+                mangaSettingsGateway.update { it.copy(clickActionMC = 0) }
             }
             context?.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
         }

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt

@@ -10,7 +10,6 @@ import io.legado.app.R
 import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.databinding.DialogMangaColorFilterBinding
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.invisible
@@ -120,7 +119,7 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man
     override fun onDismiss(dialog: DialogInterface) {
         super.onDismiss(dialog)
         lifecycleScope.launch {
-            mangaSettingsGateway.update(MangaSettingsUpdate.ColorFilter(mConfig.toJson()))
+            mangaSettingsGateway.update { it.copy(colorFilter = mConfig.toJson()) }
         }
     }
 

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt

@@ -14,7 +14,6 @@ import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.constant.EventBus
 import io.legado.app.databinding.DialogMangaFooterSettingBinding
 import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsUpdate
 import io.legado.app.ui.book.manga.entities.MangaFooterConfig
 import io.legado.app.ui.widget.ReaderInfoBarView
 import io.legado.app.utils.GSON
@@ -66,7 +65,7 @@ class MangaFooterSettingDialog :
                 }
                 setOnLongClickListener {
                     lifecycleScope.launch {
-                        mangaSettingsGateway.update(MangaSettingsUpdate.ScrollMode(mode))
+                        mangaSettingsGateway.update { it.copy(scrollMode = mode) }
                     }
                     toastOnUi("已设置为全局默认模式")
                     true
@@ -263,7 +262,7 @@ class MangaFooterSettingDialog :
     override fun onDismiss(dialog: DialogInterface) {
         super.onDismiss(dialog)
         lifecycleScope.launch {
-            mangaSettingsGateway.update(MangaSettingsUpdate.FooterConfig(GSON.toJson(config)))
+            mangaSettingsGateway.update { it.copy(footerConfig = GSON.toJson(config)) }
         }
     }
 

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

@@ -22,7 +22,6 @@ import io.legado.app.model.ReadManga
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.gateway.ReadSettingsUpdate
 import androidx.lifecycle.lifecycleScope
 import kotlinx.coroutines.launch
 import org.koin.core.context.GlobalContext
@@ -282,7 +281,7 @@ class MangaMenu @JvmOverloads constructor(
 
     private fun updateReadUrlInBrowser(enabled: Boolean) {
         activity?.lifecycleScope?.launch {
-            readSettingsGateway.update(ReadSettingsUpdate.ReadUrlInBrowser(enabled))
+            readSettingsGateway.update { it.copy(readUrlInBrowser = enabled) }
         }
     }
 

+ 107 - 76
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -40,15 +40,12 @@ import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.AppShellSettingsGateway
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
 import io.legado.app.domain.gateway.AppUiConfigurationGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
 import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
-import io.legado.app.domain.gateway.ReadSettingsUpdate
 import io.legado.app.domain.gateway.ReadStyleBooleanKey
 import io.legado.app.domain.gateway.ReadStyleColorKey
 import io.legado.app.domain.gateway.ReadStyleFloatKey
@@ -257,7 +254,7 @@ class ReadBookViewModel(
 
     fun setTextSelectMenuConfig(value: String) {
         viewModelScope.launch {
-            readSettingsRepository.update(ReadSettingsUpdate.TextSelectMenuConfig(value))
+            readSettingsRepository.update { it.copy(textSelectMenuConfig = value) }
         }
     }
 
@@ -999,7 +996,7 @@ class ReadBookViewModel(
             is ReadBookIntent.ApplySpeakEngine -> {
                 ReadBook.book?.setTtsEngine(null)
                 viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                    readAloudSettingsRepository.setTtsEngine(intent.value)
+                    readAloudSettingsRepository.update { it.copy(ttsEngine = intent.value) }
                     ReadAloud.upReadAloudClass()
                     _uiState.update {
                         it.copy(
@@ -1025,7 +1022,9 @@ class ReadBookViewModel(
 
             is ReadBookIntent.ApplyPreSynthesisConcurrency -> {
                 viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                    readAloudSettingsRepository.setPreSynthesisConcurrency(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(ttsPreSynthesisConcurrency = intent.value.coerceIn(1, 8))
+                    }
                 }
                 _uiState.update {
                     it.copy(
@@ -1037,7 +1036,9 @@ class ReadBookViewModel(
 
             is ReadBookIntent.ApplyAudioCacheCleanTime -> {
                 viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                    readAloudSettingsRepository.setAudioCacheCleanTime(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(audioCacheCleanTime = intent.value)
+                    }
                 }
                 _uiState.update {
                     it.copy(
@@ -1049,7 +1050,9 @@ class ReadBookViewModel(
 
             is ReadBookIntent.ApplyParagraphInterval -> {
                 viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                    readAloudSettingsRepository.setTtsParagraphInterval(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(ttsParagraphInterval = intent.value)
+                    }
                 }
                 _uiState.update {
                     it.copy(
@@ -1089,7 +1092,7 @@ class ReadBookViewModel(
                     loadTtsEngineItems()
                     if (ReadAloud.ttsEngine == intent.engineId.toString()) {
                         viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                            readAloudSettingsRepository.setTtsEngine(null)
+                            readAloudSettingsRepository.update { it.copy(ttsEngine = null) }
                             ReadAloud.upReadAloudClass()
                         }
                     }
@@ -1194,19 +1197,31 @@ class ReadBookViewModel(
             ReadBookIntent.SaveImportedHttpTts -> saveImportedHttpTts()
 
             is ReadBookIntent.SetReadAloudIgnoreAudioFocus -> {
-                viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(ignoreAudioFocus = intent.value) }
+                }
             }
             is ReadBookIntent.SetReadAloudPauseOnPhoneCall -> {
-                viewModelScope.launch { readAloudSettingsRepository.setPauseReadAloudWhilePhoneCalls(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update {
+                        it.copy(pauseReadAloudWhilePhoneCalls = intent.value)
+                    }
+                }
             }
             is ReadBookIntent.SetReadAloudWakeLock -> {
-                viewModelScope.launch { readAloudSettingsRepository.setReadAloudWakeLock(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(readAloudWakeLock = intent.value) }
+                }
             }
             is ReadBookIntent.SetShowReadAloudCapsule -> {
-                viewModelScope.launch { readAloudSettingsRepository.setShowReadAloudCapsule(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(showReadAloudCapsule = intent.value) }
+                }
             }
             is ReadBookIntent.SetCapsuleAutoCollapse -> {
-                viewModelScope.launch { readAloudSettingsRepository.setCapsuleAutoCollapse(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(capsuleAutoCollapse = intent.value) }
+                }
             }
             ReadBookIntent.ResetReadAloudCapsulePosition -> {
                 _uiState.update { it.copy(
@@ -1214,7 +1229,9 @@ class ReadBookViewModel(
                     readAloudCapsuleOffsetY = 0f,
                 ) }
                 viewModelScope.launch {
-                    readAloudSettingsRepository.resetCapsulePosition()
+                    readAloudSettingsRepository.update {
+                        it.copy(capsuleOffsetX = 0f, capsuleOffsetY = 0f)
+                    }
                 }
             }
             is ReadBookIntent.SetReadAloudCapsulePosition -> {
@@ -1223,21 +1240,33 @@ class ReadBookViewModel(
                     readAloudCapsuleOffsetY = intent.y,
                 ) }
                 viewModelScope.launch {
-                    readAloudSettingsRepository.setCapsulePosition(intent.x, intent.y)
+                    readAloudSettingsRepository.update {
+                        it.copy(capsuleOffsetX = intent.x, capsuleOffsetY = intent.y)
+                    }
                 }
             }
             is ReadBookIntent.SetReadAloudMediaButtonPerNext -> {
-                viewModelScope.launch { readAloudSettingsRepository.setMediaButtonPerNext(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(mediaButtonPerNext = intent.value) }
+                }
             }
             is ReadBookIntent.SetReadAloudByPage -> {
-                viewModelScope.launch { readAloudSettingsRepository.setReadAloudByPage(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(readAloudByPage = intent.value) }
+                }
                 if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false)
             }
             is ReadBookIntent.SetReadAloudSystemMediaCompat -> {
-                viewModelScope.launch { readAloudSettingsRepository.setSystemMediaControlCompatibilityChange(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update {
+                        it.copy(systemMediaControlCompatibilityChange = intent.value)
+                    }
+                }
             }
             is ReadBookIntent.SetReadAloudStreamAudio -> {
-                viewModelScope.launch { readAloudSettingsRepository.setStreamReadAloudAudio(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(streamReadAloudAudio = intent.value) }
+                }
                 if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false)
             }
             is ReadBookIntent.ReadAloudPrevParagraph -> ReadAloud.prevParagraph(context)
@@ -1254,7 +1283,9 @@ class ReadBookViewModel(
             is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
             is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value)
             is ReadBookIntent.SetReadAloudTtsFollowSys -> {
-                viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
+                viewModelScope.launch {
+                    readAloudSettingsRepository.update { it.copy(ttsFollowSys = intent.value) }
+                }
                 _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
             }
             is ReadBookIntent.SetReadAloudTtsSpeechRate -> setReadAloudTtsSpeechRate(intent.value)
@@ -1270,7 +1301,9 @@ class ReadBookViewModel(
                             return@launch
                         }
                     }
-                    readAloudSettingsRepository.setSpeechAnalysisMode(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(speechAnalysisMode = intent.value)
+                    }
                     _uiState.update { it.copy(speechAnalysisMode = intent.value) }
                 }
             }
@@ -1280,7 +1313,9 @@ class ReadBookViewModel(
                     val resumePlaying = shouldRestart && !BaseReadAloudService.pause
                     val chapter = ReadBook.curTextChapter
                     val chapterPosition = readAloudSessionStore.state.value.playback.chapterPosition
-                    readAloudSettingsRepository.setUseMultiSpeaker(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(useMultiSpeaker = intent.value)
+                    }
                     _uiState.update { it.copy(useMultiSpeaker = intent.value) }
                     if (shouldRestart && chapter != null) {
                         val pageIndex = chapter.getPageIndexByCharIndex(chapterPosition)
@@ -1304,7 +1339,13 @@ class ReadBookViewModel(
             }
             is ReadBookIntent.SetDefaultReadAloudInterface -> {
                 viewModelScope.launch {
-                    readAloudSettingsRepository.setDefaultInterface(intent.value)
+                    readAloudSettingsRepository.update {
+                        it.copy(
+                            defaultInterface = intent.value.takeIf { value ->
+                                value in ReadAloudSettingsRepository.AVAILABLE_INTERFACES
+                            } ?: ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC
+                        )
+                    }
                 }
                 _uiState.update { it.copy(defaultReadAloudInterface = intent.value) }
             }
@@ -1457,43 +1498,6 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.ToggleDayNight -> toggleDayNight()
-            is ReadBookIntent.ToggleEyeProtection -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update(
-                        ReadSettingsUpdate.EyeProtectionEnabled(
-                            !_readPreferences.value.eyeProtectionEnabled
-                        )
-                    )
-                }
-            }
-            is ReadBookIntent.EyeProtectionEnabledChanged ->
-                updateEyeProtection(ReadSettingsUpdate.EyeProtectionEnabled(intent.value))
-            is ReadBookIntent.EyeProtectionIntensityChanged ->
-                updateEyeProtection(ReadSettingsUpdate.EyeProtectionIntensity(intent.value))
-            is ReadBookIntent.EyeProtectionAutoNightChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update(ReadSettingsUpdate.EyeProtectionAutoNight(intent.value))
-                    if (intent.value) {
-                        EyeProtection.syncEnabledForNight(
-                            isNight = isNightTheme(),
-                            autoNight = true,
-                        )?.let { enabled ->
-                            readSettingsRepository.update(
-                                ReadSettingsUpdate.EyeProtectionEnabled(enabled)
-                            )
-                        }
-                    }
-                }
-            }
-            is ReadBookIntent.SyncEyeProtectionForTheme -> {
-                EyeProtection.syncEnabledForNight(
-                    isNight = intent.isNight,
-                    autoNight = _readPreferences.value.eyeProtectionAutoNight,
-                )?.takeIf { it != _readPreferences.value.eyeProtectionEnabled }
-                    ?.let { enabled ->
-                        updateEyeProtection(ReadSettingsUpdate.EyeProtectionEnabled(enabled))
-                    }
-            }
             // Text action menu
             is ReadBookIntent.TextActionAloud -> {
                 when (readAloudSettingsRepository.currentSettings.contentSelectSpeakMode) {
@@ -1580,6 +1584,30 @@ class ReadBookViewModel(
             is ReadBookIntent.CloseReadBook -> closeReadBook(intent.keepReadAloud)
             is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false)
             is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri)
+
+            ReadBookIntent.ToggleEyeProtection -> toggleEyeProtection()
+            is ReadBookIntent.EyeProtectionEnabledChanged -> {
+                viewModelScope.launch {
+                    readSettingsRepository.update { it.copy(eyeProtectionEnabled = intent.value) }
+                }
+            }
+            is ReadBookIntent.EyeProtectionIntensityChanged -> {
+                viewModelScope.launch {
+                    readSettingsRepository.update { it.copy(eyeProtectionIntensity = intent.value) }
+                }
+            }
+            is ReadBookIntent.EyeProtectionAutoNightChanged -> {
+                viewModelScope.launch {
+                    readSettingsRepository.update { it.copy(eyeProtectionAutoNight = intent.value) }
+                }
+            }
+            is ReadBookIntent.SyncEyeProtectionForTheme -> {
+                viewModelScope.launch {
+                    readSettingsRepository.update {
+                        it.copy(eyeProtectionEnabled = false)
+                    }
+                }
+            }
         }
     }
 
@@ -2272,13 +2300,17 @@ class ReadBookViewModel(
     private fun setReadAloudTtsTimer(value: Int) {
         val timer = PlaybackTimer.normalize(value)
         ReadAloud.setTimer(context, timer)
-        viewModelScope.launch { readAloudSettingsRepository.setTtsTimer(timer) }
+        viewModelScope.launch {
+            readAloudSettingsRepository.update { it.copy(ttsTimer = timer) }
+        }
         _uiState.update { it.copy(readAloudTtsTimer = timer) }
     }
 
     private fun setReadAloudTtsSpeechRate(value: Int) {
         viewModelScope.launch {
-            readAloudSettingsRepository.setTtsSpeechRate(value)
+            readAloudSettingsRepository.update {
+                it.copy(ttsSpeechRate = value.coerceIn(0, 80))
+            }
             ReadAloud.upTtsSpeechRate(context)
         }
         _uiState.update { it.copy(readAloudTtsSpeechRate = value) }
@@ -2290,6 +2322,14 @@ class ReadBookViewModel(
         }
     }
 
+    private fun toggleEyeProtection() {
+        viewModelScope.launch {
+            readSettingsRepository.update {
+                it.copy(eyeProtectionEnabled = !_readPreferences.value.eyeProtectionEnabled)
+            }
+        }
+    }
+
     private fun ReadPreferences.hasMenuClickArea(): Boolean {
         return clickActionTL * clickActionTC * clickActionTR *
                 clickActionML * clickActionMC * clickActionMR *
@@ -5245,7 +5285,7 @@ class ReadBookViewModel(
             }
             is ConfigUpdate.TitleBarCompact -> {
                 viewModelScope.launch {
-                    readSettingsRepository.update(ReadSettingsUpdate.TitleBarCompact(update.value))
+                    readSettingsRepository.update { it.copy(titleBarCompact = update.value) }
                 }
                 _uiState.update { it.copy(menuConfig = it.menuConfig.copy(titleBarCompact = update.value)) }
             }
@@ -5926,13 +5966,7 @@ class ReadBookViewModel(
         hasDismissedLightReminder = false
         val nextMode = if (isNightTheme()) "1" else "2"
         viewModelScope.launch {
-            appShellSettingsGateway.update(AppShellSettingsUpdate.ThemeMode(nextMode))
-            EyeProtection.syncEnabledForNight(
-                isNight = nextMode == "2",
-                autoNight = _readPreferences.value.eyeProtectionAutoNight,
-            )?.let { enabled ->
-                readSettingsRepository.update(ReadSettingsUpdate.EyeProtectionEnabled(enabled))
-            }
+            appShellSettingsGateway.update { it.copy(themeMode = nextMode) }
         }
         _uiState.update {
             val newActiveReminder = if (it.activeReminder?.type is ReminderType.DayNightReminder) {
@@ -5958,10 +5992,6 @@ class ReadBookViewModel(
         ))
     }
 
-    private fun updateEyeProtection(update: ReadSettingsUpdate) {
-        viewModelScope.launch { readSettingsRepository.update(update) }
-    }
-
     private fun applyReadStyleBackgroundImage(uri: Uri) {
         viewModelScope.launch(IO) {
             runCatching {
@@ -6326,7 +6356,7 @@ class ReadBookViewModel(
 
     private fun onBooksDirSelected(uri: Uri) {
         viewModelScope.launch {
-            otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(uri.toString()))
+            otherSettingsGateway.update { it.copy(defaultBookTreeUri = uri.toString()) }
         }
         val reloadChapterList = pendingBooksDirReloadChapterList
         pendingBooksDirReloadChapterList = false
@@ -6517,6 +6547,7 @@ private val DEFAULT_ENABLED_BUTTON_IDS = setOf(
     "setting",
 )
 private val DEFAULT_AI_TOOL_BUTTON_IDS = setOf("ai_summary", "ai_rewrite")
+
 private const val DARK_LUX_THRESHOLD = 8f
 private const val BRIGHT_LUX_THRESHOLD = 100f
 private const val LIGHT_LUMINANCE_THRESHOLD = 0.35

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

@@ -5,7 +5,6 @@ import androidx.lifecycle.Observer
 import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
@@ -128,7 +127,7 @@ class ReadAloudPlayerCoordinator(
     fun nextChapter() = ReadBook.moveToNextChapter(true)
 
     suspend fun setSpeed(value: Int) {
-        readAloudSettingsGateway.update(ReadAloudSettingsUpdate.SpeechRate(value))
+        readAloudSettingsGateway.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }
         ReadAloud.upTtsSpeechRate(application)
     }
 

+ 19 - 18
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt

@@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 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.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.utils.isContentScheme
@@ -48,19 +48,19 @@ class BackupConfigViewModel(
 
     fun onIntent(intent: BackupConfigIntent) {
         when (intent) {
-            is BackupConfigIntent.SetWebDavUrl -> update(BackupSettingsUpdate.WebDavUrl(intent.value))
-            is BackupConfigIntent.SetWebDavDir -> update(BackupSettingsUpdate.WebDavDir(intent.value))
+            is BackupConfigIntent.SetWebDavUrl -> update { it.copy(webDavUrl = intent.value) }
+            is BackupConfigIntent.SetWebDavDir -> update { it.copy(webDavDir = intent.value) }
             is BackupConfigIntent.SetWebDavDeviceName ->
-                update(BackupSettingsUpdate.WebDavDeviceName(intent.value))
+                update { it.copy(webDavDeviceName = intent.value) }
             is BackupConfigIntent.SetSyncBookProgress -> setSyncBookProgress(intent.value)
             is BackupConfigIntent.SetSyncBookProgressPlus ->
-                update(BackupSettingsUpdate.SyncBookProgressPlus(intent.value))
+                update { it.copy(syncBookProgressPlus = intent.value) }
             is BackupConfigIntent.SetAutoCheckNewBackup ->
-                update(BackupSettingsUpdate.AutoCheckNewBackup(intent.value))
+                update { it.copy(autoCheckNewBackup = intent.value) }
             is BackupConfigIntent.SetOnlyLatestBackup ->
-                update(BackupSettingsUpdate.OnlyLatestBackup(intent.value))
+                update { it.copy(onlyLatestBackup = intent.value) }
             is BackupConfigIntent.SetBackupSyncMode ->
-                update(BackupSettingsUpdate.BackupSyncMode(intent.value))
+                update { it.copy(backupSyncMode = intent.value) }
             is BackupConfigIntent.OpenSheet -> _uiState.update { it.copy(activeSheet = intent.sheet) }
             BackupConfigIntent.DismissSheet -> _uiState.update { it.copy(activeSheet = null) }
             BackupConfigIntent.OpenWebDavAuth -> openWebDavAuth()
@@ -107,17 +107,18 @@ class BackupConfigViewModel(
         }
     }
 
-    private fun update(update: BackupSettingsUpdate) {
-        viewModelScope.launch { settingsGateway.update(update) }
+    private fun update(transform: (BackupSettings) -> BackupSettings) {
+        viewModelScope.launch { settingsGateway.update(transform) }
     }
 
     private fun setSyncBookProgress(value: Boolean) {
         viewModelScope.launch {
-            val updates = buildList {
-                add(BackupSettingsUpdate.SyncBookProgress(value))
-                if (!value) add(BackupSettingsUpdate.SyncBookProgressPlus(false))
+            settingsGateway.update {
+                it.copy(
+                    syncBookProgress = value,
+                    syncBookProgressPlus = it.syncBookProgressPlus && value,
+                )
             }
-            settingsGateway.updateAll(updates)
         }
     }
 
@@ -143,9 +144,9 @@ class BackupConfigViewModel(
     private fun saveWebDavAuth() {
         val dialog = _uiState.value.activeDialog as? BackupConfigDialog.WebDavAuth ?: return
         viewModelScope.launch {
-            settingsGateway.update(
-                BackupSettingsUpdate.WebDavCredentials(dialog.account, dialog.password)
-            )
+            settingsGateway.update {
+                it.copy(webDavAccount = dialog.account, webDavPassword = dialog.password)
+            }
             testWebDav()
         }
     }
@@ -253,7 +254,7 @@ class BackupConfigViewModel(
 
     private fun saveBackupPath(path: String, runBackup: Boolean) {
         viewModelScope.launch {
-            settingsGateway.update(BackupSettingsUpdate.BackupPath(path))
+            settingsGateway.update { it.copy(backupPath = path) }
             if (runBackup && path.isNotEmpty()) requestBackup("both", path)
         }
     }

+ 16 - 2
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeContract.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.config.customTheme
 
 import androidx.compose.runtime.Stable
-import io.legado.app.domain.gateway.ThemeColorSlot
 
 @Stable
 data class CustomThemeUiState(
@@ -27,11 +26,26 @@ data class CustomThemeUiState(
 )
 
 sealed interface CustomThemePicker {
-    data class DeepColor(val slot: ThemeColorSlot) : CustomThemePicker
+    data class DeepColor(val slot: CustomThemeColorSlot) : CustomThemePicker
     data object DaySeed : CustomThemePicker
     data object NightSeed : CustomThemePicker
 }
 
+enum class CustomThemeColorSlot {
+    Primary,
+    Secondary,
+    PrimaryText,
+    SecondaryText,
+    Background,
+    LabelContainer,
+    PrimaryNight,
+    SecondaryNight,
+    PrimaryTextNight,
+    SecondaryTextNight,
+    BackgroundNight,
+    LabelContainerNight,
+}
+
 sealed interface CustomThemeIntent {
     data class DeepPersonalizationChanged(val value: Boolean) : CustomThemeIntent
     data class PaletteStyleChanged(val value: String) : CustomThemeIntent

+ 20 - 21
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt

@@ -18,7 +18,6 @@ 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.domain.gateway.ThemeColorSlot
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
@@ -173,43 +172,43 @@ private fun CustomColorSettings(
     background: Int,
     labelContainer: Int,
     keySuffix: String,
-    onSelect: (ThemeColorSlot) -> Unit,
+    onSelect: (CustomThemeColorSlot) -> Unit,
 ) {
     SplicedColumnGroup(title = title) {
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_primary_color),
             colorValue = primary,
-            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.Primary else ThemeColorSlot.PrimaryNight) },
+            onClick = { onSelect(if (keySuffix.isEmpty()) CustomThemeColorSlot.Primary else CustomThemeColorSlot.PrimaryNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_secondary_color),
             colorValue = secondary,
             onClick = {
                 onSelect(
-                    if (keySuffix.isEmpty()) ThemeColorSlot.Secondary
-                    else ThemeColorSlot.SecondaryNight
+                    if (keySuffix.isEmpty()) CustomThemeColorSlot.Secondary
+                    else CustomThemeColorSlot.SecondaryNight
                 )
             },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_primary_text_color),
             colorValue = primaryText,
-            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.PrimaryText else ThemeColorSlot.PrimaryTextNight) },
+            onClick = { onSelect(if (keySuffix.isEmpty()) CustomThemeColorSlot.PrimaryText else CustomThemeColorSlot.PrimaryTextNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_secondary_text_color),
             colorValue = secondaryText,
-            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.SecondaryText else ThemeColorSlot.SecondaryTextNight) },
+            onClick = { onSelect(if (keySuffix.isEmpty()) CustomThemeColorSlot.SecondaryText else CustomThemeColorSlot.SecondaryTextNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_background_color),
             colorValue = background,
-            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.Background else ThemeColorSlot.BackgroundNight) },
+            onClick = { onSelect(if (keySuffix.isEmpty()) CustomThemeColorSlot.Background else CustomThemeColorSlot.BackgroundNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_label_container_color),
             colorValue = labelContainer,
-            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.LabelContainer else ThemeColorSlot.LabelContainerNight) },
+            onClick = { onSelect(if (keySuffix.isEmpty()) CustomThemeColorSlot.LabelContainer else CustomThemeColorSlot.LabelContainerNight) },
         )
     }
 }
@@ -251,18 +250,18 @@ private fun ColorSwatch(colorValue: Int) {
 
 private fun CustomThemeUiState.colorForPicker(): Int = when (val picker = activePicker) {
     is CustomThemePicker.DeepColor -> when (picker.slot) {
-        ThemeColorSlot.Primary -> themeColor
-        ThemeColorSlot.Secondary -> secondaryThemeColor
-        ThemeColorSlot.PrimaryText -> primaryTextColor
-        ThemeColorSlot.SecondaryText -> secondaryTextColor
-        ThemeColorSlot.Background -> themeBackgroundColor
-        ThemeColorSlot.LabelContainer -> labelContainerColor
-        ThemeColorSlot.PrimaryNight -> themeColorNight
-        ThemeColorSlot.SecondaryNight -> secondaryThemeColorNight
-        ThemeColorSlot.PrimaryTextNight -> primaryTextColorNight
-        ThemeColorSlot.SecondaryTextNight -> secondaryTextColorNight
-        ThemeColorSlot.BackgroundNight -> themeBackgroundColorNight
-        ThemeColorSlot.LabelContainerNight -> labelContainerColorNight
+        CustomThemeColorSlot.Primary -> themeColor
+        CustomThemeColorSlot.Secondary -> secondaryThemeColor
+        CustomThemeColorSlot.PrimaryText -> primaryTextColor
+        CustomThemeColorSlot.SecondaryText -> secondaryTextColor
+        CustomThemeColorSlot.Background -> themeBackgroundColor
+        CustomThemeColorSlot.LabelContainer -> labelContainerColor
+        CustomThemeColorSlot.PrimaryNight -> themeColorNight
+        CustomThemeColorSlot.SecondaryNight -> secondaryThemeColorNight
+        CustomThemeColorSlot.PrimaryTextNight -> primaryTextColorNight
+        CustomThemeColorSlot.SecondaryTextNight -> secondaryTextColorNight
+        CustomThemeColorSlot.BackgroundNight -> themeBackgroundColorNight
+        CustomThemeColorSlot.LabelContainerNight -> labelContainerColorNight
     }
     CustomThemePicker.DaySeed -> primarySeedColor
     CustomThemePicker.NightSeed -> nightPrimarySeedColor

+ 31 - 11
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeViewModel.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.config.customTheme
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.domain.gateway.ThemeSettingsGateway
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.domain.model.settings.ThemeSettings
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,13 +31,13 @@ class CustomThemeViewModel(
     fun onIntent(intent: CustomThemeIntent) {
         when (intent) {
             is CustomThemeIntent.DeepPersonalizationChanged ->
-                update(ThemeSettingsUpdate.DeepPersonalization(intent.value))
+                update { it.copy(enableDeepPersonalization = intent.value) }
             is CustomThemeIntent.PaletteStyleChanged ->
-                update(ThemeSettingsUpdate.PaletteStyle(intent.value))
+                update { it.copy(paletteStyle = intent.value) }
             is CustomThemeIntent.CustomContrastChanged ->
-                update(ThemeSettingsUpdate.CustomContrast(intent.value))
+                update { it.copy(customContrast = intent.value) }
             is CustomThemeIntent.MaterialVersionChanged ->
-                update(ThemeSettingsUpdate.MaterialVersion(intent.value))
+                update { it.copy(materialVersion = intent.value) }
             is CustomThemeIntent.OpenPicker ->
                 _uiState.update { it.copy(activePicker = intent.picker) }
             CustomThemeIntent.DismissPicker ->
@@ -49,21 +48,42 @@ class CustomThemeViewModel(
 
     private fun selectColor(color: Int) {
         when (val picker = _uiState.value.activePicker) {
-            is CustomThemePicker.DeepColor ->
-                update(ThemeSettingsUpdate.CustomColor(picker.slot, color))
+            is CustomThemePicker.DeepColor -> update { settings ->
+                when (picker.slot) {
+                    CustomThemeColorSlot.Primary -> settings.copy(themeColor = color)
+                    CustomThemeColorSlot.Secondary -> settings.copy(secondaryThemeColor = color)
+                    CustomThemeColorSlot.PrimaryText -> settings.copy(primaryTextColor = color)
+                    CustomThemeColorSlot.SecondaryText ->
+                        settings.copy(secondaryTextColor = color)
+                    CustomThemeColorSlot.Background -> settings.copy(themeBackgroundColor = color)
+                    CustomThemeColorSlot.LabelContainer ->
+                        settings.copy(labelContainerColor = color)
+                    CustomThemeColorSlot.PrimaryNight -> settings.copy(themeColorNight = color)
+                    CustomThemeColorSlot.SecondaryNight ->
+                        settings.copy(secondaryThemeColorNight = color)
+                    CustomThemeColorSlot.PrimaryTextNight ->
+                        settings.copy(primaryTextColorNight = color)
+                    CustomThemeColorSlot.SecondaryTextNight ->
+                        settings.copy(secondaryTextColorNight = color)
+                    CustomThemeColorSlot.BackgroundNight ->
+                        settings.copy(themeBackgroundColorNight = color)
+                    CustomThemeColorSlot.LabelContainerNight ->
+                        settings.copy(labelContainerColorNight = color)
+                }
+            }
             CustomThemePicker.DaySeed -> {
-                update(ThemeSettingsUpdate.CustomPrimary(color))
+                update { it.copy(customPrimary = color) }
                 _effects.tryEmit(CustomThemeEffect.ApplyLegacyPrimarySeed(color))
             }
-            CustomThemePicker.NightSeed -> update(ThemeSettingsUpdate.CustomNightPrimary(color))
+            CustomThemePicker.NightSeed -> update { it.copy(customNightPrimary = color) }
             null -> return
         }
         _uiState.update { it.copy(activePicker = null) }
     }
 
-    private fun update(update: ThemeSettingsUpdate) {
+    private fun update(transform: (ThemeSettings) -> ThemeSettings) {
         viewModelScope.launch {
-            runCatching { themeSettingsGateway.update(update) }
+            runCatching { themeSettingsGateway.update(transform) }
                 .onFailure { error ->
                     _effects.tryEmit(
                         CustomThemeEffect.SettingsUpdateFailed(

+ 34 - 33
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -14,10 +14,9 @@ import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.LocalPasswordGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ReadAloudSettings
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -64,9 +63,7 @@ class OtherConfigViewModel(
                 _uiState.update { it.copy(language = language) }
             }
         }
-        updateOtherSetting(
-            OtherSettingsUpdate.ProcessText(systemGateway.isProcessTextEnabled())
-        )
+        updateOtherSetting { it.copy(processText = systemGateway.isProcessTextEnabled()) }
         viewModelScope.launch {
             otherSettingsGateway.settings.collect { settings ->
                 _uiState.update { settings.toUiState(it) }
@@ -95,49 +92,51 @@ class OtherConfigViewModel(
         when (intent) {
             is OtherConfigIntent.LanguageChanged -> appLocaleGateway.setLanguage(intent.value)
             is OtherConfigIntent.UpdateToVariantChanged ->
-                updateOtherSetting(OtherSettingsUpdate.UpdateToVariant(intent.value))
+                updateOtherSetting { it.copy(updateToVariant = intent.value) }
             is OtherConfigIntent.AutoCheckUpdateOnStartChanged ->
-                updateOtherSetting(OtherSettingsUpdate.AutoCheckUpdateOnStart(intent.value))
+                updateOtherSetting { it.copy(autoCheckUpdateOnStart = intent.value) }
             is OtherConfigIntent.WebServiceAutoStartChanged ->
-                updateOtherSetting(OtherSettingsUpdate.WebServiceAutoStart(intent.value))
+                updateOtherSetting { it.copy(webServiceAutoStart = intent.value) }
             is OtherConfigIntent.AutoRefreshChanged ->
-                updateOtherSetting(OtherSettingsUpdate.AutoRefresh(intent.value))
+                updateOtherSetting { it.copy(autoRefresh = intent.value) }
             is OtherConfigIntent.DefaultToReadChanged ->
-                updateOtherSetting(OtherSettingsUpdate.DefaultToRead(intent.value))
+                updateOtherSetting { it.copy(defaultToRead = intent.value) }
             is OtherConfigIntent.FirebaseEnableChanged ->
-                updateOtherSetting(OtherSettingsUpdate.FirebaseEnable(intent.value))
+                updateOtherSetting { it.copy(firebaseEnable = intent.value) }
             is OtherConfigIntent.DefaultBookTreeUriChanged ->
-                updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(intent.value))
+                updateOtherSetting { it.copy(defaultBookTreeUri = intent.value) }
             is OtherConfigIntent.AntiAliasChanged ->
-                updateOtherSetting(OtherSettingsUpdate.AntiAlias(intent.value))
+                updateOtherSetting { it.copy(antiAlias = intent.value) }
             is OtherConfigIntent.ReplaceEnableDefaultChanged ->
-                updateOtherSetting(OtherSettingsUpdate.ReplaceEnableDefault(intent.value))
+                updateOtherSetting { it.copy(replaceEnableDefault = intent.value) }
             is OtherConfigIntent.MediaButtonOnExitChanged ->
-                updateReadAloudSetting(ReadAloudSettingsUpdate.MediaButtonOnExit(intent.value))
+                updateReadAloudSetting { it.copy(mediaButtonOnExit = intent.value) }
             is OtherConfigIntent.ReadAloudByMediaButtonChanged ->
-                updateReadAloudSetting(ReadAloudSettingsUpdate.ReadAloudByMediaButton(intent.value))
+                updateReadAloudSetting { it.copy(readAloudByMediaButton = intent.value) }
             is OtherConfigIntent.IgnoreAudioFocusChanged ->
-                updateReadAloudSetting(ReadAloudSettingsUpdate.IgnoreAudioFocus(intent.value))
+                updateReadAloudSetting { it.copy(ignoreAudioFocus = intent.value) }
             is OtherConfigIntent.AutoClearExpiredChanged ->
-                updateOtherSetting(OtherSettingsUpdate.AutoClearExpired(intent.value))
+                updateOtherSetting { it.copy(autoClearExpired = intent.value) }
             is OtherConfigIntent.ShowAddToShelfAlertChanged ->
-                updateOtherSetting(OtherSettingsUpdate.ShowAddToShelfAlert(intent.value))
+                updateOtherSetting { it.copy(showAddToShelfAlert = intent.value) }
             is OtherConfigIntent.ShowMangaUiChanged ->
-                updateOtherSetting(OtherSettingsUpdate.ShowMangaUi(intent.value))
+                updateOtherSetting { it.copy(showMangaUi = intent.value) }
             is OtherConfigIntent.WebServiceWakeLockChanged ->
-                updateOtherSetting(OtherSettingsUpdate.WebServiceWakeLock(intent.value))
+                updateOtherSetting { it.copy(webServiceWakeLock = intent.value) }
             is OtherConfigIntent.SourceEditMaxLineChanged ->
-                updateOtherSetting(OtherSettingsUpdate.SourceEditMaxLine(intent.value))
+                updateOtherSetting { it.copy(sourceEditMaxLine = intent.value) }
             is OtherConfigIntent.WebPortChanged -> {
-                updateOtherSetting(OtherSettingsUpdate.WebPort(intent.value)) {
-                    _effects.tryEmit(OtherConfigEffect.RestartWebService)
-                }
+                updateOtherSetting(
+                    onSuccess = {
+                        _effects.tryEmit(OtherConfigEffect.RestartWebService)
+                    },
+                ) { it.copy(webPort = intent.value) }
             }
             is OtherConfigIntent.ProcessTextChanged -> setProcessTextEnable(intent.value)
             is OtherConfigIntent.RecordLogChanged ->
-                updateOtherSetting(OtherSettingsUpdate.RecordLog(intent.value))
+                updateOtherSetting { it.copy(recordLog = intent.value) }
             is OtherConfigIntent.RecordHeapDumpChanged ->
-                updateOtherSetting(OtherSettingsUpdate.RecordHeapDump(intent.value))
+                updateOtherSetting { it.copy(recordHeapDump = intent.value) }
             is OtherConfigIntent.CheckSourceTimeoutChanged ->
                 _uiState.update { it.copy(checkSourceTimeoutSeconds = intent.value) }
             is OtherConfigIntent.CheckSearchChanged -> _uiState.update {
@@ -224,11 +223,11 @@ class OtherConfigViewModel(
     }
 
     private fun updateOtherSetting(
-        update: OtherSettingsUpdate,
         onSuccess: () -> Unit = {},
+        transform: (OtherSettings) -> OtherSettings,
     ) {
         viewModelScope.launch {
-            runCatching { otherSettingsGateway.update(update) }
+            runCatching { otherSettingsGateway.update(transform) }
                 .onSuccess { onSuccess() }
                 .onFailure { error ->
                     showMessage(error.message ?: error.javaClass.simpleName)
@@ -236,9 +235,11 @@ class OtherConfigViewModel(
         }
     }
 
-    private fun updateReadAloudSetting(update: ReadAloudSettingsUpdate) {
+    private fun updateReadAloudSetting(
+        transform: (ReadAloudSettings) -> ReadAloudSettings,
+    ) {
         viewModelScope.launch {
-            runCatching { readAloudSettingsGateway.update(update) }
+            runCatching { readAloudSettingsGateway.update(transform) }
                 .onFailure { showMessage(it.localizedMessage ?: "设置失败") }
         }
     }
@@ -248,7 +249,7 @@ class OtherConfigViewModel(
             val previous = systemGateway.isProcessTextEnabled()
             runCatching {
                 systemGateway.setProcessTextEnabled(enable)
-                otherSettingsGateway.update(OtherSettingsUpdate.ProcessText(enable))
+                otherSettingsGateway.update { it.copy(processText = enable) }
             }.onFailure {
                 if (previous != enable) {
                     runCatching { systemGateway.setProcessTextEnabled(previous) }
@@ -291,7 +292,7 @@ class OtherConfigViewModel(
     }
 
     fun updateLocalBookDir(path: String) {
-        updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(path))
+        updateOtherSetting { it.copy(defaultBookTreeUri = path) }
     }
 
     private fun saveCheckSourceConfig() {

+ 16 - 17
app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.config.readConfig
 
 import io.legado.app.constant.EventBus
-import io.legado.app.domain.gateway.ReadSettingsUpdate
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.utils.postEvent
@@ -9,35 +8,35 @@ import io.legado.app.utils.postEvent
 /** Applies runtime reader changes after a setting has entered the effective settings snapshot. */
 class ApplyReadSettingUseCase {
 
-    operator fun invoke(update: ReadSettingsUpdate) {
-        when (update) {
-            is ReadSettingsUpdate.HideStatusBar,
-            is ReadSettingsUpdate.HideNavigationBar -> {
+    operator fun invoke(intent: ReadConfigIntent) {
+        when (intent) {
+            is ReadConfigIntent.HideStatusBarChanged,
+            is ReadConfigIntent.HideNavigationBarChanged -> {
                 postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
             }
 
-            is ReadSettingsUpdate.ReadMenuBlurAlpha,
-            is ReadSettingsUpdate.ReadSliderMode,
-            is ReadSettingsUpdate.ShowReadTitleAddition,
-            is ReadSettingsUpdate.ShowMenuIcon -> {
+            is ReadConfigIntent.ReadMenuBlurAlphaChanged,
+            is ReadConfigIntent.ReadSliderModeChanged,
+            is ReadConfigIntent.ShowReadTitleAdditionChanged,
+            is ReadConfigIntent.ShowMenuIconChanged -> {
                 postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
             }
 
-            is ReadSettingsUpdate.TextFullJustify,
-            is ReadSettingsUpdate.TextBottomJustify,
-            is ReadSettingsUpdate.UseZhLayout,
-            is ReadSettingsUpdate.DoubleHorizontalPage -> updateLayout()
+            is ReadConfigIntent.TextFullJustifyChanged,
+            is ReadConfigIntent.TextBottomJustifyChanged,
+            is ReadConfigIntent.UseZhLayoutChanged,
+            is ReadConfigIntent.DoubleHorizontalPageChanged -> updateLayout()
 
-            is ReadSettingsUpdate.ProgressBarBehavior -> {
+            is ReadConfigIntent.ProgressBarBehaviorChanged -> {
                 postEvent(EventBus.UP_SEEK_BAR, true)
             }
 
-            is ReadSettingsUpdate.PageTouchSlop -> {
+            is ReadConfigIntent.PageTouchSlopChanged -> {
                 postEvent(EventBus.UP_CONFIG, arrayListOf(4))
             }
 
-            is ReadSettingsUpdate.NoAnimScrollPage -> ReadBook.callBack?.upPageAnim()
-            is ReadSettingsUpdate.OptimizeRender -> updateStyle()
+            is ReadConfigIntent.NoAnimScrollPageChanged -> ReadBook.callBack?.upPageAnim()
+            is ReadConfigIntent.OptimizeRenderChanged -> updateStyle()
             else -> Unit
         }
     }

+ 54 - 59
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt

@@ -3,9 +3,7 @@ package io.legado.app.ui.config.readConfig
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 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.ui.book.read.EyeProtection
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -38,9 +36,8 @@ class ReadConfigViewModel(
         when (intent) {
             ReadConfigIntent.OpenPageKeys -> setSheet(ReadConfigSheet.PageKeys)
             ReadConfigIntent.OpenClickActions -> setSheet(ReadConfigSheet.ClickActions)
-            ReadConfigIntent.OpenEyeProtection -> setSheet(ReadConfigSheet.EyeProtection)
             ReadConfigIntent.DismissSheet -> setSheet(null)
-            else -> updateSetting(intent.toSettingsUpdate())
+            else -> updateSetting(intent)
         }
     }
 
@@ -48,22 +45,12 @@ class ReadConfigViewModel(
         _uiState.update { it.copy(activeSheet = sheet) }
     }
 
-    private fun updateSetting(update: ReadSettingsUpdate) {
+    private fun updateSetting(intent: ReadConfigIntent) {
         viewModelScope.launch {
-            runCatching {
-                settingsGateway.update(update)
-                if (update is ReadSettingsUpdate.EyeProtectionAutoNight && update.value) {
-                    EyeProtection.syncEnabledForNight(
-                        isNight = ReadConfig.isNightTheme,
-                        autoNight = true,
-                    )?.let { enabled ->
-                        settingsGateway.update(ReadSettingsUpdate.EyeProtectionEnabled(enabled))
-                    }
-                }
-            }
+            runCatching { settingsGateway.update(intent.toSettingsTransform()) }
                 .onSuccess {
-                    applyReadSetting(update)
-                    if (update is ReadSettingsUpdate.PageKeys) setSheet(null)
+                    applyReadSetting(intent)
+                    if (intent is ReadConfigIntent.PageKeysChanged) setSheet(null)
                 }
                 .onFailure { error ->
                     _effects.tryEmit(
@@ -76,49 +63,57 @@ class ReadConfigViewModel(
     }
 }
 
-private fun ReadConfigIntent.toSettingsUpdate(): ReadSettingsUpdate = when (this) {
-    is ReadConfigIntent.ScreenOrientationChanged -> ReadSettingsUpdate.ScreenOrientation(value)
-    is ReadConfigIntent.KeepLightChanged -> ReadSettingsUpdate.KeepLight(value)
-    is ReadConfigIntent.HideStatusBarChanged -> ReadSettingsUpdate.HideStatusBar(value)
-    is ReadConfigIntent.HideNavigationBarChanged -> ReadSettingsUpdate.HideNavigationBar(value)
-    is ReadConfigIntent.PaddingDisplayCutoutsChanged -> ReadSettingsUpdate.PaddingDisplayCutouts(value)
-    is ReadConfigIntent.TitleBarModeChanged -> ReadSettingsUpdate.TitleBarMode(value)
-    is ReadConfigIntent.ReadMenuBlurAlphaChanged -> ReadSettingsUpdate.ReadMenuBlurAlpha(value)
-    is ReadConfigIntent.ReadBodyToLhChanged -> ReadSettingsUpdate.ReadBodyToLh(value)
-    is ReadConfigIntent.DefaultSourceChangeAllChanged -> ReadSettingsUpdate.DefaultSourceChangeAll(value)
-    is ReadConfigIntent.TextFullJustifyChanged -> ReadSettingsUpdate.TextFullJustify(value)
-    is ReadConfigIntent.TextBottomJustifyChanged -> ReadSettingsUpdate.TextBottomJustify(value)
-    is ReadConfigIntent.AdaptSpecialStyleChanged -> ReadSettingsUpdate.AdaptSpecialStyle(value)
-    is ReadConfigIntent.UseZhLayoutChanged -> ReadSettingsUpdate.UseZhLayout(value)
-    ReadConfigIntent.OpenEyeProtection -> error("Sheet intents do not update settings")
-    is ReadConfigIntent.EyeProtectionEnabledChanged -> ReadSettingsUpdate.EyeProtectionEnabled(value)
-    is ReadConfigIntent.EyeProtectionIntensityChanged -> ReadSettingsUpdate.EyeProtectionIntensity(value)
-    is ReadConfigIntent.EyeProtectionAutoNightChanged -> ReadSettingsUpdate.EyeProtectionAutoNight(value)
-    is ReadConfigIntent.ShowBrightnessViewChanged -> ReadSettingsUpdate.ShowBrightnessView(value)
-    is ReadConfigIntent.BrightnessVwPosChanged -> ReadSettingsUpdate.BrightnessVwPos(value)
-    is ReadConfigIntent.UseUnderlineChanged -> ReadSettingsUpdate.UseUnderline(value)
-    is ReadConfigIntent.ReadSliderModeChanged -> ReadSettingsUpdate.ReadSliderMode(value)
-    is ReadConfigIntent.DoubleHorizontalPageChanged -> ReadSettingsUpdate.DoubleHorizontalPage(value)
-    is ReadConfigIntent.ProgressBarBehaviorChanged -> ReadSettingsUpdate.ProgressBarBehavior(value)
-    is ReadConfigIntent.MouseWheelPageChanged -> ReadSettingsUpdate.MouseWheelPage(value)
-    is ReadConfigIntent.VolumeKeyPageChanged -> ReadSettingsUpdate.VolumeKeyPage(value)
-    is ReadConfigIntent.VolumeKeyPageOnPlayChanged -> ReadSettingsUpdate.VolumeKeyPageOnPlay(value)
-    is ReadConfigIntent.KeyPageOnLongPressChanged -> ReadSettingsUpdate.KeyPageOnLongPress(value)
-    is ReadConfigIntent.PageTouchSlopChanged -> ReadSettingsUpdate.PageTouchSlop(value)
-    is ReadConfigIntent.SliderVibratorChanged -> ReadSettingsUpdate.SliderVibrator(value)
-    is ReadConfigIntent.SelectVibratorChanged -> ReadSettingsUpdate.SelectVibrator(value)
-    is ReadConfigIntent.AutoChangeSourceChanged -> ReadSettingsUpdate.AutoChangeSource(value)
-    is ReadConfigIntent.AutoSuggestDayNightChanged -> ReadSettingsUpdate.AutoSuggestDayNight(value)
-    is ReadConfigIntent.SelectTextChanged -> ReadSettingsUpdate.SelectText(value)
-    is ReadConfigIntent.NoAnimScrollPageChanged -> ReadSettingsUpdate.NoAnimScrollPage(value)
-    is ReadConfigIntent.ClickImgWayChanged -> ReadSettingsUpdate.ClickImgWay(value)
-    is ReadConfigIntent.OptimizeRenderChanged -> ReadSettingsUpdate.OptimizeRender(value)
-    is ReadConfigIntent.DisableReturnKeyChanged -> ReadSettingsUpdate.DisableReturnKey(value)
-    is ReadConfigIntent.ShowReadTitleAdditionChanged -> ReadSettingsUpdate.ShowReadTitleAddition(value)
-    is ReadConfigIntent.ShowMenuIconChanged -> ReadSettingsUpdate.ShowMenuIcon(value)
-    is ReadConfigIntent.PageKeysChanged -> ReadSettingsUpdate.PageKeys(prevKeys, nextKeys)
+private fun ReadConfigIntent.toSettingsTransform(): (ReadSettings) -> ReadSettings = when (this) {
+    is ReadConfigIntent.ScreenOrientationChanged -> { settings -> settings.copy(screenOrientation = value) }
+    is ReadConfigIntent.KeepLightChanged -> { settings -> settings.copy(keepLight = value) }
+    is ReadConfigIntent.HideStatusBarChanged -> { settings -> settings.copy(hideStatusBar = value) }
+    is ReadConfigIntent.HideNavigationBarChanged -> { settings -> settings.copy(hideNavigationBar = value) }
+    is ReadConfigIntent.PaddingDisplayCutoutsChanged -> { settings -> settings.copy(paddingDisplayCutouts = value) }
+    is ReadConfigIntent.TitleBarModeChanged -> { settings -> settings.copy(titleBarMode = value) }
+    is ReadConfigIntent.ReadMenuBlurAlphaChanged -> { settings -> settings.copy(readMenuBlurAlpha = value) }
+    is ReadConfigIntent.ReadBodyToLhChanged -> { settings -> settings.copy(readBodyToLh = value) }
+    is ReadConfigIntent.DefaultSourceChangeAllChanged -> { settings -> settings.copy(defaultSourceChangeAll = value) }
+    is ReadConfigIntent.TextFullJustifyChanged -> { settings -> settings.copy(textFullJustify = value) }
+    is ReadConfigIntent.TextBottomJustifyChanged -> { settings -> settings.copy(textBottomJustify = value) }
+    is ReadConfigIntent.AdaptSpecialStyleChanged -> { settings -> settings.copy(adaptSpecialStyle = value) }
+    is ReadConfigIntent.UseZhLayoutChanged -> { settings -> settings.copy(useZhLayout = value) }
+    is ReadConfigIntent.ShowBrightnessViewChanged -> { settings -> settings.copy(showBrightnessView = value) }
+    is ReadConfigIntent.BrightnessVwPosChanged -> { settings -> settings.copy(brightnessVwPos = value) }
+    is ReadConfigIntent.UseUnderlineChanged -> { settings -> settings.copy(useUnderline = value) }
+    is ReadConfigIntent.ReadSliderModeChanged -> { settings -> settings.copy(readSliderMode = value) }
+    is ReadConfigIntent.DoubleHorizontalPageChanged -> { settings -> settings.copy(doubleHorizontalPage = value) }
+    is ReadConfigIntent.ProgressBarBehaviorChanged -> { settings -> settings.copy(progressBarBehavior = value) }
+    is ReadConfigIntent.MouseWheelPageChanged -> { settings -> settings.copy(mouseWheelPage = value) }
+    is ReadConfigIntent.VolumeKeyPageChanged -> { settings -> settings.copy(volumeKeyPage = value) }
+    is ReadConfigIntent.VolumeKeyPageOnPlayChanged -> { settings -> settings.copy(volumeKeyPageOnPlay = value) }
+    is ReadConfigIntent.KeyPageOnLongPressChanged -> { settings -> settings.copy(keyPageOnLongPress = value) }
+    is ReadConfigIntent.PageTouchSlopChanged -> { settings -> settings.copy(pageTouchSlop = value) }
+    is ReadConfigIntent.SliderVibratorChanged -> { settings -> settings.copy(sliderVibrator = value) }
+    is ReadConfigIntent.SelectVibratorChanged -> { settings -> settings.copy(selectVibrator = value) }
+    is ReadConfigIntent.AutoChangeSourceChanged -> { settings -> settings.copy(autoChangeSource = value) }
+    is ReadConfigIntent.AutoSuggestDayNightChanged -> { settings -> settings.copy(autoSuggestDayNight = value) }
+    is ReadConfigIntent.SelectTextChanged -> { settings -> settings.copy(selectText = value) }
+    is ReadConfigIntent.NoAnimScrollPageChanged -> { settings -> settings.copy(noAnimScrollPage = value) }
+    is ReadConfigIntent.ClickImgWayChanged -> { settings -> settings.copy(clickImgWay = value) }
+    is ReadConfigIntent.OptimizeRenderChanged -> { settings -> settings.copy(optimizeRender = value) }
+    is ReadConfigIntent.DisableReturnKeyChanged -> { settings -> settings.copy(disableReturnKey = value) }
+    is ReadConfigIntent.ShowReadTitleAdditionChanged -> { settings -> settings.copy(showReadTitleAddition = value) }
+    is ReadConfigIntent.ShowMenuIconChanged -> { settings -> settings.copy(showMenuIcon = value) }
+    is ReadConfigIntent.PageKeysChanged -> { settings ->
+        settings.copy(prevKeys = prevKeys, nextKeys = nextKeys)
+    }
+    is ReadConfigIntent.EyeProtectionEnabledChanged -> { settings ->
+        settings.copy(eyeProtectionEnabled = value)
+    }
+    is ReadConfigIntent.EyeProtectionIntensityChanged -> { settings ->
+        settings.copy(eyeProtectionIntensity = value)
+    }
+    is ReadConfigIntent.EyeProtectionAutoNightChanged -> { settings ->
+        settings.copy(eyeProtectionAutoNight = value)
+    }
     ReadConfigIntent.OpenPageKeys,
     ReadConfigIntent.OpenClickActions,
+    ReadConfigIntent.OpenEyeProtection,
     ReadConfigIntent.DismissSheet -> error("Sheet intents do not update settings")
 }
 

+ 28 - 4
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt

@@ -1,8 +1,6 @@
 package io.legado.app.ui.config.themeConfig
 
 import androidx.compose.runtime.Stable
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.domain.model.settings.AppShellSettings
 import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.utils.FileDoc
@@ -26,12 +24,29 @@ sealed interface ThemeConfigSheet {
     data object Font : ThemeConfigSheet
 }
 
+enum class ThemeTimeField {
+    EyeProtectionStart,
+    EyeProtectionEnd,
+}
+
 sealed interface ThemeConfigIntent {
-    data class UpdateAppShell(val update: AppShellSettingsUpdate) : ThemeConfigIntent
-    data class UpdateTheme(val update: ThemeSettingsUpdate) : ThemeConfigIntent
+    data class UpdateTheme(
+        val transform: (ThemeSettings) -> ThemeSettings,
+    ) : ThemeConfigIntent
     data class ShowSheet(val sheet: ThemeConfigSheet) : ThemeConfigIntent
     data object DismissSheet : ThemeConfigIntent
     data class SelectTheme(val value: String) : ThemeConfigIntent
+    data class SetThemeMode(val value: String) : ThemeConfigIntent
+    data class SetComposeEngine(val value: String) : ThemeConfigIntent
+    data class SetPredictiveBackEnabled(val enabled: Boolean) : ThemeConfigIntent
+    data class SetFontScale(val value: Int) : ThemeConfigIntent
+    data class SetShowStatusBar(val visible: Boolean) : ThemeConfigIntent
+    data class SetSwipeAnimation(val enabled: Boolean) : ThemeConfigIntent
+    data class SetShowBottomView(val visible: Boolean) : ThemeConfigIntent
+    data class SetUseFloatingBottomBar(val enabled: Boolean) : ThemeConfigIntent
+    data class SetUseFloatingBottomBarLiquidGlass(val enabled: Boolean) : ThemeConfigIntent
+    data class SetTabletInterface(val value: String) : ThemeConfigIntent
+    data class SetLabelVisibilityMode(val value: String) : ThemeConfigIntent
     data class SetMiuixMonet(val enabled: Boolean) : ThemeConfigIntent
     data class SetDynamicColors(val enabled: Boolean) : ThemeConfigIntent
     data class SetBlurEnabled(val enabled: Boolean) : ThemeConfigIntent
@@ -51,6 +66,11 @@ sealed interface ThemeConfigIntent {
     data object ClearAppFont : ThemeConfigIntent
     data class SetFontFolder(val path: String) : ThemeConfigIntent
     data object RequestFontFolder : ThemeConfigIntent
+    data class RequestTimePicker(
+        val field: ThemeTimeField,
+        val currentValue: String,
+    ) : ThemeConfigIntent
+    data class SetTime(val field: ThemeTimeField, val value: String) : ThemeConfigIntent
     data object DismissRefactorTip : ThemeConfigIntent
 }
 
@@ -61,5 +81,9 @@ sealed interface ThemeConfigEffect {
     data object OpenFontFolder : ThemeConfigEffect
     data class OpenNavigationIcon(val destination: String) : ThemeConfigEffect
     data class OpenBackgroundImage(val dark: Boolean) : ThemeConfigEffect
+    data class OpenTimePicker(
+        val field: ThemeTimeField,
+        val currentValue: String,
+    ) : ThemeConfigEffect
     data class ShowToast(val stringRes: Int) : ThemeConfigEffect
 }

+ 3 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigRouteScreen.kt

@@ -89,6 +89,9 @@ fun ThemeConfigRouteScreen(
                     backgroundImageLauncher.launch("image/*")
                 }
                 is ThemeConfigEffect.ShowToast -> context.toastOnUi(effect.stringRes)
+                is ThemeConfigEffect.OpenTimePicker -> {
+                    // Time picker intent handled by ThemeConfigScreen internally
+                }
             }
         }
     }

+ 140 - 169
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -60,14 +60,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.constraintlayout.compose.ConstraintLayout
 import io.legado.app.R
-import io.legado.app.domain.gateway.AppShellBooleanSetting
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
-import io.legado.app.domain.gateway.AppShellStringSetting
-import io.legado.app.domain.gateway.ThemeBooleanSetting
-import io.legado.app.domain.gateway.ThemeFloatSetting
-import io.legado.app.domain.gateway.ThemeIntSetting
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
-import io.legado.app.domain.gateway.ThemeStringSetting
+import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -102,15 +95,8 @@ fun ThemeConfigScreen(
     val theme = state.theme
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val context = LocalContext.current
-    fun updateTheme(setting: ThemeBooleanSetting, value: Boolean) = onIntent(
-        ThemeConfigIntent.UpdateTheme(ThemeSettingsUpdate.BooleanValue(setting, value))
-    )
-    fun updateTheme(setting: ThemeIntSetting, value: Int) = onIntent(
-        ThemeConfigIntent.UpdateTheme(ThemeSettingsUpdate.IntValue(setting, value))
-    )
-    fun updateTheme(setting: ThemeFloatSetting, value: Float) = onIntent(
-        ThemeConfigIntent.UpdateTheme(ThemeSettingsUpdate.FloatValue(setting, value))
-    )
+    fun updateTheme(transform: (ThemeSettings) -> ThemeSettings) =
+        onIntent(ThemeConfigIntent.UpdateTheme(transform))
     val fontFolderState = remember(state.fontFolder) {
         FontFolderState.Loaded(state.fontFolder.takeIf { it.isNotEmpty() }?.let(android.net.Uri::parse))
     }
@@ -201,9 +187,7 @@ fun ThemeConfigScreen(
                             entryValues = stringArrayResource(R.array.theme_mode_v),
                             onValueChange = { mode ->
                                 onIntent(
-                                    ThemeConfigIntent.UpdateAppShell(
-                                        AppShellSettingsUpdate.ThemeMode(mode)
-                                    )
+                                    ThemeConfigIntent.SetThemeMode(mode)
                                 )
                             }
                         )
@@ -232,9 +216,7 @@ fun ThemeConfigScreen(
                             selectedMode = appShell.themeMode,
                             onModeSelected = { mode ->
                                 onIntent(
-                                    ThemeConfigIntent.UpdateAppShell(
-                                        AppShellSettingsUpdate.ThemeMode(mode)
-                                    )
+                                    ThemeConfigIntent.SetThemeMode(mode)
                                 )
                             }
                         )
@@ -280,9 +262,7 @@ fun ThemeConfigScreen(
                         entryValues = stringArrayResource(R.array.composeEngine_value),
                         onValueChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.ComposeEngine(it)
-                                )
+                                ThemeConfigIntent.SetComposeEngine(it)
                             )
                         }
                     )
@@ -299,12 +279,7 @@ fun ThemeConfigScreen(
                         checked = appShell.predictiveBackEnabled,
                         onCheckedChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.BooleanValue(
-                                        AppShellBooleanSetting.PredictiveBack,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetPredictiveBackEnabled(it)
                             )
                         }
                     )
@@ -319,9 +294,7 @@ fun ThemeConfigScreen(
                         steps = 7,
                         onValueChange = { value ->
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.FontScale(value.toInt())
-                                )
+                                ThemeConfigIntent.SetFontScale(value.toInt())
                             )
                         }
                     )
@@ -345,12 +318,7 @@ fun ThemeConfigScreen(
                         checked = appShell.showStatusBar,
                         onCheckedChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.BooleanValue(
-                                        AppShellBooleanSetting.ShowStatusBar,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetShowStatusBar(it)
                             )
                         }
                     )
@@ -360,12 +328,7 @@ fun ThemeConfigScreen(
                         checked = appShell.swipeAnimation,
                         onCheckedChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.BooleanValue(
-                                        AppShellBooleanSetting.SwipeAnimation,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetSwipeAnimation(it)
                             )
                         }
                     )
@@ -375,12 +338,7 @@ fun ThemeConfigScreen(
                         checked = appShell.showBottomView,
                         onCheckedChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.BooleanValue(
-                                        AppShellBooleanSetting.ShowBottomView,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetShowBottomView(it)
                             )
                         }
                     )
@@ -390,12 +348,7 @@ fun ThemeConfigScreen(
                         checked = appShell.useFloatingBottomBar,
                         onCheckedChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.BooleanValue(
-                                        AppShellBooleanSetting.UseFloatingBottomBar,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetUseFloatingBottomBar(it)
                             )
                         }
                     )
@@ -409,12 +362,7 @@ fun ThemeConfigScreen(
                                 checked = appShell.useFloatingBottomBarLiquidGlass,
                                 onCheckedChange = {
                                     onIntent(
-                                        ThemeConfigIntent.UpdateAppShell(
-                                            AppShellSettingsUpdate.BooleanValue(
-                                                AppShellBooleanSetting.UseFloatingBottomBarLiquidGlass,
-                                                it,
-                                            )
-                                        )
+                                        ThemeConfigIntent.SetUseFloatingBottomBarLiquidGlass(it)
                                     )
                                 }
                             )
@@ -424,15 +372,8 @@ fun ThemeConfigScreen(
                                 value = theme.bottomBarLensRadius,
                                 defaultValue = 24f,
                                 valueRange = 0f..50f,
-                                onValueChange = {
-                                    onIntent(
-                                        ThemeConfigIntent.UpdateTheme(
-                                            ThemeSettingsUpdate.FloatValue(
-                                                ThemeFloatSetting.BottomBarLensRadius,
-                                                it,
-                                            )
-                                        )
-                                    )
+                                onValueChange = { value ->
+                                    updateTheme { it.copy(bottomBarLensRadius = value) }
                                 }
                             )
                         }
@@ -445,12 +386,7 @@ fun ThemeConfigScreen(
                         entryValues = stringArrayResource(R.array.tabletInterface_value),
                         onValueChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.StringValue(
-                                        AppShellStringSetting.TabletInterface,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetTabletInterface(it)
                             )
                         }
                     )
@@ -461,12 +397,7 @@ fun ThemeConfigScreen(
                         entryValues = stringArrayResource(R.array.label_vis_mode_value),
                         onValueChange = {
                             onIntent(
-                                ThemeConfigIntent.UpdateAppShell(
-                                    AppShellSettingsUpdate.StringValue(
-                                        AppShellStringSetting.LabelVisibilityMode,
-                                        it,
-                                    )
-                                )
+                                ThemeConfigIntent.SetLabelVisibilityMode(it)
                             )
                         }
                     )
@@ -477,15 +408,8 @@ fun ThemeConfigScreen(
                         title = stringResource(R.string.book_info_follow_cover_color),
                         description = stringResource(R.string.book_info_follow_cover_color_summary),
                         checked = theme.bookInfoFollowCoverColor,
-                        onCheckedChange = {
-                            onIntent(
-                                ThemeConfigIntent.UpdateTheme(
-                                    ThemeSettingsUpdate.BooleanValue(
-                                        ThemeBooleanSetting.BookInfoFollowCoverColor,
-                                        it,
-                                    )
-                                )
-                            )
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(bookInfoFollowCoverColor = value) }
                         }
                     )
                     DropdownListSettingItem(
@@ -493,15 +417,8 @@ fun ThemeConfigScreen(
                         selectedValue = theme.bookInfoNetworkCoverBackground,
                         displayEntries = stringArrayResource(R.array.book_info_background_blur_entries),
                         entryValues = stringArrayResource(R.array.book_info_background_blur_values),
-                        onValueChange = {
-                            onIntent(
-                                ThemeConfigIntent.UpdateTheme(
-                                    ThemeSettingsUpdate.StringValue(
-                                        ThemeStringSetting.BookInfoNetworkCoverBackground,
-                                        it,
-                                    )
-                                )
-                            )
+                        onValueChange = { value ->
+                            updateTheme { it.copy(bookInfoNetworkCoverBackground = value) }
                         }
                     )
                     DropdownListSettingItem(
@@ -509,17 +426,77 @@ fun ThemeConfigScreen(
                         selectedValue = theme.bookInfoDefaultCoverBackground,
                         displayEntries = stringArrayResource(R.array.book_info_background_blur_entries),
                         entryValues = stringArrayResource(R.array.book_info_background_blur_values),
-                        onValueChange = {
-                            onIntent(
-                                ThemeConfigIntent.UpdateTheme(
-                                    ThemeSettingsUpdate.StringValue(
-                                        ThemeStringSetting.BookInfoDefaultCoverBackground,
-                                        it,
-                                    )
-                                )
-                            )
+                        onValueChange = { value ->
+                            updateTheme { it.copy(bookInfoDefaultCoverBackground = value) }
+                        }
+                    )
+                }
+
+                SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.eye_protection_enabled),
+                        description = stringResource(R.string.eye_protection_enabled_summary),
+                        checked = theme.eyeProtectionEnabled,
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(eyeProtectionEnabled = value) }
                         }
                     )
+
+                    AnimatedVisibility(visible = theme.eyeProtectionEnabled) {
+                        Column {
+                            SliderSettingItem(
+                                title = stringResource(R.string.color_temperature),
+                                description = stringResource(
+                                    R.string.color_temperature_summary,
+                                    theme.colorTemperature
+                                ),
+                                value = theme.colorTemperature.toFloat(),
+                                defaultValue = 50f,
+                                valueRange = 0f..100f,
+                                onValueChange = { value ->
+                                    updateTheme { it.copy(colorTemperature = value.toInt()) }
+                                }
+                            )
+
+                            SwitchSettingItem(
+                                title = stringResource(R.string.eye_protection_schedule),
+                                description = stringResource(R.string.eye_protection_schedule_summary),
+                                checked = theme.eyeProtectionSchedule,
+                                onCheckedChange = { value ->
+                                    updateTheme { it.copy(eyeProtectionSchedule = value) }
+                                }
+                            )
+
+                            AnimatedVisibility(visible = theme.eyeProtectionSchedule) {
+                                Column {
+                                    ClickableSettingItem(
+                                        title = stringResource(R.string.eye_protection_start_time),
+                                        option = theme.eyeProtectionStartTime,
+                                        onClick = {
+                                            onIntent(
+                                                ThemeConfigIntent.RequestTimePicker(
+                                                    ThemeTimeField.EyeProtectionStart,
+                                                    theme.eyeProtectionStartTime,
+                                                )
+                                            )
+                                        }
+                                    )
+                                    ClickableSettingItem(
+                                        title = stringResource(R.string.eye_protection_end_time),
+                                        option = theme.eyeProtectionEndTime,
+                                        onClick = {
+                                            onIntent(
+                                                ThemeConfigIntent.RequestTimePicker(
+                                                    ThemeTimeField.EyeProtectionEnd,
+                                                    theme.eyeProtectionEndTime,
+                                                )
+                                            )
+                                        }
+                                    )
+                                }
+                            }
+                        }
+                    }
                 }
 
                 SplicedColumnGroup(title = stringResource(R.string.compose_related)) {
@@ -527,19 +504,15 @@ fun ThemeConfigScreen(
                         SwitchSettingItem(
                             title = stringResource(R.string.pure_black),
                             checked = theme.isPureBlack,
-                            onCheckedChange = {
-                                onIntent(
-                                    ThemeConfigIntent.UpdateTheme(
-                                        ThemeSettingsUpdate.PureBlack(it)
-                                    )
-                                )
+                            onCheckedChange = { value ->
+                                updateTheme { it.copy(isPureBlack = value) }
                             }
                         )
                         SwitchSettingItem(
                             title = stringResource(R.string.use_flexible_top_bar),
                             checked = theme.useFlexibleTopAppBar,
-                            onCheckedChange = {
-                                updateTheme(ThemeBooleanSetting.UseFlexibleTopAppBar, it)
+                            onCheckedChange = { value ->
+                                updateTheme { it.copy(useFlexibleTopAppBar = value) }
                             }
                         )
                     }
@@ -552,8 +525,8 @@ fun ThemeConfigScreen(
                         SwitchSettingItem(
                             title = stringResource(R.string.is_blur_progressive_enable),
                             checked = theme.enableProgressiveBlur,
-                            onCheckedChange = {
-                                updateTheme(ThemeBooleanSetting.EnableProgressiveBlur, it)
+                            onCheckedChange = { value ->
+                                updateTheme { it.copy(enableProgressiveBlur = value) }
                             }
                         )
                     }
@@ -564,8 +537,8 @@ fun ThemeConfigScreen(
                             value = theme.topBarBlurRadius.toFloat(),
                             defaultValue = 24f,
                             valueRange = 0f..30f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.TopBarBlurRadius, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(topBarBlurRadius = value.toInt()) }
                             }
                         )
                         SliderSettingItem(
@@ -574,8 +547,8 @@ fun ThemeConfigScreen(
                             value = theme.bottomBarBlurRadius.toFloat(),
                             defaultValue = 8f,
                             valueRange = 0f..10f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.BottomBarBlurRadius, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(bottomBarBlurRadius = value.toInt()) }
                             }
                         )
                         SliderSettingItem(
@@ -583,8 +556,8 @@ fun ThemeConfigScreen(
                             value = theme.topBarBlurAlpha.toFloat(),
                             defaultValue = 73f,
                             valueRange = 0f..100f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.TopBarBlurAlpha, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(topBarBlurAlpha = value.toInt()) }
                             }
                         )
                         SliderSettingItem(
@@ -592,8 +565,8 @@ fun ThemeConfigScreen(
                             value = theme.bottomBarBlurAlpha.toFloat(),
                             defaultValue = 40f,
                             valueRange = 0f..100f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.BottomBarBlurAlpha, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(bottomBarBlurAlpha = value.toInt()) }
                             }
                         )
                     }
@@ -610,8 +583,8 @@ fun ThemeConfigScreen(
                                 value = theme.topBarOpacity.toFloat(),
                                 defaultValue = 100f,
                                 valueRange = 0f..100f,
-                                onValueChange = {
-                                    updateTheme(ThemeIntSetting.TopBarOpacity, it.toInt())
+                                onValueChange = { value ->
+                                    updateTheme { it.copy(topBarOpacity = value.toInt()) }
                                 }
                             )
                             SliderSettingItem(
@@ -623,8 +596,8 @@ fun ThemeConfigScreen(
                                 value = theme.bottomBarOpacity.toFloat(),
                                 defaultValue = 100f,
                                 valueRange = 0f..100f,
-                                onValueChange = {
-                                    updateTheme(ThemeIntSetting.BottomBarOpacity, it.toInt())
+                                onValueChange = { value ->
+                                    updateTheme { it.copy(bottomBarOpacity = value.toInt()) }
                                 }
                             )
                         }
@@ -639,8 +612,8 @@ fun ThemeConfigScreen(
                             value = theme.containerOpacity.toFloat(),
                             defaultValue = 100f,
                             valueRange = 0f..100f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.ContainerOpacity, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(containerOpacity = value.toInt()) }
                             }
                         )
                     }
@@ -666,8 +639,8 @@ fun ThemeConfigScreen(
                             value = theme.backgroundImageBlurring.toFloat(),
                             defaultValue = 0f,
                             valueRange = 0f..100f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.BackgroundImageBlurring, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(backgroundImageBlurring = value.toInt()) }
                             }
                         )
                     }
@@ -690,8 +663,8 @@ fun ThemeConfigScreen(
                             value = theme.backgroundImageDarkBlurring.toFloat(),
                             defaultValue = 0f,
                             valueRange = 0f..100f,
-                            onValueChange = {
-                                updateTheme(ThemeIntSetting.BackgroundImageDarkBlurring, it.toInt())
+                            onValueChange = { value ->
+                                updateTheme { it.copy(backgroundImageDarkBlurring = value.toInt()) }
                             }
                         )
                     }
@@ -705,19 +678,18 @@ fun ThemeConfigScreen(
                         title = stringResource(R.string.disable_spliced_group_corner_radius),
                         description = stringResource(R.string.disable_spliced_group_corner_radius_summary),
                         checked = theme.disableSplicedColumnGroupCornerRadius,
-                        onCheckedChange = {
-                            updateTheme(
-                                ThemeBooleanSetting.DisableSplicedColumnGroupCornerRadius,
-                                it
-                            )
+                        onCheckedChange = { value ->
+                            updateTheme {
+                                it.copy(disableSplicedColumnGroupCornerRadius = value)
+                            }
                         }
                     )
                     SwitchSettingItem(
                         title = stringResource(R.string.base_card_corner_radius_override),
                         description = stringResource(R.string.base_card_override_summary),
                         checked = theme.overrideBaseCardCornerRadius,
-                        onCheckedChange = {
-                            updateTheme(ThemeBooleanSetting.OverrideBaseCardCornerRadius, it)
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(overrideBaseCardCornerRadius = value) }
                         }
                     )
                     AnimatedVisibility(visible = theme.overrideBaseCardCornerRadius) {
@@ -729,8 +701,8 @@ fun ThemeConfigScreen(
                             valueRange = 0f..40f,
                             steps = 79,
                             decimal = true,
-                            onValueChange = {
-                                updateTheme(ThemeFloatSetting.BaseCardCornerRadius, it)
+                            onValueChange = { value ->
+                                updateTheme { it.copy(baseCardCornerRadius = value) }
                             }
                         )
                     }
@@ -738,8 +710,8 @@ fun ThemeConfigScreen(
                         title = stringResource(R.string.base_card_border_override),
                         description = stringResource(R.string.base_card_override_summary),
                         checked = theme.overrideBaseCardBorder,
-                        onCheckedChange = {
-                            updateTheme(ThemeBooleanSetting.OverrideBaseCardBorder, it)
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(overrideBaseCardBorder = value) }
                         }
                     )
                     AnimatedVisibility(visible = theme.overrideBaseCardBorder) {
@@ -752,8 +724,8 @@ fun ThemeConfigScreen(
                                 valueRange = 0f..5f,
                                 steps = 49,
                                 decimal = true,
-                                onValueChange = {
-                                    updateTheme(ThemeFloatSetting.BaseCardBorderWidth, it)
+                                onValueChange = { value ->
+                                    updateTheme { it.copy(baseCardBorderWidth = value) }
                                 }
                             )
                             BaseCardBorderColorSettingItem(
@@ -783,8 +755,8 @@ fun ThemeConfigScreen(
                     SwitchSettingItem(
                         title = stringResource(R.string.show_divider_line),
                         checked = theme.enableItemDivider,
-                        onCheckedChange = {
-                            updateTheme(ThemeBooleanSetting.EnableItemDivider, it)
+                        onCheckedChange = { value ->
+                            updateTheme { it.copy(enableItemDivider = value) }
                         }
                     )
                     if (theme.enableItemDivider) {
@@ -796,8 +768,8 @@ fun ThemeConfigScreen(
                             valueRange = 0f..5f,
                             steps = 49,
                             decimal = true,
-                            onValueChange = {
-                                updateTheme(ThemeFloatSetting.ItemDividerWidth, it)
+                            onValueChange = { value ->
+                                updateTheme { it.copy(itemDividerWidth = value) }
                             }
                         )
                         SliderSettingItem(
@@ -807,8 +779,8 @@ fun ThemeConfigScreen(
                             defaultValue = 80f,
                             valueRange = 30f..100f,
                             steps = 14,
-                            onValueChange = {
-                                updateTheme(ThemeFloatSetting.ItemDividerLength, it)
+                            onValueChange = { value ->
+                                updateTheme { it.copy(itemDividerLength = value) }
                             }
                         )
                         ClickableSettingItem(
@@ -908,8 +880,8 @@ fun ThemeConfigScreen(
         show = state.activeSheet == ThemeConfigSheet.DividerColor,
         initialColor = theme.itemDividerColor,
         onDismissRequest = { onIntent(ThemeConfigIntent.DismissSheet) },
-        onColorSelected = {
-            updateTheme(ThemeIntSetting.ItemDividerColor, it)
+        onColorSelected = { value ->
+            updateTheme { it.copy(itemDividerColor = value) }
             onIntent(ThemeConfigIntent.DismissSheet)
         }
     )
@@ -923,15 +895,14 @@ fun ThemeConfigScreen(
             theme.baseCardBorderColor
         },
         onDismissRequest = { onIntent(ThemeConfigIntent.DismissSheet) },
-        onColorSelected = {
-            updateTheme(
+        onColorSelected = { value ->
+            updateTheme {
                 if (baseCardBorderColorSheet?.dark == true) {
-                    ThemeIntSetting.BaseCardBorderColorNight
+                    it.copy(baseCardBorderColorNight = value)
                 } else {
-                    ThemeIntSetting.BaseCardBorderColor
-                },
-                it
-            )
+                    it.copy(baseCardBorderColor = value)
+                }
+            }
             onIntent(ThemeConfigIntent.DismissSheet)
         }
     )

+ 148 - 115
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -5,18 +5,12 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.AppShellBooleanSetting
 import io.legado.app.domain.gateway.AppShellSettingsGateway
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
-import io.legado.app.domain.gateway.AppShellStringSetting
-import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.gateway.ReadSettingsUpdate
-import io.legado.app.domain.gateway.ThemeBooleanSetting
-import io.legado.app.domain.gateway.ThemeIntSetting
 import io.legado.app.domain.gateway.ThemeSettingsGateway
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
-import io.legado.app.domain.gateway.ThemeStringSetting
+import io.legado.app.domain.gateway.LabSettingsGateway
+import io.legado.app.domain.model.settings.AppShellSettings
+import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.ui.main.MainDestination
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileUtils
@@ -24,6 +18,8 @@ import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.inputStream
 import io.legado.app.utils.openInputStream
+import java.io.File
+import java.io.FileOutputStream
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,8 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import splitties.init.appCtx
-import java.io.File
-import java.io.FileOutputStream
 
 class ThemeConfigViewModel(
     private val appShellSettingsGateway: AppShellSettingsGateway,
@@ -80,26 +74,57 @@ class ThemeConfigViewModel(
 
     fun onIntent(intent: ThemeConfigIntent) {
         when (intent) {
-            is ThemeConfigIntent.UpdateAppShell -> updateAppShell(intent.update)
-            is ThemeConfigIntent.UpdateTheme -> updateTheme(intent.update)
+            is ThemeConfigIntent.UpdateTheme -> updateTheme(intent.transform)
             is ThemeConfigIntent.ShowSheet ->
                 _uiState.update { it.copy(activeSheet = intent.sheet) }
             ThemeConfigIntent.DismissSheet ->
                 _uiState.update { it.copy(activeSheet = null) }
             is ThemeConfigIntent.SelectTheme -> selectTheme(intent.value)
+            is ThemeConfigIntent.SetThemeMode -> updateAppShell(
+                transform = { it.copy(themeMode = intent.value) },
+                effect = ThemeConfigEffect.ApplyDayNight,
+            )
+            is ThemeConfigIntent.SetComposeEngine -> updateAppShell {
+                it.copy(composeEngine = intent.value)
+            }
+            is ThemeConfigIntent.SetPredictiveBackEnabled -> updateAppShell {
+                it.copy(predictiveBackEnabled = intent.enabled)
+            }
+            is ThemeConfigIntent.SetFontScale -> updateAppShell {
+                it.copy(fontScale = intent.value)
+            }
+            is ThemeConfigIntent.SetShowStatusBar -> updateAppShell(
+                transform = { it.copy(showStatusBar = intent.visible) },
+                effect = ThemeConfigEffect.NotifyMain,
+            )
+            is ThemeConfigIntent.SetSwipeAnimation -> updateAppShell {
+                it.copy(swipeAnimation = intent.enabled)
+            }
+            is ThemeConfigIntent.SetShowBottomView -> updateAppShell {
+                it.copy(showBottomView = intent.visible)
+            }
+            is ThemeConfigIntent.SetUseFloatingBottomBar -> updateAppShell {
+                it.copy(useFloatingBottomBar = intent.enabled)
+            }
+            is ThemeConfigIntent.SetUseFloatingBottomBarLiquidGlass -> updateAppShell {
+                it.copy(useFloatingBottomBarLiquidGlass = intent.enabled)
+            }
+            is ThemeConfigIntent.SetTabletInterface -> updateAppShell {
+                it.copy(tabletInterface = intent.value)
+            }
+            is ThemeConfigIntent.SetLabelVisibilityMode -> updateAppShell {
+                it.copy(labelVisibilityMode = intent.value)
+            }
             is ThemeConfigIntent.SetMiuixMonet -> setMiuixMonet(intent.enabled)
             is ThemeConfigIntent.SetDynamicColors -> setDynamicColors(intent.enabled)
             is ThemeConfigIntent.SetBlurEnabled -> setBlurEnabled(intent.enabled)
             is ThemeConfigIntent.SetMainDestinationVisible -> setMainDestinationVisible(intent)
-            is ThemeConfigIntent.SetMainNavigationOrder -> updateAppShell(
-                AppShellSettingsUpdate.MainNavigationOrder(intent.routes)
-            )
-            is ThemeConfigIntent.SetDefaultHomePage -> updateAppShell(
-                AppShellSettingsUpdate.StringValue(
-                    AppShellStringSetting.DefaultHomePage,
-                    intent.route,
-                )
-            )
+            is ThemeConfigIntent.SetMainNavigationOrder -> updateAppShell {
+                it.copy(mainNavigationOrder = intent.routes)
+            }
+            is ThemeConfigIntent.SetDefaultHomePage -> updateAppShell {
+                it.copy(defaultHomePage = intent.route)
+            }
             is ThemeConfigIntent.SelectLauncherIcon -> selectLauncherIcon(intent.value)
             is ThemeConfigIntent.SelectNavigationIcon -> selectNavigationIcon(intent)
             is ThemeConfigIntent.RequestNavigationIcon -> _effects.tryEmit(
@@ -111,35 +136,33 @@ class ThemeConfigViewModel(
             is ThemeConfigIntent.SelectBackground -> setBackground(intent.uri, intent.dark)
             is ThemeConfigIntent.RemoveBackground -> removeBackground(intent.dark)
             is ThemeConfigIntent.SelectAppFont -> setAppFont(intent.file)
-            ThemeConfigIntent.ClearAppFont -> updateTheme(ThemeSettingsUpdate.AppFontPath(null))
+            ThemeConfigIntent.ClearAppFont -> updateTheme { it.copy(appFontPath = null) }
             is ThemeConfigIntent.SetFontFolder -> viewModelScope.launch {
-                readSettingsGateway.update(ReadSettingsUpdate.FontFolder(intent.path))
+                readSettingsGateway.update { it.copy(fontFolder = intent.path) }
             }
             ThemeConfigIntent.RequestFontFolder -> _effects.tryEmit(ThemeConfigEffect.OpenFontFolder)
-            ThemeConfigIntent.DismissRefactorTip -> updateTheme(
-                ThemeSettingsUpdate.BooleanValue(ThemeBooleanSetting.ShowRefactorTip, false)
+            is ThemeConfigIntent.RequestTimePicker -> _effects.tryEmit(
+                ThemeConfigEffect.OpenTimePicker(intent.field, intent.currentValue)
             )
+            is ThemeConfigIntent.SetTime -> setTime(intent.field, intent.value)
+            ThemeConfigIntent.DismissRefactorTip -> updateTheme {
+                it.copy(showRefactorTip = false)
+            }
         }
     }
 
-    private fun updateAppShell(update: AppShellSettingsUpdate) {
+    private fun updateAppShell(
+        effect: ThemeConfigEffect? = null,
+        transform: (AppShellSettings) -> AppShellSettings,
+    ) {
         viewModelScope.launch {
-            appShellSettingsGateway.update(update)
-            when (update) {
-                is AppShellSettingsUpdate.ThemeMode ->
-                    _effects.tryEmit(ThemeConfigEffect.ApplyDayNight)
-                is AppShellSettingsUpdate.BooleanValue -> if (
-                    update.setting == AppShellBooleanSetting.ShowStatusBar
-                ) {
-                    _effects.tryEmit(ThemeConfigEffect.NotifyMain)
-                }
-                else -> Unit
-            }
+            appShellSettingsGateway.update(transform)
+            effect?.let(_effects::tryEmit)
         }
     }
 
-    private fun updateTheme(update: ThemeSettingsUpdate) {
-        viewModelScope.launch { themeSettingsGateway.update(update) }
+    private fun updateTheme(transform: (ThemeSettings) -> ThemeSettings) {
+        viewModelScope.launch { themeSettingsGateway.update(transform) }
     }
 
     private fun selectTheme(value: String) {
@@ -151,32 +174,27 @@ class ThemeConfigViewModel(
             return
         }
         viewModelScope.launch {
-            val updates = buildList {
-                if (value == "13") {
-                    add(ThemeSettingsUpdate.IntValue(ThemeIntSetting.ContainerOpacity, 0))
-                }
-                add(ThemeSettingsUpdate.AppTheme(value))
+            themeSettingsGateway.update {
+                it.copy(
+                    appTheme = value,
+                    containerOpacity = if (value == "13") 0 else it.containerOpacity,
+                )
             }
-            themeSettingsGateway.updateAll(updates)
         }
     }
 
     private fun setMiuixMonet(enabled: Boolean) {
         viewModelScope.launch {
-            val currentTheme = _uiState.value.theme.appTheme
-            themeSettingsGateway.updateAll(
-                buildList {
-                    add(
-                        ThemeSettingsUpdate.BooleanValue(
-                            ThemeBooleanSetting.UseMiuixMonet,
-                            enabled,
-                        )
-                    )
-                    if (enabled && currentTheme != "0" && currentTheme != "12") {
-                        add(ThemeSettingsUpdate.AppTheme("0"))
-                    }
-                }
-            )
+            themeSettingsGateway.update {
+                it.copy(
+                    useMiuixMonet = enabled,
+                    appTheme = if (enabled && it.appTheme != "0" && it.appTheme != "12") {
+                        "0"
+                    } else {
+                        it.appTheme
+                    },
+                )
+            }
         }
     }
 
@@ -187,70 +205,88 @@ class ThemeConfigViewModel(
 
     private fun setBlurEnabled(enabled: Boolean) {
         viewModelScope.launch {
-            themeSettingsGateway.updateAll(
-                buildList {
-                    add(
-                        ThemeSettingsUpdate.BooleanValue(
-                            ThemeBooleanSetting.EnableBlur,
-                            enabled,
-                        )
-                    )
-                    if (!enabled) {
-                        add(
-                            ThemeSettingsUpdate.BooleanValue(
-                                ThemeBooleanSetting.EnableProgressiveBlur,
-                                false,
-                            )
-                        )
-                    }
-                }
-            )
+            themeSettingsGateway.update {
+                it.copy(
+                    enableBlur = enabled,
+                    enableProgressiveBlur = if (enabled) {
+                        it.enableProgressiveBlur
+                    } else {
+                        false
+                    },
+                )
+            }
         }
     }
 
     private fun setMainDestinationVisible(intent: ThemeConfigIntent.SetMainDestinationVisible) {
-        val setting = when (intent.route) {
-            MainDestination.Home.route -> AppShellBooleanSetting.ShowHome
-            MainDestination.Explore.route -> AppShellBooleanSetting.ShowDiscovery
-            MainDestination.Rss.route -> AppShellBooleanSetting.ShowRss
+        val transform: (AppShellSettings) -> AppShellSettings = when (intent.route) {
+            MainDestination.Home.route -> { current ->
+                current.copy(
+                    showHome = intent.visible,
+                    defaultHomePage = current.fallbackHomePage(intent),
+                )
+            }
+            MainDestination.Explore.route -> { current ->
+                current.copy(
+                    showDiscovery = intent.visible,
+                    defaultHomePage = current.fallbackHomePage(intent),
+                )
+            }
+            MainDestination.Rss.route -> { current ->
+                current.copy(
+                    showRss = intent.visible,
+                    defaultHomePage = current.fallbackHomePage(intent),
+                )
+            }
             else -> return
         }
-        viewModelScope.launch {
-            appShellSettingsGateway.updateAll(
-                buildList {
-                    add(AppShellSettingsUpdate.BooleanValue(setting, intent.visible))
-                    if (!intent.visible && _uiState.value.appShell.defaultHomePage == intent.route) {
-                        add(
-                            AppShellSettingsUpdate.StringValue(
-                                AppShellStringSetting.DefaultHomePage,
-                                MainDestination.Bookshelf.route,
-                            )
-                        )
-                    }
-                }
-            )
-        }
+        updateAppShell(transform = transform)
+    }
+
+    private fun AppShellSettings.fallbackHomePage(
+        intent: ThemeConfigIntent.SetMainDestinationVisible,
+    ): String = if (!intent.visible && defaultHomePage == intent.route) {
+        MainDestination.Bookshelf.route
+    } else {
+        defaultHomePage
     }
 
     private fun selectLauncherIcon(value: String) {
         viewModelScope.launch {
-            appShellSettingsGateway.update(
-                AppShellSettingsUpdate.StringValue(AppShellStringSetting.LauncherIcon, value)
-            )
+            appShellSettingsGateway.update { it.copy(launcherIcon = value) }
             _effects.tryEmit(ThemeConfigEffect.ChangeLauncherIcon(value))
         }
     }
 
     private fun selectNavigationIcon(intent: ThemeConfigIntent.SelectNavigationIcon) {
-        val setting = when (intent.destination) {
-            MainDestination.Home.route -> AppShellStringSetting.NavIconHome
-            MainDestination.Bookshelf.route -> AppShellStringSetting.NavIconBookshelf
-            MainDestination.Explore.route -> AppShellStringSetting.NavIconExplore
-            MainDestination.Rss.route -> AppShellStringSetting.NavIconRss
-            MainDestination.My.route -> AppShellStringSetting.NavIconMy
+        val transform: (AppShellSettings) -> AppShellSettings = when (intent.destination) {
+            MainDestination.Home.route -> { settings ->
+                settings.copy(navIconHome = intent.path)
+            }
+            MainDestination.Bookshelf.route -> { settings ->
+                settings.copy(navIconBookshelf = intent.path)
+            }
+            MainDestination.Explore.route -> { settings ->
+                settings.copy(navIconExplore = intent.path)
+            }
+            MainDestination.Rss.route -> { settings ->
+                settings.copy(navIconRss = intent.path)
+            }
+            MainDestination.My.route -> { settings ->
+                settings.copy(navIconMy = intent.path)
+            }
             else -> return
         }
-        updateAppShell(AppShellSettingsUpdate.StringValue(setting, intent.path))
+        updateAppShell(transform = transform)
+    }
+
+    private fun setTime(field: ThemeTimeField, value: String) {
+        updateTheme {
+            when (field) {
+                ThemeTimeField.EyeProtectionStart -> it.copy(eyeProtectionStartTime = value)
+                ThemeTimeField.EyeProtectionEnd -> it.copy(eyeProtectionEndTime = value)
+            }
+        }
     }
 
     private fun setBackground(uriString: String, dark: Boolean) {
@@ -289,13 +325,10 @@ class ThemeConfigViewModel(
                 .takeIf { it.absolutePath.contains(appCtx.externalFiles.absolutePath) }
                 ?.delete()
         }
-        themeSettingsGateway.update(
-            ThemeSettingsUpdate.StringValue(
-                if (dark) ThemeStringSetting.BackgroundImageDark
-                else ThemeStringSetting.BackgroundImageLight,
-                newPath,
-            )
-        )
+        themeSettingsGateway.update {
+            if (dark) it.copy(backgroundImageDark = newPath)
+            else it.copy(backgroundImageLight = newPath)
+        }
     }
 
     private fun setAppFont(fileDoc: FileDoc) {
@@ -316,7 +349,7 @@ class ThemeConfigViewModel(
                     temp.copyTo(target, overwrite = true)
                     temp.delete()
                 }
-                themeSettingsGateway.update(ThemeSettingsUpdate.AppFontPath(target.absolutePath))
+                themeSettingsGateway.update { it.copy(appFontPath = target.absolutePath) }
             }.onFailure(Throwable::printStackTrace)
         }
     }

+ 1 - 8
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -3,9 +3,7 @@ package io.legado.app.ui.main
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.constant.EventBus
-import io.legado.app.domain.gateway.AppShellBooleanSetting
 import io.legado.app.domain.gateway.AppShellSettingsGateway
-import io.legado.app.domain.gateway.AppShellSettingsUpdate
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.AppShellSettings
 import io.legado.app.domain.model.settings.ThemeSettings
@@ -74,12 +72,7 @@ class MainViewModel(
     private fun setNavExtended(expanded: Boolean) {
         if (_uiState.value.navExtended == expanded) return
         viewModelScope.launch {
-            appShellSettingsGateway.update(
-                AppShellSettingsUpdate.BooleanValue(
-                    AppShellBooleanSetting.NavExtended,
-                    expanded,
-                )
-            )
+            appShellSettingsGateway.update { it.copy(navExtended = expanded) }
         }
     }
 

+ 145 - 101
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt

@@ -31,12 +31,7 @@ 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.domain.gateway.BookshelfBooleanSetting
-import io.legado.app.domain.gateway.BookshelfIntSetting
-import io.legado.app.domain.gateway.BookshelfSettingsUpdate
 import io.legado.app.domain.model.settings.BookshelfSettings
-import io.legado.app.domain.gateway.ThemeBooleanSetting
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.ui.config.themeConfig.LabelColorManageSheet
 import io.legado.app.ui.config.themeConfig.TagColorPair
 import io.legado.app.ui.theme.LegadoTheme
@@ -54,11 +49,11 @@ import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
 fun BookshelfConfigSheet(
     show: Boolean,
     settings: BookshelfSettings,
-    onUpdate: (BookshelfSettingsUpdate) -> Unit,
+    onUpdate: ((BookshelfSettings) -> BookshelfSettings) -> Unit,
     enableCustomTagColors: Boolean,
     customTagColors: List<TagColorPair>,
     themeColor: Int,
-    onThemeUpdate: (ThemeSettingsUpdate) -> Unit,
+    onCustomTagColorsEnabledChange: (Boolean) -> Unit,
     onCustomTagColorsChange: (List<TagColorPair>) -> Unit,
     onDismissRequest: () -> Unit
 ) {
@@ -87,14 +82,18 @@ fun BookshelfConfigSheet(
                 selectedValue = settings.bookGroupStyle.toString(),
                 displayEntries = stringArrayResource(R.array.group_style),
                 entryValues = Array(stringArrayResource(R.array.group_style).size) { it.toString() },
-                onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.BookGroupStyle, it.toInt())) }
+                onValueChange = { value ->
+                    onUpdate { it.copy(bookGroupStyle = value.toInt()) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.hide_empty_groups),
                 checked = settings.hideEmptyGroups,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.HideEmptyGroups, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(hideEmptyGroups = value) }
+                }
             )
 
             PillHeaderDivider(title = stringResource(R.string.sort))
@@ -104,7 +103,9 @@ fun BookshelfConfigSheet(
                 selectedValue = settings.bookshelfSort.toString(),
                 displayEntries = stringArrayResource(R.array.bookshelf_px_array),
                 entryValues = Array(stringArrayResource(R.array.bookshelf_px_array).size) { it.toString() },
-                onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.Sort, it.toInt())) }
+                onValueChange = { value ->
+                    onUpdate { it.copy(bookshelfSort = value.toInt()) }
+                }
             )
 
             // Sort Order
@@ -116,7 +117,9 @@ fun BookshelfConfigSheet(
                     stringResource(R.string.descending_order)
                 ),
                 entryValues = arrayOf("0", "1"),
-                onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.SortOrder, it.toInt())) }
+                onValueChange = { value ->
+                    onUpdate { it.copy(bookshelfSortOrder = value.toInt()) }
+                }
             )
 
             PillHeaderDivider(title = stringResource(R.string.bookshelf_section_layout))
@@ -139,14 +142,14 @@ fun BookshelfConfigSheet(
                         selectedValue = folderLayoutMode.toString(),
                         displayEntries = arrayOf(stringResource(R.string.layout_mode_list), stringResource(R.string.layout_mode_grid)),
                         entryValues = arrayOf("0", "1"),
-                        onValueChange = {
-                            onUpdate(
-                                BookshelfSettingsUpdate.IntValue(
-                                    if (isLandscape) BookshelfIntSetting.FolderLayoutModeLandscape
-                                    else BookshelfIntSetting.FolderLayoutModePortrait,
-                                    it.toInt(),
-                                )
-                            )
+                        onValueChange = { value ->
+                            onUpdate {
+                                if (isLandscape) {
+                                    it.copy(bookshelfFolderLayoutModeLandscape = value.toInt())
+                                } else {
+                                    it.copy(bookshelfFolderLayoutModePortrait = value.toInt())
+                                }
+                            }
                         }
                     )
 
@@ -159,14 +162,14 @@ fun BookshelfConfigSheet(
                             value = folderGridCount.toFloat(),
                             valueRange = 1f..15f,
                             steps = 14,
-                            onValueChange = {
-                                onUpdate(
-                                    BookshelfSettingsUpdate.IntValue(
-                                        if (isLandscape) BookshelfIntSetting.FolderLayoutGridLandscape
-                                        else BookshelfIntSetting.FolderLayoutGridPortrait,
-                                        it.toInt(),
-                                    )
-                                )
+                            onValueChange = { value ->
+                                onUpdate {
+                                    if (isLandscape) {
+                                        it.copy(bookshelfFolderLayoutGridLandscape = value.toInt())
+                                    } else {
+                                        it.copy(bookshelfFolderLayoutGridPortrait = value.toInt())
+                                    }
+                                }
                             }
                         )
                     }
@@ -181,14 +184,14 @@ fun BookshelfConfigSheet(
                                 value = folderListCount.toFloat(),
                                 valueRange = 1f..5f,
                                 steps = 4,
-                                onValueChange = {
-                                    onUpdate(
-                                        BookshelfSettingsUpdate.IntValue(
-                                            if (isLandscape) BookshelfIntSetting.FolderLayoutListLandscape
-                                            else BookshelfIntSetting.FolderLayoutListPortrait,
-                                            it.toInt(),
-                                        )
-                                    )
+                                onValueChange = { value ->
+                                    onUpdate {
+                                        if (isLandscape) {
+                                            it.copy(bookshelfFolderLayoutListLandscape = value.toInt())
+                                        } else {
+                                            it.copy(bookshelfFolderLayoutListPortrait = value.toInt())
+                                        }
+                                    }
                                 }
                             )
 
@@ -197,8 +200,8 @@ fun BookshelfConfigSheet(
                                 value = settings.bookshelfListCoverWidth.toFloat(),
                                 valueRange = 40f..120f,
                                 steps = 80,
-                                onValueChange = {
-                                    onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.ListCoverWidth, it.toInt()))
+                                onValueChange = { value ->
+                                    onUpdate { it.copy(bookshelfListCoverWidth = value.toInt()) }
                                 }
                             )
                         }
@@ -215,8 +218,8 @@ fun BookshelfConfigSheet(
                                 selectedValue = settings.bookshelfGroupListStyle.toString(),
                                 displayEntries = arrayOf(stringResource(R.string.group), stringResource(R.string.compact_list), stringResource(R.string.horizontal_cover_count)),
                                 entryValues = arrayOf("0", "1", "2"),
-                                onValueChange = {
-                                    onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.GroupListStyle, it.toInt()))
+                                onValueChange = { value ->
+                                    onUpdate { it.copy(bookshelfGroupListStyle = value.toInt()) }
                                 }
                             )
                             AnimatedVisibility(visible = settings.bookshelfGroupListStyle == 2) {
@@ -225,8 +228,8 @@ fun BookshelfConfigSheet(
                                     value = settings.bookshelfGroupCoverCount.toFloat(),
                                     valueRange = 1f..10f,
                                     steps = 9,
-                                    onValueChange = {
-                                        onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.GroupCoverCount, it.toInt()))
+                                    onValueChange = { value ->
+                                        onUpdate { it.copy(bookshelfGroupCoverCount = value.toInt()) }
                                     }
                                 )
                             }
@@ -246,14 +249,14 @@ fun BookshelfConfigSheet(
                     stringResource(R.string.layout_mode_grid)
                 ),
                 entryValues = arrayOf("0", "1"),
-                onValueChange = {
-                    onUpdate(
-                        BookshelfSettingsUpdate.IntValue(
-                            if (isLandscape) BookshelfIntSetting.LayoutModeLandscape
-                            else BookshelfIntSetting.LayoutModePortrait,
-                            it.toInt(),
-                        )
-                    )
+                onValueChange = { value ->
+                    onUpdate {
+                        if (isLandscape) {
+                            it.copy(bookshelfLayoutModeLandscape = value.toInt())
+                        } else {
+                            it.copy(bookshelfLayoutModePortrait = value.toInt())
+                        }
+                    }
                 }
             )
 
@@ -268,7 +271,9 @@ fun BookshelfConfigSheet(
                         selectedValue = settings.bookshelfGridLayout.toString(),
                         displayEntries = stringArrayResource(R.array.bookshelf_grid_layout),
                         entryValues = Array(stringArrayResource(R.array.bookshelf_grid_layout).size) { it.toString() },
-                        onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.GridLayout, it.toInt())) }
+                        onValueChange = { value ->
+                            onUpdate { it.copy(bookshelfGridLayout = value.toInt()) }
+                        }
                     )
 
                     val gridCount =
@@ -278,14 +283,14 @@ fun BookshelfConfigSheet(
                         value = gridCount.toFloat(),
                         valueRange = 1f..15f,
                         steps = 14,
-                        onValueChange = {
-                            onUpdate(
-                                BookshelfSettingsUpdate.IntValue(
-                                    if (isLandscape) BookshelfIntSetting.LayoutGridLandscape
-                                    else BookshelfIntSetting.LayoutGridPortrait,
-                                    it.toInt(),
-                                )
-                            )
+                        onValueChange = { value ->
+                            onUpdate {
+                                if (isLandscape) {
+                                    it.copy(bookshelfLayoutGridLandscape = value.toInt())
+                                } else {
+                                    it.copy(bookshelfLayoutGridPortrait = value.toInt())
+                                }
+                            }
                         }
                     )
 
@@ -293,14 +298,18 @@ fun BookshelfConfigSheet(
                         title = stringResource(R.string.compact_title_font),
                         checked = settings.bookshelfTitleSmallFont,
                         color = LegadoTheme.colorScheme.surface,
-                        onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.TitleSmallFont, it)) }
+                        onCheckedChange = { value ->
+                            onUpdate { it.copy(bookshelfTitleSmallFont = value) }
+                        }
                     )
 
                     CompactSwitchSettingItem(
                         title = stringResource(R.string.center_aligned_title),
                         checked = settings.bookshelfTitleCenter,
                         color = LegadoTheme.colorScheme.surface,
-                        onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.TitleCenter, it)) }
+                        onCheckedChange = { value ->
+                            onUpdate { it.copy(bookshelfTitleCenter = value) }
+                        }
                     )
 
                     CompactSliderSettingItem(
@@ -308,7 +317,9 @@ fun BookshelfConfigSheet(
                         value = settings.bookshelfGridCoverWidth.toFloat(),
                         valueRange = 40f..150f,
                         steps = 110,
-                        onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.GridCoverWidth, it.toInt())) }
+                        onValueChange = { value ->
+                            onUpdate { it.copy(bookshelfGridCoverWidth = value.toInt()) }
+                        }
                     )
                 }
             }
@@ -323,7 +334,9 @@ fun BookshelfConfigSheet(
                         title = stringResource(R.string.show_divider_line),
                         checked = settings.bookshelfShowDivider,
                         color = LegadoTheme.colorScheme.surface,
-                        onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowDivider, it)) }
+                        onCheckedChange = { value ->
+                            onUpdate { it.copy(bookshelfShowDivider = value) }
+                        }
                     )
 
                     CompactClickableSettingItem(
@@ -372,7 +385,9 @@ fun BookshelfConfigSheet(
                         title = stringResource(R.string.compact_details),
                         checked = settings.bookshelfLayoutCompact,
                         color = LegadoTheme.colorScheme.surface,
-                        onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.LayoutCompact, it)) }
+                        onCheckedChange = { value ->
+                            onUpdate { it.copy(bookshelfLayoutCompact = value) }
+                        }
                     )
 
                     val listColCount =
@@ -382,7 +397,9 @@ fun BookshelfConfigSheet(
                         value = settings.bookshelfListCoverWidth.toFloat(),
                         valueRange = 40f..120f,
                         steps = 80,
-                        onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.ListCoverWidth, it.toInt())) }
+                        onValueChange = { value ->
+                            onUpdate { it.copy(bookshelfListCoverWidth = value.toInt()) }
+                        }
                     )
 
                     CompactSliderSettingItem(
@@ -390,14 +407,14 @@ fun BookshelfConfigSheet(
                         value = listColCount.toFloat(),
                         valueRange = 1f..5f,
                         steps = 4,
-                        onValueChange = {
-                            onUpdate(
-                                BookshelfSettingsUpdate.IntValue(
-                                    if (isLandscape) BookshelfIntSetting.LayoutListLandscape
-                                    else BookshelfIntSetting.LayoutListPortrait,
-                                    it.toInt(),
-                                )
-                            )
+                        onValueChange = { value ->
+                            onUpdate {
+                                if (isLandscape) {
+                                    it.copy(bookshelfLayoutListLandscape = value.toInt())
+                                } else {
+                                    it.copy(bookshelfLayoutListPortrait = value.toInt())
+                                }
+                            }
                         }
                     )
 
@@ -405,7 +422,9 @@ fun BookshelfConfigSheet(
                         title = stringResource(R.string.show_more_info),
                         checked = settings.showBookIntro,
                         color = LegadoTheme.colorScheme.surface,
-                        onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowBookIntro, it)) }
+                        onCheckedChange = { value ->
+                            onUpdate { it.copy(showBookIntro = value) }
+                        }
                     )
 
                     AnimatedVisibility(visible = settings.showBookIntro) {
@@ -416,8 +435,8 @@ fun BookshelfConfigSheet(
                                 title = stringResource(R.string.show_latest_chapter),
                                 checked = settings.bookshelfShowLatestChapter,
                                 color = LegadoTheme.colorScheme.surface,
-                                onCheckedChange = {
-                                    onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowLatestChapter, it))
+                                onCheckedChange = { value ->
+                                    onUpdate { it.copy(bookshelfShowLatestChapter = value) }
                                 }
                             )
 
@@ -425,7 +444,9 @@ fun BookshelfConfigSheet(
                                 title = stringResource(R.string.show_synopsis),
                                 checked = settings.bookshelfShowIntro,
                                 color = LegadoTheme.colorScheme.surface,
-                                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowIntro, it)) }
+                                onCheckedChange = { value ->
+                                    onUpdate { it.copy(bookshelfShowIntro = value) }
+                                }
                             )
                             AnimatedVisibility(
                                 visible = settings.bookshelfShowIntro
@@ -436,8 +457,8 @@ fun BookshelfConfigSheet(
                                     value = settings.bookshelfIntroMaxLines.toFloat(),
                                     valueRange = 0f..10f,
                                     steps = 10,
-                                    onValueChange = {
-                                        onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.IntroMaxLines, it.toInt()))
+                                    onValueChange = { value ->
+                                        onUpdate { it.copy(bookshelfIntroMaxLines = value.toInt()) }
                                     }
                                 )
                             }
@@ -445,7 +466,9 @@ fun BookshelfConfigSheet(
                                 title = stringResource(R.string.show_tags),
                                 checked = settings.bookshelfShowTag,
                                 color = LegadoTheme.colorScheme.surface,
-                                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowTag, it)) }
+                                onCheckedChange = { value ->
+                                    onUpdate { it.copy(bookshelfShowTag = value) }
+                                }
                             )
                             AnimatedVisibility(
                                 visible = settings.bookshelfShowTag
@@ -457,14 +480,7 @@ fun BookshelfConfigSheet(
                                         title = stringResource(R.string.custom_tag_colors),
                                         checked = enableCustomTagColors,
                                         color = LegadoTheme.colorScheme.surface,
-                                        onCheckedChange = {
-                                            onThemeUpdate(
-                                                ThemeSettingsUpdate.BooleanValue(
-                                                    ThemeBooleanSetting.EnableCustomTagColors,
-                                                    it,
-                                                )
-                                            )
-                                        }
+                                        onCheckedChange = onCustomTagColorsEnabledChange
                                     )
                                     AnimatedVisibility(visible = enableCustomTagColors) {
                                         CompactClickableSettingItem(
@@ -487,14 +503,18 @@ fun BookshelfConfigSheet(
                 value = settings.bookshelfTitleMaxLines.toFloat(),
                 valueRange = 1f..5f,
                 steps = 4,
-                onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.TitleMaxLines, it.toInt())) }
+                onValueChange = { value ->
+                    onUpdate { it.copy(bookshelfTitleMaxLines = value.toInt()) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.cover_shadow),
                 checked = settings.bookshelfCoverShadow,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.CoverShadow, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(bookshelfCoverShadow = value) }
+                }
             )
 
             PillHeaderDivider(title = stringResource(R.string.bookshelf_section_badge))
@@ -503,42 +523,54 @@ fun BookshelfConfigSheet(
                 title = stringResource(R.string.show_unread),
                 checked = settings.showUnread,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowUnread, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showUnread = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_unread_new),
                 checked = settings.showUnreadNew,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowUnreadNew, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showUnreadNew = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_wait_up_count),
                 checked = settings.showWaitUpCount,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowWaitUpCount, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showWaitUpCount = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_book_count),
                 checked = settings.showBookCount,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowBookCount, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showBookCount = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_last_update_time),
                 checked = settings.showLastUpdateTime,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowLastUpdateTime, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showLastUpdateTime = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_tip),
                 checked = settings.showTip,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowTip, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showTip = value) }
+                }
             )
 
             PillHeaderDivider(title = stringResource(R.string.other))
@@ -547,21 +579,27 @@ fun BookshelfConfigSheet(
                 title = stringResource(R.string.search_filter_first),
                 checked = settings.bookshelfSearchActionDirectToSearch,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.SearchActionDirectToSearch, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(bookshelfSearchActionDirectToSearch = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_bookshelf_fast_scroller),
                 checked = settings.showBookshelfFastScroller,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowFastScroller, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(showBookshelfFastScroller = value) }
+                }
             )
 
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_bookshelf_tab_menu),
                 checked = settings.shouldShowExpandButton,
                 color = LegadoTheme.colorScheme.surface,
-                onCheckedChange = { onUpdate(BookshelfSettingsUpdate.BooleanValue(BookshelfBooleanSetting.ShowExpandButton, it)) }
+                onCheckedChange = { value ->
+                    onUpdate { it.copy(shouldShowExpandButton = value) }
+                }
             )
 
             CompactSliderSettingItem(
@@ -570,7 +608,9 @@ fun BookshelfConfigSheet(
                 value = settings.bookshelfRefreshingLimit.toFloat(),
                 valueRange = 0f..100f,
                 steps = 100,
-                onValueChange = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.RefreshingLimit, it.toInt())) }
+                onValueChange = { value ->
+                    onUpdate { it.copy(bookshelfRefreshingLimit = value.toInt()) }
+                }
             )
 
             Spacer(modifier = Modifier.height(32.dp))
@@ -589,14 +629,18 @@ fun BookshelfConfigSheet(
             show = showColorPicker,
             initialColor = if (settings.bookshelfCardColor != 0) settings.bookshelfCardColor else LegadoTheme.colorScheme.surfaceVariant.toArgb(),
             onDismissRequest = { showColorPicker = false },
-            onColorSelected = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.CardColor, it)) }
+            onColorSelected = { value ->
+                onUpdate { it.copy(bookshelfCardColor = value) }
+            }
         )
 
         ColorPickerSheet(
             show = showColorPickerDark,
             initialColor = if (settings.bookshelfCardColorDark != 0) settings.bookshelfCardColorDark else LegadoTheme.colorScheme.surfaceVariant.toArgb(),
             onDismissRequest = { showColorPickerDark = false },
-            onColorSelected = { onUpdate(BookshelfSettingsUpdate.IntValue(BookshelfIntSetting.CardColorDark, it)) }
+            onColorSelected = { value ->
+                onUpdate { it.copy(bookshelfCardColorDark = value) }
+            }
         )
     }
 }

+ 3 - 1
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -1193,7 +1193,9 @@ private fun BookshelfOverlays(
         enableCustomTagColors = uiState.enableCustomTagColors,
         customTagColors = uiState.customTagColors,
         themeColor = uiState.themeColor,
-        onThemeUpdate = { onIntent(BookshelfIntent.UpdateThemeSetting(it)) },
+        onCustomTagColorsEnabledChange = {
+            onIntent(BookshelfIntent.SetCustomTagColorsEnabled(it))
+        },
         onCustomTagColorsChange = {
             onIntent(BookshelfIntent.SetCustomTagColors(it))
         },

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

@@ -4,8 +4,6 @@ import androidx.compose.runtime.Stable
 import android.net.Uri
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.domain.model.settings.BookshelfSettings
-import io.legado.app.domain.gateway.BookshelfSettingsUpdate
-import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.ui.config.themeConfig.TagColorPair
 import io.legado.app.ui.widget.components.list.ListUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -62,8 +60,10 @@ sealed interface BookshelfIntent {
     data class ExportToUri(val uri: Uri, val books: List<BookUiItem>) : BookshelfIntent
     data class UploadBookshelf(val books: List<BookUiItem>) : BookshelfIntent
     data class ImportFromUri(val uri: Uri, val groupId: Long) : BookshelfIntent
-    data class UpdateSetting(val update: BookshelfSettingsUpdate) : BookshelfIntent
-    data class UpdateThemeSetting(val update: ThemeSettingsUpdate) : BookshelfIntent
+    data class UpdateSetting(
+        val transform: (BookshelfSettings) -> BookshelfSettings,
+    ) : BookshelfIntent
+    data class SetCustomTagColorsEnabled(val enabled: Boolean) : BookshelfIntent
     data class SetCustomTagColors(val colors: List<TagColorPair>) : BookshelfIntent
     data object UploadResultConsumed : BookshelfIntent
 }

+ 9 - 11
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -23,7 +23,6 @@ import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
-import io.legado.app.domain.gateway.BookshelfSettingsUpdate
 import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.exception.NoStackTraceException
@@ -696,18 +695,17 @@ class BookshelfViewModel(
             is BookshelfIntent.UploadBookshelf -> uploadBookshelf(intent.books)
             is BookshelfIntent.ImportFromUri -> importBookshelf(intent.uri, intent.groupId)
             is BookshelfIntent.UpdateSetting -> viewModelScope.launch {
-                bookshelfSettingsGateway.update(intent.update)
+                bookshelfSettingsGateway.update(intent.transform)
             }
-            is BookshelfIntent.UpdateThemeSetting -> viewModelScope.launch {
-                themeSettingsGateway.update(intent.update)
+            is BookshelfIntent.SetCustomTagColorsEnabled -> viewModelScope.launch {
+                themeSettingsGateway.update {
+                    it.copy(enableCustomTagColors = intent.enabled)
+                }
             }
             is BookshelfIntent.SetCustomTagColors -> viewModelScope.launch {
-                themeSettingsGateway.update(
-                    io.legado.app.domain.gateway.ThemeSettingsUpdate.StringValue(
-                        io.legado.app.domain.gateway.ThemeStringSetting.CustomTagColorsJson,
-                        GSON.toJson(intent.colors),
-                    )
-                )
+                themeSettingsGateway.update {
+                    it.copy(customTagColorsJson = GSON.toJson(intent.colors))
+                }
             }
             BookshelfIntent.UploadResultConsumed -> pendingUploadUrlFlow.value = null
         }
@@ -757,7 +755,7 @@ class BookshelfViewModel(
         if (groupIdFlow.value != groupId) {
             groupIdFlow.value = groupId
             viewModelScope.launch {
-                bookshelfSettingsGateway.update(BookshelfSettingsUpdate.SaveTabPosition(groupId))
+                bookshelfSettingsGateway.update { it.copy(saveTabPosition = groupId) }
             }
             clearSelection()
             clearDragState()

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

@@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.BackupSettingsGateway
-import io.legado.app.domain.gateway.BackupSettingsUpdate
 import io.legado.app.domain.model.HomeDashboardSection
 import io.legado.app.domain.model.HomeReadingBook
 import io.legado.app.domain.model.WebDavBackup
@@ -247,7 +246,7 @@ class HomeViewModel(
             try {
                 runCatching {
                     if (savePath) {
-                        backupSettingsGateway.update(BackupSettingsUpdate.BackupPath(path))
+                        backupSettingsGateway.update { it.copy(backupPath = path) }
                     }
                     if (destination != HomeBackupDestination.Local) {
                         webDavBackupUseCase.refreshConfig()

+ 1 - 2
app/src/main/java/io/legado/app/ui/welcome/BookFolderFragment.kt

@@ -6,7 +6,6 @@ import io.legado.app.R
 import io.legado.app.base.BaseFragment
 import io.legado.app.databinding.FragmentBookFolderBinding
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 import androidx.lifecycle.lifecycleScope
@@ -21,7 +20,7 @@ class BookFolderFragment : BaseFragment(R.layout.fragment_book_folder) {
     private val selectBookFolder = registerForActivityResult(HandleFileContract()) { result ->
         result.uri?.let { treeUri ->
             lifecycleScope.launch {
-                otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(treeUri.toString()))
+                otherSettingsGateway.update { it.copy(defaultBookTreeUri = treeUri.toString()) }
                 updatePathText()
             }
         }

+ 6 - 6
app/src/main/java/io/legado/app/ui/welcome/WebDavFragment.kt

@@ -15,7 +15,6 @@ import io.legado.app.constant.AppLog
 import io.legado.app.databinding.FragmentWebdavAuthBinding
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.gateway.BackupSettingsGateway
-import io.legado.app.domain.gateway.BackupSettingsUpdate
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
@@ -132,12 +131,13 @@ class WebDavFragment : BaseFragment(R.layout.fragment_webdav_auth) {
     }
 
     suspend fun saveWebDavConfig(config: WebDavConfig, onSaved: (() -> Unit)? = null) {
-        backupSettingsGateway.updateAll(
-            listOf(
-                BackupSettingsUpdate.WebDavUrl(config.url),
-                BackupSettingsUpdate.WebDavCredentials(config.account, config.password),
+        backupSettingsGateway.update {
+            it.copy(
+                webDavUrl = config.url,
+                webDavAccount = config.account,
+                webDavPassword = config.password,
             )
-        )
+        }
         onSaved?.invoke()
     }
 

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

@@ -49,7 +49,6 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.help.loadFontFiles
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.utils.FileDoc
@@ -172,7 +171,7 @@ fun FontSelectGrid(
                                 text = { Text(stringResource(sort.labelRes)) },
                                 onClick = {
                                     scope.launch {
-                                        otherSettings.update(OtherSettingsUpdate.FontSort(sort.ordinal))
+                                        otherSettings.update { it.copy(fontSort = sort.ordinal) }
                                     }
                                     expanded = false
                                 },

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

@@ -2237,6 +2237,12 @@
     <string name="eye_protection_intensity_summary">Current intensity: %1$d</string>
     <string name="eye_protection_auto_night">Enable with dark reading mode</string>
     <string name="eye_protection_auto_night_summary">Automatically follow the reader day/night mode</string>
+    <string name="color_temperature">Color Temperature</string>
+    <string name="color_temperature_summary">Current temperature: %1$d</string>
+    <string name="eye_protection_schedule">Scheduled</string>
+    <string name="eye_protection_schedule_summary">Only apply during the configured time range</string>
+    <string name="eye_protection_start_time">Start Time</string>
+    <string name="eye_protection_end_time">End Time</string>
 
     <!-- Tag Rules -->
     <string name="highlight_tag_config">Tag Rules</string>

+ 100 - 0
app/src/test/java/io/legado/app/data/repository/AppShellSettingsMappingTest.kt

@@ -0,0 +1,100 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.settings.AppShellSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AppShellSettingsMappingTest {
+
+    @Test
+    fun `AppShell 23 键写读映射逐字段对应`() {
+        appShellMappingSamples().forEach { expected ->
+            assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
+            assertEquals(
+                expected,
+                expected.expectedPrefMap().toTestPreferences().toAppShellSettings(),
+            )
+        }
+    }
+
+    @Test
+    fun `导航可见性与默认首页通过真实原子路径单批写入`() {
+        val values = captureAtomicUpdateValues(
+            current = AppShellSettings(showHome = true, defaultHomePage = "home"),
+            read = { it.toAppShellSettings() },
+            toPrefMap = AppShellSettings::toPrefMap,
+            transform = {
+                it.copy(
+                    showHome = false,
+                    defaultHomePage = "bookshelf",
+                )
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.showHome to false,
+                PreferKey.defaultHomePage to "bookshelf",
+            ),
+            values,
+        )
+    }
+}
+
+private fun appShellMappingSamples(): List<AppShellSettings> {
+    val base = AppShellSettings(
+        themeMode = "theme-mode",
+        fontScale = 13,
+        composeEngine = "compose-engine",
+        tabletInterface = "tablet-interface",
+        labelVisibilityMode = "label-mode",
+        defaultHomePage = "default-page",
+        mainNavigationOrder = "navigation-order",
+        navIconHome = "icon-home",
+        navIconBookshelf = "icon-bookshelf",
+        navIconExplore = "icon-explore",
+        navIconRss = "icon-rss",
+        navIconMy = "icon-my",
+        launcherIcon = "launcher-icon",
+    )
+    return listOf(
+        base,
+        base.copy(showHome = false),
+        base.copy(showDiscovery = false),
+        base.copy(showRss = false),
+        base.copy(showStatusBar = false),
+        base.copy(swipeAnimation = false),
+        base.copy(predictiveBackEnabled = false),
+        base.copy(showBottomView = false),
+        base.copy(useFloatingBottomBar = true),
+        base.copy(useFloatingBottomBarLiquidGlass = true),
+        base.copy(navExtended = true),
+    )
+}
+
+private fun AppShellSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.themeMode to themeMode,
+    PreferKey.fontScale to fontScale,
+    PreferKey.composeEngine to composeEngine,
+    PreferKey.showHome to showHome,
+    PreferKey.showDiscovery to showDiscovery,
+    PreferKey.showRss to showRss,
+    PreferKey.showStatusBar to showStatusBar,
+    PreferKey.swipeAnimation to swipeAnimation,
+    PreferKey.isPredictiveBackEnabled to predictiveBackEnabled,
+    PreferKey.showBottomView to showBottomView,
+    PreferKey.useFloatingBottomBar to useFloatingBottomBar,
+    PreferKey.useFloatingBottomBarLiquidGlass to useFloatingBottomBarLiquidGlass,
+    PreferKey.tabletInterface to tabletInterface,
+    PreferKey.labelVisibilityMode to labelVisibilityMode,
+    PreferKey.defaultHomePage to defaultHomePage,
+    PreferKey.mainNavigationOrder to mainNavigationOrder,
+    PreferKey.navExtended to navExtended,
+    PreferKey.navIconHome to navIconHome,
+    PreferKey.navIconBookshelf to navIconBookshelf,
+    PreferKey.navIconExplore to navIconExplore,
+    PreferKey.navIconRss to navIconRss,
+    PreferKey.navIconMy to navIconMy,
+    PreferKey.launcherIcon to launcherIcon,
+)

+ 39 - 0
app/src/test/java/io/legado/app/data/repository/AtomicSettingsTestUtils.kt

@@ -0,0 +1,39 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.mutablePreferencesOf
+import io.legado.app.help.config.PendingOverlayCore
+import io.legado.app.help.config.setPrefValue
+import kotlinx.coroutines.runBlocking
+
+internal fun <T> captureAtomicUpdateValues(
+    current: T,
+    read: (Preferences) -> T,
+    toPrefMap: (T) -> Map<String, Any?>,
+    transform: (T) -> T,
+): Map<String, Any?> {
+    val initial = toPrefMap(current).toTestPreferences()
+    val writeQueue = ArrayDeque<suspend () -> Unit>()
+    var persistedValues: Map<String, Any?>? = null
+    val core = PendingOverlayCore(
+        initial = initial,
+        launchWrite = { writeQueue += it },
+        persist = { _, _ -> error("不会执行单键落盘") },
+        persistAll = { values ->
+            persistedValues = values
+            initial
+        },
+    )
+
+    core.atomicUpdate(read, toPrefMap, transform)
+    if (writeQueue.isEmpty()) return emptyMap()
+    check(writeQueue.size == 1)
+    runBlocking { writeQueue.removeFirst().invoke() }
+    return checkNotNull(persistedValues)
+}
+
+internal fun Map<String, Any?>.toTestPreferences(): Preferences {
+    val preferences = mutablePreferencesOf()
+    forEach { (key, value) -> preferences.setPrefValue(key, value) }
+    return preferences
+}

+ 71 - 0
app/src/test/java/io/legado/app/data/repository/BackupSettingsMappingTest.kt

@@ -0,0 +1,71 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.Preferences
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.settings.BackupSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BackupSettingsMappingTest {
+
+    @Test
+    fun `备份设置各字段写读映射往返恒等且可识别布尔键交换`() {
+        val base = BackupSettings(
+            webDavUrl = "https://backup.example.test/dav",
+            webDavAccount = "backup-account",
+            webDavPassword = "backup-password",
+            webDavDir = "backup-directory",
+            webDavDeviceName = "backup-device",
+            backupSyncMode = "local",
+            backupPath = "content://backup/path",
+        )
+        val samples = listOf(
+            base,
+            base.copy(syncBookProgress = false),
+            base.copy(syncBookProgressPlus = true),
+            base.copy(autoCheckNewBackup = false),
+            base.copy(onlyLatestBackup = false),
+        )
+
+        samples.forEach { expected ->
+            assertEquals(expected, expected.toPrefMap().toTestPreferences().toBackupSettings())
+        }
+    }
+
+    @Test
+    fun `copy 同时变更多字段只产生对应差量并支持删除 nullable 键`() {
+        val current = BackupSettings(
+            webDavAccount = "old-account",
+            webDavPassword = "old-password",
+            syncBookProgress = true,
+            syncBookProgressPlus = true,
+            backupPath = "content://old/path",
+        )
+
+        val diff = captureAtomicUpdateValues(
+            current = current,
+            read = Preferences::toBackupSettings,
+            toPrefMap = BackupSettings::toPrefMap,
+            transform = {
+                it.copy(
+                    webDavAccount = "new-account",
+                    webDavPassword = "new-password",
+                    syncBookProgress = false,
+                    syncBookProgressPlus = false,
+                    backupPath = null,
+                )
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.webDavAccount to "new-account",
+                PreferKey.webDavPassword to "new-password",
+                PreferKey.syncBookProgress to false,
+                PreferKey.syncBookProgressPlus to false,
+                PreferKey.backupPath to null,
+            ),
+            diff,
+        )
+    }
+}

+ 156 - 0
app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt

@@ -0,0 +1,156 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.settings.BookshelfSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BookshelfSettingsMappingTest {
+
+    @Test
+    fun `Bookshelf 46 键写读映射逐字段对应`() {
+        assertEquals(46, BookshelfSettings().toPrefMap().size)
+        bookshelfMappingSamples().forEach { expected ->
+            assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
+            assertEquals(
+                expected,
+                expected.expectedPrefMap().toTestPreferences().toBookshelfSettings(),
+            )
+        }
+    }
+
+    @Test
+    fun `隐藏空分组通过真实原子路径只写对应键`() {
+        val values = captureAtomicUpdateValues(
+            current = BookshelfSettings(hideEmptyGroups = false),
+            read = { it.toBookshelfSettings() },
+            toPrefMap = BookshelfSettings::toPrefMap,
+            transform = { it.copy(hideEmptyGroups = true) },
+        )
+
+        assertEquals(mapOf(PreferKey.hideEmptyGroups to true), values)
+    }
+}
+
+private fun bookshelfMappingSamples(): List<BookshelfSettings> {
+    val base = BookshelfSettings(
+        bookGroupStyle = 101,
+        hideEmptyGroups = false,
+        bookshelfSort = 102,
+        bookshelfSortOrder = 103,
+        showUnread = false,
+        showUnreadNew = false,
+        showTip = false,
+        showBookCount = false,
+        showLastUpdateTime = false,
+        showBookIntro = false,
+        bookshelfShowIntro = false,
+        bookshelfShowTag = false,
+        bookshelfShowLatestChapter = false,
+        bookshelfIntroMaxLines = 104,
+        showWaitUpCount = false,
+        showBookshelfFastScroller = false,
+        shouldShowExpandButton = false,
+        bookshelfRefreshingLimit = 105,
+        bookshelfLayoutModePortrait = 106,
+        bookshelfLayoutGridPortrait = 107,
+        bookshelfLayoutModeLandscape = 108,
+        bookshelfLayoutGridLandscape = 109,
+        bookshelfLayoutListPortrait = 110,
+        bookshelfLayoutListLandscape = 111,
+        bookshelfFolderLayoutModePortrait = 112,
+        bookshelfFolderLayoutGridPortrait = 113,
+        bookshelfFolderLayoutModeLandscape = 114,
+        bookshelfFolderLayoutGridLandscape = 115,
+        bookshelfFolderLayoutListPortrait = 116,
+        bookshelfFolderLayoutListLandscape = 117,
+        bookshelfGridLayout = 118,
+        bookshelfLayoutCompact = false,
+        bookshelfShowDivider = false,
+        bookshelfTitleSmallFont = false,
+        bookshelfTitleCenter = false,
+        bookshelfTitleMaxLines = 119,
+        bookshelfCoverShadow = false,
+        bookshelfCardColor = 120,
+        bookshelfCardColorDark = 121,
+        bookshelfGroupListStyle = 122,
+        bookshelfGroupCoverCount = 123,
+        bookshelfListCoverWidth = 124,
+        bookshelfGridCoverWidth = 125,
+        bookshelfSearchActionDirectToSearch = false,
+        autoRefreshBook = false,
+        saveTabPosition = 987654321L,
+    )
+    return listOf(
+        base,
+        base.copy(hideEmptyGroups = true),
+        base.copy(showUnread = true),
+        base.copy(showUnreadNew = true),
+        base.copy(showTip = true),
+        base.copy(showBookCount = true),
+        base.copy(showLastUpdateTime = true),
+        base.copy(showBookIntro = true),
+        base.copy(bookshelfShowIntro = true),
+        base.copy(bookshelfShowTag = true),
+        base.copy(bookshelfShowLatestChapter = true),
+        base.copy(showWaitUpCount = true),
+        base.copy(showBookshelfFastScroller = true),
+        base.copy(shouldShowExpandButton = true),
+        base.copy(bookshelfLayoutCompact = true),
+        base.copy(bookshelfShowDivider = true),
+        base.copy(bookshelfTitleSmallFont = true),
+        base.copy(bookshelfTitleCenter = true),
+        base.copy(bookshelfCoverShadow = true),
+        base.copy(bookshelfSearchActionDirectToSearch = true),
+        base.copy(autoRefreshBook = true),
+    )
+}
+
+private fun BookshelfSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.bookGroupStyle to bookGroupStyle,
+    PreferKey.hideEmptyGroups to hideEmptyGroups,
+    PreferKey.bookshelfSort to bookshelfSort,
+    PreferKey.bookshelfSortOrder to bookshelfSortOrder,
+    PreferKey.showUnread to showUnread,
+    PreferKey.showUnreadNew to showUnreadNew,
+    PreferKey.showTip to showTip,
+    PreferKey.showBookCount to showBookCount,
+    PreferKey.showLastUpdateTime to showLastUpdateTime,
+    PreferKey.showBookIntro to showBookIntro,
+    PreferKey.bookshelfShowIntro to bookshelfShowIntro,
+    PreferKey.bookshelfShowTag to bookshelfShowTag,
+    PreferKey.bookshelfShowLatestChapter to bookshelfShowLatestChapter,
+    PreferKey.bookshelfIntroMaxLines to bookshelfIntroMaxLines,
+    PreferKey.showWaitUpCount to showWaitUpCount,
+    PreferKey.showBookshelfFastScroller to showBookshelfFastScroller,
+    PreferKey.shouldShowExpandButton to shouldShowExpandButton,
+    PreferKey.bookshelfRefreshingLimit to bookshelfRefreshingLimit,
+    PreferKey.bookshelfLayoutModePortrait to bookshelfLayoutModePortrait,
+    PreferKey.bookshelfLayoutGridPortrait to bookshelfLayoutGridPortrait,
+    PreferKey.bookshelfLayoutModeLandscape to bookshelfLayoutModeLandscape,
+    PreferKey.bookshelfLayoutGridLandscape to bookshelfLayoutGridLandscape,
+    PreferKey.bookshelfLayoutListPortrait to bookshelfLayoutListPortrait,
+    PreferKey.bookshelfLayoutListLandscape to bookshelfLayoutListLandscape,
+    PreferKey.bookshelfFolderLayoutModePortrait to bookshelfFolderLayoutModePortrait,
+    PreferKey.bookshelfFolderLayoutGridPortrait to bookshelfFolderLayoutGridPortrait,
+    PreferKey.bookshelfFolderLayoutModeLandscape to bookshelfFolderLayoutModeLandscape,
+    PreferKey.bookshelfFolderLayoutGridLandscape to bookshelfFolderLayoutGridLandscape,
+    PreferKey.bookshelfFolderLayoutListPortrait to bookshelfFolderLayoutListPortrait,
+    PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
+    PreferKey.bookshelfGridLayout to bookshelfGridLayout,
+    PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfShowDivider to bookshelfShowDivider,
+    PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
+    PreferKey.bookshelfTitleCenter to bookshelfTitleCenter,
+    PreferKey.bookshelfTitleMaxLines to bookshelfTitleMaxLines,
+    PreferKey.bookshelfCoverShadow to bookshelfCoverShadow,
+    PreferKey.bookshelfCardColor to bookshelfCardColor,
+    PreferKey.bookshelfCardColorDark to bookshelfCardColorDark,
+    PreferKey.bookshelfGroupListStyle to bookshelfGroupListStyle,
+    PreferKey.bookshelfGroupCoverCount to bookshelfGroupCoverCount,
+    PreferKey.bookshelfListCoverWidth to bookshelfListCoverWidth,
+    PreferKey.bookshelfGridCoverWidth to bookshelfGridCoverWidth,
+    PreferKey.bookshelfSearchActionDirectToSearch to bookshelfSearchActionDirectToSearch,
+    PreferKey.autoRefresh to autoRefreshBook,
+    PreferKey.saveTabPosition to saveTabPosition,
+)

+ 23 - 26
app/src/test/java/io/legado/app/data/repository/LabSettingsMappingTest.kt

@@ -1,11 +1,9 @@
 package io.legado.app.data.repository
 
 import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.mutablePreferencesOf
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.settings.LabSettings
 import io.legado.app.help.config.PendingOverlayCore
-import io.legado.app.help.config.setPrefValue
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -19,27 +17,32 @@ class LabSettingsMappingTest {
         val samples = listOf(
             LabSettings(enabled = true),
             LabSettings(eInkDisplay = true),
+            LabSettings(eyeProtection = true),
         )
 
         samples.forEach { expected ->
-            assertEquals(expected, expected.toPrefMap().toPreferences().toLabSettings())
+            assertEquals(expected, expected.toPrefMap().toTestPreferences().toLabSettings())
         }
     }
 
     @Test
-    fun `update transform 只返回发生变化的键`() {
+    fun `原子 update 只入队发生变化的键`() {
         val current = LabSettings(enabled = true)
 
-        val diff = current.diffPrefMap(
-            transform = { it.copy(eInkDisplay = true) },
+        val diff = captureAtomicUpdateValues(
+            current = current,
+            read = Preferences::toLabSettings,
             toPrefMap = LabSettings::toPrefMap,
+            transform = { it.copy(eyeProtection = true) },
         )
 
-        assertEquals(mapOf(PreferKey.labEInkDisplay to true), diff)
+        assertEquals(mapOf(PreferKey.labEyeProtection to true), diff)
         assertTrue(
-            current.diffPrefMap(
-                transform = { it.copy() },
+            captureAtomicUpdateValues(
+                current = current,
+                read = Preferences::toLabSettings,
                 toPrefMap = LabSettings::toPrefMap,
+                transform = { it.copy() },
             ).isEmpty()
         )
     }
@@ -47,25 +50,25 @@ class LabSettingsMappingTest {
     @Test
     fun `基于同一快照并发更新不同字段不丢更新`() {
         val current = LabSettings()
-        val enabledDiff = current.diffPrefMap(
-            transform = { it.copy(enabled = true) },
-            toPrefMap = LabSettings::toPrefMap,
-        )
-        val eInkDisplayDiff = current.diffPrefMap(
-            transform = { it.copy(eInkDisplay = true) },
-            toPrefMap = LabSettings::toPrefMap,
+        val transforms: List<(LabSettings) -> LabSettings> = listOf(
+            { it.copy(enabled = true) },
+            { it.copy(eyeProtection = true) },
         )
         val core = PendingOverlayCore(
-            initial = current.toPrefMap().toPreferences(),
+            initial = current.toPrefMap().toTestPreferences(),
             launchWrite = {},
             persist = { _, _ -> error("不会执行落盘") },
             persistAll = { error("不会执行落盘") },
         )
         val start = CountDownLatch(1)
-        val writers = listOf(enabledDiff, eInkDisplayDiff).map { diff ->
+        val writers = transforms.map { transform ->
             thread(start = true) {
                 start.await()
-                core.putAll(diff)
+                core.atomicUpdate(
+                    read = Preferences::toLabSettings,
+                    toPrefMap = LabSettings::toPrefMap,
+                    transform = transform,
+                )
             }
         }
 
@@ -73,14 +76,8 @@ class LabSettingsMappingTest {
         writers.forEach(Thread::join)
 
         assertEquals(
-            current.copy(enabled = true, eInkDisplay = true),
+            current.copy(enabled = true, eyeProtection = true),
             core.preferencesFlow.value.toLabSettings(),
         )
     }
 }
-
-private fun Map<String, Any?>.toPreferences(): Preferences {
-    val preferences = mutablePreferencesOf()
-    forEach { (key, value) -> preferences.setPrefValue(key, value) }
-    return preferences
-}

+ 196 - 47
app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt

@@ -1,56 +1,38 @@
 package io.legado.app.data.repository
 
+import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.mutablePreferencesOf
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.gateway.MangaClickArea
-import io.legado.app.domain.gateway.MangaSettingsUpdate
+import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.help.config.setPrefValue
+import io.legado.app.help.config.PendingOverlayCore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
 
 class MangaSettingsMappingTest {
 
     @Test
-    fun `漫画设置写读映射往返恒等`() {
-        val expected = MangaSettings(
-            showMangaUi = false,
-            disableMangaScale = false,
-            disableMangaScrollAnimation = true,
-            disableMangaCrossFade = true,
-            scrollMode = 2,
-            preDownloadNum = 22,
-            autoPageSpeed = 8,
-            footerConfig = "footer",
-            disableClickScroll = true,
-            longClick = false,
-            background = 0xFF123456.toInt(),
-            colorFilter = "filter",
-            hideTitle = true,
-            enableEInk = true,
-            eInkThreshold = 188,
-            enableGray = true,
-            webtoonSidePaddingDp = 12,
-            volumeKeyPage = true,
-            reverseVolumeKeyPage = true,
-            clickActionTL = 0,
-            clickActionTC = 1,
-            clickActionTR = 2,
-            clickActionML = 3,
-            clickActionMC = 4,
-            clickActionMR = 5,
-            clickActionBL = 6,
-            clickActionBC = 7,
-            clickActionBR = 8,
-        )
-        val preferences = mutablePreferencesOf().apply {
-            expected.toPrefMap().forEach { (key, value) -> setPrefValue(key, value) }
+    fun `漫画设置 28 键写映射逐字段对应`() {
+        mangaMappingSamples().forEach { settings ->
+            assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
+    }
 
-        assertEquals(expected, preferences.toMangaSettings())
+    @Test
+    fun `漫画设置 28 键读映射逐字段对应`() {
+        mangaMappingSamples().forEach { expected ->
+            assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toMangaSettings())
+        }
     }
 
     @Test
@@ -69,16 +51,104 @@ class MangaSettingsMappingTest {
     }
 
     @Test
-    fun `墨水屏与点击区域更新映射为同一批键值`() {
-        val values = listOf(
-            MangaSettingsUpdate.EInk(enabled = true, threshold = 188),
-            MangaSettingsUpdate.ClickAction(MangaClickArea.MC, 0),
-        ).toMangaPreferenceValues()
-
-        assertEquals(true, values[PreferKey.enableMangaEInk])
-        assertEquals(false, values[PreferKey.enableMangaGray])
-        assertEquals(188, values[PreferKey.mangaEInkThreshold])
-        assertEquals(0, values[PreferKey.mangaClickActionMC])
+    fun `墨水屏更新一次产生启用关闭灰度和阈值三项差量`() {
+        val diff = captureAtomicUpdateValues(
+            current = MangaSettings(enableGray = true),
+            read = Preferences::toMangaSettings,
+            toPrefMap = MangaSettings::toPrefMap,
+            transform = {
+                it.copy(enableEInk = true, enableGray = false, eInkThreshold = 188)
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.enableMangaEInk to true,
+                PreferKey.mangaEInkThreshold to 188,
+                PreferKey.enableMangaGray to false,
+            ),
+            diff,
+        )
+    }
+
+    @Test
+    fun `灰度更新一次产生启用灰度和关闭墨水屏两项差量`() {
+        val diff = captureAtomicUpdateValues(
+            current = MangaSettings(enableEInk = true),
+            read = Preferences::toMangaSettings,
+            toPrefMap = MangaSettings::toPrefMap,
+            transform = { it.copy(enableEInk = false, enableGray = true) },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.enableMangaEInk to false,
+                PreferKey.enableMangaGray to true,
+            ),
+            diff,
+        )
+    }
+
+    @Test
+    fun `gateway 通过不可变快照 transform 更新设置`() = runBlocking {
+        val gateway = FakeMangaSettingsGateway(MangaSettings(enableGray = true))
+
+        gateway.update {
+            it.copy(enableEInk = true, enableGray = false, eInkThreshold = 188)
+        }
+
+        assertEquals(
+            MangaSettings(enableEInk = true, enableGray = false, eInkThreshold = 188),
+            gateway.currentSettings,
+        )
+    }
+
+    @Test
+    fun `并发启用墨水屏与灰度时完整 transform 串行且保持互斥`() {
+        val initial = MangaSettings()
+        val core = PendingOverlayCore(
+            initial = initial.expectedPrefMap().toTestPreferences(),
+            launchWrite = {},
+            persist = { _, _ -> error("不会执行落盘") },
+            persistAll = { error("不会执行落盘") },
+        )
+        val firstEntered = CountDownLatch(1)
+        val releaseFirst = CountDownLatch(1)
+        val first = thread {
+            core.atomicUpdate(
+                read = Preferences::toMangaSettings,
+                toPrefMap = MangaSettings::toPrefMap,
+            ) {
+                firstEntered.countDown()
+                releaseFirst.await()
+                it.copy(enableEInk = true, enableGray = false)
+            }
+        }
+        firstEntered.await()
+
+        val secondStarted = CountDownLatch(1)
+        val secondEntered = CountDownLatch(1)
+        val second = thread {
+            secondStarted.countDown()
+            core.atomicUpdate(
+                read = Preferences::toMangaSettings,
+                toPrefMap = MangaSettings::toPrefMap,
+            ) {
+                secondEntered.countDown()
+                it.copy(enableEInk = false, enableGray = true)
+            }
+        }
+        secondStarted.await()
+        val secondRacedWithFirst = secondEntered.await(200, TimeUnit.MILLISECONDS)
+        releaseFirst.countDown()
+        first.join()
+        second.join()
+
+        assertFalse(secondRacedWithFirst)
+        assertEquals(
+            MangaSettings(enableEInk = false, enableGray = true),
+            core.preferencesFlow.value.toMangaSettings(),
+        )
     }
 
     @Test
@@ -88,4 +158,83 @@ class MangaSettingsMappingTest {
         assertTrue(settings.hasMenuClickArea())
         assertFalse(settings.enableGray)
     }
+
+    private class FakeMangaSettingsGateway(initial: MangaSettings) : MangaSettingsGateway {
+        private val state = MutableStateFlow(initial)
+
+        override val currentSettings: MangaSettings
+            get() = state.value
+        override val settings: Flow<MangaSettings> = state.asStateFlow()
+
+        override suspend fun update(transform: (MangaSettings) -> MangaSettings) {
+            state.value = transform(state.value)
+        }
+    }
+}
+
+private fun mangaMappingSamples(): List<MangaSettings> {
+    val base = MangaSettings(
+        scrollMode = 11,
+        preDownloadNum = 22,
+        autoPageSpeed = 33,
+        footerConfig = "manga-footer",
+        background = 0xFF123456.toInt(),
+        colorFilter = "manga-filter",
+        eInkThreshold = 44,
+        webtoonSidePaddingDp = 55,
+        clickActionTL = 101,
+        clickActionTC = 102,
+        clickActionTR = 103,
+        clickActionML = 104,
+        clickActionMC = 105,
+        clickActionMR = 106,
+        clickActionBL = 107,
+        clickActionBC = 108,
+        clickActionBR = 109,
+    )
+    return listOf(
+        base,
+        base.copy(showMangaUi = false),
+        base.copy(disableMangaScale = false),
+        base.copy(disableMangaScrollAnimation = true),
+        base.copy(disableMangaCrossFade = true),
+        base.copy(disableClickScroll = true),
+        base.copy(longClick = false),
+        base.copy(hideTitle = true),
+        base.copy(enableEInk = true),
+        base.copy(enableGray = true),
+        base.copy(volumeKeyPage = true),
+        base.copy(reverseVolumeKeyPage = true),
+    )
 }
+
+private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.showMangaUi to showMangaUi,
+    PreferKey.disableMangaScale to disableMangaScale,
+    PreferKey.disableMangaScrollAnimation to disableMangaScrollAnimation,
+    PreferKey.disableMangaCrossFade to disableMangaCrossFade,
+    PreferKey.mangaScrollMode to scrollMode,
+    PreferKey.mangaPreDownloadNum to preDownloadNum,
+    PreferKey.mangaAutoPageSpeed to autoPageSpeed,
+    PreferKey.mangaFooterConfig to footerConfig,
+    PreferKey.disableClickScroll to disableClickScroll,
+    PreferKey.mangaLongClick to longClick,
+    PreferKey.mangaBackground to background,
+    PreferKey.mangaColorFilter to colorFilter,
+    PreferKey.hideMangaTitle to hideTitle,
+    PreferKey.enableMangaEInk to enableEInk,
+    PreferKey.mangaEInkThreshold to eInkThreshold,
+    PreferKey.enableMangaGray to enableGray,
+    PreferKey.webtoonSidePaddingDp to webtoonSidePaddingDp,
+    PreferKey.mangaVolumeKeyPage to volumeKeyPage,
+    PreferKey.reverseVolumeKeyPage to reverseVolumeKeyPage,
+    PreferKey.mangaClickActionTL to clickActionTL,
+    PreferKey.mangaClickActionTC to clickActionTC,
+    PreferKey.mangaClickActionTR to clickActionTR,
+    PreferKey.mangaClickActionML to clickActionML,
+    PreferKey.mangaClickActionMC to clickActionMC,
+    PreferKey.mangaClickActionMR to clickActionMR,
+    PreferKey.mangaClickActionBL to clickActionBL,
+    PreferKey.mangaClickActionBC to clickActionBC,
+    PreferKey.mangaClickActionBR to clickActionBR,
+)

+ 90 - 0
app/src/test/java/io/legado/app/data/repository/OtherSettingsMappingTest.kt

@@ -0,0 +1,90 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.settings.OtherSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class OtherSettingsMappingTest {
+
+    @Test
+    fun `其他设置 25 键写读映射逐字段对应`() {
+        otherMappingSamples().forEach { expected ->
+            assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
+            assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toOtherSettings())
+        }
+    }
+
+    @Test
+    fun `默认书籍目录 nullable 删除走真实原子路径`() {
+        val values = captureAtomicUpdateValues(
+            current = OtherSettings(defaultBookTreeUri = "content://old"),
+            read = { it.toOtherSettings() },
+            toPrefMap = OtherSettings::toPrefMap,
+            transform = { it.copy(defaultBookTreeUri = null) },
+        )
+
+        assertEquals(mapOf(PreferKey.defaultBookTreeUri to null), values)
+    }
+}
+
+private fun otherMappingSamples(): List<OtherSettings> {
+    val base = OtherSettings(
+        updateToVariant = "beta",
+        defaultBookTreeUri = "content://books",
+        sourceEditMaxLine = 123,
+        webPort = 4321,
+        fontSort = 7,
+    )
+    return listOf(
+        base,
+        base.copy(autoCheckUpdateOnStart = true),
+        base.copy(webServiceAutoStart = true),
+        base.copy(autoRefresh = true),
+        base.copy(defaultToRead = true),
+        base.copy(notificationsPost = false),
+        base.copy(ignoreBatteryPermission = false),
+        base.copy(firebaseEnable = false),
+        base.copy(antiAlias = true),
+        base.copy(replaceEnableDefault = false),
+        base.copy(autoClearExpired = false),
+        base.copy(showAddToShelfAlert = false),
+        base.copy(showMangaUi = false),
+        base.copy(webServiceWakeLock = true),
+        base.copy(processText = false),
+        base.copy(recordLog = true),
+        base.copy(recordHeapDump = true),
+        base.copy(audioPlayUseWakeLock = true),
+        base.copy(importKeepName = true),
+        base.copy(importKeepGroup = true),
+        base.copy(importKeepEnable = true),
+    )
+}
+
+private fun OtherSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.updateToVariant to updateToVariant,
+    PreferKey.autoCheckUpdateOnStart to autoCheckUpdateOnStart,
+    PreferKey.webServiceAutoStart to webServiceAutoStart,
+    PreferKey.autoRefresh to autoRefresh,
+    PreferKey.defaultToRead to defaultToRead,
+    PreferKey.notificationsPost to notificationsPost,
+    PreferKey.ignoreBatteryPermission to ignoreBatteryPermission,
+    PreferKey.firebaseEnable to firebaseEnable,
+    PreferKey.defaultBookTreeUri to defaultBookTreeUri,
+    PreferKey.antiAlias to antiAlias,
+    PreferKey.replaceEnableDefault to replaceEnableDefault,
+    PreferKey.autoClearExpired to autoClearExpired,
+    PreferKey.showAddToShelfAlert to showAddToShelfAlert,
+    PreferKey.showMangaUi to showMangaUi,
+    PreferKey.webServiceWakeLock to webServiceWakeLock,
+    PreferKey.sourceEditMaxLine to sourceEditMaxLine,
+    PreferKey.webPort to webPort,
+    PreferKey.processText to processText,
+    PreferKey.recordLog to recordLog,
+    PreferKey.recordHeapDump to recordHeapDump,
+    PreferKey.audioPlayWakeLock to audioPlayUseWakeLock,
+    PreferKey.importKeepName to importKeepName,
+    PreferKey.importKeepGroup to importKeepGroup,
+    PreferKey.importKeepEnable to importKeepEnable,
+    PreferKey.fontSort to fontSort,
+)

+ 5 - 7
app/src/test/java/io/legado/app/data/repository/Phase2SettingsMappingTest.kt

@@ -1,7 +1,6 @@
 package io.legado.app.data.repository
 
 import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.mutablePreferencesOf
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.domain.model.settings.BookExportSettings
@@ -10,7 +9,6 @@ import io.legado.app.domain.model.settings.CoverSettings
 import io.legado.app.domain.model.settings.DownloadCacheSettings
 import io.legado.app.domain.model.settings.ImportBookSettings
 import io.legado.app.domain.model.settings.TranslationSettings
-import io.legado.app.help.config.setPrefValue
 import org.junit.Assert.assertEquals
 import org.junit.Test
 
@@ -73,9 +71,11 @@ class Phase2SettingsMappingTest {
 
         assertEquals(
             mapOf(PreferKey.importBookPath to null),
-            settings.diffPrefMap(
-                transform = { it.copy(importBookPath = null) },
+            captureAtomicUpdateValues(
+                current = settings,
+                read = Preferences::toImportBookSettings,
                 toPrefMap = ImportBookSettings::toPrefMap,
+                transform = { it.copy(importBookPath = null) },
             ),
         )
     }
@@ -159,9 +159,7 @@ private fun <T> assertRoundTrips(
     fromPreferences: (Preferences) -> T,
 ) {
     samples.forEach { expected ->
-        val preferences = mutablePreferencesOf().apply {
-            toPrefMap(expected).forEach { (key, value) -> setPrefValue(key, value) }
-        }
+        val preferences = toPrefMap(expected).toTestPreferences()
         assertEquals(expected, fromPreferences(preferences))
     }
 }

+ 113 - 0
app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt

@@ -0,0 +1,113 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReadAloudSettingsMappingTest {
+
+    @Test
+    fun `朗读设置 23 键写映射逐字段对应`() {
+        readAloudMappingSamples().forEach { settings ->
+            assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
+        }
+    }
+
+    @Test
+    fun `朗读设置 23 键读映射逐字段对应`() {
+        readAloudMappingSamples().forEach { expected ->
+            assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toReadAloudSettings())
+        }
+    }
+
+    @Test
+    fun `胶囊坐标与 nullable 引擎通过真实原子路径单批写入`() {
+        val values = captureAtomicUpdateValues(
+            current = ReadAloudSettings(ttsEngine = "engine-old"),
+            read = { it.toReadAloudSettings() },
+            toPrefMap = ReadAloudSettings::toPrefMap,
+            transform = {
+                it.copy(
+                    ttsEngine = null,
+                    capsuleOffsetX = 12.5f,
+                    capsuleOffsetY = -8.25f,
+                )
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.ttsEngine to null,
+                CAPSULE_OFFSET_X to 12.5f,
+                CAPSULE_OFFSET_Y to -8.25f,
+            ),
+            values,
+        )
+    }
+}
+
+private const val CAPSULE_OFFSET_X = "read_aloud_capsule_offset_x"
+private const val CAPSULE_OFFSET_Y = "read_aloud_capsule_offset_y"
+private const val MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
+
+private fun readAloudMappingSamples(): List<ReadAloudSettings> {
+    val base = ReadAloudSettings(
+        ttsEngine = "engine-unique",
+        ttsParagraphInterval = 11,
+        audioCacheCleanTime = 22,
+        capsuleOffsetX = 3.25f,
+        capsuleOffsetY = -4.5f,
+        ttsTimer = 33,
+        ttsSpeechRate = 44,
+        speechAnalysisMode = "ai",
+        defaultInterface = "player",
+        contentSelectSpeakMode = 55,
+        audioPreDownloadNum = 66,
+        capsuleAutoCollapse = false,
+        ttsPreSynthesisConcurrency = 7,
+    )
+    return listOf(
+        base,
+        base.copy(ignoreAudioFocus = true),
+        base.copy(mediaButtonOnExit = false),
+        base.copy(readAloudByMediaButton = true),
+        base.copy(pauseReadAloudWhilePhoneCalls = true),
+        base.copy(readAloudWakeLock = true),
+        base.copy(showReadAloudCapsule = false),
+        base.copy(mediaButtonPerNext = true),
+        base.copy(readAloudByPage = true),
+        base.copy(systemMediaControlCompatibilityChange = false),
+        base.copy(streamReadAloudAudio = true),
+        base.copy(ttsFollowSys = false),
+        base.copy(useMultiSpeaker = false),
+    )
+}
+
+private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.ttsEngine to ttsEngine,
+    PreferKey.ttsParagraphInterval to ttsParagraphInterval,
+    PreferKey.audioCacheCleanTime to audioCacheCleanTime,
+    PreferKey.ignoreAudioFocus to ignoreAudioFocus,
+    PreferKey.mediaButtonOnExit to mediaButtonOnExit,
+    PreferKey.readAloudByMediaButton to readAloudByMediaButton,
+    PreferKey.pauseReadAloudWhilePhoneCalls to pauseReadAloudWhilePhoneCalls,
+    PreferKey.readAloudWakeLock to readAloudWakeLock,
+    PreferKey.showReadAloudCapsule to showReadAloudCapsule,
+    PreferKey.capsuleAutoCollapse to capsuleAutoCollapse,
+    CAPSULE_OFFSET_X to capsuleOffsetX,
+    CAPSULE_OFFSET_Y to capsuleOffsetY,
+    MEDIA_BUTTON_PER_NEXT to mediaButtonPerNext,
+    PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
+    PreferKey.streamReadAloudAudio to streamReadAloudAudio,
+    PreferKey.ttsTimer to ttsTimer,
+    PreferKey.ttsFollowSys to ttsFollowSys,
+    PreferKey.ttsSpeechRate to ttsSpeechRate,
+    PreferKey.speechAnalysisMode to speechAnalysisMode,
+    PreferKey.useMultiSpeaker to useMultiSpeaker,
+    PreferKey.defaultReadAloudInterface to defaultInterface,
+    PreferKey.contentSelectSpeakMod to contentSelectSpeakMode,
+    PreferKey.audioPreDownloadNum to audioPreDownloadNum,
+    PreferKey.ttsPreSynthesisConcurrency to ttsPreSynthesisConcurrency,
+)

+ 150 - 0
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -1,7 +1,9 @@
 package io.legado.app.data.repository
 
 import androidx.datastore.preferences.core.mutablePreferencesOf
+import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurStyle
+import io.legado.app.domain.model.settings.ReadSettings
 import kotlinx.coroutines.flow.MutableStateFlow
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -10,6 +12,53 @@ import org.junit.Test
 
 class ReadSettingsMappingTest {
 
+    @Test
+    fun `gateway 持久化边界固定为显式声明的 45 键`() {
+        val actualKeys = ReadSettings().toGatewayPrefMap().keys
+        val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
+
+        assertEquals(45, actualKeys.size)
+        assertEquals(expectedKeys, actualKeys)
+    }
+
+    @Test
+    fun `阅读设置 gateway 45 键写读映射逐字段对应`() {
+        val repository = ReadSettingsRepository(
+            settingsRepository = SettingsRepository(),
+            preferencesFlow = MutableStateFlow(mutablePreferencesOf()),
+        )
+
+        readSettingsMappingSamples().forEach { expected ->
+            assertEquals(expected.expectedGatewayPrefMap(), expected.toGatewayPrefMap())
+            val actual = with(repository) {
+                expected.expectedGatewayPrefMap().toTestPreferences().toReadSettings()
+            }
+            assertEquals(expected, actual)
+        }
+    }
+
+    @Test
+    fun `PageKeys previous next 通过真实原子路径对称单批写入`() {
+        val repository = ReadSettingsRepository(
+            settingsRepository = SettingsRepository(),
+            preferencesFlow = MutableStateFlow(mutablePreferencesOf()),
+        )
+        val values = captureAtomicUpdateValues(
+            current = ReadSettings(prevKeys = "old-prev", nextKeys = "old-next"),
+            read = { with(repository) { it.toReadSettings() } },
+            toPrefMap = ReadSettings::toGatewayPrefMap,
+            transform = { it.copy(prevKeys = "new-prev", nextKeys = "new-next") },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.prevKeys to "new-prev",
+                PreferKey.nextKeys to "new-next",
+            ),
+            values,
+        )
+    }
+
     @Test
     fun `空快照使用精简阅读菜单默认值`() {
         val preferences = mutablePreferencesOf()
@@ -35,3 +84,104 @@ class ReadSettingsMappingTest {
         assertFalse(settings.titleBarCompact)
     }
 }
+
+private fun readSettingsMappingSamples(): List<ReadSettings> {
+    val base = ReadSettings(
+        screenOrientation = "orientation",
+        keepLight = "keep-light",
+        titleBarMode = "title-mode",
+        readMenuBlurAlpha = 37,
+        showBrightnessView = "brightness-view",
+        brightnessVwPos = "brightness-pos",
+        readBrightness = 73,
+        readSliderMode = "slider-mode",
+        doubleHorizontalPage = "double-page",
+        progressBarBehavior = "chapter",
+        pageTouchSlop = 19,
+        clickImgWay = "click-way",
+        textSelectMenuConfig = "select-menu",
+        prevKeys = "previous-keys",
+        nextKeys = "next-keys",
+        fontFolder = "font-folder",
+        systemTypefaces = 23,
+        preDownloadNum = 29,
+    )
+    return listOf(
+        base,
+        base.copy(hideStatusBar = true),
+        base.copy(hideNavigationBar = true),
+        base.copy(paddingDisplayCutouts = true),
+        base.copy(readBodyToLh = false),
+        base.copy(defaultSourceChangeAll = false),
+        base.copy(textFullJustify = false),
+        base.copy(textBottomJustify = false),
+        base.copy(adaptSpecialStyle = false),
+        base.copy(useZhLayout = true),
+        base.copy(brightnessAuto = true),
+        base.copy(useUnderline = true),
+        base.copy(mouseWheelPage = false),
+        base.copy(volumeKeyPage = false),
+        base.copy(volumeKeyPageOnPlay = false),
+        base.copy(keyPageOnLongPress = true),
+        base.copy(sliderVibrator = true),
+        base.copy(selectVibrator = true),
+        base.copy(autoChangeSource = false),
+        base.copy(autoSuggestDayNight = true),
+        base.copy(selectText = false),
+        base.copy(noAnimScrollPage = true),
+        base.copy(optimizeRender = true),
+        base.copy(disableReturnKey = true),
+        base.copy(showReadTitleAddition = false),
+        base.copy(readUrlInBrowser = true),
+        base.copy(showMenuIcon = true),
+        base.copy(titleBarCompact = true),
+    )
+}
+
+private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.screenOrientation to screenOrientation,
+    PreferKey.keepLight to keepLight,
+    PreferKey.hideStatusBar to hideStatusBar,
+    PreferKey.hideNavigationBar to hideNavigationBar,
+    PreferKey.paddingDisplayCutouts to paddingDisplayCutouts,
+    PreferKey.titleBarMode to titleBarMode,
+    PreferKey.readMenuBlurAlpha to readMenuBlurAlpha,
+    PreferKey.readBodyToLh to readBodyToLh,
+    PreferKey.defaultSourceChangeAll to defaultSourceChangeAll,
+    PreferKey.textFullJustify to textFullJustify,
+    PreferKey.textBottomJustify to textBottomJustify,
+    PreferKey.adaptSpecialStyle to adaptSpecialStyle,
+    PreferKey.useZhLayout to useZhLayout,
+    PreferKey.showBrightnessView to showBrightnessView,
+    PreferKey.brightnessVwPos to brightnessVwPos,
+    PreferKey.brightness to readBrightness,
+    PreferKey.brightnessAuto to brightnessAuto,
+    PreferKey.useUnderline to useUnderline,
+    PreferKey.readSliderMode to readSliderMode,
+    PreferKey.doublePageHorizontal to doubleHorizontalPage,
+    PreferKey.progressBarBehavior to progressBarBehavior,
+    PreferKey.mouseWheelPage to mouseWheelPage,
+    PreferKey.volumeKeyPage to volumeKeyPage,
+    PreferKey.volumeKeyPageOnPlay to volumeKeyPageOnPlay,
+    PreferKey.keyPageOnLongPress to keyPageOnLongPress,
+    PreferKey.pageTouchSlop to pageTouchSlop,
+    PreferKey.sliderVibrator to sliderVibrator,
+    PreferKey.selectVibrator to selectVibrator,
+    PreferKey.autoChangeSource to autoChangeSource,
+    PreferKey.autoSuggestDayNight to autoSuggestDayNight,
+    PreferKey.selectText to selectText,
+    PreferKey.noAnimScrollPage to noAnimScrollPage,
+    PreferKey.clickImgWay to clickImgWay,
+    PreferKey.optimizeRender to optimizeRender,
+    PreferKey.disableReturnKey to disableReturnKey,
+    PreferKey.showReadTitleAddition to showReadTitleAddition,
+    PreferKey.textSelectMenuConfig to textSelectMenuConfig,
+    PreferKey.readUrlOpenInBrowser to readUrlInBrowser,
+    PreferKey.showMenuIcon to showMenuIcon,
+    PreferKey.titleBarCompact to titleBarCompact,
+    PreferKey.prevKeys to prevKeys,
+    PreferKey.nextKeys to nextKeys,
+    PreferKey.fontFolder to fontFolder,
+    PreferKey.systemTypefaces to systemTypefaces,
+    PreferKey.preDownloadNum to preDownloadNum,
+)

+ 276 - 0
app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt

@@ -0,0 +1,276 @@
+package io.legado.app.data.repository
+
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.mutablePreferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import io.legado.app.constant.PreferKey
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.domain.model.settings.ThemeSettings
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class ThemeSettingsMappingTest {
+
+    @Test
+    fun `Theme gateway 持久化边界固定为 59 键`() {
+        val actualKeys = ThemeSettings().toGatewayPrefMap().keys
+        val expectedKeys = ThemeSettings().expectedGatewayPrefMap().keys
+
+        assertEquals(59, actualKeys.size)
+        assertEquals(expectedKeys, actualKeys)
+        assertFalse(PreferKey.customMode in actualKeys)
+        assertFalse(PreferKey.bookInfoInputColor in actualKeys)
+    }
+
+    @Test
+    fun `Theme gateway 59 键写读映射逐字段对应`() {
+        themeMappingSamples().forEach { expected ->
+            assertEquals(expected.expectedGatewayPrefMap(), expected.toGatewayPrefMap())
+            assertEquals(
+                expected,
+                expected.expectedGatewayPrefMap().toTestPreferences().toThemeSettings(),
+            )
+        }
+    }
+
+    @Test
+    fun `gateway 排除字段仍由 Theme 读模型读取`() {
+        val preferences = mutablePreferencesOf(
+            stringPreferencesKey(PreferKey.customMode) to "accent",
+            intPreferencesKey(PreferKey.bookInfoInputColor) to 0x102030,
+        )
+
+        val settings = preferences.toThemeSettings()
+
+        assertEquals("accent", settings.customMode)
+        assertEquals(0x102030, settings.bookInfoInputColor)
+    }
+
+    @Test
+    fun `关闭模糊通过真实原子路径同时关闭渐进模糊`() {
+        val values = captureAtomicUpdateValues(
+            current = ThemeSettings(enableBlur = true, enableProgressiveBlur = true),
+            read = { it.toThemeSettings() },
+            toPrefMap = ThemeSettings::toGatewayPrefMap,
+            transform = {
+                it.copy(enableBlur = false, enableProgressiveBlur = false)
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.enableBlur to false,
+                PreferKey.enableProgressiveBlur to false,
+            ),
+            values,
+        )
+    }
+
+    @Test
+    fun `Miuix Monet 根据原子路径当前主题决定是否回退`() {
+        fun enableMiuixMonet(current: ThemeSettings) = captureAtomicUpdateValues(
+            current = current,
+            read = { it.toThemeSettings() },
+            toPrefMap = ThemeSettings::toGatewayPrefMap,
+            transform = {
+                it.copy(
+                    useMiuixMonet = true,
+                    appTheme = if (it.appTheme != "0" && it.appTheme != "12") {
+                        "0"
+                    } else {
+                        it.appTheme
+                    },
+                )
+            },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.appTheme to "0",
+                PreferKey.useMiuixMonet to true,
+            ),
+            enableMiuixMonet(ThemeSettings(appTheme = "7")),
+        )
+        assertEquals(
+            mapOf(PreferKey.useMiuixMonet to true),
+            enableMiuixMonet(ThemeSettings(appTheme = "12")),
+        )
+    }
+
+    @Test
+    fun `透明主题通过真实原子路径同时写主题与容器透明度`() {
+        val values = captureAtomicUpdateValues(
+            current = ThemeSettings(appTheme = "0", containerOpacity = 80),
+            read = { it.toThemeSettings() },
+            toPrefMap = ThemeSettings::toGatewayPrefMap,
+            transform = { it.copy(appTheme = "13", containerOpacity = 0) },
+        )
+
+        assertEquals(
+            mapOf(
+                PreferKey.appTheme to "13",
+                PreferKey.containerOpacity to 0,
+            ),
+            values,
+        )
+    }
+
+    @Test
+    fun `清空背景路径通过真实原子路径删除对应键`() {
+        val values = captureAtomicUpdateValues(
+            current = ThemeSettings(backgroundImageLight = "old-background"),
+            read = { it.toThemeSettings() },
+            toPrefMap = ThemeSettings::toGatewayPrefMap,
+            transform = { it.copy(backgroundImageLight = null) },
+        )
+
+        assertEquals(mapOf(PreferKey.bgImage to null), values)
+    }
+}
+
+private fun themeMappingSamples(): List<ThemeSettings> {
+    val base = ThemeSettings(
+        appTheme = "app-theme",
+        useMiuixMonet = false,
+        isPureBlack = false,
+        paletteStyle = "palette-style",
+        materialVersion = "material-version",
+        customContrast = "custom-contrast",
+        customMode = "tonalSpot",
+        appFontPath = "app-font",
+        customPrimary = 101,
+        customNightPrimary = 102,
+        enableDeepPersonalization = false,
+        themeColor = 103,
+        secondaryThemeColor = 104,
+        primaryTextColor = 105,
+        secondaryTextColor = 106,
+        themeBackgroundColor = 107,
+        labelContainerColor = 108,
+        themeColorNight = 109,
+        secondaryThemeColorNight = 110,
+        primaryTextColorNight = 111,
+        secondaryTextColorNight = 112,
+        themeBackgroundColorNight = 113,
+        labelContainerColorNight = 114,
+        containerOpacity = 115,
+        overrideBaseCardCornerRadius = false,
+        baseCardCornerRadius = 1.25f,
+        overrideBaseCardBorder = false,
+        baseCardBorderWidth = 2.5f,
+        baseCardBorderColor = 116,
+        baseCardBorderColorNight = 117,
+        disableSplicedColumnGroupCornerRadius = false,
+        topBarOpacity = 118,
+        bottomBarOpacity = 119,
+        enableBlur = false,
+        enableProgressiveBlur = false,
+        topBarBlurRadius = 120,
+        bottomBarBlurRadius = 121,
+        topBarBlurAlpha = 122,
+        bottomBarBlurAlpha = 123,
+        bottomBarLensRadius = 3.75f,
+        useFlexibleTopAppBar = false,
+        bookInfoFollowCoverColor = false,
+        bookInfoNetworkCoverBackground = "network-background",
+        bookInfoDefaultCoverBackground = "default-background",
+        bookInfoInputColor = 0,
+        backgroundImageLight = "light-background",
+        backgroundImageDark = "dark-background",
+        backgroundImageBlurring = 124,
+        backgroundImageDarkBlurring = 125,
+        enableItemDivider = false,
+        itemDividerWidth = 4.5f,
+        itemDividerLength = 5.75f,
+        itemDividerColor = 126,
+        eyeProtectionEnabled = false,
+        colorTemperature = 127,
+        eyeProtectionSchedule = false,
+        eyeProtectionStartTime = "start-time",
+        eyeProtectionEndTime = "end-time",
+        showRefactorTip = false,
+        enableCustomTagColors = false,
+        customTagColorsJson = "tag-colors",
+    )
+    return listOf(
+        base,
+        base.copy(useMiuixMonet = true),
+        base.copy(isPureBlack = true),
+        base.copy(enableDeepPersonalization = true),
+        base.copy(overrideBaseCardCornerRadius = true),
+        base.copy(overrideBaseCardBorder = true),
+        base.copy(disableSplicedColumnGroupCornerRadius = true),
+        base.copy(enableBlur = true),
+        base.copy(enableProgressiveBlur = true),
+        base.copy(useFlexibleTopAppBar = true),
+        base.copy(bookInfoFollowCoverColor = true),
+        base.copy(enableItemDivider = true),
+        base.copy(eyeProtectionEnabled = true),
+        base.copy(eyeProtectionSchedule = true),
+        base.copy(showRefactorTip = true),
+        base.copy(enableCustomTagColors = true),
+    )
+}
+
+private fun ThemeSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
+    PreferKey.appTheme to appTheme,
+    PreferKey.useMiuixMonet to useMiuixMonet,
+    PreferKey.pureBlack to isPureBlack,
+    PreferKey.paletteStyle to paletteStyle,
+    PreferKey.materialVersion to materialVersion,
+    PreferKey.customContrast to customContrast,
+    PreferKey.appFontPath to appFontPath,
+    PreferKey.cPrimary to customPrimary,
+    PreferKey.cNPrimary to customNightPrimary,
+    PreferKey.enableDeepPersonalization to enableDeepPersonalization,
+    PreferKey.themeColor to themeColor,
+    PreferKey.secondaryThemeColor to secondaryThemeColor,
+    PreferKey.primaryTextColor to primaryTextColor,
+    PreferKey.secondaryTextColor to secondaryTextColor,
+    PreferKey.themeBackgroundColor to themeBackgroundColor,
+    PreferKey.labelContainerColor to labelContainerColor,
+    PreferKey.themeColorNight to themeColorNight,
+    PreferKey.secondaryThemeColorNight to secondaryThemeColorNight,
+    PreferKey.primaryTextColorNight to primaryTextColorNight,
+    PreferKey.secondaryTextColorNight to secondaryTextColorNight,
+    PreferKey.themeBackgroundColorNight to themeBackgroundColorNight,
+    PreferKey.labelContainerColorNight to labelContainerColorNight,
+    PreferKey.containerOpacity to containerOpacity,
+    PreferKey.overrideBaseCardCornerRadius to overrideBaseCardCornerRadius,
+    PreferKey.baseCardCornerRadius to baseCardCornerRadius,
+    PreferKey.overrideBaseCardBorder to overrideBaseCardBorder,
+    PreferKey.baseCardBorderWidth to baseCardBorderWidth,
+    PreferKey.baseCardBorderColor to baseCardBorderColor,
+    PreferKey.baseCardBorderColorNight to baseCardBorderColorNight,
+    PreferKey.disableSplicedColumnGroupCornerRadius to disableSplicedColumnGroupCornerRadius,
+    PreferKey.topBarOpacity to topBarOpacity,
+    PreferKey.bottomBarOpacity to bottomBarOpacity,
+    PreferKey.enableBlur to enableBlur,
+    PreferKey.enableProgressiveBlur to enableProgressiveBlur,
+    PreferKey.topBarBlurRadius to topBarBlurRadius,
+    PreferKey.bottomBarBlurRadius to bottomBarBlurRadius,
+    PreferKey.topBarBlurAlpha to topBarBlurAlpha,
+    PreferKey.bottomBarBlurAlpha to bottomBarBlurAlpha,
+    PreferKey.bottomBarLensRadius to bottomBarLensRadius,
+    PreferKey.useFlexibleTopAppBar to useFlexibleTopAppBar,
+    PreferKey.bookInfoFollowCoverColor to bookInfoFollowCoverColor,
+    PreferKey.bookInfoNetworkCoverBackground to bookInfoNetworkCoverBackground,
+    PreferKey.bookInfoDefaultCoverBackground to bookInfoDefaultCoverBackground,
+    PreferKey.bgImage to backgroundImageLight,
+    PreferKey.bgImageN to backgroundImageDark,
+    PreferKey.bgImageBlurring to backgroundImageBlurring,
+    PreferKey.bgImageNBlurring to backgroundImageDarkBlurring,
+    PreferKey.enableItemDivider to enableItemDivider,
+    PreferKey.itemDividerWidth to itemDividerWidth,
+    PreferKey.itemDividerLength to itemDividerLength,
+    PreferKey.itemDividerColor to itemDividerColor,
+    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
+    PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
+    PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
+    PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,
+    LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP.name to showRefactorTip,
+    PreferKey.enableCustomTagColors to enableCustomTagColors,
+    PreferKey.customTagColors to customTagColorsJson,
+)

+ 119 - 1
app/src/test/java/io/legado/app/help/config/PendingOverlayCoreTest.kt

@@ -1,15 +1,20 @@
 package io.legado.app.help.config
 
 import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.mutablePreferencesOf
 import androidx.datastore.preferences.core.stringPreferencesKey
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull
 import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
 
 /**
  * 覆盖 PR-1 合入门禁要求的 pending overlay 三个竞态时序:
@@ -17,6 +22,11 @@ import org.junit.Test
  */
 class PendingOverlayCoreTest {
 
+    private data class CoupledSettings(
+        val eInk: Boolean = false,
+        val gray: Boolean = false,
+    )
+
     /** 模拟的 DataStore 落盘状态 + 可手动执行的写队列 */
     private class Harness(initial: Preferences) {
         var dsState: Preferences = initial
@@ -254,4 +264,112 @@ class PendingOverlayCoreTest {
         assertEquals("disk full", result.await().exceptionOrNull()?.message)
         assertEquals(1, core.preferencesFlow.value.compatDsInt("k"))
     }
+
+    @Test
+    fun `原子 transform 串行读取 overlay 并保持跨字段不变式`() {
+        val core = PendingOverlayCore(
+            initial = CoupledSettings().toPreferences(),
+            launchWrite = {},
+            persist = { _, _ -> error("不会执行落盘") },
+            persistAll = { error("不会执行落盘") },
+        )
+        val firstEntered = CountDownLatch(1)
+        val releaseFirst = CountDownLatch(1)
+        val first = thread {
+            core.atomicUpdate(
+                read = { it.toCoupledSettings() },
+                toPrefMap = { it.toPrefMap() },
+            ) {
+                firstEntered.countDown()
+                releaseFirst.await()
+                it.copy(eInk = true, gray = false)
+            }
+        }
+        firstEntered.await()
+
+        val secondStarted = CountDownLatch(1)
+        val secondEntered = CountDownLatch(1)
+        val second = thread {
+            secondStarted.countDown()
+            core.atomicUpdate(
+                read = { it.toCoupledSettings() },
+                toPrefMap = { it.toPrefMap() },
+            ) {
+                secondEntered.countDown()
+                it.copy(eInk = false, gray = true)
+            }
+        }
+        secondStarted.await()
+        val secondRacedWithFirst = secondEntered.await(200, TimeUnit.MILLISECONDS)
+        releaseFirst.countDown()
+        first.join()
+        second.join()
+
+        assertFalse(secondRacedWithFirst)
+        assertEquals(
+            CoupledSettings(eInk = false, gray = true),
+            core.preferencesFlow.value.toCoupledSettings(),
+        )
+    }
+
+    @Test
+    fun `等待原子 transform 落盘失败会回滚并传给调用方`() = runBlocking {
+        val initial = mutablePreferencesOf(intPreferencesKey("k") to 1)
+        val writeQueue = ArrayDeque<suspend () -> Unit>()
+        val core = PendingOverlayCore(
+            initial = initial,
+            launchWrite = { writeQueue += it },
+            persist = { _, _ -> error("unused") },
+            persistAll = { throw java.io.IOException("atomic disk full") },
+        )
+
+        val result = async(start = CoroutineStart.UNDISPATCHED) {
+            runCatching {
+                core.atomicUpdateAndAwait<Int>(
+                    read = { preferences -> preferences.compatDsInt("k") ?: 0 },
+                    toPrefMap = { value -> mapOf("k" to value) },
+                    transform = { it + 1 },
+                )
+            }
+        }
+        assertEquals(2, core.preferencesFlow.value.compatDsInt("k"))
+
+        writeQueue.removeFirst().invoke()
+
+        assertEquals("atomic disk full", result.await().exceptionOrNull()?.message)
+        assertEquals(1, core.preferencesFlow.value.compatDsInt("k"))
+    }
+
+    @Test
+    fun `等待原子 transform 无差量时不入队并立即返回`() = runBlocking {
+        val core = PendingOverlayCore(
+            initial = mutablePreferencesOf(intPreferencesKey("k") to 1),
+            launchWrite = { error("无差量不应入队") },
+            persist = { _, _ -> error("unused") },
+            persistAll = { error("unused") },
+        )
+
+        core.atomicUpdateAndAwait(
+            read = { preferences -> preferences.compatDsInt("k") ?: 0 },
+            toPrefMap = { value -> mapOf("k" to value) },
+            transform = { it },
+        )
+
+        assertEquals(1, core.preferencesFlow.value.compatDsInt("k"))
+    }
+
+    private fun Preferences.toCoupledSettings(): CoupledSettings = CoupledSettings(
+        eInk = compatDsBoolean("eInk") ?: false,
+        gray = compatDsBoolean("gray") ?: false,
+    )
+
+    private fun CoupledSettings.toPrefMap(): Map<String, Any?> = mapOf(
+        "eInk" to eInk,
+        "gray" to gray,
+    )
+
+    private fun CoupledSettings.toPreferences(): Preferences = mutablePreferencesOf(
+        booleanPreferencesKey("eInk") to eInk,
+        booleanPreferencesKey("gray") to gray,
+    )
 }

+ 3 - 2
app/src/test/java/io/legado/app/ui/about/AboutViewModelTest.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.about
 import android.app.Application
 import android.os.Looper
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.model.settings.OtherSettings
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,6 +50,8 @@ class AboutViewModelTest {
             state.value = state.value.copy(updateToVariant = variant)
         }
 
-        override suspend fun update(update: OtherSettingsUpdate) = Unit
+        override suspend fun update(transform: (OtherSettings) -> OtherSettings) {
+            state.value = transform(state.value)
+        }
     }
 }

+ 7 - 6
app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

@@ -12,9 +12,7 @@ import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.LocalPasswordGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsUpdate
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
 import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.domain.model.settings.DownloadCacheSettings
@@ -192,16 +190,15 @@ class OtherConfigViewModelTest {
 
     private class FakeOtherSettingsGateway : OtherSettingsGateway {
         private val state = MutableStateFlow(OtherSettings())
-        val updates = mutableListOf<OtherSettingsUpdate>()
         var failure: Throwable? = null
 
         override val currentSettings: OtherSettings
             get() = state.value
         override val settings: Flow<OtherSettings> = state
 
-        override suspend fun update(update: OtherSettingsUpdate) {
+        override suspend fun update(transform: (OtherSettings) -> OtherSettings) {
             failure?.let { throw it }
-            updates += update
+            state.value = transform(state.value)
         }
     }
 
@@ -212,7 +209,11 @@ class OtherConfigViewModelTest {
             get() = state.value
         override val settings: Flow<ReadAloudSettings> = state
 
-        override suspend fun update(update: ReadAloudSettingsUpdate) = Unit
+        override suspend fun update(
+            transform: (ReadAloudSettings) -> ReadAloudSettings,
+        ) {
+            state.value = transform(state.value)
+        }
     }
 
     private class FakeDownloadCacheSettingsGateway : DownloadCacheSettingsGateway {

+ 14 - 1
build.gradle.kts

@@ -37,6 +37,10 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
         val readBookConfigMutationCall = Regex(
             """\bReadBookConfig\.durConfig\.set[A-Za-z0-9_]*\s*\("""
         )
+        val settingsUpdateDeclaration = Regex(
+            """\b(?:class|interface|object|typealias)\s+[A-Za-z0-9_]*SettingsUpdate\b"""
+        )
+        val updateAllDeclaration = Regex("""\bfun\s+(?:<[^>\n]+>\s*)?updateAll\s*\(""")
         val injectedConfigFiles = setOf(
             "io/legado/app/help/config/AppConfig.kt",
             "io/legado/app/help/config/ReadBookConfig.kt",
@@ -81,6 +85,15 @@ abstract class VerifyConfigArchitectureTask : DefaultTask() {
             if (relativePath in injectedConfigFiles && "GlobalContext" in text) {
                 violations += "$displayPath: 配置所有者必须显式注入依赖,禁止 GlobalContext"
             }
+            if (settingsUpdateDeclaration.containsMatchIn(text)) {
+                violations += "$displayPath: 设置网关禁止重新引入 *SettingsUpdate 分发类型"
+            }
+            if (relativePath.startsWith("io/legado/app/domain/gateway/") &&
+                file.name.endsWith("SettingsGateway.kt") &&
+                updateAllDeclaration.containsMatchIn(text)
+            ) {
+                violations += "$displayPath: 设置网关批量修改必须使用单次 update { copy(...) }"
+            }
 
             val preferenceCalls = preferenceCall.findAll(text).count()
             val allowedCalls = preferenceBaseline[relativePath] ?: 0
@@ -124,7 +137,7 @@ val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
     "verifyConfigArchitecture"
 ) {
     group = "verification"
-    description = "禁止配置 Snapshot 桥、跨层全局 Config 直读和新增旧偏好调用"
+    description = "禁止配置 Snapshot 桥、SettingsUpdate 分发、跨层全局 Config 直读和新增旧偏好调用"
     sourceRoot.set(layout.projectDirectory.dir("app/src/main/java"))
     legacyPreferenceCallBaseline.set(
         mapOf(