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

fix: 避免配置切换重建并收口 UDF 状态

5151561 3 месяцев назад
Родитель
Сommit
1ba32fcfc3
23 измененных файлов с 203 добавлено и 91 удалено
  1. 9 9
      app/src/main/AndroidManifest.xml
  2. 9 1
      app/src/main/java/io/legado/app/App.kt
  3. 4 3
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  4. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  5. 2 2
      app/src/main/java/io/legado/app/data/repository/FeatureSettingsRepositories.kt
  6. 1 1
      app/src/main/java/io/legado/app/di/appModule.kt
  7. 3 2
      app/src/main/java/io/legado/app/help/config/LocalConfig.kt
  8. 0 10
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt
  9. 0 3
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeRouteScreen.kt
  10. 1 1
      app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt
  11. 11 10
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  12. 6 16
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  13. 0 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt
  14. 0 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigRouteScreen.kt
  15. 0 2
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  16. 15 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  17. 2 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt
  18. 19 16
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  19. 16 3
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  20. 1 2
      app/src/main/java/io/legado/app/ui/theme/CustomColorScheme.kt
  21. 19 6
      app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt
  22. 2 2
      app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt
  23. 82 0
      app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

+ 9 - 9
app/src/main/AndroidManifest.xml

@@ -39,7 +39,7 @@
         <activity
             android:name=".ui.main.LauncherW"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcherw">
             <intent-filter>
@@ -55,7 +55,7 @@
         <activity
             android:name=".ui.main.Launcher0"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher0">
             <intent-filter>
@@ -72,7 +72,7 @@
         <activity
             android:name=".ui.main.Launcher1"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher1">
             <intent-filter>
@@ -89,7 +89,7 @@
         <activity
             android:name=".ui.main.Launcher2"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher2">
             <intent-filter>
@@ -106,7 +106,7 @@
         <activity
             android:name=".ui.main.Launcher3"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher3">
             <intent-filter>
@@ -123,7 +123,7 @@
         <activity
             android:name=".ui.main.Launcher4"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher4">
             <intent-filter>
@@ -140,7 +140,7 @@
         <activity
             android:name=".ui.main.Launcher5"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher5">
             <intent-filter>
@@ -157,7 +157,7 @@
         <activity
             android:name=".ui.main.Launcher6"
             android:enabled="false"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher6">
             <intent-filter>
@@ -174,7 +174,7 @@
         <activity
             android:name=".ui.main.MainActivity"
             android:alwaysRetainTaskState="true"
-            android:configChanges="uiMode"
+            android:configChanges="locale|layoutDirection|uiMode"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:exported="true">

+ 9 - 1
app/src/main/java/io/legado/app/App.kt

@@ -37,6 +37,7 @@ import io.legado.app.data.entities.rule.ExploreRule
 import io.legado.app.data.entities.rule.SearchRule
 import io.legado.app.di.appDatabaseModule
 import io.legado.app.di.appModule
+import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.help.AppFreezeMonitor
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.CrashHandler
@@ -70,6 +71,8 @@ import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isDebuggable
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import org.chromium.base.ThreadUtils
 import org.koin.android.ext.android.get
 import org.koin.android.ext.koin.androidContext
