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

fix: 修复护眼配置与分支审查问题

5151561 3 месяцев назад
Родитель
Сommit
b3814987b0

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

@@ -65,6 +65,9 @@ data class ThemeSettings(
     val customTagColorsJson: String? = null,
 )
 
+val ThemeSettings.isEyeProtectionConfigured: Boolean
+    get() = eyeProtectionEnabled || eyeProtectionAutoNight
+
 data class ThemeCustomColors(
     val primary: Int,
     val secondary: Int,

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

@@ -246,12 +246,6 @@ object BackupConfig {
         PreferKey.showMenuIcon,
         PreferKey.titleBarMode,
         PreferKey.shouldShowExpandButton,
-        PreferKey.eyeProtectionEnabled,
-        PreferKey.colorTemperature,
-        PreferKey.eyeProtectionAutoNight,
-        PreferKey.eyeProtectionSchedule,
-        PreferKey.eyeProtectionStartTime,
-        PreferKey.eyeProtectionEndTime,
     )
 
     private val themePrefKeys = arrayOf(
@@ -298,7 +292,13 @@ object BackupConfig {
         PreferKey.primaryTextColorNight,
         PreferKey.secondaryTextColorNight,
         PreferKey.themeBackgroundColorNight,
-        PreferKey.labelContainerColorNight
+        PreferKey.labelContainerColorNight,
+        PreferKey.eyeProtectionEnabled,
+        PreferKey.colorTemperature,
+        PreferKey.eyeProtectionAutoNight,
+        PreferKey.eyeProtectionSchedule,
+        PreferKey.eyeProtectionStartTime,
+        PreferKey.eyeProtectionEndTime,
     )
 
     private val bookshelfPrefKeys = arrayOf(

+ 12 - 5
app/src/main/java/io/legado/app/ui/book/read/EyeProtection.kt

@@ -51,7 +51,7 @@ object EyeProtection {
 
     /**
      * 计算护眼在给定时刻是否应生效。全 App 共用这一处判定,不落盘、不回写总开关。
-     * - 开启「跟随深色模式」→ 由当前深浅色决定,忽略总开关;否则看总开关;
+     * - 手动开关开启,或「跟随深色模式」开启且当前为深色 → 进入护眼候选状态;
      * - 未启用定时 → 上一条为真即生效;
      * - 启用定时但时间无法解析 → 退化为不看时段;
      * - 启用定时 → 仅在 [startTime, endTime) 时段内生效(支持跨零点,如 22:00–07:00)。
@@ -65,7 +65,7 @@ object EyeProtection {
         endTime: String,
         now: LocalTime,
     ): Boolean {
-        if (!(if (autoNight) isDark else enabled)) return false
+        if (!(enabled || (autoNight && isDark))) return false
         if (!schedule) return true
         val start = parseTime(startTime) ?: return true
         val end = parseTime(endTime) ?: return true
@@ -77,8 +77,15 @@ object EyeProtection {
         }
     }
 
-    private fun parseTime(value: String): LocalTime? =
-        runCatching { LocalTime.parse(value.trim()) }.getOrNull()
+    private fun parseTime(value: String): LocalTime? = runCatching {
+        val normalized = buildString {
+            value.trim().forEach { char ->
+                val digit = Character.digit(char, 10)
+                append(if (digit >= 0) ('0'.code + digit).toChar() else char)
+            }
+        }
+        LocalTime.parse(normalized)
+    }.getOrNull()
 
     private val IDENTITY_MATRIX = floatArrayOf(
         1f, 0f, 0f, 0f, 0f,
@@ -121,7 +128,7 @@ fun rememberEyeProtectionActive(
     startTime: String,
     endTime: String,
 ): Boolean {
-    if (!(if (autoNight) isDark else enabled)) return false
+    if (!(enabled || (autoNight && isDark))) return false
     if (!schedule) return true
     var now by remember { mutableStateOf(LocalTime.now()) }
     LaunchedEffect(startTime, endTime) {

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

@@ -345,7 +345,10 @@ data class EyeProtectionUiState(
     val schedule: Boolean = false,
     val startTime: String = "22:00",
     val endTime: String = "07:00",
-)
+) {
+    val configured: Boolean
+        get() = enabled || autoNight
+}
 
 @Stable
 data class HighlightRuleConfigUiState(

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

@@ -194,6 +194,7 @@ import kotlin.math.roundToInt
 fun ReadBookMenuBar(
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     onIntent: (ReadBookIntent) -> Unit,
     onBrightnessPreview: (Int) -> Unit,
     backdrop: Backdrop? = null,
@@ -262,6 +263,7 @@ fun ReadBookMenuBar(
                     FloatingIconRow(
                         state = state,
                         preferences = preferences,
+                        eyeProtectionActive = eyeProtectionActive,
                         colors = menuColors,
                         alignment = if (state.menuConfig.titleBarIconPosition == 0) {
                             Alignment.Start
@@ -377,6 +379,7 @@ fun ReadBookMenuBar(
                     FloatingIconRow(
                         state = state,
                         preferences = preferences,
+                        eyeProtectionActive = eyeProtectionActive,
                         colors = menuColors,
                         alignment = if (state.menuConfig.titleBarIconPosition == 2) {
                             Alignment.Start
@@ -391,6 +394,7 @@ fun ReadBookMenuBar(
                     contentTarget = contentTarget,
                     state = state,
                     preferences = preferences,
+                    eyeProtectionActive = eyeProtectionActive,
                     colors = menuColors,
                     onIntent = onIntent,
                     context = context,
@@ -416,6 +420,7 @@ private fun ReadBookMenuSurface(
     contentTarget: ReadBookMenuContent,
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     colors: ReadMenuColors,
     onIntent: (ReadBookIntent) -> Unit,
     context: Context,
@@ -672,7 +677,7 @@ private fun ReadBookMenuSurface(
                     ReadBookMenuRoute.Main -> {
                     MenuBottomBar(
                         state = state,
-                        eyeProtectionEnabled = state.eyeProtection.enabled,
+                        eyeProtectionEnabled = eyeProtectionActive,
                         colors = colors,
                         onIntent = onIntent,
                         context = context,
@@ -720,7 +725,7 @@ private fun ReadBookMenuSurface(
                                 readMenuCustomIcons = state.menuConfig.readMenuCustomIcons,
                                 bottomBarButtons = state.menuConfig.bottomBarButtons,
                                 preferences = preferences,
-                                eyeProtectionEnabled = state.eyeProtection.enabled,
+                                eyeProtectionEnabled = eyeProtectionActive,
                                 onIntent = onIntent,
                                 styleConfig = state.styleConfig,
                             )
@@ -1935,6 +1940,7 @@ private fun CharsetActionButton(
 private fun FloatingIconRow(
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     colors: ReadMenuColors,
     alignment: Alignment.Horizontal = Alignment.CenterHorizontally,
     onIntent: (ReadBookIntent) -> Unit,
@@ -1947,12 +1953,13 @@ private fun FloatingIconRow(
         state.isAutoPage,
         state.translationMode,
         state.useReplaceRule,
-        state.eyeProtection.enabled,
+        eyeProtectionActive,
     ) {
         loadFloatingIcons(
             context = context,
             state = state,
             preferences = preferences,
+            eyeProtectionActive = eyeProtectionActive,
             onIntent = onIntent,
         )
     }
@@ -3345,6 +3352,7 @@ private fun loadFloatingIcons(
     context: Context,
     state: ReadBookUiState,
     preferences: ReadPreferences,
+    eyeProtectionActive: Boolean,
     onIntent: (ReadBookIntent) -> Unit,
 ): List<FloatingIconDef> {
     val infoMap = readMenuButtonInfos(context).associateBy { it.id }
@@ -3386,7 +3394,7 @@ private fun loadFloatingIcons(
         if (state.isAutoPage) add("auto_page")
         if (state.translationMode) add("translate")
         if (state.useReplaceRule) add("replace")
-        if (state.eyeProtection.enabled) add("eye_protection")
+        if (eyeProtectionActive) add("eye_protection")
     }
 
     return state.menuConfig.titleBarButtons

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

@@ -135,6 +135,14 @@ fun ReadBookRouteScreen(
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val isDarkTheme = LocalAppUiConfiguration.current.isDarkTheme
+    val eyeProtectionActive = rememberEyeProtectionActive(
+        enabled = state.eyeProtection.enabled,
+        autoNight = state.eyeProtection.autoNight,
+        isDark = isDarkTheme,
+        schedule = state.eyeProtection.schedule,
+        startTime = state.eyeProtection.startTime,
+        endTime = state.eyeProtection.endTime,
+    )
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
     val menuBackdrop = rememberLayerBackdrop()
     val menuHazeState = remember { HazeState() }
@@ -542,6 +550,7 @@ fun ReadBookRouteScreen(
             ReadBookMenuBar(
                 state = state,
                 preferences = readPreferences,
+                eyeProtectionActive = eyeProtectionActive,
                 onIntent = viewModel::onIntent,
                 onBrightnessPreview = host::previewBrightness,
                 backdrop = menuBackdrop,

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

@@ -57,6 +57,7 @@ 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.settings.isEyeProtectionConfigured
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.TextProcessAction
@@ -2340,7 +2341,14 @@ class ReadBookViewModel(
     }
 
     private fun toggleEyeProtection() = updateEyeProtection {
-        it.copy(eyeProtectionEnabled = !_uiState.value.eyeProtection.enabled)
+        if (it.isEyeProtectionConfigured) {
+            it.copy(
+                eyeProtectionEnabled = false,
+                eyeProtectionAutoNight = false,
+            )
+        } else {
+            it.copy(eyeProtectionEnabled = true)
+        }
     }
 
     private fun updateEyeProtection(transform: (ThemeSettings) -> ThemeSettings) {

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

@@ -38,6 +38,7 @@ fun EyeProtectionConfigSheet(
 ) {
     var editingStartTime by remember { mutableStateOf(false) }
     var editingEndTime by remember { mutableStateOf(false) }
+    val configured = enabled || autoNight
 
     AppModalBottomSheet(
         show = show,
@@ -50,7 +51,13 @@ fun EyeProtectionConfigSheet(
             checked = enabled,
             onCheckedChange = onEnabledChange,
         )
-        AnimatedVisibility(visible = enabled) {
+        SwitchSettingItem(
+            title = stringResource(R.string.eye_protection_auto_night),
+            description = stringResource(R.string.eye_protection_auto_night_summary),
+            checked = autoNight,
+            onCheckedChange = onAutoNightChange,
+        )
+        AnimatedVisibility(visible = configured) {
             Column {
                 SliderSettingItem(
                     title = stringResource(R.string.eye_protection_intensity),
@@ -63,12 +70,6 @@ fun EyeProtectionConfigSheet(
                     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),

+ 1 - 1
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 (state.eyeProtection.enabled) {
+                    option = if (state.eyeProtection.configured) {
                         stringResource(R.string.enabled)
                     } else {
                         stringResource(R.string.disabled)

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

@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
 import androidx.constraintlayout.compose.ConstraintLayout
 import io.legado.app.R
 import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -444,7 +445,16 @@ fun ThemeConfigScreen(
                         }
                     )
 
-                    AnimatedVisibility(visible = theme.eyeProtectionEnabled) {
+                    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) }
+                        }
+                    )
+
+                    AnimatedVisibility(visible = theme.isEyeProtectionConfigured) {
                         Column {
                             SliderSettingItem(
                                 title = stringResource(R.string.color_temperature),
@@ -460,15 +470,6 @@ 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),

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

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material.icons.filled.Update
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -756,7 +757,9 @@ fun BookItem(
         columnContent = if (layoutMode == 0 && !isCompact && settings.showBookIntro) {
             {
                 val kindList = bookUi.displayTags
-                val intro = HtmlFormatter.formatDisplayText(book.intro).takeIf { it.isNotBlank() }
+                val intro = remember(book.intro) {
+                    HtmlFormatter.formatDisplayText(book.intro).takeIf { it.isNotBlank() }
+                }
                 if (settings.bookshelfShowTag && kindList.isNotEmpty()) {
                     Row(
                         modifier = Modifier

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

@@ -11,6 +11,22 @@ 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
+import java.util.Locale
+
+internal fun formatTimeValue(hour: Int, minute: Int): String =
+    String.format(Locale.ROOT, "%02d:%02d", hour, minute)
+
+internal fun parseTimeNumber(value: String): Int? {
+    if (value.isEmpty()) return null
+    val normalized = buildString(value.length) {
+        value.forEach { char ->
+            val digit = Character.digit(char, 10)
+            if (digit < 0) return null
+            append(('0'.code + digit).toChar())
+        }
+    }
+    return normalized.toIntOrNull()
+}
 
 /**
  * 24 小时制时间选择对话框。[currentValue] 与回调值均为 `HH:mm`,
@@ -25,8 +41,10 @@ fun TimePickerDialog(
     onConfirm: (String) -> Unit,
 ) {
     val timePickerState = rememberTimePickerState(
-        initialHour = currentValue.substringBefore(':').toIntOrNull()?.coerceIn(0, 23) ?: 0,
-        initialMinute = currentValue.substringAfter(':', "").toIntOrNull()?.coerceIn(0, 59) ?: 0,
+        initialHour = parseTimeNumber(currentValue.substringBefore(':'))
+            ?.coerceIn(0, 23) ?: 0,
+        initialMinute = parseTimeNumber(currentValue.substringAfter(':', ""))
+            ?.coerceIn(0, 59) ?: 0,
         is24Hour = true,
     )
     AppAlertDialog(
@@ -43,7 +61,7 @@ fun TimePickerDialog(
         },
         confirmText = stringResource(R.string.ok),
         onConfirm = {
-            onConfirm("%02d:%02d".format(timePickerState.hour, timePickerState.minute))
+            onConfirm(formatTimeValue(timePickerState.hour, timePickerState.minute))
         },
         dismissText = stringResource(R.string.cancel),
         onDismiss = onDismissRequest,

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

@@ -6,12 +6,26 @@ 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 io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ThemeSettingsMappingTest {
 
+    @Test
+    fun `手动护眼和跟随深色模式是独立配置来源`() {
+        val automatic = ThemeSettings(
+            eyeProtectionEnabled = false,
+            eyeProtectionAutoNight = true,
+        )
+
+        assertFalse(automatic.eyeProtectionEnabled)
+        assertTrue(automatic.eyeProtectionAutoNight)
+        assertTrue(automatic.isEyeProtectionConfigured)
+    }
+
     @Test
     fun `Theme gateway 持久化边界固定为 60 键`() {
         val actualKeys = ThemeSettings().toGatewayPrefMap().keys

+ 18 - 5
app/src/test/java/io/legado/app/ui/book/read/EyeProtectionTest.kt

@@ -8,6 +8,12 @@ import java.time.LocalTime
 
 class EyeProtectionTest {
 
+    @Test
+    fun `跟随深色模式开启时护眼配置视为已启用`() {
+        assertTrue(EyeProtectionUiState(enabled = false, autoNight = true).configured)
+        assertFalse(EyeProtectionUiState(enabled = false, autoNight = false).configured)
+    }
+
     @Test
     fun `零强度不改变颜色`() {
         assertArrayEquals(
@@ -50,12 +56,13 @@ class EyeProtectionTest {
     )
 
     @Test
-    fun `跟随深色模式时由深浅色决定并忽略总开关`() {
-        // 浅色下即使总开关是开的也不生效
-        assertFalse(activeAt(3, enabled = true, autoNight = true, isDark = false, schedule = false))
-        // 深色下即使总开关是关的也生效
+    fun `手动开关与跟随深色模式独立决定是否生效`() {
+        // 手动开启后,浅色模式下仍然生效
+        assertTrue(activeAt(3, enabled = true, autoNight = true, isDark = false, schedule = false))
+        // 仅开启跟随深色时,浅色不生效、深色生效
+        assertFalse(activeAt(3, enabled = false, autoNight = true, isDark = false, schedule = false))
         assertTrue(activeAt(3, enabled = false, autoNight = true, isDark = true, schedule = false))
-        // 未开跟随深色时,深浅色不参与判定
+        // 仅手动开启时,深浅色不参与判定
         assertTrue(activeAt(3, enabled = true, autoNight = false, isDark = false, schedule = false))
     }
 
@@ -101,6 +108,12 @@ class EyeProtectionTest {
         assertTrue(activeAt(12, start = "22:00", end = ""))
     }
 
+    @Test
+    fun `定时时间兼容 Unicode 数字`() {
+        assertTrue(activeAt(23, start = "٢٢:٠٠", end = "٠٧:٠٠"))
+        assertFalse(activeAt(12, start = "٢٢:٠٠", end = "٠٧:٠٠"))
+    }
+
     @Test
     fun `起止时间相同视为全天生效`() {
         assertTrue(activeAt(3, start = "08:00", end = "08:00"))

+ 23 - 0
app/src/test/java/io/legado/app/ui/widget/components/dialog/TimePickerDialogTest.kt

@@ -0,0 +1,23 @@
+package io.legado.app.ui.widget.components.dialog
+
+import java.util.Locale
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TimePickerDialogTest {
+
+    @Test
+    fun `时间值在非拉丁数字语言下仍使用 ASCII 格式`() {
+        val original = Locale.getDefault()
+        try {
+            Locale.setDefault(Locale.forLanguageTag("ar"))
+            assertEquals("22:07", formatTimeValue(hour = 22, minute = 7))
+            assertEquals(22, parseTimeNumber("٢٢"))
+            assertEquals(7, parseTimeNumber("٠٧"))
+            assertNull(parseTimeNumber("999999999999"))
+        } finally {
+            Locale.setDefault(original)
+        }
+    }
+}