feat(ui): complete ABox app experience redesign #10
+90
@@ -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<ComponentActivity>()
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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.navigateToSocket
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
||||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
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.SceneListScreen
|
||||||
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
||||||
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
||||||
@@ -46,9 +47,11 @@ fun ABoxManagerApp(
|
|||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
||||||
loginViewModel: LoginViewModel = hiltViewModel(),
|
loginViewModel: LoginViewModel = hiltViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val screenKey = sessionState.screenKey()
|
val screenKey = sessionState.screenKey()
|
||||||
LaunchedEffect(screenKey) {
|
LaunchedEffect(screenKey) {
|
||||||
sessionActionsViewModel.dismissError()
|
sessionActionsViewModel.dismissError()
|
||||||
@@ -56,7 +59,7 @@ fun ABoxManagerApp(
|
|||||||
loginViewModel.clearSensitiveState()
|
loginViewModel.clearSensitiveState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ABoxTheme {
|
ABoxTheme(preferences = settingsState.preferences) {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val state = sessionState) {
|
when (val state = sessionState) {
|
||||||
AppSessionUiState.Loading -> Box(
|
AppSessionUiState.Loading -> Box(
|
||||||
|
|||||||
+227
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -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<SettingsUiState> = _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<PreferenceField> = 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
|
||||||
|
}
|
||||||
@@ -259,4 +259,32 @@
|
|||||||
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
||||||
<string name="error_no_data">No sensor data available.</string>
|
<string name="error_no_data">No sensor data available.</string>
|
||||||
<string name="error_unknown">An unexpected error occurred.</string>
|
<string name="error_unknown">An unexpected error occurred.</string>
|
||||||
|
|
||||||
|
<!-- Interface settings -->
|
||||||
|
<string name="settings_title">界面设置</string>
|
||||||
|
<string name="settings_style_section">样式</string>
|
||||||
|
<string name="settings_renderer_label">渲染器</string>
|
||||||
|
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
|
||||||
|
<string name="settings_renderer_material3">Material 3</string>
|
||||||
|
<string name="settings_renderer_miuix">Miuix</string>
|
||||||
|
<string name="settings_theme_section">主题</string>
|
||||||
|
<string name="settings_theme_label">主题模式</string>
|
||||||
|
<string name="settings_theme_system">跟随系统</string>
|
||||||
|
<string name="settings_theme_light">浅色</string>
|
||||||
|
<string name="settings_theme_dark">深色</string>
|
||||||
|
<string name="settings_dynamic_color">动态取色</string>
|
||||||
|
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string>
|
||||||
|
<string name="settings_surface_section">界面效果</string>
|
||||||
|
<string name="settings_blur">模糊效果</string>
|
||||||
|
<string name="settings_blur_description">在支持的界面中启用背景模糊</string>
|
||||||
|
<string name="settings_navigation">导航样式</string>
|
||||||
|
<string name="settings_navigation_standard">标准导航栏</string>
|
||||||
|
<string name="settings_navigation_floating">悬浮导航栏</string>
|
||||||
|
<string name="settings_feedback_section">交互反馈</string>
|
||||||
|
<string name="settings_haptics">触觉反馈</string>
|
||||||
|
<string name="settings_haptics_description">操作时提供轻微震动反馈</string>
|
||||||
|
<string name="settings_accessibility_section">系统辅助功能</string>
|
||||||
|
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string>
|
||||||
|
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</string>
|
||||||
|
<string name="settings_error_save">无法保存界面设置,请重试</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+111
@@ -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<UiPreferences> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user