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

feat: Add settings management for ReadAloud, Read, and Theme configurations

- Implemented ReadAloudSettingsGateway and ReadSettingsGateway interfaces for managing read aloud and read settings.
- Created data models for ReadAloudSettings, ReadSettings, ThemeSettings, AppShellSettings, and OtherSettings.
- Developed CustomTheme and OtherConfig UI components for managing theme and other application settings.
- Introduced ApplyReadSettingUseCase to apply runtime reader changes based on updated settings.
- Added unit tests for LocalUiStatusMigration and BackupConfigSecurity to ensure data integrity during migrations and security of sensitive information.
5151561 3 месяцев назад
Родитель
Сommit
f528ac5106
63 измененных файлов с 2242 добавлено и 1392 удалено
  1. 2 2
      app/src/main/java/io/legado/app/base/BaseService.kt
  2. 0 52
      app/src/main/java/io/legado/app/data/local/preferences/AppSettings.kt
  3. 1 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  4. 0 254
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt
  5. 1 2
      app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt
  6. 188 0
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  7. 1 2
      app/src/main/java/io/legado/app/data/repository/HomeDashboardRepository.kt
  8. 44 42
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  9. 1 2
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  10. 153 217
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  11. 5 4
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  12. 56 36
      app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt
  13. 19 4
      app/src/main/java/io/legado/app/di/appModule.kt
  14. 18 0
      app/src/main/java/io/legado/app/domain/gateway/AppShellSettingsGateway.kt
  15. 31 0
      app/src/main/java/io/legado/app/domain/gateway/OtherSettingsGateway.kt
  16. 25 0
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt
  17. 50 0
      app/src/main/java/io/legado/app/domain/gateway/ReadSettingsGateway.kt
  18. 38 0
      app/src/main/java/io/legado/app/domain/gateway/ThemeSettingsGateway.kt
  19. 25 0
      app/src/main/java/io/legado/app/domain/model/settings/AppShellSettings.kt
  20. 25 0
      app/src/main/java/io/legado/app/domain/model/settings/OtherSettings.kt
  21. 16 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  22. 104 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  23. 59 0
      app/src/main/java/io/legado/app/domain/model/settings/ThemeSettings.kt
  24. 3 3
      app/src/main/java/io/legado/app/help/config/LocalConfig.kt
  25. 18 0
      app/src/main/java/io/legado/app/help/config/PreferencesDsCompat.kt
  26. 2 1
      app/src/main/java/io/legado/app/help/config/SettingsWriter.kt
  27. 2 4
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  28. 17 15
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  29. 13 3
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  30. 3 4
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt
  31. 2 2
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  32. 4 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  33. 4 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  34. 2 1
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  35. 2 3
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  36. 16 1
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  37. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  38. 2 2
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt
  39. 2 2
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  40. 48 0
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeContract.kt
  41. 41 0
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeRouteScreen.kt
  42. 77 141
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt
  43. 98 0
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeViewModel.kt
  44. 19 53
      app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
  45. 37 34
      app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
  46. 110 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt
  47. 145 0
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt
  48. 52 175
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt
  49. 251 47
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  50. 55 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt
  51. 17 1
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt
  52. 34 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigRouteScreen.kt
  53. 45 53
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt
  54. 133 170
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
  55. 1 5
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  56. 13 16
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  57. 6 6
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  58. 26 18
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  59. 0 9
      app/src/main/java/io/legado/app/ui/theme/LocalAppSettings.kt
  60. 2 2
      app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt
  61. 50 0
      app/src/test/java/io/legado/app/data/repository/LocalUiStatusMigrationTest.kt
  62. 12 0
      app/src/test/java/io/legado/app/help/config/PreferencesDsCompatTest.kt
  63. 14 0
      app/src/test/java/io/legado/app/help/storage/BackupConfigSecurityTest.kt

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

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

+ 0 - 52
app/src/main/java/io/legado/app/data/local/preferences/AppSettings.kt

@@ -1,52 +0,0 @@
-package io.legado.app.data.local.preferences
-
-import androidx.compose.runtime.Stable
-
-@Stable
-data class AppSettings(
-    val darkTheme: Boolean = false,
-    val language: String = "auto",
-    val readAloudSpeed: Int = 5,
-    val textWeight: Int = 1,
-    val showBrightnessView: String = "1",
-    val screenOrientation: String = "0",
-    val keepLight: String = "0",
-    val hideStatusBar: Boolean = false,
-    val hideNavigationBar: Boolean = false,
-    val paddingDisplayCutouts: Boolean = false,
-    val titleBarMode: String = "1",
-    val readMenuBlurAlpha: Int = 60,
-    val readBodyToLh: Boolean = true,
-    val defaultSourceChangeAll: Boolean = true,
-    val textFullJustify: Boolean = true,
-    val textBottomJustify: Boolean = true,
-    val adaptSpecialStyle: Boolean = true,
-    val useZhLayout: Boolean = false,
-    val brightnessVwPos: String = "1",
-    val brightnessAuto: Boolean = false,
-    val useUnderline: Boolean = false,
-    val readSliderMode: String = "0",
-    val doubleHorizontalPage: String = "0",
-    val progressBarBehavior: String = "page",
-    val mouseWheelPage: Boolean = true,
-    val volumeKeyPage: Boolean = true,
-    val volumeKeyPageOnPlay: Boolean = true,
-    val keyPageOnLongPress: Boolean = false,
-    val pageTouchSlop: Int = 0,
-    val sliderVibrator: Boolean = false,
-    val selectVibrator: Boolean = false,
-    val autoChangeSource: Boolean = true,
-    val autoSuggestDayNight: Boolean = false,
-    val selectText: Boolean = true,
-    val noAnimScrollPage: Boolean = false,
-    val clickImgWay: String = "2",
-    val optimizeRender: Boolean = false,
-    val disableReturnKey: Boolean = false,
-    val expandTextMenu: Boolean = false,
-    val showSelectMenuIcon: Boolean = true,
-    val showReadTitleAddition: Boolean = true,
-    val autoReadSpeed: Int = 10,
-    val prevKeys: String = "",
-    val nextKeys: String = "",
-    val showMenuIcon: Boolean = true
-)

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

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

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

@@ -1,254 +0,0 @@
-package io.legado.app.data.local.preferences
-
-import android.content.Context
-import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.emptyPreferences
-import io.legado.app.constant.PreferKey
-import io.legado.app.help.config.AppConfigStore
-import io.legado.app.help.config.compatDsBoolean
-import io.legado.app.help.config.compatDsInt
-import io.legado.app.help.config.compatDsString
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-import java.io.IOException
-
-class LocalPreferencesRepository(private val context: Context) {
-
-    private val dataStore = context.localDataStore
-    private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
-
-    val appSettings: StateFlow<AppSettings> = AppConfigStore.preferencesFlow
-        .map { prefs ->
-            AppSettings(
-                darkTheme = prefs.compatDsString(PreferKey.themeMode).let {
-                    when (it) {
-                        "1" -> false
-                        "2" -> true
-                        else -> false
-                    }
-                },
-                language = prefs.compatDsString(PreferKey.language) ?: "auto",
-                readAloudSpeed = prefs.compatDsInt(PreferKey.ttsSpeechRate) ?: 5,
-                textWeight = prefs.compatDsInt("textWeight") ?: 1,
-                showBrightnessView = prefs.compatDsString(PreferKey.showBrightnessView) ?: "1",
-                screenOrientation = prefs.compatDsString(PreferKey.screenOrientation) ?: "0",
-                keepLight = prefs.compatDsString(PreferKey.keepLight) ?: "0",
-                hideStatusBar = prefs.compatDsBoolean(PreferKey.hideStatusBar) ?: false,
-                hideNavigationBar = prefs.compatDsBoolean(PreferKey.hideNavigationBar) ?: false,
-                paddingDisplayCutouts = prefs.compatDsBoolean(PreferKey.paddingDisplayCutouts) ?: false,
-                titleBarMode = prefs.compatDsString(PreferKey.titleBarMode) ?: "1",
-                readMenuBlurAlpha = prefs.compatDsInt(PreferKey.readMenuBlurAlpha) ?: 60,
-                readBodyToLh = prefs.compatDsBoolean(PreferKey.readBodyToLh) ?: true,
-                defaultSourceChangeAll = prefs.compatDsBoolean(PreferKey.defaultSourceChangeAll) ?: true,
-                textFullJustify = prefs.compatDsBoolean(PreferKey.textFullJustify) ?: true,
-                textBottomJustify = prefs.compatDsBoolean(PreferKey.textBottomJustify) ?: true,
-                adaptSpecialStyle = prefs.compatDsBoolean(PreferKey.adaptSpecialStyle) ?: true,
-                useZhLayout = prefs.compatDsBoolean(PreferKey.useZhLayout) ?: false,
-                brightnessVwPos = prefs.compatDsString(PreferKey.brightnessVwPos) ?: "1",
-                brightnessAuto = prefs.compatDsBoolean(PreferKey.brightnessAuto) ?: false,
-                useUnderline = prefs.compatDsBoolean(PreferKey.useUnderline) ?: false,
-                readSliderMode = prefs.compatDsString(PreferKey.readSliderMode) ?: "0",
-                doubleHorizontalPage = prefs.compatDsString(PreferKey.doublePageHorizontal) ?: "0",
-                progressBarBehavior = prefs.compatDsString(PreferKey.progressBarBehavior) ?: "page",
-                mouseWheelPage = prefs.compatDsBoolean(PreferKey.mouseWheelPage) ?: true,
-                volumeKeyPage = prefs.compatDsBoolean(PreferKey.volumeKeyPage) ?: true,
-                volumeKeyPageOnPlay = prefs.compatDsBoolean(PreferKey.volumeKeyPageOnPlay) ?: true,
-                keyPageOnLongPress = prefs.compatDsBoolean(PreferKey.keyPageOnLongPress) ?: false,
-                pageTouchSlop = prefs.compatDsInt(PreferKey.pageTouchSlop) ?: 0,
-                sliderVibrator = prefs.compatDsBoolean(PreferKey.sliderVibrator) ?: false,
-                selectVibrator = prefs.compatDsBoolean(PreferKey.selectVibrator) ?: false,
-                autoChangeSource = prefs.compatDsBoolean(PreferKey.autoChangeSource) ?: true,
-                autoSuggestDayNight = prefs.compatDsBoolean(PreferKey.autoSuggestDayNight) ?: false,
-                selectText = prefs.compatDsBoolean(PreferKey.selectText) ?: true,
-                noAnimScrollPage = prefs.compatDsBoolean(PreferKey.noAnimScrollPage) ?: false,
-                clickImgWay = prefs.compatDsString(PreferKey.clickImgWay) ?: "2",
-                optimizeRender = prefs.compatDsBoolean(PreferKey.optimizeRender) ?: false,
-                disableReturnKey = prefs.compatDsBoolean(PreferKey.disableReturnKey) ?: false,
-                expandTextMenu = prefs.compatDsBoolean(PreferKey.expandTextMenu) ?: false,
-                showSelectMenuIcon = prefs.compatDsBoolean(PreferKey.showSelectMenuIcon) ?: true,
-                showReadTitleAddition = prefs.compatDsBoolean(PreferKey.showReadTitleAddition) ?: true,
-                autoReadSpeed = prefs.compatDsInt(PreferKey.autoReadSpeed) ?: 10,
-                prevKeys = prefs.compatDsString(PreferKey.prevKeys) ?: "",
-                nextKeys = prefs.compatDsString(PreferKey.nextKeys) ?: "",
-                showMenuIcon = prefs.compatDsBoolean(PreferKey.showMenuIcon) ?: true
-            )
-        }.stateIn(
-            scope = repositoryScope,
-            started = SharingStarted.Eagerly,
-            initialValue = AppSettings()
-        )
-
-    fun updateSettings(transform: (AppSettings) -> AppSettings) {
-        val current = appSettings.value
-        val updated = transform(current)
-
-        val changes = mutableMapOf<String, Any?>()
-        if (updated.darkTheme != current.darkTheme) {
-            changes[PreferKey.themeMode] = if (updated.darkTheme) "2" else "1"
-        }
-        if (updated.language != current.language) {
-            changes[PreferKey.language] = updated.language
-        }
-        if (updated.readAloudSpeed != current.readAloudSpeed) {
-            changes[PreferKey.ttsSpeechRate] = updated.readAloudSpeed
-        }
-        if (updated.textWeight != current.textWeight) {
-            changes["textWeight"] = updated.textWeight
-        }
-        if (updated.showBrightnessView != current.showBrightnessView) {
-            changes[PreferKey.showBrightnessView] = updated.showBrightnessView
-        }
-        if (updated.screenOrientation != current.screenOrientation) {
-            changes[PreferKey.screenOrientation] = updated.screenOrientation
-        }
-        if (updated.keepLight != current.keepLight) {
-            changes[PreferKey.keepLight] = updated.keepLight
-        }
-        if (updated.hideStatusBar != current.hideStatusBar) {
-            changes[PreferKey.hideStatusBar] = updated.hideStatusBar
-        }
-        if (updated.hideNavigationBar != current.hideNavigationBar) {
-            changes[PreferKey.hideNavigationBar] = updated.hideNavigationBar
-        }
-        if (updated.paddingDisplayCutouts != current.paddingDisplayCutouts) {
-            changes[PreferKey.paddingDisplayCutouts] = updated.paddingDisplayCutouts
-        }
-        if (updated.titleBarMode != current.titleBarMode) {
-            changes[PreferKey.titleBarMode] = updated.titleBarMode
-        }
-        if (updated.readMenuBlurAlpha != current.readMenuBlurAlpha) {
-            changes[PreferKey.readMenuBlurAlpha] = updated.readMenuBlurAlpha
-        }
-        if (updated.readBodyToLh != current.readBodyToLh) {
-            changes[PreferKey.readBodyToLh] = updated.readBodyToLh
-        }
-        if (updated.defaultSourceChangeAll != current.defaultSourceChangeAll) {
-            changes[PreferKey.defaultSourceChangeAll] = updated.defaultSourceChangeAll
-        }
-        if (updated.textFullJustify != current.textFullJustify) {
-            changes[PreferKey.textFullJustify] = updated.textFullJustify
-        }
-        if (updated.textBottomJustify != current.textBottomJustify) {
-            changes[PreferKey.textBottomJustify] = updated.textBottomJustify
-        }
-        if (updated.adaptSpecialStyle != current.adaptSpecialStyle) {
-            changes[PreferKey.adaptSpecialStyle] = updated.adaptSpecialStyle
-        }
-        if (updated.useZhLayout != current.useZhLayout) {
-            changes[PreferKey.useZhLayout] = updated.useZhLayout
-        }
-        if (updated.brightnessVwPos != current.brightnessVwPos) {
-            changes[PreferKey.brightnessVwPos] = updated.brightnessVwPos
-        }
-        if (updated.brightnessAuto != current.brightnessAuto) {
-            changes[PreferKey.brightnessAuto] = updated.brightnessAuto
-        }
-        if (updated.useUnderline != current.useUnderline) {
-            changes[PreferKey.useUnderline] = updated.useUnderline
-        }
-        if (updated.readSliderMode != current.readSliderMode) {
-            changes[PreferKey.readSliderMode] = updated.readSliderMode
-        }
-        if (updated.doubleHorizontalPage != current.doubleHorizontalPage) {
-            changes[PreferKey.doublePageHorizontal] = updated.doubleHorizontalPage
-        }
-        if (updated.progressBarBehavior != current.progressBarBehavior) {
-            changes[PreferKey.progressBarBehavior] = updated.progressBarBehavior
-        }
-        if (updated.mouseWheelPage != current.mouseWheelPage) {
-            changes[PreferKey.mouseWheelPage] = updated.mouseWheelPage
-        }
-        if (updated.volumeKeyPage != current.volumeKeyPage) {
-            changes[PreferKey.volumeKeyPage] = updated.volumeKeyPage
-        }
-        if (updated.volumeKeyPageOnPlay != current.volumeKeyPageOnPlay) {
-            changes[PreferKey.volumeKeyPageOnPlay] = updated.volumeKeyPageOnPlay
-        }
-        if (updated.keyPageOnLongPress != current.keyPageOnLongPress) {
-            changes[PreferKey.keyPageOnLongPress] = updated.keyPageOnLongPress
-        }
-        if (updated.pageTouchSlop != current.pageTouchSlop) {
-            changes[PreferKey.pageTouchSlop] = updated.pageTouchSlop
-        }
-        if (updated.sliderVibrator != current.sliderVibrator) {
-            changes[PreferKey.sliderVibrator] = updated.sliderVibrator
-        }
-        if (updated.selectVibrator != current.selectVibrator) {
-            changes[PreferKey.selectVibrator] = updated.selectVibrator
-        }
-        if (updated.autoChangeSource != current.autoChangeSource) {
-            changes[PreferKey.autoChangeSource] = updated.autoChangeSource
-        }
-        if (updated.autoSuggestDayNight != current.autoSuggestDayNight) {
-            changes[PreferKey.autoSuggestDayNight] = updated.autoSuggestDayNight
-        }
-        if (updated.selectText != current.selectText) {
-            changes[PreferKey.selectText] = updated.selectText
-        }
-        if (updated.noAnimScrollPage != current.noAnimScrollPage) {
-            changes[PreferKey.noAnimScrollPage] = updated.noAnimScrollPage
-        }
-        if (updated.clickImgWay != current.clickImgWay) {
-            changes[PreferKey.clickImgWay] = updated.clickImgWay
-        }
-        if (updated.optimizeRender != current.optimizeRender) {
-            changes[PreferKey.optimizeRender] = updated.optimizeRender
-        }
-        if (updated.disableReturnKey != current.disableReturnKey) {
-            changes[PreferKey.disableReturnKey] = updated.disableReturnKey
-        }
-        if (updated.expandTextMenu != current.expandTextMenu) {
-            changes[PreferKey.expandTextMenu] = updated.expandTextMenu
-        }
-        if (updated.showSelectMenuIcon != current.showSelectMenuIcon) {
-            changes[PreferKey.showSelectMenuIcon] = updated.showSelectMenuIcon
-        }
-        if (updated.showReadTitleAddition != current.showReadTitleAddition) {
-            changes[PreferKey.showReadTitleAddition] = updated.showReadTitleAddition
-        }
-        if (updated.autoReadSpeed != current.autoReadSpeed) {
-            changes[PreferKey.autoReadSpeed] = updated.autoReadSpeed
-        }
-        if (updated.prevKeys != current.prevKeys) {
-            changes[PreferKey.prevKeys] = updated.prevKeys
-        }
-        if (updated.nextKeys != current.nextKeys) {
-            changes[PreferKey.nextKeys] = updated.nextKeys
-        }
-        if (updated.showMenuIcon != current.showMenuIcon) {
-            changes[PreferKey.showMenuIcon] = updated.showMenuIcon
-        }
-
-        if (changes.isNotEmpty()) {
-            AppConfigStore.putAll(changes)
-        }
-    }
-
-    fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
-        return dataStore.data
-            .catch { exception ->
-                if (exception is IOException) {
-                    emit(emptyPreferences())
-                } else {
-                    throw exception
-                }
-            }
-            .map { preferences ->
-                preferences[key] ?: defaultValue
-            }
-    }
-
-    suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
-        dataStore.edit { preferences ->
-            preferences[key] = value
-        }
-    }
-}

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

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

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

