From 283f56a3b6e8ba0406fe81ea5a4e6588c30f119a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 12:10:55 +0800 Subject: [PATCH] feat: add persistent interface settings --- .../ui/preferences/SettingsScreenTest.kt | 90 +++++++ .../abox/manager/di/UiPreferencesModule.kt | 21 ++ .../abox/manager/ui/ABoxManagerApp.kt | 5 +- .../manager/ui/preferences/SettingsScreen.kt | 227 ++++++++++++++++++ .../ui/preferences/SettingsViewModel.kt | 122 ++++++++++ android/app/src/main/res/values/strings.xml | 28 +++ .../ui/preferences/SettingsViewModelTest.kt | 111 +++++++++ 7 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt new file mode 100644 index 0000000..60a9e50 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -0,0 +1,90 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() { + composeRule.setContent { + ABoxTheme { + SettingsScreen( + state = SettingsUiState( + preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), + loaded = true, + ), + onAction = {}, + ) + } + } + + composeRule.onNodeWithText("界面设置").assertIsDisplayed() + composeRule.onNodeWithText("渲染器").assertIsDisplayed() + composeRule.onNodeWithText("Material 3").assertIsSelected() + composeRule.onNodeWithText("跟随系统").assertIsSelected() + composeRule.onNodeWithText("动态取色").assertIsDisplayed() + composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed() + composeRule.onNodeWithTag("settings-dynamic-color") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("settings-blur") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("settings-haptics") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + } + + @Test + fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() { + var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3)) + composeRule.setContent { + ABoxTheme(preferences = preferences) { + Column { + Text("当前测试内容") + SettingsScreen( + state = SettingsUiState(preferences = preferences, loaded = true), + onAction = { action -> + if (action is SettingsUiAction.SetRenderer) { + preferences = preferences.copy(renderer = action.value) + } + }, + modifier = Modifier.weight(1f), + ) + } + } + } + + composeRule.onNodeWithText("Miuix").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("当前测试内容").assertIsDisplayed() + composeRule.onNodeWithText("Miuix").assertIsSelected() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt new file mode 100644 index 0000000..674cde5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt @@ -0,0 +1,21 @@ +package com.flagship.abox.manager.di + +import android.content.Context +import com.flagship.abox.manager.preferences.DataStoreUiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UiPreferencesModule { + @Provides + @Singleton + fun provideUiPreferencesRepository( + @ApplicationContext context: Context, + ): UiPreferencesRepository = DataStoreUiPreferencesRepository(context) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 42bda6b..ce67fc0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -32,6 +32,7 @@ import com.flagship.abox.manager.ui.navigation.deviceDetailGraph import com.flagship.abox.manager.ui.navigation.navigateToSocket import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.scene.SceneRoutes import com.flagship.abox.manager.ui.scene.sceneEditorDestination @@ -46,9 +47,11 @@ fun ABoxManagerApp( appSessionViewModel: AppSessionViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), loginViewModel: LoginViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), ) { val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value + val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val screenKey = sessionState.screenKey() LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() @@ -56,7 +59,7 @@ fun ABoxManagerApp( loginViewModel.clearSensitiveState() } } - ABoxTheme { + ABoxTheme(preferences = settingsState.preferences) { ABoxSurface(modifier = Modifier.fillMaxSize()) { when (val state = sessionState) { AppSessionUiState.Loading -> Box( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt new file mode 100644 index 0000000..3a99fe5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -0,0 +1,227 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxCard +import com.flagship.abox.manager.designsystem.ABoxErrorState +import com.flagship.abox.manager.designsystem.ABoxFilterChip +import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup +import com.flagship.abox.manager.designsystem.ABoxSwitch +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.NavigationPresentation + +@Composable +fun SettingsRoute( + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + SettingsScreen(state = state, onAction = viewModel::onAction) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) { + PreferenceRow( + title = stringResource(R.string.settings_renderer_label), + description = stringResource(R.string.settings_renderer_description), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ABoxFilterChip( + selected = state.preferences.renderer == DesignRenderer.MATERIAL3, + label = stringResource(R.string.settings_renderer_material3), + onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) }, + ) + ABoxFilterChip( + selected = state.preferences.renderer == DesignRenderer.MIUIX, + label = stringResource(R.string.settings_renderer_miuix), + onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) }, + ) + } + } + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) { + PreferenceRow( + title = stringResource(R.string.settings_theme_label), + description = null, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system) + ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light) + ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark) + } + } + SwitchPreference( + title = stringResource(R.string.settings_dynamic_color), + description = stringResource(R.string.settings_dynamic_color_description), + checked = state.preferences.dynamicColorEnabled, + modifier = Modifier.testTag("settings-dynamic-color"), + onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) }, + ) + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) { + SwitchPreference( + title = stringResource(R.string.settings_blur), + description = stringResource(R.string.settings_blur_description), + checked = state.preferences.blurEnabled, + modifier = Modifier.testTag("settings-blur"), + onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) }, + ) + PreferenceRow( + title = stringResource(R.string.settings_navigation), + description = null, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NavigationChip( + presentation = NavigationPresentation.STANDARD, + selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD, + label = stringResource(R.string.settings_navigation_standard), + onAction = onAction, + ) + NavigationChip( + presentation = NavigationPresentation.FLOATING, + selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING, + label = stringResource(R.string.settings_navigation_floating), + onAction = onAction, + ) + } + } + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) { + SwitchPreference( + title = stringResource(R.string.settings_haptics), + description = stringResource(R.string.settings_haptics_description), + checked = state.preferences.hapticsEnabled, + modifier = Modifier.testTag("settings-haptics"), + onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) }, + ) + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) { + ABoxCard( + title = stringResource(R.string.settings_accessibility_card_title), + subtitle = stringResource(R.string.settings_accessibility_description), + ) + } + + state.error?.let { error -> + ABoxErrorState( + title = stringResource(R.string.settings_error_save), + description = error.asString(), + ) + } + } + } +} + +@Composable +private fun ThemeChip( + mode: AppThemeMode, + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + labelRes: Int, +) { + ABoxFilterChip( + selected = state.preferences.themeMode == mode, + label = stringResource(labelRes), + onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) }, + ) +} + +@Composable +private fun NavigationChip( + presentation: NavigationPresentation, + selected: Boolean, + label: String, + onAction: (SettingsUiAction) -> Unit, +) { + ABoxFilterChip( + selected = selected, + label = label, + onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) }, + ) +} + +@Composable +private fun PreferenceRow( + title: String, + description: String?, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + description?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + content() + } +} + +@Composable +private fun SwitchPreference( + title: String, + description: String, + checked: Boolean, + modifier: Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + ABoxSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + contentDescription = title, + modifier = modifier, + ) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt new file mode 100644 index 0000000..d8bdb09 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt @@ -0,0 +1,122 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.R +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.NavigationPresentation +import com.flagship.abox.manager.preferences.PreferenceField +import com.flagship.abox.manager.preferences.UiPreferences +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesWriteResult +import com.flagship.abox.manager.ui.common.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: UiPreferencesRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val observeJob: Job = viewModelScope.launch { + repository.observe().collect { preferences -> + _uiState.value = _uiState.value.copy( + preferences = preferences, + loaded = true, + ) + } + } + + fun onAction(action: SettingsUiAction) { + when (action) { + is SettingsUiAction.SetRenderer -> update( + field = PreferenceField.RENDERER, + transform = { it.copy(renderer = action.value) }, + ) + is SettingsUiAction.SetThemeMode -> update( + field = PreferenceField.THEME_MODE, + transform = { it.copy(themeMode = action.value) }, + ) + is SettingsUiAction.SetDynamicColor -> update( + field = PreferenceField.DYNAMIC_COLOR, + transform = { it.copy(dynamicColorEnabled = action.value) }, + ) + is SettingsUiAction.SetBlur -> update( + field = PreferenceField.BLUR, + transform = { it.copy(blurEnabled = action.value) }, + ) + is SettingsUiAction.SetNavigation -> update( + field = PreferenceField.NAVIGATION, + transform = { it.copy(navigationPresentation = action.value) }, + ) + is SettingsUiAction.SetHaptics -> update( + field = PreferenceField.HAPTICS, + transform = { it.copy(hapticsEnabled = action.value) }, + ) + SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null) + } + } + + private fun update( + field: PreferenceField, + transform: (UiPreferences) -> UiPreferences, + ) { + if (field in _uiState.value.saving) return + _uiState.value = _uiState.value.copy( + saving = _uiState.value.saving + field, + error = null, + ) + viewModelScope.launch { + try { + when (repository.update(transform)) { + UiPreferencesWriteResult.Success -> Unit + UiPreferencesWriteResult.Failure -> showWriteFailure(field) + } + } catch (cause: CancellationException) { + throw cause + } catch (_: Throwable) { + showWriteFailure(field) + } finally { + _uiState.value = _uiState.value.copy(saving = _uiState.value.saving - field) + } + } + } + + private fun showWriteFailure(field: PreferenceField) { + _uiState.value = _uiState.value.copy( + error = UiText.StringResource(R.string.settings_error_save), + saving = _uiState.value.saving - field, + ) + } + + override fun onCleared() { + observeJob.cancel() + super.onCleared() + } +} + +data class SettingsUiState( + val preferences: UiPreferences = UiPreferences(), + val loaded: Boolean = false, + val saving: Set = emptySet(), + val error: UiText? = null, +) + +sealed interface SettingsUiAction { + data class SetRenderer(val value: DesignRenderer) : SettingsUiAction + data class SetThemeMode(val value: AppThemeMode) : SettingsUiAction + data class SetDynamicColor(val value: Boolean) : SettingsUiAction + data class SetBlur(val value: Boolean) : SettingsUiAction + data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction + data class SetHaptics(val value: Boolean) : SettingsUiAction + data object DismissError : SettingsUiAction +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4745798..e3be99b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -259,4 +259,32 @@ Operation failed. Please try again. No sensor data available. An unexpected error occurred. + + + 界面设置 + 样式 + 渲染器 + 选择应用使用的界面渲染方式 + Material 3 + Miuix + 主题 + 主题模式 + 跟随系统 + 浅色 + 深色 + 动态取色 + 使用系统壁纸颜色生成界面配色 + 界面效果 + 模糊效果 + 在支持的界面中启用背景模糊 + 导航样式 + 标准导航栏 + 悬浮导航栏 + 交互反馈 + 触觉反馈 + 操作时提供轻微震动反馈 + 系统辅助功能 + 遵循系统辅助功能设置 + 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 + 无法保存界面设置,请重试 diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt new file mode 100644 index 0000000..9de023e --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt @@ -0,0 +1,111 @@ +package com.flagship.abox.manager.ui.preferences + +import com.flagship.abox.manager.R +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.ui.common.UiText.StringResource +import com.flagship.abox.manager.preferences.NavigationPresentation +import com.flagship.abox.manager.preferences.UiPreferences +import com.flagship.abox.manager.preferences.UiPreferencesRepository +import com.flagship.abox.manager.preferences.UiPreferencesWriteResult +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun rendererUpdatePersistsWithoutCallingSessionCoordinator() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) + advanceUntilIdle() + + assertEquals(1, repository.updateCalls.size) + assertEquals(DesignRenderer.MIUIX, repository.value.renderer) + assertEquals(DesignRenderer.MIUIX, viewModel.uiState.value.preferences.renderer) + } + + @Test + fun writeFailureLeavesLastValueAndShowsLocalizedError() = runTest { + val repository = RecordingUiPreferencesRepository( + initial = UiPreferences(renderer = DesignRenderer.MATERIAL3), + writeResult = UiPreferencesWriteResult.Failure, + ) + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) + advanceUntilIdle() + + assertEquals(1, repository.updateCalls.size) + assertEquals(DesignRenderer.MATERIAL3, viewModel.uiState.value.preferences.renderer) + assertTrue(viewModel.uiState.value.error is StringResource) + assertEquals( + R.string.settings_error_save, + (viewModel.uiState.value.error as StringResource).resId, + ) + assertTrue(viewModel.uiState.value.saving.isEmpty()) + } + + @Test + fun collectsRepositoryChanges() = runTest { + val repository = RecordingUiPreferencesRepository() + val viewModel = SettingsViewModel(repository) + advanceUntilIdle() + + repository.emit( + UiPreferences( + renderer = DesignRenderer.MIUIX, + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = false, + blurEnabled = true, + navigationPresentation = NavigationPresentation.FLOATING, + hapticsEnabled = false, + ), + ) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.loaded) + assertEquals(repository.value, viewModel.uiState.value.preferences) + } + + + private class RecordingUiPreferencesRepository( + initial: UiPreferences = UiPreferences(), + private val writeResult: UiPreferencesWriteResult = UiPreferencesWriteResult.Success, + ) : UiPreferencesRepository { + private val state = MutableStateFlow(initial) + val updateCalls = mutableListOf<(UiPreferences) -> UiPreferences>() + val value: UiPreferences get() = state.value + + override fun observe(): Flow = state.asStateFlow() + + override suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult { + updateCalls += transform + if (writeResult is UiPreferencesWriteResult.Success) { + state.value = transform(state.value) + } + return writeResult + } + + fun emit(value: UiPreferences) { + state.value = value + } + } +}