@@ -150,7 +153,12 @@ class App : Application(), ImageLoaderFactory {
         oldConfig = Configuration(resources.configuration)
         registerActivityLifecycleCallbacks(LifecycleHelp)
         Coroutine.async {
-            AppWebDav.upConfig()
+            get<BackupSettingsGateway>().settings
+                .map {
+                    listOf(it.webDavUrl, it.webDavDir, it.webDavAccount, it.webDavPassword)
+                }
+                .distinctUntilChanged()
+                .collect { AppWebDav.upConfig() }
         }
         Coroutine.async {
             LogUtils.init(this@App)

+ 4 - 3
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -65,9 +65,10 @@ abstract class BaseComposeActivity(
 
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
-        // manifest 声明 uiMode configChanges 后,切换深浅色不再 recreate,
-        // 而 AppCompat 手动回调本方法时不会走 View 树分发,需要自己同步给
-        // Compose(LocalConfiguration)并刷新系统栏与背景图
+        // manifest 声明 locale/layoutDirection/uiMode configChanges 后,Compose 主壳
+        // 可直接响应语言与深浅色变化,无需销毁 Activity。AppCompat 手动回调本方法时
+        // 不会走 View 树分发,需要自己同步给 Compose(LocalConfiguration),并刷新
+        // 系统栏与背景图。
         window.decorView.dispatchConfigurationChanged(newConfig)
         setupSystemBar()
         if (imageBg) {

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

@@ -49,6 +49,7 @@ object PreferKey {
     const val enableReview = "enableReview"
     const val showRss = "showRss"
     const val mainNavigationOrder = "mainNavigationOrder"
+    const val navExtended = "navExtended"
     const val showStatusBar = "showStatusBar"
     const val swipeAnimation = "swipeAnimation"
     const val bookshelfLayout = "bookshelfLayout"

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

@@ -73,7 +73,7 @@ class AppShellSettingsRepository : AppShellSettingsGateway {
                 AppShellBooleanSetting.UseFloatingBottomBar -> PreferKey.useFloatingBottomBar to update.value
                 AppShellBooleanSetting.UseFloatingBottomBarLiquidGlass ->
                     PreferKey.useFloatingBottomBarLiquidGlass to update.value
-                AppShellBooleanSetting.NavExtended -> "navExtended" to update.value
+                AppShellBooleanSetting.NavExtended -> PreferKey.navExtended to update.value
             }
             is AppShellSettingsUpdate.StringValue -> when (update.setting) {
                 AppShellStringSetting.TabletInterface -> PreferKey.tabletInterface to update.value
@@ -382,7 +382,7 @@ private fun Preferences.toAppShellSettings(): AppShellSettings = AppShellSetting
     defaultHomePage = compatDsString(PreferKey.defaultHomePage) ?: "bookshelf",
     mainNavigationOrder = compatDsString(PreferKey.mainNavigationOrder)
         ?: "home,bookshelf,explore,rss,my",
-    navExtended = compatDsBoolean("navExtended") ?: false,
+    navExtended = compatDsBoolean(PreferKey.navExtended) ?: false,
     navIconHome = compatDsString(PreferKey.navIconHome) ?: "",
     navIconBookshelf = compatDsString(PreferKey.navIconBookshelf) ?: "",
     navIconExplore = compatDsString(PreferKey.navIconExplore) ?: "",

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

@@ -366,7 +366,7 @@ val appModule = module {
     viewModelOf(::AllBookmarkViewModel)
     viewModelOf(::TxtTocRuleViewModel)
     viewModel { TxtTocRulePreviewViewModel(app = get(), repository = get()) }
-    viewModelOf(::OtherConfigViewModel)
+    viewModel { OtherConfigViewModel(get(), get()) }
     viewModelOf(::CustomThemeViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)

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

@@ -4,6 +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.constant.PreferKey
 import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.utils.getBoolean
 import io.legado.app.utils.putBoolean
@@ -64,9 +65,9 @@ by appCtx.getSharedPreferences("local", Context.MODE_PRIVATE) {
         }
 
     var navExtended: Boolean
-        get() = getBoolean("navExtended")
+        get() = getBoolean(PreferKey.navExtended)
         set(value) {
-            putBoolean("navExtended", value)
+            putBoolean(PreferKey.navExtended, value)
         }
 
     /** 旧版语言偏好是否已迁移到 AppCompat per-app locales(App.onCreate 一次性迁移) */

+ 0 - 10
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigViewModel.kt

@@ -15,9 +15,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -41,13 +38,6 @@ class BackupConfigViewModel(
                 _uiState.update { it.copy(settings = settings) }
             }
         }
-        viewModelScope.launch {
-            settingsGateway.settings
-                .map { listOf(it.webDavUrl, it.webDavDir, it.webDavAccount, it.webDavPassword) }
-                .distinctUntilChanged()
-                .drop(1)
-                .collect { withContext(Dispatchers.IO) { webDavBackupUseCase.refreshConfig() } }
-        }
     }
 
     fun onIntent(intent: BackupConfigIntent) {

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

@@ -5,9 +5,7 @@ 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
 
@@ -24,7 +22,6 @@ fun CustomThemeRouteScreen(
             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()

+ 1 - 1
app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt

@@ -21,7 +21,7 @@ object MainConfig {
     var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
     var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
     var swipeAnimation by prefDelegate(PreferKey.swipeAnimation, true)
-    var navExtended by prefDelegate("navExtended", false)
+    var navExtended by prefDelegate(PreferKey.navExtended, false)
     var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
     var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
     var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)

+ 11 - 10
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -37,6 +37,7 @@ import splitties.init.appCtx
 class OtherConfigViewModel(
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
+    initialState: OtherConfigUiState = defaultOtherConfigUiState(),
 ) : ViewModel() {
 
     companion object {
@@ -52,16 +53,7 @@ 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,
-        )
-    )
+    private val _uiState = MutableStateFlow(initialState)
     val uiState = _uiState.asStateFlow()
 
     private val _effects = MutableSharedFlow<OtherConfigEffect>(extraBufferCapacity = 16)
@@ -386,6 +378,15 @@ class OtherConfigViewModel(
 
 }
 
+internal fun defaultOtherConfigUiState() = OtherConfigUiState(
+    checkSourceTimeoutSeconds = CheckSource.timeout / 1000,
+    checkSearch = CheckSource.checkSearch,
+    checkDiscovery = CheckSource.checkDiscovery,
+    checkInfo = CheckSource.checkInfo,
+    checkCategory = CheckSource.checkCategory,
+    checkContent = CheckSource.checkContent,
+)
+
 private fun OtherSettings.toUiState(current: OtherConfigUiState): OtherConfigUiState =
     current.copy(
         language = language,

+ 6 - 16
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -83,9 +83,7 @@ object ThemeConfig {
     //m3 or miuix
     var composeEngine by prefDelegate(PreferKey.composeEngine, "material")
 
-    var useMiuixMonet by prefDelegate(PreferKey.useMiuixMonet, false) {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var useMiuixMonet by prefDelegate(PreferKey.useMiuixMonet, false)
 
     var materialVersion by prefDelegate(PreferKey.materialVersion, "material3")
 
@@ -124,13 +122,9 @@ object ThemeConfig {
 
     var customMode by prefDelegate<String?>(PreferKey.customMode, "tonalSpot")
 
-    var fontScale by prefDelegate(PreferKey.fontScale, 10) {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var fontScale by prefDelegate(PreferKey.fontScale, 10)
 
-    var appFontPath by prefDelegate<String?>(PreferKey.appFontPath, null) {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var appFontPath by prefDelegate<String?>(PreferKey.appFontPath, null)
 
     var cPrimary by prefDelegate(PreferKey.cPrimary, 0)
 
@@ -193,13 +187,9 @@ object ThemeConfig {
 
     var bookInfoInputColor by prefDelegate(PreferKey.bookInfoInputColor, 0)
 
-    var cNPrimary by prefDelegate(PreferKey.cNPrimary, 0) {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var cNPrimary by prefDelegate(PreferKey.cNPrimary, 0)
 
-    var customContrast by prefDelegate(PreferKey.customContrast, "Default") {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var customContrast by prefDelegate(PreferKey.customContrast, "Default")
 
     var launcherIcon by prefDelegate(PreferKey.launcherIcon, "ic_launcher")
 
@@ -251,7 +241,7 @@ object ThemeConfig {
         "home,bookshelf,explore,rss,my",
     )
 
-    var navExtended by prefDelegate("navExtended", false)
+    var navExtended by prefDelegate(PreferKey.navExtended, false)
 
     var webServiceAutoStart by prefDelegate(PreferKey.webServiceAutoStart, false)
 

+ 0 - 1
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt

@@ -65,7 +65,6 @@ sealed interface ThemeConfigIntent {
 
 sealed interface ThemeConfigEffect {
     data object ApplyDayNight : ThemeConfigEffect
-    data object Recreate : ThemeConfigEffect
     data object NotifyMain : ThemeConfigEffect
     data class ChangeLauncherIcon(val value: String) : ThemeConfigEffect
     data object OpenFontFolder : ThemeConfigEffect

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

@@ -77,7 +77,6 @@ fun ThemeConfigRouteScreen(
         viewModel.effects.collectLatest { effect ->
             when (effect) {
                 ThemeConfigEffect.ApplyDayNight -> ThemeConfigStore.applyDayNightLive()
-                ThemeConfigEffect.Recreate -> postEvent(EventBus.RECREATE, "")
                 ThemeConfigEffect.NotifyMain -> postEvent(EventBus.NOTIFY_MAIN, true)
                 is ThemeConfigEffect.ChangeLauncherIcon ->
                     LauncherIconHelp.changeIcon(effect.value)

+ 0 - 2
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -162,7 +162,6 @@ class ThemeConfigViewModel(
                 add(ThemeSettingsUpdate.AppTheme(value))
             }
             themeSettingsGateway.updateAll(updates)
-            _effects.tryEmit(ThemeConfigEffect.Recreate)
         }
     }
 
@@ -182,7 +181,6 @@ class ThemeConfigViewModel(
                     }
                 }
             )
-            _effects.tryEmit(ThemeConfigEffect.Recreate)
         }
     }
 

+ 15 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -240,6 +240,21 @@ fun BookshelfScreen(
         }
     }
 
+    LaunchedEffect(uiState.pendingUploadUrl) {
+        val url = uiState.pendingUploadUrl ?: return@LaunchedEffect
+        val result = snackbarHostState.showSnackbar(
+            message = "上传成功: $url",
+            actionLabel = "复制链接",
+            withDismissAction = true,
+        )
+        if (result == SnackbarResult.ActionPerformed) {
+            clipboardManager.setClipEntry(
+                ClipEntry(ClipData.newPlainText("url", url))
+            )
+        }
+        onIntent(BookshelfIntent.UploadResultConsumed)
+    }
+
     val importLauncher = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocument(),
         onResult = { uri ->

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

@@ -65,6 +65,7 @@ sealed interface BookshelfIntent {
     data class UpdateSetting(val update: BookshelfSettingsUpdate) : BookshelfIntent
     data class UpdateThemeSetting(val update: ThemeSettingsUpdate) : BookshelfIntent
     data class SetCustomTagColors(val colors: List<TagColorPair>) : BookshelfIntent
+    data object UploadResultConsumed : BookshelfIntent
 }
 
 sealed interface BookshelfEffect {
@@ -113,4 +114,5 @@ data class BookshelfUiState(
     val enableCustomTagColors: Boolean = false,
     val customTagColors: ImmutableList<TagColorPair> = persistentListOf(),
     val themeColor: Int = 0,
+    val pendingUploadUrl: String? = null,
 ) : ListUiState<BookUiItem>

+ 19 - 16
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -8,7 +8,6 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.EventBus
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
@@ -111,6 +110,7 @@ class BookshelfViewModel(
     private val draggingBooksFlow = MutableStateFlow<List<BookUiItem>?>(null)
     private val pendingSavedBooksFlow = MutableStateFlow<List<BookUiItem>?>(null)
     private val isInitialLoadingFlow = MutableStateFlow(true)
+    private val pendingUploadUrlFlow = MutableStateFlow<String?>(null)
 
     private data class BookshelfSortConfig(
         val sort: Int,
@@ -141,7 +141,8 @@ class BookshelfViewModel(
     private var cacheBookJob: Job? = null
     private val eventListenerSource = ConcurrentHashMap<BookSource, Boolean>()
 
-    val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
+    private val _scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
+    val scrollTrigger = _scrollTrigger.asSharedFlow()
 
     private val updateConcurrency: Int
         get() = AppConfig.threadCount.coerceIn(1, AppConst.MAX_THREAD)
@@ -534,13 +535,15 @@ class BookshelfViewModel(
         bookshelfSettings,
         appShellSettingsGateway.settings,
         themeSettingsGateway.settings,
-    ) { state, settings, appShellSettings, themeSettings ->
+        pendingUploadUrlFlow,
+    ) { state, settings, appShellSettings, themeSettings, pendingUploadUrl ->
         state.copy(
             settings = settings,
             useRaisedBottomInset = appShellSettings.useFloatingBottomBar || themeSettings.enableBlur,
             enableCustomTagColors = themeSettings.enableCustomTagColors,
             customTagColors = parseTagColors(themeSettings.customTagColorsJson),
             themeColor = themeSettings.themeColor,
+            pendingUploadUrl = pendingUploadUrl,
         )
     }.stateIn(
         viewModelScope,
@@ -573,8 +576,13 @@ class BookshelfViewModel(
         }
         viewModelScope.launch {
             bookshelfSettings.collect { settings ->
+                if (groupIdFlow.value != settings.saveTabPosition) {
+                    groupIdFlow.value = settings.saveTabPosition
+                    clearSelection()
+                    clearDragState()
+                }
                 updateBookGroupStyle(settings.bookGroupStyle)
-                    postUpBooksCount()
+                postUpBooksCount()
             }
         }
 
@@ -639,6 +647,7 @@ class BookshelfViewModel(
                     )
                 )
             }
+            BookshelfIntent.UploadResultConsumed -> pendingUploadUrlFlow.value = null
         }
     }
 
@@ -788,12 +797,12 @@ class BookshelfViewModel(
         val maxOrder = reorderedBooks.size
         execute {
             val updates = reorderedBooks.mapIndexedNotNull { index, bookUi ->
-                appDb.bookDao.getBook(bookUi.book.bookUrl)?.apply {
+                bookRepository.getBook(bookUi.book.bookUrl)?.apply {
                     order = if (isDescending) maxOrder - index else index + 1
                 }
             }
             if (updates.isNotEmpty()) {
-                appDb.bookDao.update(*updates.toTypedArray())
+                bookRepository.update(*updates.toTypedArray())
             }
         }.onError {
             showMessage("排序保存失败\n${it.localizedMessage}")
@@ -864,11 +873,11 @@ class BookshelfViewModel(
     }
 
     fun gotoTop() {
-        scrollTrigger.tryEmit(Unit)
+        _scrollTrigger.tryEmit(Unit)
     }
     fun upAllBookToc() {
         execute {
-            addToWaitUp(appDb.bookDao.hasUpdateBooks)
+            addToWaitUp(bookRepository.getHasUpdateBooks())
         }
     }
 
@@ -884,7 +893,7 @@ class BookshelfViewModel(
     ) {
         execute(context = updateDispatcher) {
             val bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl }
-            val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) }
+            val fullBooks = bookUrls.mapNotNull { bookRepository.getBook(it) }
             addToWaitUp(fullBooks)
         }.onError {
             if (resetRefreshWhenIdle) {
@@ -1078,13 +1087,7 @@ class BookshelfViewModel(
                 contentType = "application/json"
             )
         }.onSuccess { url ->
-            _effects.tryEmit(
-                BookshelfEffect.ShowSnackbar(
-                    message = "上传成功: $url",
-                    actionLabel = "复制链接",
-                    url = url
-                )
-            )
+            pendingUploadUrlFlow.value = url
         }.onError {
             _effects.tryEmit(
                 BookshelfEffect.ShowSnackbar(

+ 16 - 3
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -9,6 +9,7 @@ import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalInspectionMode
 import top.yukonga.miuix.kmp.theme.ColorSchemeMode
 import com.materialkolor.PaletteStyle
@@ -17,6 +18,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.domain.gateway.AppShellSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.settings.customColors
+import io.legado.app.utils.sysConfiguration
+import androidx.compose.ui.unit.Density
 import org.koin.compose.koinInject
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -90,10 +93,18 @@ private fun AppThemeActual(
     val isPureBlack = themeSettings.isPureBlack
     val paletteStyleValue = themeSettings.paletteStyle
     val materialVersion = themeSettings.materialVersion
+    val customContrast = themeSettings.customContrast
     val composeEngine = appShellSettings.composeEngine
     val customPrimary = themeSettings.customPrimary
     val customNightPrimary = themeSettings.customNightPrimary
     val appFontPath = themeSettings.appFontPath
+    val currentDensity = LocalDensity.current
+    val fontScale = (appShellSettings.fontScale / 10f)
+        .takeIf { it in 0.8f..1.6f }
+        ?: sysConfiguration.fontScale
+    val appDensity = remember(currentDensity.density, fontScale) {
+        Density(currentDensity.density, fontScale)
+    }
 
     // 2. 深度个性化配置
     val enableDeepPersonalization = themeSettings.enableDeepPersonalization
@@ -106,7 +117,7 @@ private fun AppThemeActual(
     val colorScheme = remember(
         context, appThemeMode, effectiveDarkTheme, isPureBlack, customPrimary, customNightPrimary,
         enableDeepPersonalization, customColors,
-        paletteStyleValue, materialVersion
+        paletteStyleValue, materialVersion, customContrast,
     ) {
         if (appThemeMode == AppThemeMode.Custom &&
             enableDeepPersonalization &&
@@ -130,7 +141,8 @@ private fun AppThemeActual(
                 isAmoled = isPureBlack,
                 paletteStyle = paletteStyleValue,
                 materialVersion = materialVersion,
-                customSeedColor = customSeedColor
+                customSeedColor = customSeedColor,
+                customContrast = customContrast,
             )
         }
     }
@@ -171,7 +183,8 @@ private fun AppThemeActual(
 
     // 7. 提供主题数据并根据引擎渲染
     CompositionLocalProvider(
-        LocalLegadoThemeColors provides themeColors
+        LocalLegadoThemeColors provides themeColors,
+        LocalDensity provides appDensity,
     ) {
         if (ThemeResolver.isMiuixEngine(themeColors.composeEngine)) {
             MiuixThemeWrapper(

+ 1 - 2
app/src/main/java/io/legado/app/ui/theme/CustomColorScheme.kt

@@ -10,10 +10,9 @@ class CustomColorScheme(
     seed: Int,
     style: PaletteStyle,
     colorSpec: ThemeColorSpec = ThemeColorSpec.SPEC_2021,
+    contrastLevel: Double = ThemeResolver.resolveContrastLevel(),
 ) : BaseColorScheme() {
 
-    private val contrastLevel: Double = ThemeResolver.resolveContrastLevel()
-
     private val specVersion = resolveColorSpecVersion(colorSpec)
 
     override val lightScheme: ColorScheme = dynamicColorScheme(

+ 19 - 6
app/src/main/java/io/legado/app/ui/theme/ThemeEngine.kt

@@ -7,6 +7,7 @@ import androidx.compose.material3.dynamicDarkColorScheme
 import androidx.compose.material3.dynamicLightColorScheme
 import androidx.compose.ui.graphics.Color
 import io.legado.app.lib.theme.primaryColor
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.ThemeResolver.resolvePaletteStyle
 import io.legado.app.ui.theme.colorScheme.AugustColorScheme
 import io.legado.app.ui.theme.colorScheme.CarlottaColorScheme
@@ -46,7 +47,8 @@ object ThemeEngine {
         paletteStyle: String?,
         materialVersion: String? = null,
         forceOpaque: Boolean = false,
-        customSeedColor: Int? = null
+        customSeedColor: Int? = null,
+        customContrast: String? = null,
     ): ColorScheme {
         val resolvedMode = resolveMode(mode = mode, forceOpaque = forceOpaque)
         val baseColorScheme = resolveBaseColorScheme(
@@ -55,7 +57,8 @@ object ThemeEngine {
             darkTheme = darkTheme,
             paletteStyle = paletteStyle,
             materialVersion = materialVersion,
-            customSeedColor = customSeedColor
+            customSeedColor = customSeedColor,
+            customContrast = customContrast,
         )
 
         return baseColorScheme
@@ -80,7 +83,8 @@ object ThemeEngine {
         darkTheme: Boolean,
         paletteStyle: String?,
         materialVersion: String?,
-        customSeedColor: Int?
+        customSeedColor: Int?,
+        customContrast: String?,
     ): ColorScheme {
         if (mode == AppThemeMode.Dynamic) {
             return resolveDynamicColorScheme(context = context, darkTheme = darkTheme)
@@ -90,7 +94,8 @@ object ThemeEngine {
                 seedColor = customSeedColor ?: context.primaryColor,
                 darkTheme = darkTheme,
                 paletteStyle = paletteStyle,
-                materialVersion = materialVersion
+                materialVersion = materialVersion,
+                customContrast = customContrast,
             )
         }
         return (predefinedColorSchemes[mode] ?: GRColorScheme).getColorScheme(darkTheme)
@@ -110,11 +115,19 @@ object ThemeEngine {
         seedColor: Int,
         darkTheme: Boolean,
         paletteStyle: String?,
-        materialVersion: String?
+        materialVersion: String?,
+        customContrast: String?,
     ): ColorScheme {
         val style = resolvePaletteStyle(paletteStyle)
         val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(materialVersion)
-        return CustomColorScheme(seedColor, style, colorSpec).getColorScheme(darkTheme)
+        return CustomColorScheme(
+            seed = seedColor,
+            style = style,
+            colorSpec = colorSpec,
+            contrastLevel = ThemeResolver.resolveContrastLevel(
+                customContrast ?: ThemeConfig.customContrast
+            ),
+        ).getColorScheme(darkTheme)
     }
 
     private fun ColorScheme.applyAmoledIfNeeded(

+ 2 - 2
app/src/main/java/io/legado/app/ui/theme/ThemeResolver.kt

@@ -69,8 +69,8 @@ object ThemeResolver {
         return materialPaletteStyles[value] ?: PaletteStyle.TonalSpot
     }
 
-    fun resolveContrastLevel(): Double {
-        return runCatching { Contrast.valueOf(ThemeConfig.customContrast).value }
+    fun resolveContrastLevel(value: String = ThemeConfig.customContrast): Double {
+        return runCatching { Contrast.valueOf(value).value }
             .getOrDefault(Contrast.Default.value)
     }
 

+ 82 - 0
app/src/test/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModelTest.kt

@@ -0,0 +1,82 @@
+package io.legado.app.ui.config.otherConfig
+
+import android.app.Application
+import android.os.Looper
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
+import io.legado.app.domain.model.settings.OtherSettings
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class OtherConfigViewModelTest {
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+    }
+
+    @Test
+    fun languageChanged_updatesGatewayAndEmitsApplyLanguage() = runBlocking {
+        val otherSettingsGateway = FakeOtherSettingsGateway()
+        val viewModel = OtherConfigViewModel(
+            readAloudSettingsGateway = FakeReadAloudSettingsGateway(),
+            otherSettingsGateway = otherSettingsGateway,
+            initialState = OtherConfigUiState(),
+        )
+        val effect = async(start = CoroutineStart.UNDISPATCHED) {
+            viewModel.effects.first()
+        }
+
+        viewModel.onIntent(OtherConfigIntent.LanguageChanged("en"))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertEquals(OtherConfigEffect.ApplyLanguage("en"), effect.await())
+        assertTrue(
+            otherSettingsGateway.updates.contains(OtherSettingsUpdate.Language("en"))
+        )
+        assertEquals("en", viewModel.uiState.value.language)
+    }
+
+    private class FakeOtherSettingsGateway : OtherSettingsGateway {
+        private val state = MutableStateFlow(OtherSettings())
+        val updates = mutableListOf<OtherSettingsUpdate>()
+
+        override val settings: Flow<OtherSettings> = state
+
+        override suspend fun update(update: OtherSettingsUpdate) {
+            updates += update
+            if (update is OtherSettingsUpdate.Language) {
+                state.value = state.value.copy(language = update.value)
+            }
+        }
+    }
+
+    private class FakeReadAloudSettingsGateway : ReadAloudSettingsGateway {
+        private val state = MutableStateFlow(ReadAloudSettings())
+
+        override val currentSettings: ReadAloudSettings
+            get() = state.value
+        override val settings: Flow<ReadAloudSettings> = state
+
+        override suspend fun update(update: ReadAloudSettingsUpdate) = Unit
+    }
+}