feat: add persistent interface settings

This commit is contained in:
2026-08-26 12:19:38 +08:00
parent b24ca98e14
commit 283f56a3b6
7 changed files with 603 additions and 1 deletions
@@ -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.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(
@@ -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,
)
}
}
@@ -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
}