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

refactor: 护眼模式收口到一套设置

护眼此前分裂在两个领域模型里:ThemeSettings 持有开关/色温/定时,ReadSettings
另有 eyeProtectionEnabled/eyeProtectionIntensity/eyeProtectionAutoNight。二者
虽然落在同一批 DataStore key 上,但模型、UI 和可配置项都各管一段——外观页看不到
"跟随深色",阅读器一侧看不到定时,滤镜只认 ThemeSettings。

统一为 ThemeSettings 单一来源:
- eyeProtectionAutoNight 移入 ThemeSettings,ReadSettings 删除三个重复字段;
- 阅读菜单/阅读设置/外观三处共用同一组值,EyeProtectionConfigSheet 补齐定时
  与起止时间,外观页补齐"跟随深色模式";
- 阅读器侧的护眼状态改由 ThemeSettingsGateway 供给(ReadBookUiState.eyeProtection),
  写入统一走 themeSettingsGateway.update;
- 定时时间选择器提取为共享的 TimePickerDialog,两处复用;
- 定时相关的三个 key 补进备份白名单。

顺带修好阅读设置里点「护眼模式」打不开的问题:OpenEyeProtection 没有在
onIntent 里分支,落到 updateSetting 后被 toSettingsTransform 抛错吞掉。

键数边界:ReadSettings 48→45,ThemeSettings 59→60,同步更新映射测试。
5151561 3 месяцев назад
Родитель
Сommit
2236515834
20 измененных файлов с 316 добавлено и 128 удалено
  1. 3 1
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  2. 0 9
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  3. 1 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 0 3
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  5. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  6. 3 0
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  7. 15 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  8. 4 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  9. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  10. 9 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  11. 46 24
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 83 12
      app/src/main/java/io/legado/app/ui/book/read/sheet/EyeProtectionConfigSheet.kt
  13. 2 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt
  14. 5 3
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt
  15. 10 4
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt
  16. 63 15
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
  17. 10 36
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  18. 51 0
      app/src/main/java/io/legado/app/ui/widget/components/dialog/TimePickerDialog.kt
  19. 3 10
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  20. 6 3
      app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt

+ 3 - 1
app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt

@@ -391,6 +391,7 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
     itemDividerColor = compatDsInt(PreferKey.itemDividerColor) ?: 0,
     eyeProtectionEnabled = compatDsBoolean(PreferKey.eyeProtectionEnabled) ?: false,
     colorTemperature = compatDsInt(PreferKey.colorTemperature) ?: 50,
+    eyeProtectionAutoNight = compatDsBoolean(PreferKey.eyeProtectionAutoNight) ?: false,
     eyeProtectionSchedule = compatDsBoolean(PreferKey.eyeProtectionSchedule) ?: false,
     eyeProtectionStartTime = compatDsString(PreferKey.eyeProtectionStartTime) ?: "22:00",
     eyeProtectionEndTime = compatDsString(PreferKey.eyeProtectionEndTime) ?: "07:00",
@@ -402,7 +403,7 @@ internal fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
 )
 
 /**
- * Theme gateway 的 59 键写入边界。主题包专用的 [ThemeSettings.customMode] 与
+ * Theme gateway 的 60 键写入边界。主题包专用的 [ThemeSettings.customMode] 与
  * [ThemeSettings.bookInfoInputColor] 由 ThemePackageSettingsGateway 的事务路径持久化。
  */
 internal fun ThemeSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
@@ -459,6 +460,7 @@ internal fun ThemeSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.itemDividerColor to itemDividerColor,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
     PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
     PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
     PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,

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

@@ -344,9 +344,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),
@@ -459,9 +456,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)
@@ -576,9 +570,6 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
-    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
-    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

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

@@ -601,6 +601,7 @@ val appModule = module {
             otherSettingsGateway = get(),
             downloadCacheSettingsGateway = get(),
             backupSettingsGateway = get(),
+            themeSettingsGateway = get(),
         )
     }
     viewModelOf(::ChangeCoverViewModel)

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