@@ -0,0 +1,188 @@
+package io.legado.app.data.repository
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppShellSettingsUpdate
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.gateway.ThemeColorSlot
+import io.legado.app.domain.gateway.ThemeSettingsUpdate
+import io.legado.app.domain.model.settings.AppShellSettings
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ThemeSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsBoolean
+import io.legado.app.help.config.compatDsInt
+import io.legado.app.help.config.compatDsString
+import androidx.datastore.preferences.core.Preferences
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class AppShellSettingsRepository : AppShellSettingsGateway {
+    override val currentSettings: AppShellSettings
+        get() = AppConfigStore.preferences.toAppShellSettings()
+
+    override val settings: Flow<AppShellSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toAppShellSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(update: AppShellSettingsUpdate) {
+        val (key, value) = when (update) {
+            is AppShellSettingsUpdate.ThemeMode -> PreferKey.themeMode to update.value
+            is AppShellSettingsUpdate.Language -> PreferKey.language 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
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+class ThemeSettingsRepository : ThemeSettingsGateway {
+    override val currentSettings: ThemeSettings
+        get() = AppConfigStore.preferences.toThemeSettings()
+
+    override val settings: Flow<ThemeSettings> = AppConfigStore.preferencesFlow
+        .map(Preferences::toThemeSettings)
+        .distinctUntilChanged()
+
+    override suspend fun update(update: ThemeSettingsUpdate) {
+        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
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}
+
+private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSettings(
+    themeMode = compatDsString(PreferKey.themeMode) ?: "0",
+    language = compatDsString(PreferKey.language) ?: "auto",
+    fontScale = compatDsInt(PreferKey.fontScale) ?: 10,
+    composeEngine = compatDsString(PreferKey.composeEngine) ?: "material",
+    showHome = compatDsBoolean(PreferKey.showHome) ?: true,
+    showDiscovery = compatDsBoolean(PreferKey.showDiscovery) ?: true,
+    showRss = compatDsBoolean(PreferKey.showRss) ?: true,
+    showStatusBar = compatDsBoolean(PreferKey.showStatusBar) ?: true,
+    showBottomView = compatDsBoolean(PreferKey.showBottomView) ?: true,
+    useFloatingBottomBar = compatDsBoolean(PreferKey.useFloatingBottomBar) ?: false,
+    useFloatingBottomBarLiquidGlass =
+        compatDsBoolean(PreferKey.useFloatingBottomBarLiquidGlass) ?: false,
+    tabletInterface = compatDsString(PreferKey.tabletInterface) ?: "auto",
+    labelVisibilityMode = compatDsString(PreferKey.labelVisibilityMode) ?: "auto",
+    defaultHomePage = compatDsString(PreferKey.defaultHomePage) ?: "bookshelf",
+    mainNavigationOrder = compatDsString(PreferKey.mainNavigationOrder)
+        ?: "home,bookshelf,explore,rss,my",
+    navExtended = compatDsBoolean("navExtended") ?: false,
+    navIconHome = compatDsString(PreferKey.navIconHome) ?: "",
+    navIconBookshelf = compatDsString(PreferKey.navIconBookshelf) ?: "",
+    navIconExplore = compatDsString(PreferKey.navIconExplore) ?: "",
+    navIconRss = compatDsString(PreferKey.navIconRss) ?: "",
+    navIconMy = compatDsString(PreferKey.navIconMy) ?: "",
+)
+
+private fun Preferences.toThemeSettings(): ThemeSettings = ThemeSettings(
+    appTheme = compatDsString(PreferKey.appTheme) ?: "0",
+    isPureBlack = compatDsBoolean(PreferKey.pureBlack) ?: false,
+    paletteStyle = compatDsString(PreferKey.paletteStyle) ?: "tonalSpot",
+    materialVersion = compatDsString(PreferKey.materialVersion) ?: "material3",
+    customContrast = compatDsString(PreferKey.customContrast) ?: "Default",
+    appFontPath = compatDsString(PreferKey.appFontPath),
+    customPrimary = compatDsInt(PreferKey.cPrimary) ?: 0,
+    customNightPrimary = compatDsInt(PreferKey.cNPrimary) ?: 0,
+    enableDeepPersonalization = compatDsBoolean(PreferKey.enableDeepPersonalization) ?: false,
+    themeColor = compatDsInt(PreferKey.themeColor) ?: 0,
+    secondaryThemeColor = compatDsInt(PreferKey.secondaryThemeColor) ?: 0,
+    primaryTextColor = compatDsInt(PreferKey.primaryTextColor) ?: 0,
+    secondaryTextColor = compatDsInt(PreferKey.secondaryTextColor) ?: 0,
+    themeBackgroundColor = compatDsInt(PreferKey.themeBackgroundColor) ?: 0,
+    labelContainerColor = compatDsInt(PreferKey.labelContainerColor) ?: 0,
+    themeColorNight = compatDsInt(PreferKey.themeColorNight) ?: 0,
+    secondaryThemeColorNight = compatDsInt(PreferKey.secondaryThemeColorNight) ?: 0,
+    primaryTextColorNight = compatDsInt(PreferKey.primaryTextColorNight) ?: 0,
+    secondaryTextColorNight = compatDsInt(PreferKey.secondaryTextColorNight) ?: 0,
+    themeBackgroundColorNight = compatDsInt(PreferKey.themeBackgroundColorNight) ?: 0,
+    labelContainerColorNight = compatDsInt(PreferKey.labelContainerColorNight) ?: 0,
+)
+
+class OtherSettingsRepository : OtherSettingsGateway {
+    override val settings: Flow<OtherSettings> = AppConfigStore.preferencesFlow
+        .map { preferences ->
+            val rawSourceEditMaxLine = preferences.compatDsInt(PreferKey.sourceEditMaxLine) ?: Int.MAX_VALUE
+            OtherSettings(
+                language = preferences.compatDsString(PreferKey.language) ?: "auto",
+                updateToVariant = preferences.compatDsString(PreferKey.updateToVariant) ?: "official_version",
+                autoCheckUpdateOnStart =
+                    preferences.compatDsBoolean(PreferKey.autoCheckUpdateOnStart) ?: false,
+                webServiceAutoStart = preferences.compatDsBoolean(PreferKey.webServiceAutoStart) ?: false,
+                autoRefresh = preferences.compatDsBoolean(PreferKey.autoRefresh) ?: false,
+                defaultToRead = preferences.compatDsBoolean(PreferKey.defaultToRead) ?: false,
+                notificationsPost = preferences.compatDsBoolean(PreferKey.notificationsPost) ?: true,
+                ignoreBatteryPermission =
+                    preferences.compatDsBoolean(PreferKey.ignoreBatteryPermission) ?: true,
+                firebaseEnable = preferences.compatDsBoolean(PreferKey.firebaseEnable) ?: true,
+                defaultBookTreeUri = preferences.compatDsString(PreferKey.defaultBookTreeUri),
+                antiAlias = preferences.compatDsBoolean(PreferKey.antiAlias) ?: false,
+                replaceEnableDefault = preferences.compatDsBoolean(PreferKey.replaceEnableDefault) ?: true,
+                autoClearExpired = preferences.compatDsBoolean(PreferKey.autoClearExpired) ?: true,
+                showAddToShelfAlert = preferences.compatDsBoolean(PreferKey.showAddToShelfAlert) ?: true,
+                showMangaUi = preferences.compatDsBoolean(PreferKey.showMangaUi) ?: true,
+                webServiceWakeLock = preferences.compatDsBoolean(PreferKey.webServiceWakeLock) ?: false,
+                sourceEditMaxLine = rawSourceEditMaxLine.takeIf { it >= 10 } ?: Int.MAX_VALUE,
+                webPort = preferences.compatDsInt(PreferKey.webPort) ?: 1122,
+                processText = preferences.compatDsBoolean(PreferKey.processText) ?: true,
+                recordLog = preferences.compatDsBoolean(PreferKey.recordLog) ?: false,
+                recordHeapDump = preferences.compatDsBoolean(PreferKey.recordHeapDump) ?: false,
+            )
+        }
+        .distinctUntilChanged()
+
+    override suspend fun update(update: OtherSettingsUpdate) {
+        val (key, value) = when (update) {
+            is OtherSettingsUpdate.Language -> PreferKey.language to update.value
+            is OtherSettingsUpdate.UpdateToVariant -> PreferKey.updateToVariant to update.value
+            is OtherSettingsUpdate.AutoCheckUpdateOnStart -> PreferKey.autoCheckUpdateOnStart to update.value
+            is OtherSettingsUpdate.WebServiceAutoStart -> PreferKey.webServiceAutoStart to update.value
+            is OtherSettingsUpdate.AutoRefresh -> PreferKey.autoRefresh to update.value
+            is OtherSettingsUpdate.DefaultToRead -> PreferKey.defaultToRead to update.value
+            is OtherSettingsUpdate.FirebaseEnable -> PreferKey.firebaseEnable to update.value
+            is OtherSettingsUpdate.DefaultBookTreeUri -> PreferKey.defaultBookTreeUri to update.value
+            is OtherSettingsUpdate.AntiAlias -> PreferKey.antiAlias to update.value
+            is OtherSettingsUpdate.ReplaceEnableDefault -> PreferKey.replaceEnableDefault to update.value
+            is OtherSettingsUpdate.AutoClearExpired -> PreferKey.autoClearExpired to update.value
+            is OtherSettingsUpdate.ShowAddToShelfAlert -> PreferKey.showAddToShelfAlert to update.value
+            is OtherSettingsUpdate.ShowMangaUi -> PreferKey.showMangaUi to update.value
+            is OtherSettingsUpdate.WebServiceWakeLock -> PreferKey.webServiceWakeLock to update.value
+            is OtherSettingsUpdate.SourceEditMaxLine -> PreferKey.sourceEditMaxLine to update.value
+            is OtherSettingsUpdate.WebPort -> PreferKey.webPort to update.value
+            is OtherSettingsUpdate.ProcessText -> PreferKey.processText to update.value
+            is OtherSettingsUpdate.RecordLog -> PreferKey.recordLog to update.value
+            is OtherSettingsUpdate.RecordHeapDump -> PreferKey.recordHeapDump to update.value
+        }
+        AppConfigStore.putAll(mapOf(key to value))
+    }
+}

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

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

+ 44 - 42
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -1,48 +1,50 @@
 package io.legado.app.data.repository
 
-import android.content.Context
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.emptyPreferences
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsValue
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.map
-import java.io.IOException
-
-data class ReadAloudPreferences(
-    val ignoreAudioFocus: Boolean = false,
-    val mediaButtonOnExit: Boolean = true,
-    val readAloudByMediaButton: Boolean = false,
-    val pauseReadAloudWhilePhoneCalls: Boolean = false,
-    val readAloudWakeLock: Boolean = false,
-    val mediaButtonPerNext: Boolean = false,
-    val readAloudByPage: Boolean = false,
-    val systemMediaControlCompatibilityChange: Boolean = true,
-    val streamReadAloudAudio: Boolean = false,
-    val ttsTimer: Int = 0,
-    val ttsFollowSys: Boolean = true,
-    val ttsSpeechRate: Int = 5,
-)
+
+typealias ReadAloudPreferences = ReadAloudSettings
 
 class ReadAloudSettingsRepository(
-    private val context: Context,
     private val settingsRepository: SettingsRepository
-) {
-
-    val preferences: Flow<ReadAloudPreferences> = context.dataStore.data
-        .catch { exception ->
-            if (exception is IOException) {
-                emit(emptyPreferences())
-            } else {
-                throw exception
-            }
-        }
+) : ReadAloudSettingsGateway {
+
+    override val currentSettings: ReadAloudSettings
+        get() = AppConfigStore.preferences.toReadAloudPreferences()
+
+    override val settings: Flow<ReadAloudSettings> = AppConfigStore.preferencesFlow
         .map { preferences ->
             preferences.toReadAloudPreferences()
         }
+    val preferences: Flow<ReadAloudPreferences> = settings
+
+    override suspend fun update(update: ReadAloudSettingsUpdate) {
+        when (update) {
+            is ReadAloudSettingsUpdate.IgnoreAudioFocus -> setIgnoreAudioFocus(update.value)
+            is ReadAloudSettingsUpdate.MediaButtonOnExit -> setMediaButtonOnExit(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudByMediaButton -> setReadAloudByMediaButton(update.value)
+            is ReadAloudSettingsUpdate.PauseWhilePhoneCalls -> setPauseReadAloudWhilePhoneCalls(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudWakeLock -> setReadAloudWakeLock(update.value)
+            is ReadAloudSettingsUpdate.MediaButtonPerNext -> setMediaButtonPerNext(update.value)
+            is ReadAloudSettingsUpdate.ReadAloudByPage -> setReadAloudByPage(update.value)
+            is ReadAloudSettingsUpdate.SystemMediaControlCompatibility ->
+                setSystemMediaControlCompatibilityChange(update.value)
+            is ReadAloudSettingsUpdate.StreamAudio -> setStreamReadAloudAudio(update.value)
+            is ReadAloudSettingsUpdate.Timer -> setTtsTimer(update.value)
+            is ReadAloudSettingsUpdate.FollowSystem -> setTtsFollowSys(update.value)
+            is ReadAloudSettingsUpdate.SpeechRate -> setTtsSpeechRate(update.value)
+        }
+    }
 
     suspend fun setIgnoreAudioFocus(value: Boolean) {
         ReadConfig.ignoreAudioFocus = value
@@ -95,19 +97,19 @@ class ReadAloudSettingsRepository(
 
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
-            ignoreAudioFocus = this[Keys.IgnoreAudioFocus] ?: false,
-            mediaButtonOnExit = this[Keys.MediaButtonOnExit] ?: true,
-            readAloudByMediaButton = this[Keys.ReadAloudByMediaButton] ?: false,
-            pauseReadAloudWhilePhoneCalls = this[Keys.PauseReadAloudWhilePhoneCalls] ?: false,
-            readAloudWakeLock = this[Keys.ReadAloudWakeLock] ?: false,
-            mediaButtonPerNext = this[Keys.MediaButtonPerNext] ?: false,
-            readAloudByPage = this[Keys.ReadAloudByPage] ?: false,
+            ignoreAudioFocus = compatDsValue(Keys.IgnoreAudioFocus, false),
+            mediaButtonOnExit = compatDsValue(Keys.MediaButtonOnExit, true),
+            readAloudByMediaButton = compatDsValue(Keys.ReadAloudByMediaButton, false),
+            pauseReadAloudWhilePhoneCalls = compatDsValue(Keys.PauseReadAloudWhilePhoneCalls, false),
+            readAloudWakeLock = compatDsValue(Keys.ReadAloudWakeLock, false),
+            mediaButtonPerNext = compatDsValue(Keys.MediaButtonPerNext, false),
+            readAloudByPage = compatDsValue(Keys.ReadAloudByPage, false),
             systemMediaControlCompatibilityChange =
-                this[Keys.SystemMediaControlCompatibilityChange] ?: true,
-            streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
-            ttsTimer = PlaybackTimer.normalize(this[Keys.TtsTimer] ?: 0),
-            ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
-            ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
+                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),
         )
     }
 

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

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

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

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

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

@@ -7,7 +7,6 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.utils.ChineseUtils
 import kotlinx.coroutines.Dispatchers
@@ -17,7 +16,9 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
 
-class SearchContentRepository {
+class SearchContentRepository(
+    private val titleModeProvider: () -> Int = { 0 },
+) {
 
     private var lastSearchResults: List<SearchResult>? = null
     private var lastQueryKey: String? = null
@@ -126,7 +127,7 @@ class SearchContentRepository {
         replaceEnabled: Boolean,
         regexReplace: Boolean
     ): String {
-        return "$bookUrl-$query-$replaceEnabled-$regexReplace-${ReadBookConfig.titleMode}"
+        return "$bookUrl-$query-$replaceEnabled-$regexReplace-${titleModeProvider()}"
     }
 
     private suspend fun searchChapter(
@@ -153,7 +154,7 @@ class SearchContentRepository {
             includeTitle = false,
             useReplace = replaceEnabled
         )
-        val includeTitle = ReadBookConfig.titleMode != 2 ||
+        val includeTitle = titleModeProvider() != 2 ||
                 chapter.isVolume ||
                 bodyContent.textList.isEmpty()
         val mContent = if (includeTitle) {

+ 56 - 36
app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt

@@ -6,7 +6,6 @@ import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.SharedPreferencesMigration
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
@@ -15,10 +14,17 @@ import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import io.legado.app.constant.PreferKey
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.localDataStore
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsValue
+import io.legado.app.help.config.rawPrefValue
+import io.legado.app.help.config.setPrefValue
+import java.io.IOException
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
-import java.io.IOException
 
 val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
     name = "settings",
@@ -28,11 +34,41 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
                 context,
                 "${context.packageName}_preferences"
             ),
+            LocalUiStatusMigration(context),
             ShowBrightnessViewMigration,
         )
     }
 )
 
+internal class LocalUiStatusMigration(
+    private val context: Context,
+) : DataMigration<Preferences> {
+
+    override suspend fun shouldMigrate(currentData: Preferences): Boolean =
+        currentData[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] != true
+
+    override suspend fun migrate(currentData: Preferences): Preferences {
+        val localPreferences = context.localDataStore.data
+            .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+            .first()
+        return mergeMissingLocalPreferences(currentData, localPreferences)
+    }
+
+    override suspend fun cleanUp() = Unit
+}
+
+internal fun mergeMissingLocalPreferences(
+    currentData: Preferences,
+    localPreferences: Preferences,
+): Preferences = currentData.toMutablePreferences().apply {
+    localPreferences.asMap().forEach { (key, value) ->
+        if (currentData.rawPrefValue(key.name) == null) {
+            setPrefValue(key.name, value)
+        }
+    }
+    this[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] = true
+}
+
 internal object ShowBrightnessViewMigration : DataMigration<Preferences> {
 
     private val booleanKey = booleanPreferencesKey(PreferKey.showBrightnessView)
@@ -61,29 +97,23 @@ internal object ShowBrightnessViewMigration : DataMigration<Preferences> {
 
 /**
  * 设置仓储
- * 以 DataStore 为唯一写入源,读取以 DataStore 为准。
+ * 以 DataStore 为唯一持久化源,通过 [AppConfigStore] 的有效快照统一读写。
  */
-class SettingsRepository(private val context: Context) {
-
-    private val dataStore = context.dataStore
-
-    fun <T> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
-        return dataStore.data
-            .catch { exception ->
-                if (exception is IOException) {
-                    emit(emptyPreferences())
-                } else {
-                    throw exception
-                }
-            }
-            .map { preferences ->
-                preferences[key] ?: defaultValue
+class SettingsRepository {
+
+    fun <T : Any> getPreference(key: Preferences.Key<T>, defaultValue: T): Flow<T> =
+        AppConfigStore.preferencesFlow.map { it.compatDsValue(key, defaultValue) }
+
+    suspend fun <T : Any> updatePreference(key: Preferences.Key<T>, value: T) {
+        when (value) {
+            is String -> AppConfigStore.putString(key.name, value)
+            is Int -> AppConfigStore.putInt(key.name, value)
+            is Boolean -> AppConfigStore.putBoolean(key.name, value)
+            is Long -> AppConfigStore.putLong(key.name, value)
+            is Float -> AppConfigStore.putFloat(key.name, value)
+            is Set<*> -> @Suppress("UNCHECKED_CAST") {
+                AppConfigStore.putStringSet(key.name, value as Set<String>)
             }
-    }
-
-    suspend fun <T> updatePreference(key: Preferences.Key<T>, value: T) {
-        dataStore.edit { preferences ->
-            preferences[key] = value
         }
     }
 
@@ -92,14 +122,10 @@ class SettingsRepository(private val context: Context) {
         getPreference(stringPreferencesKey(key), defaultValue)
 
     suspend fun putString(key: String, value: String) =
-        updatePreference(stringPreferencesKey(key), value)
+        AppConfigStore.putString(key, value)
 
     suspend fun putStrings(values: Map<String, String>) {
-        dataStore.edit { preferences ->
-            values.forEach { (key, value) ->
-                preferences[stringPreferencesKey(key)] = value
-            }
-        }
+        AppConfigStore.putAll(values)
     }
 
     // Int 类型的快捷访问
@@ -139,12 +165,6 @@ class SettingsRepository(private val context: Context) {
 
     // 移除配置
     suspend fun remove(key: String) {
-        dataStore.edit { preferences ->
-            preferences.remove(stringPreferencesKey(key))
-            preferences.remove(intPreferencesKey(key))
-            preferences.remove(booleanPreferencesKey(key))
-            preferences.remove(longPreferencesKey(key))
-            preferences.remove(floatPreferencesKey(key))
-        }
+        AppConfigStore.remove(key)
     }
 }

+ 19 - 4
app/src/main/java/io/legado/app/di/appModule.kt

@@ -6,7 +6,6 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.decode.SvgDecoder
 import io.legado.app.data.AppDatabase
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiMemoryRepository
@@ -15,6 +14,7 @@ import io.legado.app.data.repository.AiPromptPresetRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.AppStartupRepository
+import io.legado.app.data.repository.AppShellSettingsRepository
 import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookContentProcessRepository
@@ -36,6 +36,7 @@ import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.LocalBookRepository
+import io.legado.app.data.repository.OtherSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadRecordRepository
@@ -48,6 +49,7 @@ import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.data.repository.ThemeSettingsRepository
 import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.UploadRepository
@@ -61,6 +63,7 @@ import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.AppStartupGateway
+import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
@@ -74,7 +77,11 @@ import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.LocalBookGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
@@ -116,6 +123,7 @@ import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.config.ThemePackageManager
+import io.legado.app.ui.config.readConfig.ApplyReadSettingUseCase
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.ui.about.AboutViewModel
@@ -153,6 +161,7 @@ import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
 import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
+import io.legado.app.ui.config.customTheme.CustomThemeViewModel
 import io.legado.app.ui.config.readConfig.ReadConfigViewModel
 import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
 import io.legado.app.ui.config.themeManage.ThemeManageViewModel
@@ -199,15 +208,20 @@ val appModule = module {
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
     singleOf(::TxtTocRuleRepository)
-    singleOf(::SearchContentRepository)
+    single { SearchContentRepository { io.legado.app.help.config.ReadBookConfig.titleMode } }
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
+    single<AppShellSettingsGateway> { AppShellSettingsRepository() }
+    single<ThemeSettingsGateway> { ThemeSettingsRepository() }
+    single<OtherSettingsGateway> { OtherSettingsRepository() }
     singleOf(::ReadSettingsRepository)
+    single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
+    single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
+    singleOf(::ApplyReadSettingUseCase)
     singleOf(::HighlightRuleRepository)
     singleOf(::ReadStyleRepository)
     singleOf(::ReadBookStyleConfigRepository)
-    singleOf(::LocalPreferencesRepository)
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)
@@ -321,6 +335,7 @@ val appModule = module {
     viewModelOf(::TxtTocRuleViewModel)
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
     viewModelOf(::OtherConfigViewModel)
+    viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)
     viewModelOf(::CoverAlbumManageViewModel)
@@ -387,6 +402,7 @@ val appModule = module {
         BookshelfManageScreenViewModel(
             application = get(),
             bookRepository = get(),
+            bookSourceRepository = get(),
             bookGroupRepository = get(),
             searchRepository = get(),
             bookshelfManageScreenConfig = get(),
@@ -417,4 +433,3 @@ val appModule = module {
         )
     }
 }
-

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

@@ -0,0 +1,18 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.AppShellSettings
+import kotlinx.coroutines.flow.Flow
+
+interface AppShellSettingsGateway {
+    val currentSettings: AppShellSettings
+    val settings: Flow<AppShellSettings>
+    suspend fun update(update: AppShellSettingsUpdate)
+}
+
+sealed interface AppShellSettingsUpdate {
+    data class ThemeMode(val value: String) : AppShellSettingsUpdate
+    data class Language(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
+}

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

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

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

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

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

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

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

@@ -0,0 +1,38 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.settings.ThemeSettings
+import kotlinx.coroutines.flow.Flow
+
+interface ThemeSettingsGateway {
+    val currentSettings: ThemeSettings
+    val settings: Flow<ThemeSettings>
+    suspend fun update(update: ThemeSettingsUpdate)
+}
+
+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
+}
+
+enum class ThemeColorSlot {
+    Primary,
+    Secondary,
+    PrimaryText,
+    SecondaryText,
+    Background,
+    LabelContainer,
+    PrimaryNight,
+    SecondaryNight,
+    PrimaryTextNight,
+    SecondaryTextNight,
+    BackgroundNight,
+    LabelContainerNight,
+}

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

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

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

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

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

@@ -0,0 +1,16 @@
+package io.legado.app.domain.model.settings
+
+data class ReadAloudSettings(
+    val ignoreAudioFocus: Boolean = false,
+    val mediaButtonOnExit: Boolean = true,
+    val readAloudByMediaButton: Boolean = false,
+    val pauseReadAloudWhilePhoneCalls: Boolean = false,
+    val readAloudWakeLock: Boolean = false,
+    val mediaButtonPerNext: Boolean = false,
+    val readAloudByPage: Boolean = false,
+    val systemMediaControlCompatibilityChange: Boolean = true,
+    val streamReadAloudAudio: Boolean = false,
+    val ttsTimer: Int = 0,
+    val ttsFollowSys: Boolean = true,
+    val ttsSpeechRate: Int = 5,
+)

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

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

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

@@ -0,0 +1,59 @@
+package io.legado.app.domain.model.settings
+
+data class ThemeSettings(
+    val appTheme: String = "0",
+    val isPureBlack: Boolean = false,
+    val paletteStyle: String = "tonalSpot",
+    val materialVersion: String = "material3",
+    val customContrast: String = "Default",
+    val appFontPath: String? = null,
+    val customPrimary: Int = 0,
+    val customNightPrimary: Int = 0,
+    val enableDeepPersonalization: Boolean = false,
+    val themeColor: Int = 0,
+    val secondaryThemeColor: Int = 0,
+    val primaryTextColor: Int = 0,
+    val secondaryTextColor: Int = 0,
+    val themeBackgroundColor: Int = 0,
+    val labelContainerColor: Int = 0,
+    val themeColorNight: Int = 0,
+    val secondaryThemeColorNight: Int = 0,
+    val primaryTextColorNight: Int = 0,
+    val secondaryTextColorNight: Int = 0,
+    val themeBackgroundColorNight: Int = 0,
+    val labelContainerColorNight: Int = 0,
+)
+
+data class ThemeCustomColors(
+    val primary: Int,
+    val secondary: Int,
+    val primaryText: Int,
+    val secondaryText: Int,
+    val background: Int,
+    val labelContainer: Int,
+) {
+    val hasCustomColor: Boolean
+        get() = primary != 0 || secondary != 0 || primaryText != 0 ||
+            secondaryText != 0 || background != 0 || labelContainer != 0
+}
+
+fun ThemeSettings.customColors(isDark: Boolean): ThemeCustomColors =
+    if (isDark) {
+        ThemeCustomColors(
+            primary = themeColorNight.takeIf { it != 0 } ?: themeColor,
+            secondary = secondaryThemeColorNight.takeIf { it != 0 } ?: secondaryThemeColor,
+            primaryText = primaryTextColorNight.takeIf { it != 0 } ?: primaryTextColor,
+            secondaryText = secondaryTextColorNight.takeIf { it != 0 } ?: secondaryTextColor,
+            background = themeBackgroundColorNight.takeIf { it != 0 } ?: themeBackgroundColor,
+            labelContainer = labelContainerColorNight.takeIf { it != 0 } ?: labelContainerColor,
+        )
+    } else {
+        ThemeCustomColors(
+            primary = themeColor,
+            secondary = secondaryThemeColor,
+            primaryText = primaryTextColor,
+            secondaryText = secondaryTextColor,
+            background = themeBackgroundColor,
+            labelContainer = labelContainerColor,
+        )
+    }

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

@@ -4,7 +4,7 @@ import android.content.Context
 import android.content.SharedPreferences
 import androidx.core.content.edit
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.utils.getBoolean
 import io.legado.app.utils.putBoolean
 import io.legado.app.utils.putLong
@@ -21,8 +21,8 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
 
     private const val versionCodeKey = "appVersionCode"
 
-    private val localPreferencesRepository: LocalPreferencesRepository
-        by lazy { get(LocalPreferencesRepository::class.java) }
+    private val localPreferencesRepository: SettingsRepository
+        by lazy { get(SettingsRepository::class.java) }
 
     /**
      * 本地密码,用来对需要备份的敏感信息加密,如 webdav 配置等

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

@@ -71,6 +71,24 @@ fun Preferences.compatDsStringSet(key: String): Set<String>? = when (val raw = r
     else -> null
 }
 
+/**
+ * 按默认值的运行时类型读取设置,并兼容历史版本中同名 key 的存储类型漂移。
+ *
+ * 该入口用于数据层的类型化设置映射,避免每个 repository 重复实现 String/Int/Boolean
+ * 兼容规则。调用方必须传入非空、可识别类型的默认值。
+ */
+@Suppress("UNCHECKED_CAST")
+fun <T : Any> Preferences.compatDsValue(key: Preferences.Key<T>, defaultValue: T): T =
+    when (defaultValue) {
+        is String -> (compatDsString(key.name) ?: defaultValue) as T
+        is Int -> (compatDsInt(key.name) ?: defaultValue) as T
+        is Boolean -> (compatDsBoolean(key.name) ?: defaultValue) as T
+        is Long -> (compatDsLong(key.name) ?: defaultValue) as T
+        is Float -> (compatDsFloat(key.name) ?: defaultValue) as T
+        is Set<*> -> (compatDsStringSet(key.name) ?: defaultValue) as T
+        else -> defaultValue
+    }
+
 /**
  * 按 value 的运行时类型写入对应类型的 key;value 为 null 表示移除。
  * 由于 Key 按 name 判等,写入会覆盖同名的旧类型条目,移除一次即可清掉任意类型的条目。

+ 2 - 1
app/src/main/java/io/legado/app/help/config/SettingsWriter.kt

@@ -30,7 +30,8 @@ object SettingsWriter {
 
     /**
      * 串行写队列:所有经 [AppConfigStore] 的写入按提交顺序落盘。
-     * 写入失败仅吞掉异常(与旧 PrefDelegate 行为一致),此时值仍在内存快照中,重启后丢失。
+     * 写入异常由提交的 block 负责处理;[AppConfigStore] 会移除对应 pending overlay,
+     * 使读取回退到最近一次已落盘快照。
      */
     fun launchWrite(block: suspend () -> Unit): Job {
         pendingCount.incrementAndGet()

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

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

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

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

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

@@ -8,7 +8,8 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.coroutine.Coroutine
@@ -23,6 +24,7 @@ import io.legado.app.utils.servicePendingIntent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 
@@ -31,7 +33,9 @@ import org.koin.core.component.inject
  */
 class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener, KoinComponent {
 
-    private val localPreferencesRepository: LocalPreferencesRepository by inject()
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway by inject()
+    @Volatile
+    private var speechRateSetting: Int = 5
 
     private var textToSpeech: TextToSpeech? = null
     private var ttsInitFinish = false
@@ -44,6 +48,12 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
 
     override fun onCreate() {
         super.onCreate()
+        speechRateSetting = readAloudSettingsGateway.currentSettings.ttsSpeechRate
+        lifecycleScope.launch {
+            readAloudSettingsGateway.settings.collect {
+                speechRateSetting = it.ttsSpeechRate
+            }
+        }
         initTts()
     }
 
@@ -212,7 +222,7 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
                 initTts()
             }
         } else {
-            val speechRate = (localPreferencesRepository.appSettings.value.readAloudSpeed + 5) / 10f
+            val speechRate = (speechRateSetting + 5) / 10f
             textToSpeech?.setSpeechRate(speechRate)
             if (reset && !pause) {
                 play()

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

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

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

@@ -12,7 +12,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import android.content.res.Configuration
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.utils.stackTraceStr
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -51,7 +51,7 @@ class ExploreShowViewModel(
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -37,6 +37,8 @@ import io.legado.app.utils.sendToClip
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.collections.immutable.persistentListOf
+import io.legado.app.data.entities.BookGroup
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
@@ -205,6 +207,8 @@ fun BookInfoRouteScreen(
 
     BookInfoScreen(
         state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        groups = viewModel.allGroups
+            .collectAsStateWithLifecycle(persistentListOf<BookGroup>()).value,
         onIntent = viewModel::onIntent,
         onBack = onBack,
         sharedTransitionScope = sharedTransitionScope,

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

@@ -70,12 +70,12 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import coil.ImageLoader
 import coil.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.homepage.modules.BannerModule
@@ -127,6 +127,7 @@ import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar
 @Composable
 fun BookInfoScreen(
     state: BookInfoUiState,
+    groups: ImmutableList<BookGroup>,
     onIntent: (BookInfoIntent) -> Unit,
     onBack: () -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -173,6 +174,7 @@ fun BookInfoScreen(
     }) {
         BookInfoScreenContent(
             state = state,
+            groups = groups,
             onIntent = onIntent,
             onBack = onBack,
             sharedTransitionScope = sharedTransitionScope,
@@ -195,6 +197,7 @@ fun BookInfoScreen(
 @Composable
 private fun BookInfoScreenContent(
     state: BookInfoUiState,
+    groups: ImmutableList<BookGroup>,
     onIntent: (BookInfoIntent) -> Unit,
     onBack: () -> Unit,
     sharedTransitionScope: SharedTransitionScope?,
@@ -350,7 +353,6 @@ private fun BookInfoScreenContent(
             onSelect = { onIntent(BookInfoIntent.SelectCover(it)) },
         )
         BookInfoSheet.GroupPicker -> {
-            val groups by koinInject<io.legado.app.data.repository.BookGroupRepository>().flowSelect().collectAsStateWithLifecycle(initialValue = emptyList())
             GroupSelectSheet(
                 show = currentSheet == BookInfoSheet.GroupPicker,
                 groups = groups,

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

@@ -80,6 +80,7 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -95,7 +96,7 @@ class BookInfoViewModel(
     private val imageLoader: ImageLoader,
 ) : BaseViewModel(application) {
 
-    val allGroups = bookGroupRepository.flowAll()
+    val allGroups = bookGroupRepository.flowSelect().map { it.toImmutableList() }
 
     private val _uiState = MutableStateFlow(BookInfoUiState())
     val uiState = _uiState.asStateFlow()

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

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

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

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

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

@@ -30,7 +30,7 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -160,7 +160,7 @@ class ReadBookViewModel(
     private val readSettingsRepository: ReadSettingsRepository,
     private val readBookStyleConfigRepository: ReadBookStyleConfigRepository,
     private val readAloudSettingsRepository: ReadAloudSettingsRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
     private val highlightRuleRepository: HighlightRuleRepository,
     private val uploadRepository: UploadRepository,
     private val changeBookSourceUseCase: ChangeBookSourceUseCase,

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -7,7 +7,7 @@ import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -46,7 +46,7 @@ enum class DisplayMode {
 class ReadRecordViewModel(
     private val repository: ReadRecordRepository,
     private val bookRepository: BookRepository,
-    private val localPreferencesRepository: LocalPreferencesRepository
+    private val localPreferencesRepository: SettingsRepository
 ) : ViewModel() {
 
     private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.MatchMode
@@ -49,7 +49,7 @@ class SearchViewModel(
     private val searchBooksUseCase: SearchBooksUseCase,
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
-    private val localPreferencesRepository: LocalPreferencesRepository,
+    private val localPreferencesRepository: SettingsRepository,
 ) : ViewModel() {
 
     val searchLayoutMode = localPreferencesRepository

+ 48 - 0
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeContract.kt

@@ -0,0 +1,48 @@
+package io.legado.app.ui.config.customTheme
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.gateway.ThemeColorSlot
+
+@Stable
+data class CustomThemeUiState(
+    val enableDeepPersonalization: Boolean = false,
+    val themeColor: Int = 0,
+    val secondaryThemeColor: Int = 0,
+    val primaryTextColor: Int = 0,
+    val secondaryTextColor: Int = 0,
+    val themeBackgroundColor: Int = 0,
+    val labelContainerColor: Int = 0,
+    val themeColorNight: Int = 0,
+    val secondaryThemeColorNight: Int = 0,
+    val primaryTextColorNight: Int = 0,
+    val secondaryTextColorNight: Int = 0,
+    val themeBackgroundColorNight: Int = 0,
+    val labelContainerColorNight: Int = 0,
+    val primarySeedColor: Int = 0,
+    val nightPrimarySeedColor: Int = 0,
+    val paletteStyle: String = "tonalSpot",
+    val customContrast: String = "Default",
+    val materialVersion: String = "material3",
+    val activePicker: CustomThemePicker? = null,
+)
+
+sealed interface CustomThemePicker {
+    data class DeepColor(val slot: ThemeColorSlot) : CustomThemePicker
+    data object DaySeed : CustomThemePicker
+    data object NightSeed : CustomThemePicker
+}
+
+sealed interface CustomThemeIntent {
+    data class DeepPersonalizationChanged(val value: Boolean) : CustomThemeIntent
+    data class PaletteStyleChanged(val value: String) : CustomThemeIntent
+    data class CustomContrastChanged(val value: String) : CustomThemeIntent
+    data class MaterialVersionChanged(val value: String) : CustomThemeIntent
+    data class OpenPicker(val picker: CustomThemePicker) : CustomThemeIntent
+    data object DismissPicker : CustomThemeIntent
+    data class ColorSelected(val value: Int) : CustomThemeIntent
+}
+
+sealed interface CustomThemeEffect {
+    data class ApplyLegacyPrimarySeed(val color: Int) : CustomThemeEffect
+    data class SettingsUpdateFailed(val message: String) : CustomThemeEffect
+}

+ 41 - 0
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeRouteScreen.kt

@@ -0,0 +1,41 @@
+package io.legado.app.ui.config.customTheme
+
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.constant.EventBus
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.utils.postEvent
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun CustomThemeRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: CustomThemeViewModel = koinViewModel(),
+) {
+    val context = LocalContext.current
+    val state = viewModel.uiState.collectAsStateWithLifecycle().value
+
+    LaunchedEffect(viewModel, context) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is CustomThemeEffect.ApplyLegacyPrimarySeed -> {
+                    ThemeStore.editTheme(context).primaryColor(effect.color).apply()
+                    postEvent(EventBus.RECREATE, "")
+                }
+                is CustomThemeEffect.SettingsUpdateFailed -> {
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+                }
+            }
+        }
+    }
+
+    CustomThemeScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+}

+ 77 - 141
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt

@@ -10,23 +10,15 @@ import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
 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.constant.EventBus
-import io.legado.app.lib.theme.ThemeStore
-import io.legado.app.ui.config.themeConfig.ThemeConfig
+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
@@ -37,40 +29,15 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
-import io.legado.app.utils.postEvent
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun CustomThemeScreen(
-    onBackClick: () -> Unit
+    state: CustomThemeUiState,
+    onIntent: (CustomThemeIntent) -> Unit,
+    onBackClick: () -> Unit,
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-    var showColorPicker by remember { mutableStateOf(false) }
-    var currentColorKey by remember { mutableStateOf("themeColor") }
-    val context = LocalContext.current
-
-    val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization
-
-    val themeColor = ThemeConfig.themeColor
-    val secondaryThemeColor = ThemeConfig.secondaryThemeColor
-    val primaryTextColor = ThemeConfig.primaryTextColor
-    val secondaryTextColor = ThemeConfig.secondaryTextColor
-    val themeBackgroundColor = ThemeConfig.themeBackgroundColor
-    val labelContainerColor = ThemeConfig.labelContainerColor
-    val themeColorNight = ThemeConfig.themeColorNight
-    val secondaryThemeColorNight = ThemeConfig.secondaryThemeColorNight
-    val primaryTextColorNight = ThemeConfig.primaryTextColorNight
-    val secondaryTextColorNight = ThemeConfig.secondaryTextColorNight
-    val themeBackgroundColorNight = ThemeConfig.themeBackgroundColorNight
-    val labelContainerColorNight = ThemeConfig.labelContainerColorNight
-
-    val primaryColor = MaterialTheme.colorScheme.primary
-
-    // 自定义主题 seed color
-    var showSeedColorPicker by remember { mutableStateOf(false) }
-    var pickNightSeedColor by remember { mutableStateOf(false) }
-    val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) }
-    val nightPrimaryColorValue = remember { mutableIntStateOf(ThemeConfig.cNPrimary) }
 
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -96,44 +63,40 @@ fun CustomThemeScreen(
                 SplicedColumnGroup {
                     SwitchSettingItem(
                         title = stringResource(R.string.theme_manage_use_palette_colors),
-                        checked = !enableDeepPersonalization,
-                        onCheckedChange = { ThemeConfig.enableDeepPersonalization = !it }
+                        checked = !state.enableDeepPersonalization,
+                        onCheckedChange = {
+                            onIntent(CustomThemeIntent.DeepPersonalizationChanged(!it))
+                        }
                     )
                 }
             }
 
-            // Color settings vs Seed color toggle based on enableDeepPersonalization
-            if (enableDeepPersonalization) {
+            // Color settings vs Seed color toggle based on state.enableDeepPersonalization
+            if (state.enableDeepPersonalization) {
                 item {
                     CustomColorSettings(
                         title = stringResource(R.string.day),
-                        primary = themeColor,
-                        secondary = secondaryThemeColor,
-                        primaryText = primaryTextColor,
-                        secondaryText = secondaryTextColor,
-                        background = themeBackgroundColor,
-                        labelContainer = labelContainerColor,
+                        primary = state.themeColor,
+                        secondary = state.secondaryThemeColor,
+                        primaryText = state.primaryTextColor,
+                        secondaryText = state.secondaryTextColor,
+                        background = state.themeBackgroundColor,
+                        labelContainer = state.labelContainerColor,
                         keySuffix = "",
-                        onSelect = {
-                            currentColorKey = it
-                            showColorPicker = true
-                        },
+                        onSelect = { onIntent(CustomThemeIntent.OpenPicker(CustomThemePicker.DeepColor(it))) },
                     )
                 }
                 item {
                     CustomColorSettings(
                         title = stringResource(R.string.night),
-                        primary = themeColorNight,
-                        secondary = secondaryThemeColorNight,
-                        primaryText = primaryTextColorNight,
-                        secondaryText = secondaryTextColorNight,
-                        background = themeBackgroundColorNight,
-                        labelContainer = labelContainerColorNight,
+                        primary = state.themeColorNight,
+                        secondary = state.secondaryThemeColorNight,
+                        primaryText = state.primaryTextColorNight,
+                        secondaryText = state.secondaryTextColorNight,
+                        background = state.themeBackgroundColorNight,
+                        labelContainer = state.labelContainerColorNight,
                         keySuffix = "Night",
-                        onSelect = {
-                            currentColorKey = it
-                            showColorPicker = true
-                        },
+                        onSelect = { onIntent(CustomThemeIntent.OpenPicker(CustomThemePicker.DeepColor(it))) },
                     )
                 }
             } else {
@@ -142,45 +105,39 @@ fun CustomThemeScreen(
                         ClickableSettingItem(
                             title = stringResource(R.string.seed_color),
                             description = stringResource(R.string.day),
-                            option = formatColorOption(primaryColorValue.intValue)
+                            option = formatColorOption(state.primarySeedColor)
                                 ?: stringResource(R.string.click_to_select),
-                            onClick = {
-                                pickNightSeedColor = false
-                                showSeedColorPicker = true
-                            },
-                            trailingContent = { ColorSwatch(colorValue = primaryColorValue.intValue) }
+                            onClick = { onIntent(CustomThemeIntent.OpenPicker(CustomThemePicker.DaySeed)) },
+                            trailingContent = { ColorSwatch(colorValue = state.primarySeedColor) }
                         )
                         ClickableSettingItem(
                             title = stringResource(R.string.seed_color),
                             description = stringResource(R.string.night),
-                            option = formatColorOption(nightPrimaryColorValue.intValue)
+                            option = formatColorOption(state.nightPrimarySeedColor)
                                 ?: stringResource(R.string.click_to_select),
-                            onClick = {
-                                pickNightSeedColor = true
-                                showSeedColorPicker = true
-                            },
-                            trailingContent = { ColorSwatch(colorValue = nightPrimaryColorValue.intValue) }
+                            onClick = { onIntent(CustomThemeIntent.OpenPicker(CustomThemePicker.NightSeed)) },
+                            trailingContent = { ColorSwatch(colorValue = state.nightPrimarySeedColor) }
                         )
                         DropdownListSettingItem(
                             title = stringResource(R.string.palette_style),
-                            selectedValue = ThemeConfig.paletteStyle,
+                            selectedValue = state.paletteStyle,
                             displayEntries = stringArrayResource(R.array.paletteStyle),
                             entryValues = stringArrayResource(R.array.paletteStyle_value),
-                            onValueChange = { ThemeConfig.paletteStyle = it }
+                            onValueChange = { onIntent(CustomThemeIntent.PaletteStyleChanged(it)) }
                         )
                         DropdownListSettingItem(
                             title = stringResource(R.string.preferred_contrast),
-                            selectedValue = ThemeConfig.customContrast,
+                            selectedValue = state.customContrast,
                             displayEntries = stringArrayResource(R.array.customContrast),
                             entryValues = stringArrayResource(R.array.customContrast_value),
-                            onValueChange = { ThemeConfig.customContrast = it }
+                            onValueChange = { onIntent(CustomThemeIntent.CustomContrastChanged(it)) }
                         )
                         DropdownListSettingItem(
                             title = stringResource(R.string.material_version),
-                            selectedValue = ThemeConfig.materialVersion,
+                            selectedValue = state.materialVersion,
                             displayEntries = stringArrayResource(R.array.materialVersion),
                             entryValues = stringArrayResource(R.array.materialVersion_value),
-                            onValueChange = { ThemeConfig.materialVersion = it }
+                            onValueChange = { onIntent(CustomThemeIntent.MaterialVersionChanged(it)) }
                         )
                     }
                 }
@@ -188,65 +145,19 @@ fun CustomThemeScreen(
 
         }
 
-        // Deep personalization color picker
         ColorPickerSheet(
-            show = showColorPicker,
-            initialColor = when (currentColorKey) {
-                "themeColor" -> themeColor
-                "secondaryThemeColor" -> secondaryThemeColor
-                "primaryTextColor" -> primaryTextColor
-                "secondaryTextColor" -> secondaryTextColor
-                "themeBackgroundColor" -> themeBackgroundColor
-                "labelContainerColor" -> labelContainerColor
-                "themeColorNight" -> themeColorNight
-                "secondaryThemeColorNight" -> secondaryThemeColorNight
-                "primaryTextColorNight" -> primaryTextColorNight
-                "secondaryTextColorNight" -> secondaryTextColorNight
-                "themeBackgroundColorNight" -> themeBackgroundColorNight
-                "labelContainerColorNight" -> labelContainerColorNight
-                else -> 0
-            },
-            onDismissRequest = { showColorPicker = false },
-            onColorSelected = {
-                when (currentColorKey) {
-                    "themeColor" -> ThemeConfig.themeColor = it
-                    "secondaryThemeColor" -> ThemeConfig.secondaryThemeColor = it
-                    "primaryTextColor" -> ThemeConfig.primaryTextColor = it
-                    "secondaryTextColor" -> ThemeConfig.secondaryTextColor = it
-                    "themeBackgroundColor" -> ThemeConfig.themeBackgroundColor = it
-                    "labelContainerColor" -> ThemeConfig.labelContainerColor = it
-                    "themeColorNight" -> ThemeConfig.themeColorNight = it
-                    "secondaryThemeColorNight" -> ThemeConfig.secondaryThemeColorNight = it
-                    "primaryTextColorNight" -> ThemeConfig.primaryTextColorNight = it
-                    "secondaryTextColorNight" -> ThemeConfig.secondaryTextColorNight = it
-                    "themeBackgroundColorNight" -> ThemeConfig.themeBackgroundColorNight = it
-                    "labelContainerColorNight" -> ThemeConfig.labelContainerColorNight = it
-                }
-            }
+            show = state.activePicker is CustomThemePicker.DeepColor,
+            initialColor = state.colorForPicker(),
+            onDismissRequest = { onIntent(CustomThemeIntent.DismissPicker) },
+            onColorSelected = { onIntent(CustomThemeIntent.ColorSelected(it)) },
         )
 
-        // Seed color picker (from ThemeConfigScreen)
         ColorPickerSheet(
-            show = showSeedColorPicker,
-            initialColor = if (pickNightSeedColor) {
-                nightPrimaryColorValue.value
-            } else {
-                primaryColorValue.value
-            },
-            onDismissRequest = { showSeedColorPicker = false },
-            onColorSelected = { color ->
-                if (pickNightSeedColor) {
-                    nightPrimaryColorValue.intValue = color
-                    ThemeConfig.cNPrimary = color
-                } else {
-                    primaryColorValue.intValue = color
-                    ThemeConfig.cPrimary = color
-                    ThemeStore.editTheme(context)
-                        .primaryColor(color)
-                        .apply()
-                    postEvent(EventBus.RECREATE, "")
-                }
-            }
+            show = state.activePicker == CustomThemePicker.DaySeed ||
+                state.activePicker == CustomThemePicker.NightSeed,
+            initialColor = state.colorForPicker(),
+            onDismissRequest = { onIntent(CustomThemeIntent.DismissPicker) },
+            onColorSelected = { onIntent(CustomThemeIntent.ColorSelected(it)) },
         )
 
     }
@@ -262,38 +173,43 @@ private fun CustomColorSettings(
     background: Int,
     labelContainer: Int,
     keySuffix: String,
-    onSelect: (String) -> Unit,
+    onSelect: (ThemeColorSlot) -> Unit,
 ) {
     SplicedColumnGroup(title = title) {
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_primary_color),
             colorValue = primary,
-            onClick = { onSelect("themeColor$keySuffix") },
+            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.Primary else ThemeColorSlot.PrimaryNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_secondary_color),
             colorValue = secondary,
-            onClick = { onSelect("secondaryThemeColor$keySuffix") },
+            onClick = {
+                onSelect(
+                    if (keySuffix.isEmpty()) ThemeColorSlot.Secondary
+                    else ThemeColorSlot.SecondaryNight
+                )
+            },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_primary_text_color),
             colorValue = primaryText,
-            onClick = { onSelect("primaryTextColor$keySuffix") },
+            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.PrimaryText else ThemeColorSlot.PrimaryTextNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_secondary_text_color),
             colorValue = secondaryText,
-            onClick = { onSelect("secondaryTextColor$keySuffix") },
+            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.SecondaryText else ThemeColorSlot.SecondaryTextNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_background_color),
             colorValue = background,
-            onClick = { onSelect("themeBackgroundColor$keySuffix") },
+            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.Background else ThemeColorSlot.BackgroundNight) },
         )
         CustomColorSettingItem(
             title = stringResource(R.string.theme_manage_label_container_color),
             colorValue = labelContainer,
-            onClick = { onSelect("labelContainerColor$keySuffix") },
+            onClick = { onSelect(if (keySuffix.isEmpty()) ThemeColorSlot.LabelContainer else ThemeColorSlot.LabelContainerNight) },
         )
     }
 }
@@ -332,3 +248,23 @@ 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
+    }
+    CustomThemePicker.DaySeed -> primarySeedColor
+    CustomThemePicker.NightSeed -> nightPrimarySeedColor
+    null -> 0
+}

+ 98 - 0
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeViewModel.kt

@@ -0,0 +1,98 @@
+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
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class CustomThemeViewModel(
+    private val themeSettingsGateway: ThemeSettingsGateway,
+) : ViewModel() {
+    private val _uiState = MutableStateFlow(CustomThemeUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<CustomThemeEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            themeSettingsGateway.settings.collect { settings ->
+                _uiState.update { settings.toUiState(it.activePicker) }
+            }
+        }
+    }
+
+    fun onIntent(intent: CustomThemeIntent) {
+        when (intent) {
+            is CustomThemeIntent.DeepPersonalizationChanged ->
+                update(ThemeSettingsUpdate.DeepPersonalization(intent.value))
+            is CustomThemeIntent.PaletteStyleChanged ->
+                update(ThemeSettingsUpdate.PaletteStyle(intent.value))
+            is CustomThemeIntent.CustomContrastChanged ->
+                update(ThemeSettingsUpdate.CustomContrast(intent.value))
+            is CustomThemeIntent.MaterialVersionChanged ->
+                update(ThemeSettingsUpdate.MaterialVersion(intent.value))
+            is CustomThemeIntent.OpenPicker ->
+                _uiState.update { it.copy(activePicker = intent.picker) }
+            CustomThemeIntent.DismissPicker ->
+                _uiState.update { it.copy(activePicker = null) }
+            is CustomThemeIntent.ColorSelected -> selectColor(intent.value)
+        }
+    }
+
+    private fun selectColor(color: Int) {
+        when (val picker = _uiState.value.activePicker) {
+            is CustomThemePicker.DeepColor ->
+                update(ThemeSettingsUpdate.CustomColor(picker.slot, color))
+            CustomThemePicker.DaySeed -> {
+                update(ThemeSettingsUpdate.CustomPrimary(color))
+                _effects.tryEmit(CustomThemeEffect.ApplyLegacyPrimarySeed(color))
+            }
+            CustomThemePicker.NightSeed -> update(ThemeSettingsUpdate.CustomNightPrimary(color))
+            null -> return
+        }
+        _uiState.update { it.copy(activePicker = null) }
+    }
+
+    private fun update(update: ThemeSettingsUpdate) {
+        viewModelScope.launch {
+            runCatching { themeSettingsGateway.update(update) }
+                .onFailure { error ->
+                    _effects.tryEmit(
+                        CustomThemeEffect.SettingsUpdateFailed(
+                            error.message ?: error.javaClass.simpleName
+                        )
+                    )
+                }
+        }
+    }
+}
+
+private fun ThemeSettings.toUiState(activePicker: CustomThemePicker?) = CustomThemeUiState(
+    enableDeepPersonalization = enableDeepPersonalization,
+    themeColor = themeColor,
+    secondaryThemeColor = secondaryThemeColor,
+    primaryTextColor = primaryTextColor,
+    secondaryTextColor = secondaryTextColor,
+    themeBackgroundColor = themeBackgroundColor,
+    labelContainerColor = labelContainerColor,
+    themeColorNight = themeColorNight,
+    secondaryThemeColorNight = secondaryThemeColorNight,
+    primaryTextColorNight = primaryTextColorNight,
+    secondaryTextColorNight = secondaryTextColorNight,
+    themeBackgroundColorNight = themeBackgroundColorNight,
+    labelContainerColorNight = labelContainerColorNight,
+    primarySeedColor = customPrimary,
+    nightPrimarySeedColor = customNightPrimary,
+    paletteStyle = paletteStyle,
+    customContrast = customContrast,
+    materialVersion = materialVersion,
+    activePicker = activePicker,
+)

+ 19 - 53
app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt

@@ -1,40 +1,31 @@
 package io.legado.app.ui.config.otherConfig
 
-import android.widget.Toast
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.card.GlassCard
-import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
-import io.legado.app.ui.widget.components.text.AppText
-import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun CheckSourceBottomSheet(
     show: Boolean,
-    viewModel: OtherConfigViewModel = koinViewModel(),
-    onDismiss: () -> Unit
+    state: OtherConfigUiState,
+    onIntent: (OtherConfigIntent) -> Unit,
+    onDismiss: () -> Unit,
 ) {
-    val context = LocalContext.current
 
     AppModalBottomSheet(
         show = show,
@@ -50,9 +41,9 @@ fun CheckSourceBottomSheet(
             GlassCard {
                 SliderSettingItem(
                     title = stringResource(R.string.check_source_timeout),
-                    value = viewModel.checkSourceTimeout.toFloat(),
+                    value = state.checkSourceTimeoutSeconds.toFloat(),
                     defaultValue = 180f,
-                    onValueChange = { viewModel.checkSourceTimeout = it.toLong() },
+                    onValueChange = { onIntent(OtherConfigIntent.CheckSourceTimeoutChanged(it.toLong())) },
                     valueRange = 0f..300f,
                 )
             }
@@ -65,57 +56,38 @@ fun CheckSourceBottomSheet(
             ) {
                 CheckboxItem(
                     title = stringResource(R.string.search),
-                    checked = viewModel.checkSearch,
-                    onCheckedChange = {
-                        viewModel.checkSearch = it
-                        if (!it && !viewModel.checkDiscovery) {
-                            viewModel.checkDiscovery = true
-                        }
-                    }
+                    checked = state.checkSearch,
+                    onCheckedChange = { onIntent(OtherConfigIntent.CheckSearchChanged(it)) }
                 )
 
                 CheckboxItem(
                     title = stringResource(R.string.discovery),
-                    checked = viewModel.checkDiscovery,
-                    onCheckedChange = {
-                        viewModel.checkDiscovery = it
-                        if (!it && !viewModel.checkSearch) {
-                            viewModel.checkSearch = true
-                        }
-                    }
+                    checked = state.checkDiscovery,
+                    onCheckedChange = { onIntent(OtherConfigIntent.CheckDiscoveryChanged(it)) }
                 )
 
 
 
                 CheckboxItem(
                     title = stringResource(R.string.source_tab_info),
-                    checked = viewModel.checkInfo,
-                    onCheckedChange = {
-                        viewModel.checkInfo = it
-                        if (!it) {
-                            viewModel.checkCategory = false
-                            viewModel.checkContent = false
-                        }
-                    }
+                    checked = state.checkInfo,
+                    onCheckedChange = { onIntent(OtherConfigIntent.CheckInfoChanged(it)) }
                 )
 
 
                 CheckboxItem(
                     title = stringResource(R.string.chapter_list),
-                    checked = viewModel.checkCategory,
-                    enabled = viewModel.checkInfo,
-                    onCheckedChange = {
-                        viewModel.checkCategory = it
-                        if (!it) viewModel.checkContent = false
-                    }
+                    checked = state.checkCategory,
+                    enabled = state.checkInfo,
+                    onCheckedChange = { onIntent(OtherConfigIntent.CheckCategoryChanged(it)) }
                 )
 
 
                 CheckboxItem(
                     title = stringResource(R.string.source_tab_content),
-                    checked = viewModel.checkContent,
-                    enabled = viewModel.checkCategory,
-                    onCheckedChange = { viewModel.checkContent = it }
+                    checked = state.checkContent,
+                    enabled = state.checkCategory,
+                    onCheckedChange = { onIntent(OtherConfigIntent.CheckContentChanged(it)) }
                 )
             }
 
@@ -124,16 +96,10 @@ fun CheckSourceBottomSheet(
                     .fillMaxWidth()
                     .padding(top = 16.dp),
                 onDismiss = onDismiss,
-                onConfirm = {
-                    if (viewModel.saveCheckSourceConfig()) {
-                        onDismiss()
-                    } else {
-                        Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show()
-                    }
-                },
+                onConfirm = { onIntent(OtherConfigIntent.ConfirmCheckSource) },
                 dismissText = stringResource(R.string.cancel),
                 confirmText = stringResource(R.string.ok)
             )
         }
     }
-}
+}

+ 37 - 34
app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt

@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -50,16 +49,12 @@ import io.legado.app.utils.toastOnUi
 @Composable
 fun DirectLinkUploadBottomSheet(
     show: Boolean,
-    viewModel: OtherConfigViewModel,
-    onDismiss: () -> Unit
+    state: OtherConfigUiState,
+    onIntent: (OtherConfigIntent) -> Unit,
+    onDismiss: () -> Unit,
 ) {
     val context = LocalContext.current
     var showMenu by remember { mutableStateOf(false) }
-    var showTestResult by remember { mutableStateOf<String?>(null) }
-
-    LaunchedEffect(Unit) {
-        viewModel.initDirectLinkRule()
-    }
 
     AppModalBottomSheet(
         show = show,
@@ -67,7 +62,7 @@ fun DirectLinkUploadBottomSheet(
         startAction = {
             MediumPlainButton(
                 onClick = {
-                    viewModel.testRule { result -> showTestResult = result }
+                    onIntent(OtherConfigIntent.TestDirectLinkRule)
                 },
                 icon = Icons.Default.Checklist,
                 contentDescription = stringResource(R.string.test)
@@ -90,7 +85,14 @@ fun DirectLinkUploadBottomSheet(
                         onClick = {
                             showMenu = false
                             context.selector(DirectLinkUpload.defaultRules) { _, rule, _ ->
-                                viewModel.upView(rule)
+                                onIntent(
+                                    OtherConfigIntent.DirectRuleChanged(
+                                        uploadUrl = rule.uploadUrl,
+                                        downloadUrlRule = rule.downloadUrlRule,
+                                        summary = rule.summary,
+                                        compress = rule.compress,
+                                    )
+                                )
                             }
                         }
                     )
@@ -100,10 +102,10 @@ fun DirectLinkUploadBottomSheet(
                         onClick = {
                             showMenu = false
                             val rule = DirectLinkUpload.Rule(
-                                viewModel.uploadUrl,
-                                viewModel.downloadUrlRule,
-                                viewModel.summary,
-                                viewModel.compress
+                                state.directUploadUrl,
+                                state.directDownloadUrlRule,
+                                state.directSummary,
+                                state.directCompress
                             )
                             context.sendToClip(GSON.toJson(rule))
                         }
@@ -118,7 +120,14 @@ fun DirectLinkUploadBottomSheet(
                                     val rule =
                                         GSON.fromJsonObject<DirectLinkUpload.Rule>(it)
                                             .getOrThrow()
-                                    viewModel.upView(rule)
+                                    onIntent(
+                                        OtherConfigIntent.DirectRuleChanged(
+                                            uploadUrl = rule.uploadUrl,
+                                            downloadUrlRule = rule.downloadUrlRule,
+                                            summary = rule.summary,
+                                            compress = rule.compress,
+                                        )
+                                    )
                                 }
                             }.onFailure {
                                 context.toastOnUi(R.string.clipboard_empty_or_invalid)
@@ -138,8 +147,8 @@ fun DirectLinkUploadBottomSheet(
                 .verticalScroll(rememberScrollState())
         ) {
             AppTextField(
-                value = viewModel.uploadUrl,
-                onValueChange = { viewModel.uploadUrl = it },
+                value = state.directUploadUrl,
+                onValueChange = { onIntent(OtherConfigIntent.DirectUploadUrlChanged(it)) },
                 backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                 label = stringResource(R.string.upload_url),
                 modifier = Modifier.fillMaxWidth(),
@@ -148,8 +157,8 @@ fun DirectLinkUploadBottomSheet(
             Spacer(Modifier.height(16.dp))
 
             AppTextField(
-                value = viewModel.downloadUrlRule,
-                onValueChange = { viewModel.downloadUrlRule = it },
+                value = state.directDownloadUrlRule,
+                onValueChange = { onIntent(OtherConfigIntent.DirectDownloadUrlRuleChanged(it)) },
                 backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                 label = stringResource(R.string.download_url_rule),
                 modifier = Modifier.fillMaxWidth(),
@@ -158,8 +167,8 @@ fun DirectLinkUploadBottomSheet(
             Spacer(Modifier.height(16.dp))
 
             AppTextField(
-                value = viewModel.summary,
-                onValueChange = { viewModel.summary = it },
+                value = state.directSummary,
+                onValueChange = { onIntent(OtherConfigIntent.DirectSummaryChanged(it)) },
                 backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                 label = stringResource(R.string.summary),
                 modifier = Modifier.fillMaxWidth(),
@@ -170,8 +179,8 @@ fun DirectLinkUploadBottomSheet(
             CheckboxItem(
                 title = stringResource(R.string.is_compress),
                 color = LegadoTheme.colorScheme.onSheetContent,
-                checked = viewModel.compress,
-                onCheckedChange = { viewModel.compress = it }
+                checked = state.directCompress,
+                onCheckedChange = { onIntent(OtherConfigIntent.DirectCompressChanged(it)) }
             )
 
             Spacer(Modifier.height(24.dp))
@@ -179,13 +188,7 @@ fun DirectLinkUploadBottomSheet(
             ConfirmDismissButtonsRow(
                 modifier = Modifier.fillMaxWidth(),
                 onDismiss = onDismiss,
-                onConfirm = {
-                    if (viewModel.saveDirectLinkRule()) {
-                        onDismiss()
-                    } else {
-                        context.toastOnUi(R.string.complete_required_information)
-                    }
-                },
+                onConfirm = { onIntent(OtherConfigIntent.ConfirmDirectLinkRule) },
                 dismissText = stringResource(R.string.cancel),
                 confirmText = stringResource(R.string.ok)
             )
@@ -193,8 +196,8 @@ fun DirectLinkUploadBottomSheet(
     }
 
     AppAlertDialog(
-        data = showTestResult,
-        onDismissRequest = { showTestResult = null },
+        data = state.directTestResult,
+        onDismissRequest = { onIntent(OtherConfigIntent.DismissDirectTestResult) },
         title = "Result",
         content = { result ->
             SelectionContainer {
@@ -203,11 +206,11 @@ fun DirectLinkUploadBottomSheet(
         },
         confirmText = stringResource(R.string.ok),
         onConfirm = {
-            showTestResult = null
+            onIntent(OtherConfigIntent.DismissDirectTestResult)
         },
         dismissText = stringResource(R.string.copy_text),
         onDismiss = {
-            showTestResult?.let { context.sendToClip(it) }
+            state.directTestResult?.let { context.sendToClip(it) }
         }
     )
 }

+ 110 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigContract.kt

@@ -0,0 +1,110 @@
+package io.legado.app.ui.config.otherConfig
+
+import androidx.compose.runtime.Stable
+
+@Stable
+data class OtherConfigUiState(
+    val language: String = "auto",
+    val updateToVariant: String = "official_version",
+    val autoCheckUpdateOnStart: Boolean = false,
+    val webServiceAutoStart: Boolean = false,
+    val autoRefresh: Boolean = false,
+    val defaultToRead: Boolean = false,
+    val firebaseEnable: Boolean = true,
+    val defaultBookTreeUri: String? = null,
+    val antiAlias: Boolean = false,
+    val replaceEnableDefault: Boolean = true,
+    val mediaButtonOnExit: Boolean = true,
+    val readAloudByMediaButton: Boolean = false,
+    val ignoreAudioFocus: Boolean = false,
+    val autoClearExpired: Boolean = true,
+    val showAddToShelfAlert: Boolean = true,
+    val showMangaUi: Boolean = true,
+    val webServiceWakeLock: Boolean = false,
+    val sourceEditMaxLine: Int = Int.MAX_VALUE,
+    val webPort: Int = 1122,
+    val processText: Boolean = true,
+    val recordLog: Boolean = false,
+    val recordHeapDump: Boolean = false,
+    val checkSourceTimeoutSeconds: Long = 180,
+    val checkSearch: Boolean = true,
+    val checkDiscovery: Boolean = true,
+    val checkInfo: Boolean = true,
+    val checkCategory: Boolean = true,
+    val checkContent: Boolean = true,
+    val directUploadUrl: String = "",
+    val directDownloadUrlRule: String = "",
+    val directSummary: String = "",
+    val directCompress: Boolean = false,
+    val directTestResult: String? = null,
+    val activeOverlay: OtherConfigOverlay? = null,
+)
+
+sealed interface OtherConfigOverlay {
+    data object FilePicker : OtherConfigOverlay
+    data object CheckSource : OtherConfigOverlay
+    data object DirectLinkUpload : OtherConfigOverlay
+    data object ClearWebViewConfirmation : OtherConfigOverlay
+    data object Password : OtherConfigOverlay
+}
+
+sealed interface OtherConfigIntent {
+    data class LanguageChanged(val value: String) : OtherConfigIntent
+    data class UpdateToVariantChanged(val value: String) : OtherConfigIntent
+    data class AutoCheckUpdateOnStartChanged(val value: Boolean) : OtherConfigIntent
+    data class WebServiceAutoStartChanged(val value: Boolean) : OtherConfigIntent
+    data class AutoRefreshChanged(val value: Boolean) : OtherConfigIntent
+    data class DefaultToReadChanged(val value: Boolean) : OtherConfigIntent
+    data class FirebaseEnableChanged(val value: Boolean) : OtherConfigIntent
+    data class DefaultBookTreeUriChanged(val value: String?) : OtherConfigIntent
+    data class AntiAliasChanged(val value: Boolean) : OtherConfigIntent
+    data class ReplaceEnableDefaultChanged(val value: Boolean) : OtherConfigIntent
+    data class MediaButtonOnExitChanged(val value: Boolean) : OtherConfigIntent
+    data class ReadAloudByMediaButtonChanged(val value: Boolean) : OtherConfigIntent
+    data class IgnoreAudioFocusChanged(val value: Boolean) : OtherConfigIntent
+    data class AutoClearExpiredChanged(val value: Boolean) : OtherConfigIntent
+    data class ShowAddToShelfAlertChanged(val value: Boolean) : OtherConfigIntent
+    data class ShowMangaUiChanged(val value: Boolean) : OtherConfigIntent
+    data class WebServiceWakeLockChanged(val value: Boolean) : OtherConfigIntent
+    data class SourceEditMaxLineChanged(val value: Int) : OtherConfigIntent
+    data class WebPortChanged(val value: Int) : OtherConfigIntent
+    data class ProcessTextChanged(val value: Boolean) : OtherConfigIntent
+    data class RecordLogChanged(val value: Boolean) : OtherConfigIntent
+    data class RecordHeapDumpChanged(val value: Boolean) : OtherConfigIntent
+    data class CheckSourceTimeoutChanged(val value: Long) : OtherConfigIntent
+    data class CheckSearchChanged(val value: Boolean) : OtherConfigIntent
+    data class CheckDiscoveryChanged(val value: Boolean) : OtherConfigIntent
+    data class CheckInfoChanged(val value: Boolean) : OtherConfigIntent
+    data class CheckCategoryChanged(val value: Boolean) : OtherConfigIntent
+    data class CheckContentChanged(val value: Boolean) : OtherConfigIntent
+    data object ConfirmCheckSource : OtherConfigIntent
+    data class DirectUploadUrlChanged(val value: String) : OtherConfigIntent
+    data class DirectDownloadUrlRuleChanged(val value: String) : OtherConfigIntent
+    data class DirectSummaryChanged(val value: String) : OtherConfigIntent
+    data class DirectCompressChanged(val value: Boolean) : OtherConfigIntent
+    data class DirectRuleChanged(
+        val uploadUrl: String,
+        val downloadUrlRule: String,
+        val summary: String,
+        val compress: Boolean,
+    ) : OtherConfigIntent
+    data object ConfirmDirectLinkRule : OtherConfigIntent
+    data object TestDirectLinkRule : OtherConfigIntent
+    data object DismissDirectTestResult : OtherConfigIntent
+    data class ShowOverlay(val overlay: OtherConfigOverlay) : OtherConfigIntent
+    data object DismissOverlay : OtherConfigIntent
+    data object RequestNotificationPermission : OtherConfigIntent
+    data object RequestBatteryPermission : OtherConfigIntent
+    data object RequestSystemDirectory : OtherConfigIntent
+    data object ConfirmClearWebViewData : OtherConfigIntent
+}
+
+sealed interface OtherConfigEffect {
+    data class ApplyLanguage(val language: String) : OtherConfigEffect
+    data object RequestNotificationPermission : OtherConfigEffect
+    data object RequestBatteryPermission : OtherConfigEffect
+    data object OpenSystemDirectory : OtherConfigEffect
+    data object RestartWebService : OtherConfigEffect
+    data class ShowMessage(val resId: Int) : OtherConfigEffect
+    data class SettingsUpdateFailed(val message: String) : OtherConfigEffect
+}

+ 145 - 0
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigRouteScreen.kt

@@ -0,0 +1,145 @@
+package io.legado.app.ui.config.otherConfig
+
+import android.Manifest
+import android.app.Activity
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.service.WebService
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.utils.SystemUtils
+import io.legado.app.utils.takePersistablePermissionSafely
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun OtherConfigRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: OtherConfigViewModel = koinViewModel(),
+) {
+    val context = LocalContext.current
+    val state = viewModel.uiState.collectAsStateWithLifecycle().value
+    var password by remember { mutableStateOf("") }
+
+    val notificationPermissionLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { }
+    val selectDocTree = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocumentTree()
+    ) { uri ->
+        uri?.let {
+            it.takePersistablePermissionSafely(context)
+            viewModel.onIntent(OtherConfigIntent.DefaultBookTreeUriChanged(it.toString()))
+        }
+    }
+
+    LaunchedEffect(viewModel, context) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is OtherConfigEffect.ApplyLanguage -> {
+                    AppCompatDelegate.setApplicationLocales(appLocaleListFor(effect.language))
+                }
+                OtherConfigEffect.RequestNotificationPermission -> {
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                        notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+                    } else {
+                        Toast.makeText(context, R.string.permission_not_required, Toast.LENGTH_SHORT).show()
+                    }
+                }
+                OtherConfigEffect.RequestBatteryPermission -> {
+                    (context as? Activity)?.let(SystemUtils::ignoreBatteryOptimization)
+                }
+                OtherConfigEffect.OpenSystemDirectory -> selectDocTree.launch(null)
+                OtherConfigEffect.RestartWebService -> {
+                    if (WebService.isRun) {
+                        WebService.stop(context)
+                        WebService.start(context)
+                    }
+                }
+                is OtherConfigEffect.ShowMessage -> {
+                    Toast.makeText(context, effect.resId, Toast.LENGTH_SHORT).show()
+                }
+                is OtherConfigEffect.SettingsUpdateFailed -> {
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+                }
+            }
+        }
+    }
+
+    OtherConfigScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+
+    FilePickerSheet(
+        show = state.activeOverlay == OtherConfigOverlay.FilePicker,
+        onDismissRequest = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+        onSelectSysDir = {
+            viewModel.onIntent(OtherConfigIntent.DismissOverlay)
+            viewModel.onIntent(OtherConfigIntent.RequestSystemDirectory)
+        },
+    )
+
+    CheckSourceBottomSheet(
+        show = state.activeOverlay == OtherConfigOverlay.CheckSource,
+        state = state,
+        onIntent = viewModel::onIntent,
+        onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+    )
+
+    DirectLinkUploadBottomSheet(
+        show = state.activeOverlay == OtherConfigOverlay.DirectLinkUpload,
+        state = state,
+        onIntent = viewModel::onIntent,
+        onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+    )
+
+    AppAlertDialog(
+        show = state.activeOverlay == OtherConfigOverlay.ClearWebViewConfirmation,
+        onDismissRequest = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+        title = stringResource(R.string.clear_webview_data),
+        text = stringResource(R.string.sure_del),
+        onConfirm = { viewModel.onIntent(OtherConfigIntent.ConfirmClearWebViewData) },
+        onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+    )
+
+    AppAlertDialog(
+        show = state.activeOverlay == OtherConfigOverlay.Password,
+        onDismissRequest = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+        title = stringResource(R.string.set_local_password),
+        content = {
+            AppTextField(
+                value = password,
+                onValueChange = { password = it },
+                label = "Password",
+                backgroundColor = LegadoTheme.colorScheme.surface,
+                modifier = Modifier.fillMaxWidth(),
+            )
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            viewModel.setLocalPassword(password)
+            viewModel.onIntent(OtherConfigIntent.DismissOverlay)
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { viewModel.onIntent(OtherConfigIntent.DismissOverlay) },
+    )
+}

+ 52 - 175
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt

@@ -1,37 +1,18 @@
 package io.legado.app.ui.config.otherConfig
 
-import android.Manifest
-import android.os.Build
-import android.widget.Toast
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatDelegate
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.service.WebService
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
-import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
 import io.legado.app.ui.widget.components.settingItem.InputSettingItem
@@ -39,43 +20,16 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
-import io.legado.app.utils.takePersistablePermissionSafely
-import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun OtherConfigScreen(
+    state: OtherConfigUiState,
+    onIntent: (OtherConfigIntent) -> Unit,
     onBackClick: () -> Unit,
-    viewModel: OtherConfigViewModel = koinViewModel()
 ) {
-    val context = LocalContext.current
-    val readAloudPreferences by viewModel.readAloudPreferences.collectAsStateWithLifecycle()
-
-    val notificationPermissionLauncher = rememberLauncherForActivityResult(
-        ActivityResultContracts.RequestPermission()
-    ) { isGranted ->
-
-    }
-
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
-    var showClearWebViewDialog by remember { mutableStateOf(false) }
-    var showPasswordDialog by remember { mutableStateOf(false) }
-    var showCheckSourceSheet by remember { mutableStateOf(false) }
-    var showDirectLinkUploadSheet by remember { mutableStateOf(false) }
-
-    var tempPassword by remember { mutableStateOf("") }
-
-    var showFilePicker by remember { mutableStateOf(false) }
-    val selectDocTree = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
-    ) { uri ->
-        uri?.let {
-            it.takePersistablePermissionSafely(context)
-            viewModel.updateLocalBookDir(it.toString())
-        }
-    }
-
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
@@ -99,35 +53,32 @@ fun OtherConfigScreen(
                 SplicedColumnGroup {
                 DropdownListSettingItem(
                     title = stringResource(R.string.language),
-                    selectedValue = OtherConfig.language,
+                    selectedValue = state.language,
                     displayEntries = stringArrayResource(R.array.language),
                     entryValues = stringArrayResource(R.array.language_value),
-                    onValueChange = { newValue ->
-                        OtherConfig.language = newValue
-                        AppCompatDelegate.setApplicationLocales(appLocaleListFor(newValue))
-                    }
+                    onValueChange = { onIntent(OtherConfigIntent.LanguageChanged(it)) }
                 )
 
                 DropdownListSettingItem(
                     title = stringResource(R.string.update_to_variant_title),
                     description = stringResource(R.string.update_to_variant_summary),
-                    selectedValue = OtherConfig.updateToVariant,
+                    selectedValue = state.updateToVariant,
                     displayEntries = stringArrayResource(R.array.default_app_variant),
                     entryValues = stringArrayResource(R.array.default_app_variant_value),
-                    onValueChange = { OtherConfig.updateToVariant = it }
+                    onValueChange = { onIntent(OtherConfigIntent.UpdateToVariantChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.auto_check_update_on_start_title),
                     description = stringResource(R.string.auto_check_update_on_start_summary),
-                    checked = OtherConfig.autoCheckUpdateOnStart,
-                    onCheckedChange = { OtherConfig.autoCheckUpdateOnStart = it }
+                    checked = state.autoCheckUpdateOnStart,
+                    onCheckedChange = { onIntent(OtherConfigIntent.AutoCheckUpdateOnStartChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.web_service_auto_start),
-                    checked = OtherConfig.webServiceAutoStart,
-                    onCheckedChange = { OtherConfig.webServiceAutoStart = it }
+                    checked = state.webServiceAutoStart,
+                    onCheckedChange = { onIntent(OtherConfigIntent.WebServiceAutoStartChanged(it)) }
                 )
             }
 
@@ -136,15 +87,15 @@ fun OtherConfigScreen(
                 SwitchSettingItem(
                     title = stringResource(R.string.pt_auto_refresh),
                     description = stringResource(R.string.ps_auto_refresh),
-                    checked = OtherConfig.autoRefresh,
-                    onCheckedChange = { OtherConfig.autoRefresh = it }
+                    checked = state.autoRefresh,
+                    onCheckedChange = { onIntent(OtherConfigIntent.AutoRefreshChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.pt_default_read),
                     description = stringResource(R.string.ps_default_read),
-                    checked = OtherConfig.defaultToRead,
-                    onCheckedChange = { OtherConfig.defaultToRead = it }
+                    checked = state.defaultToRead,
+                    onCheckedChange = { onIntent(OtherConfigIntent.DefaultToReadChanged(it)) }
                 )
             }
 
@@ -153,34 +104,26 @@ fun OtherConfigScreen(
                 ClickableSettingItem(
                     title = stringResource(R.string.notification_permission),
                     description = stringResource(R.string.notification_permission_rationale),
-                    onClick = {
-                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                            notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
-                        } else {
-                            Toast.makeText(context, R.string.permission_not_required, Toast.LENGTH_SHORT).show()
-                        }
-                    }
+                    onClick = { onIntent(OtherConfigIntent.RequestNotificationPermission) }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.background_permission),
                     description = stringResource(R.string.ignore_battery_permission_rationale),
-                    onClick = {
-
-                    }
+                    onClick = { onIntent(OtherConfigIntent.RequestBatteryPermission) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.firebase_enable_title),
                     description = stringResource(R.string.firebase_enable_summary),
-                    checked = OtherConfig.firebaseEnable,
-                    onCheckedChange = { OtherConfig.firebaseEnable = it }
+                    checked = state.firebaseEnable,
+                    onCheckedChange = { onIntent(OtherConfigIntent.FirebaseEnableChanged(it)) }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.set_local_password),
                     description = stringResource(R.string.set_local_password_summary),
-                    onClick = { showPasswordDialog = true }
+                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.Password)) }
                 )
 
             }
@@ -189,63 +132,63 @@ fun OtherConfigScreen(
 
                 ClickableSettingItem(
                     title = stringResource(R.string.book_tree_uri_t),
-                    description = OtherConfig.defaultBookTreeUri,
-                    onClick = { showFilePicker = true }
+                    description = state.defaultBookTreeUri,
+                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.FilePicker)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.anti_alias),
                     description = stringResource(R.string.pref_anti_alias_summary),
-                    checked = OtherConfig.antiAlias,
-                    onCheckedChange = { OtherConfig.antiAlias = it }
+                    checked = state.antiAlias,
+                    onCheckedChange = { onIntent(OtherConfigIntent.AntiAliasChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.replace_enable_default_t),
                     description = stringResource(R.string.replace_enable_default_s),
-                    checked = OtherConfig.replaceEnableDefault,
-                    onCheckedChange = { OtherConfig.replaceEnableDefault = it }
+                    checked = state.replaceEnableDefault,
+                    onCheckedChange = { onIntent(OtherConfigIntent.ReplaceEnableDefaultChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.media_button_on_exit_title),
                     description = stringResource(R.string.media_button_on_exit_summary),
-                    checked = readAloudPreferences.mediaButtonOnExit,
-                    onCheckedChange = { viewModel.setMediaButtonOnExit(it) }
+                    checked = state.mediaButtonOnExit,
+                    onCheckedChange = { onIntent(OtherConfigIntent.MediaButtonOnExitChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.read_aloud_by_media_button_title),
                     description = stringResource(R.string.read_aloud_by_media_button_summary),
-                    checked = readAloudPreferences.readAloudByMediaButton,
-                    onCheckedChange = { viewModel.setReadAloudByMediaButton(it) }
+                    checked = state.readAloudByMediaButton,
+                    onCheckedChange = { onIntent(OtherConfigIntent.ReadAloudByMediaButtonChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.ignore_audio_focus_title),
                     description = stringResource(R.string.ignore_audio_focus_summary),
-                    checked = readAloudPreferences.ignoreAudioFocus,
-                    onCheckedChange = { viewModel.setIgnoreAudioFocus(it) }
+                    checked = state.ignoreAudioFocus,
+                    onCheckedChange = { onIntent(OtherConfigIntent.IgnoreAudioFocusChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.auto_clear_expired),
                     description = stringResource(R.string.auto_clear_expired_summary),
-                    checked = OtherConfig.autoClearExpired,
-                    onCheckedChange = { OtherConfig.autoClearExpired = it }
+                    checked = state.autoClearExpired,
+                    onCheckedChange = { onIntent(OtherConfigIntent.AutoClearExpiredChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.show_add_to_shelf_alert_title),
                     description = stringResource(R.string.show_add_to_shelf_alert_summary),
-                    checked = OtherConfig.showAddToShelfAlert,
-                    onCheckedChange = { OtherConfig.showAddToShelfAlert = it }
+                    checked = state.showAddToShelfAlert,
+                    onCheckedChange = { onIntent(OtherConfigIntent.ShowAddToShelfAlertChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.show_manga_ui),
-                    checked = ReadMangaConfig.showMangaUi,
-                    onCheckedChange = { ReadMangaConfig.showMangaUi = it }
+                    checked = state.showMangaUi,
+                    onCheckedChange = { onIntent(OtherConfigIntent.ShowMangaUiChanged(it)) }
                 )
             }
 
@@ -254,128 +197,62 @@ fun OtherConfigScreen(
                 SwitchSettingItem(
                     title = stringResource(R.string.web_service_wake_lock),
                     description = stringResource(R.string.web_service_wake_lock_summary),
-                    checked = OtherConfig.webServiceWakeLock,
-                    onCheckedChange = { OtherConfig.webServiceWakeLock = it }
+                    checked = state.webServiceWakeLock,
+                    onCheckedChange = { onIntent(OtherConfigIntent.WebServiceWakeLockChanged(it)) }
                 )
 
                 InputSettingItem(
                     title = stringResource(R.string.source_edit_text_max_line),
-                    value = OtherConfig.sourceEditMaxLine.toString(),
+                    value = state.sourceEditMaxLine.toString(),
                     defaultValue = 500.toString(),
-                    onConfirm = { OtherConfig.sourceEditMaxLine = it.toIntOrNull() ?: 500 }
+                    onConfirm = { onIntent(OtherConfigIntent.SourceEditMaxLineChanged(it.toIntOrNull() ?: 500)) }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.check_source_config),
-                    onClick = { showCheckSourceSheet = true }
+                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.CheckSource)) }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.direct_link_upload_rule),
                     description = stringResource(R.string.direct_link_upload_rule_summary),
-                    onClick = { showDirectLinkUploadSheet = true }
+                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.DirectLinkUpload)) }
                 )
 
                 InputSettingItem(
                     title = stringResource(R.string.web_port_title),
-                    value = OtherConfig.webPort.toString(),
-                    onConfirm = { newValue ->
-                        OtherConfig.webPort = newValue.toInt()
-                        if (WebService.isRun) {
-                            WebService.stop(context)
-                            WebService.start(context)
-                        }
-                    }
+                    value = state.webPort.toString(),
+                    onConfirm = { onIntent(OtherConfigIntent.WebPortChanged(it.toInt())) }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.clear_webview_data),
                     description = stringResource(R.string.clear_webview_data_summary),
-                    onClick = { showClearWebViewDialog = true }
+                    onClick = { onIntent(OtherConfigIntent.ShowOverlay(OtherConfigOverlay.ClearWebViewConfirmation)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.add_to_text_context_menu_t),
                     description = stringResource(R.string.add_to_text_context_menu_s),
-                    checked = OtherConfig.processText,
-                    onCheckedChange = { viewModel.setProcessTextEnable(it) }
+                    checked = state.processText,
+                    onCheckedChange = { onIntent(OtherConfigIntent.ProcessTextChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.record_log),
                     description = stringResource(R.string.record_debug_log),
-                    checked = OtherConfig.recordLog,
-                    onCheckedChange = { OtherConfig.recordLog = it }
+                    checked = state.recordLog,
+                    onCheckedChange = { onIntent(OtherConfigIntent.RecordLogChanged(it)) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.record_heap_dump_t),
                     description = stringResource(R.string.record_heap_dump_s),
-                    checked = OtherConfig.recordHeapDump,
-                    onCheckedChange = { OtherConfig.recordHeapDump = it }
+                    checked = state.recordHeapDump,
+                    onCheckedChange = { onIntent(OtherConfigIntent.RecordHeapDumpChanged(it)) }
                 )
                 }
             }
         }
-
-        FilePickerSheet(
-            show = showFilePicker,
-            onDismissRequest = { showFilePicker = false },
-            onSelectSysDir = {
-                showFilePicker = false
-                try {
-                    selectDocTree.launch(null)
-                } catch (e: Exception) {
-
-                }
-            }
-        )
-
-        CheckSourceBottomSheet(
-            show = showCheckSourceSheet,
-            viewModel = viewModel,
-            onDismiss = { showCheckSourceSheet = false }
-        )
-
-        DirectLinkUploadBottomSheet(
-            show = showDirectLinkUploadSheet,
-            viewModel = viewModel,
-            onDismiss = { showDirectLinkUploadSheet = false }
-        )
-
-        AppAlertDialog(
-            show = showClearWebViewDialog,
-            onDismissRequest = { showClearWebViewDialog = false },
-            title = stringResource(R.string.clear_webview_data),
-            text = stringResource(R.string.sure_del),
-            onConfirm = {
-                viewModel.clearWebViewData()
-                showClearWebViewDialog = false
-            },
-            onDismiss = { showClearWebViewDialog = false }
-        )
-
-        AppAlertDialog(
-            show = showPasswordDialog,
-            onDismissRequest = { showPasswordDialog = false },
-            title = stringResource(R.string.set_local_password),
-            content = {
-                AppTextField(
-                    value = tempPassword,
-                    onValueChange = { tempPassword = it },
-                    label = "Password",
-                    backgroundColor = LegadoTheme.colorScheme.surface,
-                    modifier = Modifier.fillMaxWidth()
-                )
-            },
-            confirmText = stringResource(R.string.ok),
-            onConfirm = {
-                viewModel.setLocalPassword(tempPassword)
-                showPasswordDialog = false
-            },
-            dismissText = stringResource(R.string.cancel),
-            onDismiss = { showPasswordDialog = false }
-        )
     }
-
 }

+ 251 - 47
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -4,16 +4,16 @@ import android.content.ComponentName
 import android.content.pm.PackageManager
 import android.os.Handler
 import android.os.Looper
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
-import io.legado.app.data.repository.ReadAloudPreferences
-import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
+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.help.DirectLinkUpload
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.webView.WebViewDataCleaner
@@ -26,14 +26,18 @@ import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 
 class OtherConfigViewModel(
-    private val readAloudSettingsRepository: ReadAloudSettingsRepository
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val otherSettingsGateway: OtherSettingsGateway,
 ) : ViewModel() {
 
     companion object {
@@ -49,33 +53,200 @@ class OtherConfigViewModel(
     private var clearWebViewDataJob: Job? = null
     private var restartScheduled = false
 
+    private val _uiState = MutableStateFlow(
+        OtherConfigUiState(
+            checkSourceTimeoutSeconds = CheckSource.timeout / 1000,
+            checkSearch = CheckSource.checkSearch,
+            checkDiscovery = CheckSource.checkDiscovery,
+            checkInfo = CheckSource.checkInfo,
+            checkCategory = CheckSource.checkCategory,
+            checkContent = CheckSource.checkContent,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<OtherConfigEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
     init {
         viewModelScope.launch(Dispatchers.IO) {
-            OtherConfig.processText = isProcessTextEnabled()
+            updateOtherSetting(OtherSettingsUpdate.ProcessText(isProcessTextEnabled()))
+        }
+        viewModelScope.launch {
+            otherSettingsGateway.settings.collect { settings ->
+                _uiState.update { settings.toUiState(it) }
+            }
+        }
+        viewModelScope.launch {
+            readAloudSettingsGateway.settings.collect { preferences ->
+                _uiState.update {
+                    it.copy(
+                        mediaButtonOnExit = preferences.mediaButtonOnExit,
+                        readAloudByMediaButton = preferences.readAloudByMediaButton,
+                        ignoreAudioFocus = preferences.ignoreAudioFocus,
+                    )
+                }
+            }
         }
     }
 
-    val readAloudPreferences = readAloudSettingsRepository.preferences.stateIn(
-        scope = viewModelScope,
-        started = SharingStarted.WhileSubscribed(5_000),
-        initialValue = ReadAloudPreferences()
-    )
+    fun onIntent(intent: OtherConfigIntent) {
+        when (intent) {
+            is OtherConfigIntent.LanguageChanged -> {
+                updateOtherSetting(OtherSettingsUpdate.Language(intent.value))
+                _effects.tryEmit(OtherConfigEffect.ApplyLanguage(intent.value))
+            }
+            is OtherConfigIntent.UpdateToVariantChanged ->
+                updateOtherSetting(OtherSettingsUpdate.UpdateToVariant(intent.value))
+            is OtherConfigIntent.AutoCheckUpdateOnStartChanged ->
+                updateOtherSetting(OtherSettingsUpdate.AutoCheckUpdateOnStart(intent.value))
+            is OtherConfigIntent.WebServiceAutoStartChanged ->
+                updateOtherSetting(OtherSettingsUpdate.WebServiceAutoStart(intent.value))
+            is OtherConfigIntent.AutoRefreshChanged ->
+                updateOtherSetting(OtherSettingsUpdate.AutoRefresh(intent.value))
+            is OtherConfigIntent.DefaultToReadChanged ->
+                updateOtherSetting(OtherSettingsUpdate.DefaultToRead(intent.value))
+            is OtherConfigIntent.FirebaseEnableChanged ->
+                updateOtherSetting(OtherSettingsUpdate.FirebaseEnable(intent.value))
+            is OtherConfigIntent.DefaultBookTreeUriChanged ->
+                updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(intent.value))
+            is OtherConfigIntent.AntiAliasChanged ->
+                updateOtherSetting(OtherSettingsUpdate.AntiAlias(intent.value))
+            is OtherConfigIntent.ReplaceEnableDefaultChanged ->
+                updateOtherSetting(OtherSettingsUpdate.ReplaceEnableDefault(intent.value))
+            is OtherConfigIntent.MediaButtonOnExitChanged -> setMediaButtonOnExit(intent.value)
+            is OtherConfigIntent.ReadAloudByMediaButtonChanged -> setReadAloudByMediaButton(intent.value)
+            is OtherConfigIntent.IgnoreAudioFocusChanged -> setIgnoreAudioFocus(intent.value)
+            is OtherConfigIntent.AutoClearExpiredChanged ->
+                updateOtherSetting(OtherSettingsUpdate.AutoClearExpired(intent.value))
+            is OtherConfigIntent.ShowAddToShelfAlertChanged ->
+                updateOtherSetting(OtherSettingsUpdate.ShowAddToShelfAlert(intent.value))
+            is OtherConfigIntent.ShowMangaUiChanged ->
+                updateOtherSetting(OtherSettingsUpdate.ShowMangaUi(intent.value))
+            is OtherConfigIntent.WebServiceWakeLockChanged ->
+                updateOtherSetting(OtherSettingsUpdate.WebServiceWakeLock(intent.value))
+            is OtherConfigIntent.SourceEditMaxLineChanged ->
+                updateOtherSetting(OtherSettingsUpdate.SourceEditMaxLine(intent.value))
+            is OtherConfigIntent.WebPortChanged -> {
+                updateOtherSetting(OtherSettingsUpdate.WebPort(intent.value))
+                _effects.tryEmit(OtherConfigEffect.RestartWebService)
+            }
+            is OtherConfigIntent.ProcessTextChanged -> setProcessTextEnable(intent.value)
+            is OtherConfigIntent.RecordLogChanged ->
+                updateOtherSetting(OtherSettingsUpdate.RecordLog(intent.value))
+            is OtherConfigIntent.RecordHeapDumpChanged ->
+                updateOtherSetting(OtherSettingsUpdate.RecordHeapDump(intent.value))
+            is OtherConfigIntent.CheckSourceTimeoutChanged ->
+                _uiState.update { it.copy(checkSourceTimeoutSeconds = intent.value) }
+            is OtherConfigIntent.CheckSearchChanged -> _uiState.update {
+                it.copy(
+                    checkSearch = intent.value,
+                    checkDiscovery = if (!intent.value && !it.checkDiscovery) true else it.checkDiscovery,
+                )
+            }
+            is OtherConfigIntent.CheckDiscoveryChanged -> _uiState.update {
+                it.copy(
+                    checkDiscovery = intent.value,
+                    checkSearch = if (!intent.value && !it.checkSearch) true else it.checkSearch,
+                )
+            }
+            is OtherConfigIntent.CheckInfoChanged -> _uiState.update {
+                it.copy(
+                    checkInfo = intent.value,
+                    checkCategory = if (intent.value) it.checkCategory else false,
+                    checkContent = if (intent.value) it.checkContent else false,
+                )
+            }
+            is OtherConfigIntent.CheckCategoryChanged -> _uiState.update {
+                it.copy(
+                    checkCategory = intent.value,
+                    checkContent = if (intent.value) it.checkContent else false,
+                )
+            }
+            is OtherConfigIntent.CheckContentChanged ->
+                _uiState.update { it.copy(checkContent = intent.value) }
+            OtherConfigIntent.ConfirmCheckSource -> {
+                if (saveCheckSourceConfig()) {
+                    _uiState.update { it.copy(activeOverlay = null) }
+                } else {
+                    _effects.tryEmit(OtherConfigEffect.ShowMessage(R.string.error))
+                }
+            }
+            is OtherConfigIntent.DirectUploadUrlChanged ->
+                _uiState.update { it.copy(directUploadUrl = intent.value) }
+            is OtherConfigIntent.DirectDownloadUrlRuleChanged ->
+                _uiState.update { it.copy(directDownloadUrlRule = intent.value) }
+            is OtherConfigIntent.DirectSummaryChanged ->
+                _uiState.update { it.copy(directSummary = intent.value) }
+            is OtherConfigIntent.DirectCompressChanged ->
+                _uiState.update { it.copy(directCompress = intent.value) }
+            is OtherConfigIntent.DirectRuleChanged -> _uiState.update {
+                it.copy(
+                    directUploadUrl = intent.uploadUrl,
+                    directDownloadUrlRule = intent.downloadUrlRule,
+                    directSummary = intent.summary,
+                    directCompress = intent.compress,
+                )
+            }
+            OtherConfigIntent.ConfirmDirectLinkRule -> {
+                if (saveDirectLinkRule()) {
+                    _uiState.update { it.copy(activeOverlay = null) }
+                } else {
+                    _effects.tryEmit(
+                        OtherConfigEffect.ShowMessage(R.string.complete_required_information)
+                    )
+                }
+            }
+            OtherConfigIntent.TestDirectLinkRule -> testRule()
+            OtherConfigIntent.DismissDirectTestResult ->
+                _uiState.update { it.copy(directTestResult = null) }
+            is OtherConfigIntent.ShowOverlay -> {
+                _uiState.update { it.copy(activeOverlay = intent.overlay) }
+                if (intent.overlay == OtherConfigOverlay.DirectLinkUpload) initDirectLinkRule()
+            }
+            OtherConfigIntent.DismissOverlay ->
+                _uiState.update { it.copy(activeOverlay = null) }
+            OtherConfigIntent.RequestNotificationPermission ->
+                _effects.tryEmit(OtherConfigEffect.RequestNotificationPermission)
+            OtherConfigIntent.RequestBatteryPermission ->
+                _effects.tryEmit(OtherConfigEffect.RequestBatteryPermission)
+            OtherConfigIntent.RequestSystemDirectory ->
+                _effects.tryEmit(OtherConfigEffect.OpenSystemDirectory)
+            OtherConfigIntent.ConfirmClearWebViewData -> {
+                _uiState.update { it.copy(activeOverlay = null) }
+                clearWebViewData()
+            }
+        }
+    }
+
+    private fun updateOtherSetting(update: OtherSettingsUpdate) {
+        viewModelScope.launch {
+            runCatching { otherSettingsGateway.update(update) }
+                .onFailure { error ->
+                    _effects.tryEmit(
+                        OtherConfigEffect.SettingsUpdateFailed(
+                            error.message ?: error.javaClass.simpleName
+                        )
+                    )
+                }
+        }
+    }
 
     fun setMediaButtonOnExit(value: Boolean) {
         viewModelScope.launch {
-            readAloudSettingsRepository.setMediaButtonOnExit(value)
+            readAloudSettingsGateway.update(ReadAloudSettingsUpdate.MediaButtonOnExit(value))
         }
     }
 
     fun setReadAloudByMediaButton(value: Boolean) {
         viewModelScope.launch {
-            readAloudSettingsRepository.setReadAloudByMediaButton(value)
+            readAloudSettingsGateway.update(ReadAloudSettingsUpdate.ReadAloudByMediaButton(value))
         }
     }
 
     fun setIgnoreAudioFocus(value: Boolean) {
         viewModelScope.launch {
-            readAloudSettingsRepository.setIgnoreAudioFocus(value)
+            readAloudSettingsGateway.update(ReadAloudSettingsUpdate.IgnoreAudioFocus(value))
         }
     }
 
@@ -96,7 +267,7 @@ class OtherConfigViewModel(
                     state,
                     PackageManager.DONT_KILL_APP
                 )
-                OtherConfig.processText = enable
+                updateOtherSetting(OtherSettingsUpdate.ProcessText(enable))
             }.onFailure {
                 appCtx.toastOnUi(it.localizedMessage)
             }
@@ -134,68 +305,101 @@ class OtherConfigViewModel(
     }
 
     fun updateLocalBookDir(path: String) {
-        OtherConfig.defaultBookTreeUri = path
+        updateOtherSetting(OtherSettingsUpdate.DefaultBookTreeUri(path))
     }
 
-    var checkSourceTimeout by mutableStateOf((CheckSource.timeout / 1000))
-    var checkSearch by mutableStateOf(CheckSource.checkSearch)
-    var checkDiscovery by mutableStateOf(CheckSource.checkDiscovery)
-    var checkInfo by mutableStateOf(CheckSource.checkInfo)
-    var checkCategory by mutableStateOf(CheckSource.checkCategory)
-    var checkContent by mutableStateOf(CheckSource.checkContent)
-
     fun saveCheckSourceConfig(): Boolean {
-        val timeoutLong = checkSourceTimeout
+        val state = _uiState.value
+        val timeoutLong = state.checkSourceTimeoutSeconds
         if (timeoutLong <= 0) return false // 验证失败
 
         CheckSource.timeout = timeoutLong * 1000
-        CheckSource.checkSearch = checkSearch
-        CheckSource.checkDiscovery = checkDiscovery
-        CheckSource.checkInfo = checkInfo
-        CheckSource.checkCategory = checkCategory
-        CheckSource.checkContent = checkContent
+        CheckSource.checkSearch = state.checkSearch
+        CheckSource.checkDiscovery = state.checkDiscovery
+        CheckSource.checkInfo = state.checkInfo
+        CheckSource.checkCategory = state.checkCategory
+        CheckSource.checkContent = state.checkContent
 
         CheckSource.putConfig()
         appCtx.putPrefString(PreferKey.checkSource, CheckSource.summary)
         return true
     }
 
-    var uploadUrl by mutableStateOf("")
-    var downloadUrlRule by mutableStateOf("")
-    var summary by mutableStateOf("")
-    var compress by mutableStateOf(false)
-
     fun initDirectLinkRule() {
         val rule = DirectLinkUpload.getRule()
         upView(rule)
     }
 
     fun upView(rule: DirectLinkUpload.Rule) {
-        uploadUrl = rule.uploadUrl
-        downloadUrlRule = rule.downloadUrlRule
-        summary = rule.summary
-        compress = rule.compress
+        _uiState.update {
+            it.copy(
+                directUploadUrl = rule.uploadUrl,
+                directDownloadUrlRule = rule.downloadUrlRule,
+                directSummary = rule.summary,
+                directCompress = rule.compress,
+            )
+        }
     }
 
     fun saveDirectLinkRule(): Boolean {
-        if (uploadUrl.isBlank() || downloadUrlRule.isBlank() || summary.isBlank()) return false
-        val rule = DirectLinkUpload.Rule(uploadUrl, downloadUrlRule, summary, compress)
+        val state = _uiState.value
+        if (state.directUploadUrl.isBlank() ||
+            state.directDownloadUrlRule.isBlank() ||
+            state.directSummary.isBlank()
+        ) return false
+        val rule = DirectLinkUpload.Rule(
+            state.directUploadUrl,
+            state.directDownloadUrlRule,
+            state.directSummary,
+            state.directCompress,
+        )
         DirectLinkUpload.putConfig(rule)
         return true
     }
 
-    fun testRule(onResult: (String) -> Unit) {
+    fun testRule() {
+        val state = _uiState.value
         viewModelScope.launch(Dispatchers.IO) {
-            val rule = DirectLinkUpload.Rule(uploadUrl, downloadUrlRule, summary, compress)
+            val rule = DirectLinkUpload.Rule(
+                state.directUploadUrl,
+                state.directDownloadUrlRule,
+                state.directSummary,
+                state.directCompress,
+            )
             runCatching {
                 DirectLinkUpload.upLoad("test.json", "{}", "application/json", rule)
             }.onSuccess {
-                onResult(it)
+                _uiState.update { state -> state.copy(directTestResult = it) }
             }.onFailure {
-                onResult(it.localizedMessage ?: "ERROR")
+                _uiState.update { state ->
+                    state.copy(directTestResult = it.localizedMessage ?: "ERROR")
+                }
             }
         }
     }
 
 
 }
+
+private fun OtherSettings.toUiState(current: OtherConfigUiState): OtherConfigUiState =
+    current.copy(
+        language = language,
+        updateToVariant = updateToVariant,
+        autoCheckUpdateOnStart = autoCheckUpdateOnStart,
+        webServiceAutoStart = webServiceAutoStart,
+        autoRefresh = autoRefresh,
+        defaultToRead = defaultToRead,
+        firebaseEnable = firebaseEnable,
+        defaultBookTreeUri = defaultBookTreeUri,
+        antiAlias = antiAlias,
+        replaceEnableDefault = replaceEnableDefault,
+        autoClearExpired = autoClearExpired,
+        showAddToShelfAlert = showAddToShelfAlert,
+        showMangaUi = showMangaUi,
+        webServiceWakeLock = webServiceWakeLock,
+        sourceEditMaxLine = sourceEditMaxLine,
+        webPort = webPort,
+        processText = processText,
+        recordLog = recordLog,
+        recordHeapDump = recordHeapDump,
+    )

+ 55 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ApplyReadSettingUseCase.kt

@@ -0,0 +1,55 @@
+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
+
+/** 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 -> {
+                postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
+            }
+
+            is ReadSettingsUpdate.ReadMenuBlurAlpha,
+            is ReadSettingsUpdate.ReadSliderMode,
+            is ReadSettingsUpdate.ShowReadTitleAddition,
+            is ReadSettingsUpdate.ShowMenuIcon -> {
+                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+            }
+
+            is ReadSettingsUpdate.TextFullJustify,
+            is ReadSettingsUpdate.TextBottomJustify,
+            is ReadSettingsUpdate.UseZhLayout,
+            is ReadSettingsUpdate.DoubleHorizontalPage -> updateLayout()
+
+            is ReadSettingsUpdate.ProgressBarBehavior -> {
+                postEvent(EventBus.UP_SEEK_BAR, true)
+            }
+
+            is ReadSettingsUpdate.PageTouchSlop -> {
+                postEvent(EventBus.UP_CONFIG, arrayListOf(4))
+            }
+
+            is ReadSettingsUpdate.NoAnimScrollPage -> ReadBook.callBack?.upPageAnim()
+            is ReadSettingsUpdate.OptimizeRender -> updateStyle()
+            else -> Unit
+        }
+    }
+
+    private fun updateLayout() {
+        ChapterProvider.upLayout()
+        ReadBook.loadContent(false)
+    }
+
+    private fun updateStyle() {
+        ChapterProvider.upStyle()
+        ReadBook.callBack?.upPageAnim(true)
+        ReadBook.loadContent(false)
+    }
+}

+ 17 - 1
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt

@@ -1,5 +1,8 @@
 package io.legado.app.ui.config.readConfig
 
+import androidx.compose.runtime.Stable
+
+@Stable
 data class ReadConfigUiState(
     val screenOrientation: String = "0",
     val keepLight: String = "0",
@@ -41,10 +44,19 @@ data class ReadConfigUiState(
     val autoReadSpeed: Int = 10,
     val prevKeys: String = "",
     val nextKeys: String = "",
-    val showMenuIcon: Boolean = true
+    val showMenuIcon: Boolean = true,
+    val activeSheet: ReadConfigSheet? = null,
 )
 
+sealed interface ReadConfigSheet {
+    data object PageKeys : ReadConfigSheet
+    data object ClickActions : ReadConfigSheet
+}
+
 sealed interface ReadConfigIntent {
+    data object OpenPageKeys : ReadConfigIntent
+    data object OpenClickActions : ReadConfigIntent
+    data object DismissSheet : ReadConfigIntent
     data class ScreenOrientationChanged(val value: String) : ReadConfigIntent
     data class KeepLightChanged(val value: String) : ReadConfigIntent
     data class HideStatusBarChanged(val value: Boolean) : ReadConfigIntent
@@ -82,3 +94,7 @@ sealed interface ReadConfigIntent {
     data class AutoSuggestDayNightChanged(val value: Boolean) : ReadConfigIntent
     data class PageKeysChanged(val prevKeys: String, val nextKeys: String) : ReadConfigIntent
 }
+
+sealed interface ReadConfigEffect {
+    data class SettingsUpdateFailed(val message: String) : ReadConfigEffect
+}

+ 34 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigRouteScreen.kt

@@ -0,0 +1,34 @@
+package io.legado.app.ui.config.readConfig
+
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun ReadConfigRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: ReadConfigViewModel = koinViewModel(),
+) {
+    val context = LocalContext.current
+    val state = viewModel.uiState.collectAsStateWithLifecycle().value
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is ReadConfigEffect.SettingsUpdateFailed -> {
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+                }
+            }
+        }
+    }
+
+    ReadConfigScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+}

+ 45 - 53
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt

@@ -4,10 +4,6 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringArrayResource
@@ -15,7 +11,6 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.book.read.sheet.ClickActionConfigSheet
-import io.legado.app.ui.theme.LocalAppSettings
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
@@ -27,18 +22,16 @@ import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
-import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun ReadConfigScreen(
+    state: ReadConfigUiState,
+    onIntent: (ReadConfigIntent) -> Unit,
     onBackClick: () -> Unit,
-    viewModel: ReadConfigViewModel = koinViewModel()
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-    var showPageKeySheet by remember { mutableStateOf(false) }
-    var showClickActionSheet by remember { mutableStateOf(false) }
-    val settings = LocalAppSettings.current
+    val settings = state
 
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -67,7 +60,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.screen_direction_title),
                     entryValues = stringArrayResource(R.array.screen_direction_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ScreenOrientationChanged(it))
+                        onIntent(ReadConfigIntent.ScreenOrientationChanged(it))
                     }
                 )
 
@@ -77,7 +70,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.screen_time_out),
                     entryValues = stringArrayResource(R.array.screen_time_out_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.KeepLightChanged(it))
+                        onIntent(ReadConfigIntent.KeepLightChanged(it))
                     }
                 )
 
@@ -85,7 +78,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.pt_hide_status_bar),
                     checked = settings.hideStatusBar,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.HideStatusBarChanged(it))
+                        onIntent(ReadConfigIntent.HideStatusBarChanged(it))
                     }
                 )
 
@@ -93,7 +86,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.pt_hide_navigation_bar),
                     checked = settings.hideNavigationBar,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.HideNavigationBarChanged(it))
+                        onIntent(ReadConfigIntent.HideNavigationBarChanged(it))
                     }
                 )
 
@@ -101,7 +94,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.padding_display_cutouts),
                     checked = settings.paddingDisplayCutouts,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.PaddingDisplayCutoutsChanged(it))
+                        onIntent(ReadConfigIntent.PaddingDisplayCutoutsChanged(it))
                     }
                 )
 
@@ -111,7 +104,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.title_bar_mode),
                     entryValues = stringArrayResource(R.array.title_bar_mode_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.TitleBarModeChanged(it))
+                        onIntent(ReadConfigIntent.TitleBarModeChanged(it))
                     }
                 )
 
@@ -122,7 +115,7 @@ fun ReadConfigScreen(
                     defaultValue = 60f,
                     valueRange = 0f..100f,
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ReadMenuBlurAlphaChanged(it.toInt()))
+                        onIntent(ReadConfigIntent.ReadMenuBlurAlphaChanged(it.toInt()))
                     }
                 )
 
@@ -130,7 +123,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.read_body_to_lh),
                     checked = settings.readBodyToLh,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.ReadBodyToLhChanged(it))
+                        onIntent(ReadConfigIntent.ReadBodyToLhChanged(it))
                     }
                 )
 
@@ -139,7 +132,7 @@ fun ReadConfigScreen(
                     description = stringResource(R.string.read_change_all_s),
                     checked = settings.defaultSourceChangeAll,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.DefaultSourceChangeAllChanged(it))
+                        onIntent(ReadConfigIntent.DefaultSourceChangeAllChanged(it))
                     }
                 )
 
@@ -147,7 +140,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.text_full_justify),
                     checked = settings.textFullJustify,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.TextFullJustifyChanged(it))
+                        onIntent(ReadConfigIntent.TextFullJustifyChanged(it))
                     }
                 )
 
@@ -155,7 +148,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.text_bottom_justify),
                     checked = settings.textBottomJustify,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.TextBottomJustifyChanged(it))
+                        onIntent(ReadConfigIntent.TextBottomJustifyChanged(it))
                     }
                 )
 
@@ -163,7 +156,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.adapt_special_style),
                     checked = settings.adaptSpecialStyle,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.AdaptSpecialStyleChanged(it))
+                        onIntent(ReadConfigIntent.AdaptSpecialStyleChanged(it))
                     }
                 )
 
@@ -171,7 +164,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.use_zh_layout),
                     checked = settings.useZhLayout,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.UseZhLayoutChanged(it))
+                        onIntent(ReadConfigIntent.UseZhLayoutChanged(it))
                     }
                 )
 
@@ -181,7 +174,7 @@ fun ReadConfigScreen(
                         displayEntries = stringArrayResource(R.array.brightness_bar_mode_title),
                         entryValues = stringArrayResource(R.array.brightness_bar_mode_value),
                         onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ShowBrightnessViewChanged(it))
+                        onIntent(ReadConfigIntent.ShowBrightnessViewChanged(it))
                     }
                 )
 
@@ -192,7 +185,7 @@ fun ReadConfigScreen(
                             displayEntries = stringArrayResource(R.array.brightness_bar_position_title),
                             entryValues = stringArrayResource(R.array.brightness_bar_position_value),
                             onValueChange = {
-                                viewModel.onIntent(ReadConfigIntent.BrightnessVwPosChanged(it))
+                                onIntent(ReadConfigIntent.BrightnessVwPosChanged(it))
                             }
                         )
                     }
@@ -201,7 +194,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.use_underline),
                     checked = settings.useUnderline,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.UseUnderlineChanged(it))
+                        onIntent(ReadConfigIntent.UseUnderlineChanged(it))
                     }
                 )
             }
@@ -213,7 +206,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.read_slider_mode),
                     entryValues = stringArrayResource(R.array.read_slider_mode_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ReadSliderModeChanged(it))
+                        onIntent(ReadConfigIntent.ReadSliderModeChanged(it))
                     }
                 )
 
@@ -223,7 +216,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.double_page_title),
                     entryValues = stringArrayResource(R.array.double_page_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.DoubleHorizontalPageChanged(it))
+                        onIntent(ReadConfigIntent.DoubleHorizontalPageChanged(it))
                     }
                 )
 
@@ -233,7 +226,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.progress_bar_behavior_title),
                     entryValues = stringArrayResource(R.array.progress_bar_behavior_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ProgressBarBehaviorChanged(it))
+                        onIntent(ReadConfigIntent.ProgressBarBehaviorChanged(it))
                     }
                 )
 
@@ -241,7 +234,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.mouse_wheel_page),
                     checked = settings.mouseWheelPage,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.MouseWheelPageChanged(it))
+                        onIntent(ReadConfigIntent.MouseWheelPageChanged(it))
                     }
                 )
 
@@ -249,7 +242,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.volume_key_page),
                     checked = settings.volumeKeyPage,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.VolumeKeyPageChanged(it))
+                        onIntent(ReadConfigIntent.VolumeKeyPageChanged(it))
                     }
                 )
 
@@ -257,7 +250,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.volume_key_page_on_play),
                     checked = settings.volumeKeyPageOnPlay,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.VolumeKeyPageOnPlayChanged(it))
+                        onIntent(ReadConfigIntent.VolumeKeyPageOnPlayChanged(it))
                     }
                 )
 
@@ -265,7 +258,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.key_page_on_long_press),
                     checked = settings.keyPageOnLongPress,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.KeyPageOnLongPressChanged(it))
+                        onIntent(ReadConfigIntent.KeyPageOnLongPressChanged(it))
                     }
                 )
 
@@ -279,7 +272,7 @@ fun ReadConfigScreen(
                     defaultValue = 0f,
                     valueRange = 0f..1000f,
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.PageTouchSlopChanged(it.toInt()))
+                        onIntent(ReadConfigIntent.PageTouchSlopChanged(it.toInt()))
                     }
                 )
             }
@@ -289,7 +282,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.enable_slider_vibrator),
                     checked = settings.sliderVibrator,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.SliderVibratorChanged(it))
+                        onIntent(ReadConfigIntent.SliderVibratorChanged(it))
                     }
                 )
 
@@ -297,7 +290,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.enable_select_vibrator),
                     checked = settings.selectVibrator,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.SelectVibratorChanged(it))
+                        onIntent(ReadConfigIntent.SelectVibratorChanged(it))
                     }
                 )
 
@@ -305,7 +298,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.auto_change_source),
                     checked = settings.autoChangeSource,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.AutoChangeSourceChanged(it))
+                        onIntent(ReadConfigIntent.AutoChangeSourceChanged(it))
                     }
                 )
 
@@ -314,7 +307,7 @@ fun ReadConfigScreen(
                     description = stringResource(R.string.auto_switch_theme_reminder_desc),
                     checked = settings.autoSuggestDayNight,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.AutoSuggestDayNightChanged(it))
+                        onIntent(ReadConfigIntent.AutoSuggestDayNightChanged(it))
                     }
                 )
 
@@ -322,7 +315,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.selectText),
                     checked = settings.selectText,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.SelectTextChanged(it))
+                        onIntent(ReadConfigIntent.SelectTextChanged(it))
                     }
                 )
 
@@ -330,7 +323,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.no_anim_scroll_page),
                     checked = settings.noAnimScrollPage,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.NoAnimScrollPageChanged(it))
+                        onIntent(ReadConfigIntent.NoAnimScrollPageChanged(it))
                     }
                 )
 
@@ -340,7 +333,7 @@ fun ReadConfigScreen(
                     displayEntries = stringArrayResource(R.array.click_image_way_title),
                     entryValues = stringArrayResource(R.array.click_image_way_value),
                     onValueChange = {
-                        viewModel.onIntent(ReadConfigIntent.ClickImgWayChanged(it))
+                        onIntent(ReadConfigIntent.ClickImgWayChanged(it))
                     }
                 )
 
@@ -349,34 +342,34 @@ fun ReadConfigScreen(
                         title = stringResource(R.string.enable_optimize_render),
                         checked = settings.optimizeRender,
                         onCheckedChange = {
-                            viewModel.onIntent(ReadConfigIntent.OptimizeRenderChanged(it))
+                            onIntent(ReadConfigIntent.OptimizeRenderChanged(it))
                         }
                     )
                 }
 
                 ClickableSettingItem(
                     title = stringResource(R.string.click_regional_config),
-                    onClick = { showClickActionSheet = true }
+                    onClick = { onIntent(ReadConfigIntent.OpenClickActions) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.disable_return_key),
                     checked = settings.disableReturnKey,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.DisableReturnKeyChanged(it))
+                        onIntent(ReadConfigIntent.DisableReturnKeyChanged(it))
                     }
                 )
 
                 ClickableSettingItem(
                     title = stringResource(R.string.custom_page_key),
-                    onClick = { showPageKeySheet = true }
+                    onClick = { onIntent(ReadConfigIntent.OpenPageKeys) }
                 )
 
                 SwitchSettingItem(
                     title = stringResource(R.string.show_read_title_addition),
                     checked = settings.showReadTitleAddition,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.ShowReadTitleAdditionChanged(it))
+                        onIntent(ReadConfigIntent.ShowReadTitleAdditionChanged(it))
                     }
                 )
 
@@ -384,7 +377,7 @@ fun ReadConfigScreen(
                     title = stringResource(R.string.show_menu_icon),
                     checked = settings.showMenuIcon,
                     onCheckedChange = {
-                        viewModel.onIntent(ReadConfigIntent.ShowMenuIconChanged(it))
+                        onIntent(ReadConfigIntent.ShowMenuIconChanged(it))
                     }
                 )
                 }
@@ -393,19 +386,18 @@ fun ReadConfigScreen(
     }
 
     PageKeySheet(
-        show = showPageKeySheet,
+        show = state.activeSheet == ReadConfigSheet.PageKeys,
         prevKeys = settings.prevKeys,
         nextKeys = settings.nextKeys,
-        onDismissRequest = { showPageKeySheet = false },
+        onDismissRequest = { onIntent(ReadConfigIntent.DismissSheet) },
         onConfirm = { prevKeys, nextKeys ->
-            viewModel.onIntent(ReadConfigIntent.PageKeysChanged(prevKeys, nextKeys))
-            showPageKeySheet = false
+            onIntent(ReadConfigIntent.PageKeysChanged(prevKeys, nextKeys))
         }
     )
 
-    if (showClickActionSheet) {
+    if (state.activeSheet == ReadConfigSheet.ClickActions) {
         ClickActionConfigSheet(
-            onDismissRequest = { showClickActionSheet = false },
+            onDismissRequest = { onIntent(ReadConfigIntent.DismissSheet) },
         )
     }
 }

+ 133 - 170
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt

@@ -1,188 +1,151 @@
 package io.legado.app.ui.config.readConfig
 
 import androidx.lifecycle.ViewModel
-import io.legado.app.constant.EventBus
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
-import io.legado.app.model.ReadBook
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.utils.postEvent
+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 kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 
 class ReadConfigViewModel(
-    private val localPreferencesRepository: LocalPreferencesRepository
+    private val settingsGateway: ReadSettingsGateway,
+    private val applyReadSetting: ApplyReadSettingUseCase,
 ) : ViewModel() {
 
-    fun onIntent(intent: ReadConfigIntent) {
-        when (intent) {
-            is ReadConfigIntent.ScreenOrientationChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(screenOrientation = intent.value) }
-            }
-
-            is ReadConfigIntent.KeepLightChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(keepLight = intent.value) }
-            }
-
-            is ReadConfigIntent.HideStatusBarChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(hideStatusBar = intent.value) }
-                postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
-            }
-
-            is ReadConfigIntent.HideNavigationBarChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(hideNavigationBar = intent.value) }
-                postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
-            }
-
-            is ReadConfigIntent.PaddingDisplayCutoutsChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(paddingDisplayCutouts = intent.value) }
-            }
-
-            is ReadConfigIntent.TitleBarModeChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(titleBarMode = intent.value) }
-            }
-
-            is ReadConfigIntent.ReadMenuBlurAlphaChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(readMenuBlurAlpha = intent.value) }
-                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
-            }
-
-            is ReadConfigIntent.ReadBodyToLhChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(readBodyToLh = intent.value) }
-            }
-
-            is ReadConfigIntent.DefaultSourceChangeAllChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(defaultSourceChangeAll = intent.value) }
-            }
-
-            is ReadConfigIntent.TextFullJustifyChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(textFullJustify = intent.value) }
-                upLayout()
-            }
-
-            is ReadConfigIntent.TextBottomJustifyChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(textBottomJustify = intent.value) }
-                upLayout()
-            }
-
-            is ReadConfigIntent.AdaptSpecialStyleChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(adaptSpecialStyle = intent.value) }
-            }
-
-            is ReadConfigIntent.UseZhLayoutChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(useZhLayout = intent.value) }
-                upLayout()
-            }
-
-            is ReadConfigIntent.ShowBrightnessViewChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(showBrightnessView = intent.value) }
-            }
-
-            is ReadConfigIntent.BrightnessVwPosChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(brightnessVwPos = intent.value) }
-            }
-
-            is ReadConfigIntent.UseUnderlineChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(useUnderline = intent.value) }
-            }
-
-            is ReadConfigIntent.ReadSliderModeChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(readSliderMode = intent.value) }
-                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
-            }
-
-            is ReadConfigIntent.DoubleHorizontalPageChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(doubleHorizontalPage = intent.value) }
-                upLayout()
-            }
-
-            is ReadConfigIntent.ProgressBarBehaviorChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(progressBarBehavior = intent.value) }
-                postEvent(EventBus.UP_SEEK_BAR, true)
-            }
-
-            is ReadConfigIntent.MouseWheelPageChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(mouseWheelPage = intent.value) }
-            }
-
-            is ReadConfigIntent.VolumeKeyPageChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(volumeKeyPage = intent.value) }
-            }
-
-            is ReadConfigIntent.VolumeKeyPageOnPlayChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(volumeKeyPageOnPlay = intent.value) }
-            }
-
-            is ReadConfigIntent.KeyPageOnLongPressChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(keyPageOnLongPress = intent.value) }
-            }
-
-            is ReadConfigIntent.PageTouchSlopChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(pageTouchSlop = intent.value) }
-                postEvent(EventBus.UP_CONFIG, arrayListOf(4))
-            }
-
-            is ReadConfigIntent.SliderVibratorChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(sliderVibrator = intent.value) }
-            }
-
-            is ReadConfigIntent.SelectVibratorChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(selectVibrator = intent.value) }
-            }
-
-            is ReadConfigIntent.AutoChangeSourceChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(autoChangeSource = intent.value) }
-            }
-
-            is ReadConfigIntent.AutoSuggestDayNightChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(autoSuggestDayNight = intent.value) }
-            }
-
-            is ReadConfigIntent.SelectTextChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(selectText = intent.value) }
-            }
-
-            is ReadConfigIntent.NoAnimScrollPageChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(noAnimScrollPage = intent.value) }
-                ReadBook.callBack?.upPageAnim()
-            }
-
-            is ReadConfigIntent.ClickImgWayChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(clickImgWay = intent.value) }
-            }
-
-            is ReadConfigIntent.OptimizeRenderChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(optimizeRender = intent.value) }
-                upStyle()
-            }
+    private val _uiState = MutableStateFlow(ReadConfigUiState())
+    val uiState = _uiState.asStateFlow()
 
-            is ReadConfigIntent.DisableReturnKeyChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(disableReturnKey = intent.value) }
-            }
-
-            is ReadConfigIntent.ShowReadTitleAdditionChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(showReadTitleAddition = intent.value) }
-                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
-            }
+    private val _effects = MutableSharedFlow<ReadConfigEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
-            is ReadConfigIntent.ShowMenuIconChanged -> {
-                localPreferencesRepository.updateSettings { it.copy(showMenuIcon = intent.value) }
-                postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
+    init {
+        viewModelScope.launch {
+            settingsGateway.settings.collect { settings ->
+                _uiState.update { settings.toUiState(activeSheet = it.activeSheet) }
             }
+        }
+    }
 
-            is ReadConfigIntent.PageKeysChanged -> {
-                localPreferencesRepository.updateSettings {
-                    it.copy(prevKeys = intent.prevKeys, nextKeys = intent.nextKeys)
-                }
-            }
+    fun onIntent(intent: ReadConfigIntent) {
+        when (intent) {
+            ReadConfigIntent.OpenPageKeys -> setSheet(ReadConfigSheet.PageKeys)
+            ReadConfigIntent.OpenClickActions -> setSheet(ReadConfigSheet.ClickActions)
+            ReadConfigIntent.DismissSheet -> setSheet(null)
+            else -> updateSetting(intent.toSettingsUpdate())
         }
     }
 
-    private fun upLayout() {
-        ChapterProvider.upLayout()
-        ReadBook.loadContent(false)
+    private fun setSheet(sheet: ReadConfigSheet?) {
+        _uiState.update { it.copy(activeSheet = sheet) }
     }
 
-    private fun upStyle() {
-        ChapterProvider.upStyle()
-        ReadBook.callBack?.upPageAnim(true)
-        ReadBook.loadContent(false)
+    private fun updateSetting(update: ReadSettingsUpdate) {
+        viewModelScope.launch {
+            runCatching { settingsGateway.update(update) }
+                .onSuccess {
+                    applyReadSetting(update)
+                    if (update is ReadSettingsUpdate.PageKeys) setSheet(null)
+                }
+                .onFailure { error ->
+                    _effects.tryEmit(
+                        ReadConfigEffect.SettingsUpdateFailed(
+                            error.message ?: error.javaClass.simpleName
+                        )
+                    )
+                }
+        }
     }
 }
+
+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)
+    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)
+    ReadConfigIntent.OpenPageKeys,
+    ReadConfigIntent.OpenClickActions,
+    ReadConfigIntent.DismissSheet -> error("Sheet intents do not update settings")
+}
+
+private fun ReadSettings.toUiState(activeSheet: ReadConfigSheet?): ReadConfigUiState =
+    ReadConfigUiState(
+        screenOrientation = screenOrientation,
+        keepLight = keepLight,
+        hideStatusBar = hideStatusBar,
+        hideNavigationBar = hideNavigationBar,
+        paddingDisplayCutouts = paddingDisplayCutouts,
+        titleBarMode = titleBarMode,
+        readMenuBlurAlpha = readMenuBlurAlpha,
+        readBodyToLh = readBodyToLh,
+        defaultSourceChangeAll = defaultSourceChangeAll,
+        textFullJustify = textFullJustify,
+        textBottomJustify = textBottomJustify,
+        adaptSpecialStyle = adaptSpecialStyle,
+        useZhLayout = useZhLayout,
+        showBrightnessView = showBrightnessView,
+        brightnessVwPos = brightnessVwPos,
+        brightnessAuto = brightnessAuto,
+        useUnderline = useUnderline,
+        readSliderMode = readSliderMode,
+        doubleHorizontalPage = doubleHorizontalPage,
+        progressBarBehavior = progressBarBehavior,
+        mouseWheelPage = mouseWheelPage,
+        volumeKeyPage = volumeKeyPage,
+        volumeKeyPageOnPlay = volumeKeyPageOnPlay,
+        keyPageOnLongPress = keyPageOnLongPress,
+        pageTouchSlop = pageTouchSlop,
+        sliderVibrator = sliderVibrator,
+        selectVibrator = selectVibrator,
+        autoChangeSource = autoChangeSource,
+        autoSuggestDayNight = autoSuggestDayNight,
+        selectText = selectText,
+        noAnimScrollPage = noAnimScrollPage,
+        clickImgWay = clickImgWay,
+        optimizeRender = optimizeRender,
+        disableReturnKey = disableReturnKey,
+        expandTextMenu = expandTextMenu,
+        showSelectMenuIcon = showSelectMenuIcon,
+        showReadTitleAddition = showReadTitleAddition,
+        autoReadSpeed = autoReadSpeed,
+        prevKeys = prevKeys,
+        nextKeys = nextKeys,
+        showMenuIcon = showMenuIcon,
+        activeSheet = activeSheet,
+    )

+ 1 - 5
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -76,7 +76,6 @@ import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.ui.config.labConfig.LabConfig
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LocalAppSettings
 import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.adaptiveContentPadding
@@ -110,7 +109,6 @@ fun ThemeConfigScreen(
     onNavigateToThemeManage: () -> Unit,
     viewModel: ThemeConfigViewModel = koinViewModel()
 ) {
-    val appSettings = LocalAppSettings.current
     val systemInDark = isSystemInDarkTheme()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     var manageKey by remember { mutableStateOf<Boolean?>(null) }
@@ -171,7 +169,7 @@ fun ThemeConfigScreen(
                 val isMiuixEngine = remember(composeEngine) {
                     ThemeResolver.isMiuixEngine(composeEngine)
                 }
-                val isDarkTheme = appSettings.darkTheme
+                val isDarkTheme = LegadoTheme.isDark
 
                 if (!isMiuixEngine) {
                     Column(
@@ -234,7 +232,6 @@ fun ThemeConfigScreen(
                             onValueChange = { mode ->
                                 selectedThemeMode = mode
                                 val expectedDark = if (mode == "0") systemInDark else mode == "2"
-                                viewModel.updateSettings { it.copy(darkTheme = expectedDark) }
                                 ThemeConfig.themeMode = mode
                                 ThemeConfigStore.applyDayNightLive()
                                 LogUtils.d("ThemeConfigScreen", "Theme sync log: mode=$mode (resolved expectedDark=$expectedDark), ThemeConfig.themeMode=${ThemeConfig.themeMode}")
@@ -277,7 +274,6 @@ fun ThemeConfigScreen(
                             onModeSelected = { mode ->
                                 selectedThemeMode = mode
                                 val expectedDark = if (mode == "0") systemInDark else mode == "2"
-                                viewModel.updateSettings { it.copy(darkTheme = expectedDark) }
                                 ThemeConfig.themeMode = mode
                                 ThemeConfigStore.applyDayNightLive()
                                 LogUtils.d("ThemeConfigScreen", "Theme sync log: mode=$mode (resolved expectedDark=$expectedDark), ThemeConfig.themeMode=${ThemeConfig.themeMode}")

+ 13 - 16
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -4,10 +4,12 @@ import android.net.Uri
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.constant.PreferKey
-import io.legado.app.data.local.preferences.AppSettings
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
-import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsUpdate
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsUpdate
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.MD5Utils
@@ -25,17 +27,12 @@ import java.io.File
 import java.io.FileOutputStream
 
 class ThemeConfigViewModel(
-    private val localPreferencesRepository: LocalPreferencesRepository,
-    private val readSettingsRepository: ReadSettingsRepository
+    private val settingsRepository: SettingsRepository,
+    private val readSettingsGateway: ReadSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
 ) : ViewModel() {
 
-    val appSettings = localPreferencesRepository.appSettings
-
-    fun updateSettings(transform: (AppSettings) -> AppSettings) {
-        localPreferencesRepository.updateSettings(transform)
-    }
-
-    val fontFolder = readSettingsRepository.preferences
+    val fontFolder = readSettingsGateway.settings
         .map { preferences ->
             val fontFolder: String? = preferences.fontFolder
             fontFolder
@@ -45,18 +42,18 @@ class ThemeConfigViewModel(
     fun setFontFolder(path: String) {
         viewModelScope.launch {
             runCatching {
-                readSettingsRepository.setFontFolder(path)
+                readSettingsGateway.update(ReadSettingsUpdate.FontFolder(path))
             }
         }
     }
 
-    val showThemeRefactorTip = localPreferencesRepository
+    val showThemeRefactorTip = settingsRepository
         .getPreference(LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP, true)
         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
 
     fun setShowThemeRefactorTip(show: Boolean) {
         viewModelScope.launch {
-            localPreferencesRepository.updatePreference(
+            settingsRepository.updatePreference(
                 LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP,
                 show
             )
@@ -123,7 +120,7 @@ class ThemeConfigViewModel(
                     temp.copyTo(target, overwrite = true)
                     temp.delete()
                 }
-                ThemeConfig.appFontPath = target.absolutePath
+                themeSettingsGateway.update(ThemeSettingsUpdate.AppFontPath(target.absolutePath))
             }.onFailure(Throwable::printStackTrace)
         }
     }

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

@@ -54,11 +54,11 @@ import io.legado.app.ui.config.ai.summary.AiSummaryConfigRouteScreen
 import io.legado.app.ui.config.backupConfig.BackupConfigScreen
 import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigScreen
-import io.legado.app.ui.config.customTheme.CustomThemeScreen
+import io.legado.app.ui.config.customTheme.CustomThemeRouteScreen
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
 import io.legado.app.ui.config.labConfig.LabConfigScreen
-import io.legado.app.ui.config.otherConfig.OtherConfigScreen
-import io.legado.app.ui.config.readConfig.ReadConfigScreen
+import io.legado.app.ui.config.otherConfig.OtherConfigRouteScreen
+import io.legado.app.ui.config.readConfig.ReadConfigRouteScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
 import io.legado.app.ui.config.themeManage.ThemeManageScreen
 import io.legado.app.ui.config.translation.TranslationConfigScreen
@@ -202,11 +202,11 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteSettingsOther> {
-        OtherConfigScreen(onBackClick = { onNavigateBack() })
+        OtherConfigRouteScreen(onBackClick = { onNavigateBack() })
     }
 
     entry<MainRouteSettingsRead> {
-        ReadConfigScreen(onBackClick = { onNavigateBack() })
+        ReadConfigRouteScreen(onBackClick = { onNavigateBack() })
     }
 
     entry<MainRouteSettingsCover> {
@@ -305,7 +305,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteSettingsCustomTheme> {
-        CustomThemeScreen(
+        CustomThemeRouteScreen(
             onBackClick = { onNavigateBack() }
         )
     }

+ 26 - 18
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -10,13 +10,13 @@ import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalInspectionMode
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import top.yukonga.miuix.kmp.theme.ColorSchemeMode
 import com.materialkolor.PaletteStyle
 import androidx.compose.runtime.getValue
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import io.legado.app.data.local.preferences.AppSettings
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.settings.customColors
 import org.koin.compose.koinInject
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -49,7 +49,6 @@ private fun AppThemePreview(
         composeEngine = "material"
     )
     CompositionLocalProvider(
-        LocalAppSettings provides AppSettings(),
         LocalLegadoThemeColors provides themeColors
     ) {
         MaterialThemeWrapper(
@@ -66,29 +65,39 @@ private fun AppThemeActual(
     darkTheme: Boolean,
     content: @Composable () -> Unit
 ) {
-    val preferencesRepository = koinInject<LocalPreferencesRepository>()
-    val settings by preferencesRepository.appSettings.collectAsStateWithLifecycle()
+    val appShellSettingsGateway = koinInject<AppShellSettingsGateway>()
+    val themeSettingsGateway = koinInject<ThemeSettingsGateway>()
+    val initialAppShellSettings = remember(appShellSettingsGateway) {
+        appShellSettingsGateway.currentSettings
+    }
+    val initialThemeSettings = remember(themeSettingsGateway) {
+        themeSettingsGateway.currentSettings
+    }
+    val appShellSettings by appShellSettingsGateway.settings
+        .collectAsStateWithLifecycle(initialAppShellSettings)
+    val themeSettings by themeSettingsGateway.settings
+        .collectAsStateWithLifecycle(initialThemeSettings)
     val context = LocalContext.current
     
     // 1. 获取基础配置
-    val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme)
-    val themeModeValue = ThemeConfig.themeMode
+    val appThemeMode = ThemeResolver.resolveThemeMode(themeSettings.appTheme)
+    val themeModeValue = appShellSettings.themeMode
     val effectiveDarkTheme = when (themeModeValue) {
         "1" -> false
         "2" -> true
         else -> darkTheme
     }
-    val isPureBlack = ThemeConfig.isPureBlack
-    val paletteStyleValue = ThemeConfig.paletteStyle
-    val materialVersion = ThemeConfig.materialVersion
-    val composeEngine = ThemeConfig.composeEngine
-    val customPrimary = ThemeConfig.cPrimary
-    val customNightPrimary = ThemeConfig.cNPrimary
-    val appFontPath = ThemeConfig.appFontPath
+    val isPureBlack = themeSettings.isPureBlack
+    val paletteStyleValue = themeSettings.paletteStyle
+    val materialVersion = themeSettings.materialVersion
+    val composeEngine = appShellSettings.composeEngine
+    val customPrimary = themeSettings.customPrimary
+    val customNightPrimary = themeSettings.customNightPrimary
+    val appFontPath = themeSettings.appFontPath
 
     // 2. 深度个性化配置
-    val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization
-    val customColors = ThemeConfig.customThemeColors(effectiveDarkTheme)
+    val enableDeepPersonalization = themeSettings.enableDeepPersonalization
+    val customColors = themeSettings.customColors(effectiveDarkTheme)
 
     // 3. 加载自定义字体
     val customFontFamily = rememberCustomFont(appFontPath)
@@ -162,7 +171,6 @@ private fun AppThemeActual(
 
     // 7. 提供主题数据并根据引擎渲染
     CompositionLocalProvider(
-        LocalAppSettings provides settings,
         LocalLegadoThemeColors provides themeColors
     ) {
         if (ThemeResolver.isMiuixEngine(themeColors.composeEngine)) {

+ 0 - 9
app/src/main/java/io/legado/app/ui/theme/LocalAppSettings.kt

@@ -1,9 +0,0 @@
-package io.legado.app.ui.theme
-
-import androidx.compose.runtime.staticCompositionLocalOf
-import io.legado.app.data.local.preferences.AppSettings
-
-/**
- * 全局可访问的静态配置快照流
- */
-val LocalAppSettings = staticCompositionLocalOf { AppSettings() }

+ 2 - 2
app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt

@@ -8,7 +8,7 @@ import io.ktor.websocket.readText
 import io.ktor.websocket.send
 import io.legado.app.R
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
-import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.MatchMode
 import io.legado.app.domain.usecase.BookSearchControl
@@ -32,7 +32,7 @@ import splitties.init.appCtx
 class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) : CoroutineScope by session {
 
     private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
-    private val localPreferencesRepository: LocalPreferencesRepository by lazy {
+    private val localPreferencesRepository: SettingsRepository by lazy {
         GlobalContext.get().get()
     }
     private val searchControl = BookSearchControl()

+ 50 - 0
app/src/test/java/io/legado/app/data/repository/LocalUiStatusMigrationTest.kt

@@ -0,0 +1,50 @@
+package io.legado.app.data.repository
+
+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 io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.help.config.rawPrefValue
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalUiStatusMigrationTest {
+
+    @Test
+    fun `copies legacy values missing from settings`() {
+        val current = mutablePreferencesOf(stringPreferencesKey("existing") to "current")
+        val local = mutablePreferencesOf(
+            intPreferencesKey("search_layout_mode") to 2,
+            booleanPreferencesKey("privacy_policy_ok") to true,
+        )
+
+        val migrated = mergeMissingLocalPreferences(current, local)
+
+        assertEquals(2, migrated.rawPrefValue("search_layout_mode"))
+        assertEquals(true, migrated.rawPrefValue("privacy_policy_ok"))
+        assertEquals("current", migrated.rawPrefValue("existing"))
+        assertTrue(migrated[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] == true)
+    }
+
+    @Test
+    fun `keeps newer settings value when legacy contains same key`() {
+        val current = mutablePreferencesOf(intPreferencesKey("search_layout_mode") to 3)
+        val local = mutablePreferencesOf(intPreferencesKey("search_layout_mode") to 1)
+
+        val migrated = mergeMissingLocalPreferences(current, local)
+
+        assertEquals(3, migrated.rawPrefValue("search_layout_mode"))
+    }
+
+    @Test
+    fun `marks migration complete when legacy store is empty`() {
+        val migrated = mergeMissingLocalPreferences(
+            currentData = mutablePreferencesOf(),
+            localPreferences = mutablePreferencesOf(),
+        )
+
+        assertTrue(migrated[LocalPreferencesKeys.MIGRATED_TO_SETTINGS] == true)
+    }
+}

+ 12 - 0
app/src/test/java/io/legado/app/help/config/PreferencesDsCompatTest.kt

@@ -130,4 +130,16 @@ class PreferencesDsCompatTest {
         assertEquals(3f, prefs.compatDsFloat("f"))
         assertEquals(setOf("x"), prefs.compatDsStringSet("set"))
     }
+
+    @Test
+    fun `compatDsValue 按默认值类型兼容读取并在缺失时回退`() {
+        val prefs = mutablePreferencesOf(
+            stringPreferencesKey("int") to "7",
+            intPreferencesKey("boolean") to 1,
+        )
+
+        assertEquals(7, prefs.compatDsValue(intPreferencesKey("int"), 0))
+        assertEquals(true, prefs.compatDsValue(booleanPreferencesKey("boolean"), false))
+        assertEquals("fallback", prefs.compatDsValue(stringPreferencesKey("absent"), "fallback"))
+    }
 }

+ 14 - 0
app/src/test/java/io/legado/app/help/storage/BackupConfigSecurityTest.kt

@@ -0,0 +1,14 @@
+package io.legado.app.help.storage
+
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BackupConfigSecurityTest {
+
+    @Test
+    fun `local password and migration marker are never exported`() {
+        assertTrue(LocalPreferencesKeys.PASSWORD.name in alwaysIgnoredPreferenceKeys)
+        assertTrue(LocalPreferencesKeys.MIGRATED_TO_SETTINGS.name in alwaysIgnoredPreferenceKeys)
+    }
+}