@@ -17,9 +17,6 @@ data class ReadSettings(
     val textBottomJustify: Boolean = true,
     val adaptSpecialStyle: Boolean = true,
     val useZhLayout: Boolean = false,
-    val eyeProtectionEnabled: Boolean = false,
-    val eyeProtectionIntensity: Int = 50,
-    val eyeProtectionAutoNight: Boolean = false,
     val showBrightnessView: String = "0",
     val brightnessVwPos: String = "1",
     val readBrightness: Int = 100,

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

@@ -56,6 +56,7 @@ data class ThemeSettings(
     val itemDividerColor: Int = 0,
     val eyeProtectionEnabled: Boolean = false,
     val colorTemperature: Int = 50,
+    val eyeProtectionAutoNight: Boolean = false,
     val eyeProtectionSchedule: Boolean = false,
     val eyeProtectionStartTime: String = "22:00",
     val eyeProtectionEndTime: String = "07:00",

+ 3 - 0
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -249,6 +249,9 @@ object BackupConfig {
         PreferKey.eyeProtectionEnabled,
         PreferKey.colorTemperature,
         PreferKey.eyeProtectionAutoNight,
+        PreferKey.eyeProtectionSchedule,
+        PreferKey.eyeProtectionStartTime,
+        PreferKey.eyeProtectionEndTime,
     )
 
     private val themePrefKeys = arrayOf(

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

@@ -330,11 +330,23 @@ data class ReadBookUiState(
     val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
     val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
     val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+    val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
 ) {
     val menuVisible: Boolean
         get() = menuState.visible
 }
 
+/** 护眼模式设置,来源是 ThemeSettings,与外观设置共用同一份值。 */
+@Stable
+data class EyeProtectionUiState(
+    val enabled: Boolean = false,
+    val intensity: Int = 50,
+    val autoNight: Boolean = false,
+    val schedule: Boolean = false,
+    val startTime: String = "22:00",
+    val endTime: String = "07:00",
+)
+
 @Stable
 data class HighlightRuleConfigUiState(
     val rules: ImmutableList<HighlightRule> = persistentListOf(),
@@ -665,6 +677,9 @@ sealed interface ReadBookIntent {
     data class EyeProtectionEnabledChanged(val value: Boolean) : ReadBookIntent
     data class EyeProtectionIntensityChanged(val value: Int) : ReadBookIntent
     data class EyeProtectionAutoNightChanged(val value: Boolean) : ReadBookIntent
+    data class EyeProtectionScheduleChanged(val value: Boolean) : ReadBookIntent
+    data class EyeProtectionStartTimeChanged(val value: String) : ReadBookIntent
+    data class EyeProtectionEndTimeChanged(val value: String) : ReadBookIntent
     data class SyncEyeProtectionForTheme(val isNight: Boolean) : ReadBookIntent
 
     // Default font picker (needs Activity for AlertDialog)

+ 4 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -672,7 +672,7 @@ private fun ReadBookMenuSurface(
                     ReadBookMenuRoute.Main -> {
                     MenuBottomBar(
                         state = state,
-                        eyeProtectionEnabled = preferences.eyeProtectionEnabled,
+                        eyeProtectionEnabled = state.eyeProtection.enabled,
                         colors = colors,
                         onIntent = onIntent,
                         context = context,
@@ -720,6 +720,7 @@ private fun ReadBookMenuSurface(
                                 readMenuCustomIcons = state.menuConfig.readMenuCustomIcons,
                                 bottomBarButtons = state.menuConfig.bottomBarButtons,
                                 preferences = preferences,
+                                eyeProtectionEnabled = state.eyeProtection.enabled,
                                 onIntent = onIntent,
                                 styleConfig = state.styleConfig,
                             )
@@ -1946,7 +1947,7 @@ private fun FloatingIconRow(
         state.isAutoPage,
         state.translationMode,
         state.useReplaceRule,
-        preferences.eyeProtectionEnabled,
+        state.eyeProtection.enabled,
     ) {
         loadFloatingIcons(
             context = context,
@@ -3383,7 +3384,7 @@ private fun loadFloatingIcons(
         if (state.isAutoPage) add("auto_page")
         if (state.translationMode) add("translate")
         if (state.useReplaceRule) add("replace")
-        if (preferences.eyeProtectionEnabled) add("eye_protection")
+        if (state.eyeProtection.enabled) add("eye_protection")
     }
 
     return state.menuConfig.titleBarButtons

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

@@ -155,7 +155,7 @@ fun ReadBookRouteScreen(
         controller.onMenuVisibilityChanged(state.menuVisible)
     }
 
-    LaunchedEffect(isDarkTheme, readPreferences.eyeProtectionAutoNight) {
+    LaunchedEffect(isDarkTheme, state.eyeProtection.autoNight) {
         viewModel.onIntent(ReadBookIntent.SyncEyeProtectionForTheme(isDarkTheme))
     }
 

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

@@ -240,13 +240,19 @@ fun ReadBookScreen(
     )
     EyeProtectionConfigSheet(
         show = state.activeSheet is ReadBookSheet.EyeProtection,
-        enabled = preferences.eyeProtectionEnabled,
-        intensity = preferences.eyeProtectionIntensity,
-        autoNight = preferences.eyeProtectionAutoNight,
+        enabled = state.eyeProtection.enabled,
+        intensity = state.eyeProtection.intensity,
+        autoNight = state.eyeProtection.autoNight,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
         onDismissRequest = dismissSheet,
         onEnabledChange = { onIntent(ReadBookIntent.EyeProtectionEnabledChanged(it)) },
         onIntensityChange = { onIntent(ReadBookIntent.EyeProtectionIntensityChanged(it)) },
         onAutoNightChange = { onIntent(ReadBookIntent.EyeProtectionAutoNightChanged(it)) },
+        onScheduleChange = { onIntent(ReadBookIntent.EyeProtectionScheduleChanged(it)) },
+        onStartTimeChange = { onIntent(ReadBookIntent.EyeProtectionStartTimeChanged(it)) },
+        onEndTimeChange = { onIntent(ReadBookIntent.EyeProtectionEndTimeChanged(it)) },
     )
     FloatingBarIconConfigSheet(
         show = state.activeSheet is ReadBookSheet.FloatingBarIconConfig,

+ 46 - 24
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -54,7 +54,9 @@ import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ReadStyleIntKey
 import io.legado.app.domain.gateway.ReadStyleMutation
 import io.legado.app.domain.gateway.ReadStyleStringKey
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.TextProcessAction
@@ -197,6 +199,7 @@ class ReadBookViewModel(
     private val otherSettingsGateway: OtherSettingsGateway,
     private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
     private val backupSettingsGateway: BackupSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
     // --- MVI State ---
@@ -263,6 +266,7 @@ class ReadBookViewModel(
         ReadBook.register(this)
         refreshButtonConfigs()
         collectReadPreferences()
+        collectEyeProtectionSettings()
         collectReadAloudPreferences()
         collectEventBus()
         execute { syncConfiguredTtsVoices() }
@@ -1577,31 +1581,30 @@ class ReadBookViewModel(
             is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri)
 
             ReadBookIntent.ToggleEyeProtection -> toggleEyeProtection()
-            is ReadBookIntent.EyeProtectionEnabledChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionEnabled = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionEnabledChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEnabled = intent.value)
             }
-            is ReadBookIntent.EyeProtectionIntensityChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionIntensity = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionIntensityChanged -> updateEyeProtection {
+                it.copy(colorTemperature = intent.value.coerceIn(0, 100))
             }
-            is ReadBookIntent.EyeProtectionAutoNightChanged -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update { it.copy(eyeProtectionAutoNight = intent.value) }
-                }
+            is ReadBookIntent.EyeProtectionAutoNightChanged -> updateEyeProtection {
+                it.copy(eyeProtectionAutoNight = intent.value)
+            }
+            is ReadBookIntent.EyeProtectionScheduleChanged -> updateEyeProtection {
+                it.copy(eyeProtectionSchedule = intent.value)
+            }
+            is ReadBookIntent.EyeProtectionStartTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionStartTime = intent.value)
+            }
+            is ReadBookIntent.EyeProtectionEndTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEndTime = intent.value)
             }
             is ReadBookIntent.SyncEyeProtectionForTheme -> {
                 EyeProtection.syncEnabledForNight(
                     isNight = intent.isNight,
-                    autoNight = _readPreferences.value.eyeProtectionAutoNight,
+                    autoNight = _uiState.value.eyeProtection.autoNight,
                 )?.let { enabled ->
-                    viewModelScope.launch {
-                        readSettingsRepository.update {
-                            it.copy(eyeProtectionEnabled = enabled)
-                        }
-                    }
+                    updateEyeProtection { it.copy(eyeProtectionEnabled = enabled) }
                 }
             }
         }
@@ -2247,6 +2250,25 @@ class ReadBookViewModel(
         }
     }
 
+    private fun collectEyeProtectionSettings() {
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                _uiState.update {
+                    it.copy(
+                        eyeProtection = EyeProtectionUiState(
+                            enabled = settings.eyeProtectionEnabled,
+                            intensity = settings.colorTemperature,
+                            autoNight = settings.eyeProtectionAutoNight,
+                            schedule = settings.eyeProtectionSchedule,
+                            startTime = settings.eyeProtectionStartTime,
+                            endTime = settings.eyeProtectionEndTime,
+                        )
+                    )
+                }
+            }
+        }
+    }
+
     private suspend fun syncReadPreferencesSnapshot() {
         val preferences = readSettingsRepository.preferences.first()
         _readPreferences.value = preferences
@@ -2319,12 +2341,12 @@ class ReadBookViewModel(
         }
     }
 
-    private fun toggleEyeProtection() {
-        viewModelScope.launch {
-            readSettingsRepository.update {
-                it.copy(eyeProtectionEnabled = !_readPreferences.value.eyeProtectionEnabled)
-            }
-        }
+    private fun toggleEyeProtection() = updateEyeProtection {
+        it.copy(eyeProtectionEnabled = !_uiState.value.eyeProtection.enabled)
+    }
+
+    private fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {
+        viewModelScope.launch { themeSettingsGateway.update(transform) }
     }
 
     private fun ReadPreferences.hasMenuClickArea(): Boolean {

+ 83 - 12
app/src/main/java/io/legado/app/ui/book/read/sheet/EyeProtectionConfigSheet.kt

@@ -1,23 +1,44 @@
 package io.legado.app.ui.book.read.sheet
 
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.res.stringResource
 import io.legado.app.R
+import io.legado.app.ui.widget.components.dialog.TimePickerDialog
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 
+/**
+ * 护眼模式设置。阅读菜单与阅读设置共用这一份,字段全部落在 ThemeSettings 上,
+ * 与外观设置里的护眼分组是同一套值。
+ */
 @Composable
 fun EyeProtectionConfigSheet(
     show: Boolean,
     enabled: Boolean,
     intensity: Int,
     autoNight: Boolean,
+    schedule: Boolean,
+    startTime: String,
+    endTime: String,
     onDismissRequest: () -> Unit,
     onEnabledChange: (Boolean) -> Unit,
     onIntensityChange: (Int) -> Unit,
     onAutoNightChange: (Boolean) -> Unit,
+    onScheduleChange: (Boolean) -> Unit,
+    onStartTimeChange: (String) -> Unit,
+    onEndTimeChange: (String) -> Unit,
 ) {
+    var editingStartTime by remember { mutableStateOf(false) }
+    var editingEndTime by remember { mutableStateOf(false) }
+
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
@@ -29,19 +50,69 @@ fun EyeProtectionConfigSheet(
             checked = enabled,
             onCheckedChange = onEnabledChange,
         )
-        SliderSettingItem(
-            title = stringResource(R.string.eye_protection_intensity),
-            description = stringResource(R.string.eye_protection_intensity_summary, intensity),
-            value = intensity.toFloat(),
-            defaultValue = 50f,
-            valueRange = 0f..100f,
-            onValueChange = { onIntensityChange(it.toInt()) },
+        AnimatedVisibility(visible = enabled) {
+            Column {
+                SliderSettingItem(
+                    title = stringResource(R.string.eye_protection_intensity),
+                    description = stringResource(
+                        R.string.eye_protection_intensity_summary,
+                        intensity
+                    ),
+                    value = intensity.toFloat(),
+                    defaultValue = 50f,
+                    valueRange = 0f..100f,
+                    onValueChange = { onIntensityChange(it.toInt()) },
+                )
+                SwitchSettingItem(
+                    title = stringResource(R.string.eye_protection_auto_night),
+                    description = stringResource(R.string.eye_protection_auto_night_summary),
+                    checked = autoNight,
+                    onCheckedChange = onAutoNightChange,
+                )
+                SwitchSettingItem(
+                    title = stringResource(R.string.eye_protection_schedule),
+                    description = stringResource(R.string.eye_protection_schedule_summary),
+                    checked = schedule,
+                    onCheckedChange = onScheduleChange,
+                )
+                AnimatedVisibility(visible = schedule) {
+                    Column {
+                        ClickableSettingItem(
+                            title = stringResource(R.string.eye_protection_start_time),
+                            option = startTime,
+                            onClick = { editingStartTime = true },
+                        )
+                        ClickableSettingItem(
+                            title = stringResource(R.string.eye_protection_end_time),
+                            option = endTime,
+                            onClick = { editingEndTime = true },
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    if (editingStartTime) {
+        TimePickerDialog(
+            title = stringResource(R.string.eye_protection_start_time),
+            currentValue = startTime,
+            onDismissRequest = { editingStartTime = false },
+            onConfirm = {
+                onStartTimeChange(it)
+                editingStartTime = false
+            },
         )
-        SwitchSettingItem(
-            title = stringResource(R.string.eye_protection_auto_night),
-            description = stringResource(R.string.eye_protection_auto_night_summary),
-            checked = autoNight,
-            onCheckedChange = onAutoNightChange,
+    }
+    if (editingEndTime) {
+        TimePickerDialog(
+            title = stringResource(R.string.eye_protection_end_time),
+            currentValue = endTime,
+            onDismissRequest = { editingEndTime = false },
+            onConfirm = {
+                onEndTimeChange(it)
+                editingEndTime = false
+            },
         )
     }
 }

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

@@ -49,6 +49,7 @@ fun ReadStyleContent(
     readMenuCustomIcons: Map<String, String> = emptyMap(),
     bottomBarButtons: List<ReadBookButtonConfigItem> = emptyList(),
     preferences: ReadPreferences,
+    eyeProtectionEnabled: Boolean,
     modifier: Modifier = Modifier,
     onIntent: (ReadBookIntent) -> Unit,
     styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
@@ -94,7 +95,7 @@ fun ReadStyleContent(
                 when (page) {
                     0 -> GlobalThemePage(
                         onToggleDayNight = onToggleDayNight,
-                        eyeProtectionEnabled = preferences.eyeProtectionEnabled,
+                        eyeProtectionEnabled = eyeProtectionEnabled,
                         onOpenBgTextConfig = onOpenBgTextConfig,
                         onOpenTextTitle = onOpenTextTitle,
                         onOpenPaddingConfig = onOpenPaddingConfig,

+ 5 - 3
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.config.readConfig
 
 import androidx.compose.runtime.Stable
+import io.legado.app.ui.book.read.EyeProtectionUiState
 
 @Stable
 data class ReadConfigUiState(
@@ -17,9 +18,7 @@ data class ReadConfigUiState(
     val textBottomJustify: Boolean = true,
     val adaptSpecialStyle: Boolean = true,
     val useZhLayout: Boolean = false,
-    val eyeProtectionEnabled: Boolean = false,
-    val eyeProtectionIntensity: Int = 50,
-    val eyeProtectionAutoNight: Boolean = false,
+    val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
     val showBrightnessView: String = "0",
     val brightnessVwPos: String = "1",
     val brightnessAuto: Boolean = true,
@@ -78,6 +77,9 @@ sealed interface ReadConfigIntent {
     data class EyeProtectionEnabledChanged(val value: Boolean) : ReadConfigIntent
     data class EyeProtectionIntensityChanged(val value: Int) : ReadConfigIntent
     data class EyeProtectionAutoNightChanged(val value: Boolean) : ReadConfigIntent
+    data class EyeProtectionScheduleChanged(val value: Boolean) : ReadConfigIntent
+    data class EyeProtectionStartTimeChanged(val value: String) : ReadConfigIntent
+    data class EyeProtectionEndTimeChanged(val value: String) : ReadConfigIntent
     data class ShowBrightnessViewChanged(val value: String) : ReadConfigIntent
     data class BrightnessVwPosChanged(val value: String) : ReadConfigIntent
     data class UseUnderlineChanged(val value: Boolean) : ReadConfigIntent

+ 10 - 4
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt

@@ -171,7 +171,7 @@ fun ReadConfigScreen(
 
                 ClickableSettingItem(
                     title = stringResource(R.string.eye_protection),
-                    option = if (settings.eyeProtectionEnabled) {
+                    option = if (state.eyeProtection.enabled) {
                         stringResource(R.string.enabled)
                     } else {
                         stringResource(R.string.disabled)
@@ -414,12 +414,18 @@ fun ReadConfigScreen(
 
     EyeProtectionConfigSheet(
         show = state.activeSheet == ReadConfigSheet.EyeProtection,
-        enabled = settings.eyeProtectionEnabled,
-        intensity = settings.eyeProtectionIntensity,
-        autoNight = settings.eyeProtectionAutoNight,
+        enabled = state.eyeProtection.enabled,
+        intensity = state.eyeProtection.intensity,
+        autoNight = state.eyeProtection.autoNight,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
         onDismissRequest = { onIntent(ReadConfigIntent.DismissSheet) },
         onEnabledChange = { onIntent(ReadConfigIntent.EyeProtectionEnabledChanged(it)) },
         onIntensityChange = { onIntent(ReadConfigIntent.EyeProtectionIntensityChanged(it)) },
         onAutoNightChange = { onIntent(ReadConfigIntent.EyeProtectionAutoNightChanged(it)) },
+        onScheduleChange = { onIntent(ReadConfigIntent.EyeProtectionScheduleChanged(it)) },
+        onStartTimeChange = { onIntent(ReadConfigIntent.EyeProtectionStartTimeChanged(it)) },
+        onEndTimeChange = { onIntent(ReadConfigIntent.EyeProtectionEndTimeChanged(it)) },
     )
 }

+ 63 - 15
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt

@@ -3,7 +3,10 @@ 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.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.ReadSettings
+import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.ui.book.read.EyeProtectionUiState
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -13,11 +16,15 @@ import kotlinx.coroutines.launch
 
 class ReadConfigViewModel(
     private val settingsGateway: ReadSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
     private val applyReadSetting: ApplyReadSettingUseCase,
 ) : ViewModel() {
 
     private val _uiState = MutableStateFlow(
-        settingsGateway.currentSettings.toUiState(activeSheet = null)
+        settingsGateway.currentSettings.toUiState(
+            activeSheet = null,
+            eyeProtection = themeSettingsGateway.currentSettings.toEyeProtectionUiState(),
+        )
     )
     val uiState = _uiState.asStateFlow()
 
@@ -27,7 +34,17 @@ class ReadConfigViewModel(
     init {
         viewModelScope.launch {
             settingsGateway.settings.collect { settings ->
-                _uiState.update { settings.toUiState(activeSheet = it.activeSheet) }
+                _uiState.update {
+                    settings.toUiState(
+                        activeSheet = it.activeSheet,
+                        eyeProtection = it.eyeProtection,
+                    )
+                }
+            }
+        }
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                _uiState.update { it.copy(eyeProtection = settings.toEyeProtectionUiState()) }
             }
         }
     }
@@ -36,11 +53,34 @@ class ReadConfigViewModel(
         when (intent) {
             ReadConfigIntent.OpenPageKeys -> setSheet(ReadConfigSheet.PageKeys)
             ReadConfigIntent.OpenClickActions -> setSheet(ReadConfigSheet.ClickActions)
+            ReadConfigIntent.OpenEyeProtection -> setSheet(ReadConfigSheet.EyeProtection)
             ReadConfigIntent.DismissSheet -> setSheet(null)
+            is ReadConfigIntent.EyeProtectionEnabledChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEnabled = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionIntensityChanged -> updateEyeProtection {
+                it.copy(colorTemperature = intent.value.coerceIn(0, 100))
+            }
+            is ReadConfigIntent.EyeProtectionAutoNightChanged -> updateEyeProtection {
+                it.copy(eyeProtectionAutoNight = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionScheduleChanged -> updateEyeProtection {
+                it.copy(eyeProtectionSchedule = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionStartTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionStartTime = intent.value)
+            }
+            is ReadConfigIntent.EyeProtectionEndTimeChanged -> updateEyeProtection {
+                it.copy(eyeProtectionEndTime = intent.value)
+            }
             else -> updateSetting(intent)
         }
     }
 
+    private fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {
+        viewModelScope.launch { themeSettingsGateway.update(transform) }
+    }
+
     private fun setSheet(sheet: ReadConfigSheet?) {
         _uiState.update { it.copy(activeSheet = sheet) }
     }
@@ -102,22 +142,32 @@ private fun ReadConfigIntent.toSettingsTransform(): (ReadSettings) -> ReadSettin
     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)
-    }
+    is ReadConfigIntent.EyeProtectionEnabledChanged,
+    is ReadConfigIntent.EyeProtectionIntensityChanged,
+    is ReadConfigIntent.EyeProtectionAutoNightChanged,
+    is ReadConfigIntent.EyeProtectionScheduleChanged,
+    is ReadConfigIntent.EyeProtectionStartTimeChanged,
+    is ReadConfigIntent.EyeProtectionEndTimeChanged ->
+        error("Eye protection intents update ThemeSettings")
     ReadConfigIntent.OpenPageKeys,
     ReadConfigIntent.OpenClickActions,
     ReadConfigIntent.OpenEyeProtection,
     ReadConfigIntent.DismissSheet -> error("Sheet intents do not update settings")
 }
 
-private fun ReadSettings.toUiState(activeSheet: ReadConfigSheet?): ReadConfigUiState =
+private fun ThemeSettings.toEyeProtectionUiState(): EyeProtectionUiState = EyeProtectionUiState(
+    enabled = eyeProtectionEnabled,
+    intensity = colorTemperature,
+    autoNight = eyeProtectionAutoNight,
+    schedule = eyeProtectionSchedule,
+    startTime = eyeProtectionStartTime,
+    endTime = eyeProtectionEndTime,
+)
+
+private fun ReadSettings.toUiState(
+    activeSheet: ReadConfigSheet?,
+    eyeProtection: EyeProtectionUiState,
+): ReadConfigUiState =
     ReadConfigUiState(
         screenOrientation = screenOrientation,
         keepLight = keepLight,
@@ -132,9 +182,7 @@ private fun ReadSettings.toUiState(activeSheet: ReadConfigSheet?): ReadConfigUiS
         textBottomJustify = textBottomJustify,
         adaptSpecialStyle = adaptSpecialStyle,
         useZhLayout = useZhLayout,
-        eyeProtectionEnabled = eyeProtectionEnabled,
-        eyeProtectionIntensity = eyeProtectionIntensity,
-        eyeProtectionAutoNight = eyeProtectionAutoNight,
+        eyeProtection = eyeProtection,
         showBrightnessView = showBrightnessView,
         brightnessVwPos = brightnessVwPos,
         brightnessAuto = brightnessAuto,

+ 10 - 36
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -40,10 +40,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
-import androidx.compose.material3.TimePicker
 import androidx.compose.material3.ToggleButton
 import androidx.compose.material3.ToggleButtonDefaults
-import androidx.compose.material3.rememberTimePickerState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
@@ -75,6 +73,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.dialog.TimePickerDialog
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
@@ -461,6 +460,15 @@ fun ThemeConfigScreen(
                                 }
                             )
 
+                            SwitchSettingItem(
+                                title = stringResource(R.string.eye_protection_auto_night),
+                                description = stringResource(R.string.eye_protection_auto_night_summary),
+                                checked = theme.eyeProtectionAutoNight,
+                                onCheckedChange = { value ->
+                                    updateTheme { it.copy(eyeProtectionAutoNight = value) }
+                                }
+                            )
+
                             SwitchSettingItem(
                                 title = stringResource(R.string.eye_protection_schedule),
                                 description = stringResource(R.string.eye_protection_schedule_summary),
@@ -948,40 +956,6 @@ fun ThemeConfigScreen(
 
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun TimePickerDialog(
-    title: String,
-    currentValue: String,
-    onDismissRequest: () -> Unit,
-    onConfirm: (String) -> Unit,
-) {
-    val timePickerState = rememberTimePickerState(
-        initialHour = currentValue.substringBefore(':').toIntOrNull()?.coerceIn(0, 23) ?: 0,
-        initialMinute = currentValue.substringAfter(':', "").toIntOrNull()?.coerceIn(0, 59) ?: 0,
-        is24Hour = true,
-    )
-    AppAlertDialog(
-        show = true,
-        onDismissRequest = onDismissRequest,
-        title = title,
-        content = {
-            Box(
-                modifier = Modifier.fillMaxWidth(),
-                contentAlignment = Alignment.Center
-            ) {
-                TimePicker(state = timePickerState)
-            }
-        },
-        confirmText = stringResource(R.string.ok),
-        onConfirm = {
-            onConfirm("%02d:%02d".format(timePickerState.hour, timePickerState.minute))
-        },
-        dismissText = stringResource(R.string.cancel),
-        onDismiss = onDismissRequest,
-    )
-}
-
 @Composable
 private fun BaseCardBorderColorSettingItem(
     title: String,

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

@@ -0,0 +1,51 @@
+package io.legado.app.ui.widget.components.dialog
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TimePicker
+import androidx.compose.material3.rememberTimePickerState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.legado.app.R
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+
+/**
+ * 24 小时制时间选择对话框。[currentValue] 与回调值均为 `HH:mm`,
+ * 解析失败时回落到 00:00。
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TimePickerDialog(
+    title: String,
+    currentValue: String,
+    onDismissRequest: () -> Unit,
+    onConfirm: (String) -> Unit,
+) {
+    val timePickerState = rememberTimePickerState(
+        initialHour = currentValue.substringBefore(':').toIntOrNull()?.coerceIn(0, 23) ?: 0,
+        initialMinute = currentValue.substringAfter(':', "").toIntOrNull()?.coerceIn(0, 59) ?: 0,
+        is24Hour = true,
+    )
+    AppAlertDialog(
+        show = true,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        content = {
+            Box(
+                modifier = Modifier.fillMaxWidth(),
+                contentAlignment = Alignment.Center
+            ) {
+                TimePicker(state = timePickerState)
+            }
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            onConfirm("%02d:%02d".format(timePickerState.hour, timePickerState.minute))
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismissRequest,
+    )
+}

+ 3 - 10
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -13,16 +13,16 @@ import org.junit.Test
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化边界固定为显式声明的 48 键`() {
+    fun `gateway 持久化边界固定为显式声明的 45 键`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(48, actualKeys.size)
+        assertEquals(45, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
     @Test
-    fun `阅读设置 gateway 48 键写读映射逐字段对应`() {
+    fun `阅读设置 gateway 45 键写读映射逐字段对应`() {
         val repository = ReadSettingsRepository(
             settingsRepository = SettingsRepository(),
             preferencesFlow = MutableStateFlow(mutablePreferencesOf()),
@@ -92,7 +92,6 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         keepLight = "keep-light",
         titleBarMode = "title-mode",
         readMenuBlurAlpha = 37,
-        eyeProtectionIntensity = 73,
         showBrightnessView = "brightness-view",
         brightnessVwPos = "brightness-pos",
         readBrightness = 73,
@@ -119,9 +118,6 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(textBottomJustify = false),
         base.copy(adaptSpecialStyle = false),
         base.copy(useZhLayout = true),
-        base.copy(eyeProtectionEnabled = true),
-        base.copy(eyeProtectionIntensity = 87),
-        base.copy(eyeProtectionAutoNight = true),
         base.copy(brightnessAuto = true),
         base.copy(useUnderline = true),
         base.copy(mouseWheelPage = false),
@@ -157,9 +153,6 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
-    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
-    PreferKey.colorTemperature to eyeProtectionIntensity.coerceIn(0, 100),
-    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

+ 6 - 3
app/src/test/java/io/legado/app/data/repository/ThemeSettingsMappingTest.kt

@@ -13,18 +13,18 @@ import org.junit.Test
 class ThemeSettingsMappingTest {
 
     @Test
-    fun `Theme gateway 持久化边界固定为 59 键`() {
+    fun `Theme gateway 持久化边界固定为 60 键`() {
         val actualKeys = ThemeSettings().toGatewayPrefMap().keys
         val expectedKeys = ThemeSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(59, actualKeys.size)
+        assertEquals(60, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
         assertFalse(PreferKey.customMode in actualKeys)
         assertFalse(PreferKey.bookInfoInputColor in actualKeys)
     }
 
     @Test
-    fun `Theme gateway 59 键写读映射逐字段对应`() {
+    fun `Theme gateway 60 键写读映射逐字段对应`() {
         themeMappingSamples().forEach { expected ->
             assertEquals(expected.expectedGatewayPrefMap(), expected.toGatewayPrefMap())
             assertEquals(
@@ -186,6 +186,7 @@ private fun themeMappingSamples(): List<ThemeSettings> {
         itemDividerColor = 126,
         eyeProtectionEnabled = false,
         colorTemperature = 127,
+        eyeProtectionAutoNight = false,
         eyeProtectionSchedule = false,
         eyeProtectionStartTime = "start-time",
         eyeProtectionEndTime = "end-time",
@@ -207,6 +208,7 @@ private fun themeMappingSamples(): List<ThemeSettings> {
         base.copy(bookInfoFollowCoverColor = true),
         base.copy(enableItemDivider = true),
         base.copy(eyeProtectionEnabled = true),
+        base.copy(eyeProtectionAutoNight = true),
         base.copy(eyeProtectionSchedule = true),
         base.copy(showRefactorTip = true),
         base.copy(enableCustomTagColors = true),
@@ -267,6 +269,7 @@ private fun ThemeSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.itemDividerColor to itemDividerColor,
     PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
     PreferKey.colorTemperature to colorTemperature,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.eyeProtectionSchedule to eyeProtectionSchedule,
     PreferKey.eyeProtectionStartTime to eyeProtectionStartTime,
     PreferKey.eyeProtectionEndTime to eyeProtectionEndTime,