feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
14 changed files with 322 additions and 180 deletions
Showing only changes of commit a5f31486b4 - Show all commits
@@ -57,7 +57,6 @@ class SettingsScreenTest {
"settings-theme-mode",
"settings-color-style",
"settings-color-standard",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
@@ -74,7 +73,6 @@ class SettingsScreenTest {
"settings-blur",
"settings-floating-navigation",
"settings-liquid-glass",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
+1 -1
View File
@@ -3,7 +3,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".ABoxManagerApplication"
android:enableOnBackInvokedCallback="true"
android:enableOnBackInvokedCallback="false"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -20,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.designsystem.ABoxBackLayer
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.ui.login.LoginRoute
@@ -108,13 +108,7 @@ private fun AuthenticatedNavHost(
onShowAccountDialogChange: (Boolean) -> Unit,
onShowSettingsChange: (Boolean) -> Unit,
) {
if (showSettings) {
BackHandler { onShowSettingsChange(false) }
SettingsRoute(
experienceState = settingsExperienceState,
onExit = { onShowSettingsChange(false) },
)
} else {
Box(Modifier.fillMaxSize()) {
AuthenticatedNavigation(
username = username,
profile = profile,
@@ -122,6 +116,17 @@ private fun AuthenticatedNavHost(
onProviderClick = { onShowAccountDialogChange(true) },
onAvatarClick = { onShowAccountDialogChange(true) },
)
if (showSettings) {
ABoxBackLayer(
onBack = { onShowSettingsChange(false) },
enabled = settingsExperienceState.isHome,
) {
SettingsRoute(
experienceState = settingsExperienceState,
onExit = { onShowSettingsChange(false) },
)
}
}
}
if (showAccountDialog) {
@@ -131,6 +136,7 @@ private fun AuthenticatedNavHost(
onDismissRequest = { onShowAccountDialogChange(false) },
onOpenSettings = {
onShowAccountDialogChange(false)
settingsExperienceState.showTheme(returnToHome = false)
onShowSettingsChange(true)
},
onSwitchProvider = {
@@ -1,15 +1,11 @@
package com.flagship.abox.manager.ui.navigation
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import android.os.Build
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,14 +13,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -60,9 +53,6 @@ import com.flagship.abox.manager.ui.shell.MainAppShell
import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.widthClass
import com.flagship.abox.manager.designsystem.LocalABoxPredictiveBackEnabled
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collect
import com.flagship.abox.manager.ui.socket.SocketScreen
import com.flagship.abox.manager.ui.socket.SocketUiEvent
import com.flagship.abox.manager.ui.socket.SocketViewModel
@@ -169,12 +159,11 @@ fun AuthenticatedNavigationStateHost(
}
val selection = selectedDevice ?: return@LaunchedEffect
when (detailPresentation(windowSizeClass, selection)) {
DetailPresentation.PANE -> {
if (savedSelection != selection) {
savedSelection = selection
}
when (detailPresentation(windowSizeClass, selection)) {
DetailPresentation.PANE -> {
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack(MainTab.DEVICES.route, inclusive = false)
}
@@ -184,6 +173,9 @@ fun AuthenticatedNavigationStateHost(
navController.navigate(deviceRoute(selection)) {
launchSingleTop = true
}
savedSelection = null
} else if (savedSelection != null) {
savedSelection = null
}
}
}
@@ -194,21 +186,6 @@ fun AuthenticatedNavigationStateHost(
selectedDevice = selectedDevice,
detailPresentation = selectedPresentation,
)
val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route
val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
var backProgress by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier.fillMaxSize()
.clip(RoundedCornerShape((28f * backProgress).dp))
.graphicsLayer {
val scale = 1f - 0.04f * backProgress
scaleX = scale
scaleY = scale
translationX = 28.dp.toPx() * backProgress
},
) {
content(
snapshot,
{ tab ->
@@ -225,31 +202,11 @@ fun AuthenticatedNavigationStateHost(
}
}
},
{ deviceName, type ->
savedSelection = DeviceSelection(profileId, deviceName, type)
},
{ deviceName, type -> savedSelection = DeviceSelection(profileId, deviceName, type) },
{ savedSelection = null },
)
}
PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events ->
try {
events.collect { event -> backProgress = event.progress }
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
backProgress = 0f
}
}
BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) {
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
}
}
@Composable
fun AuthenticatedNavigation(
username: Username,
@@ -355,6 +312,23 @@ private fun AuthenticatedNavHost(
onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) {
val activeBackStackEntry by navController.currentBackStackEntryAsState()
val activeRoute = activeBackStackEntry?.actualRoute()
var backNavigationInFlight by remember { mutableStateOf(false) }
LaunchedEffect(activeRoute) {
backNavigationInFlight = false
}
val navigateBackFromDetail: () -> Unit = {
val liveRoute = navController.currentBackStackEntry?.actualRoute()
val isMainDestination = MainTab.entries.any { it.route == liveRoute }
if (!backNavigationInFlight && !isMainDestination) {
backNavigationInFlight = true
if (!navController.popBackStack()) {
backNavigationInFlight = false
}
}
}
NavHost(
navController = navController,
startDestination = MainTab.DEVICES.route,
@@ -427,14 +401,14 @@ private fun AuthenticatedNavHost(
deviceDetailGraph(
onBackClick = {
onClearDeviceSelection()
navController.popBackStack()
navigateBackFromDetail()
},
)
sceneEditorDestination(
onBack = navController::popBackStack,
onBack = navigateBackFromDetail,
onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) },
)
sceneExecutionDestination(onBack = navController::popBackStack)
sceneExecutionDestination(onBack = navigateBackFromDetail)
composable(
route = Routes.INFRARED_HUB,
arguments = listOf(
@@ -445,7 +419,7 @@ private fun AuthenticatedNavHost(
InfraredHubRoute(
onBack = {
onClearDeviceSelection()
navController.popBackStack()
navigateBackFromDetail()
},
onOpenRemote = { remoteId ->
navController.navigate(
@@ -467,7 +441,7 @@ private fun AuthenticatedNavHost(
),
) {
RemotePanelRoute(
onBack = navController::popBackStack,
onBack = navigateBackFromDetail,
onSessionInvalid = onLogout,
)
}
@@ -479,7 +453,7 @@ private fun AuthenticatedNavHost(
),
) {
InfraredControlRoute(
onBack = navController::popBackStack,
onBack = navigateBackFromDetail,
onSessionInvalid = onLogout,
)
}
@@ -561,9 +535,3 @@ private fun NavBackStackEntry.actualRoute(): String? {
}
return route
}
private fun NavHostController.popCurrentDestinationUnlessRoot() {
if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) {
popBackStack()
}
}
@@ -1,6 +1,5 @@
package com.flagship.abox.manager.ui.preferences
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -58,6 +57,9 @@ import com.flagship.abox.manager.designsystem.LocalABoxShapes
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.designsystem.tokens.ABoxAccentPalette
import com.flagship.abox.manager.designsystem.ABoxBackLayer
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
@@ -70,11 +72,27 @@ private enum class SettingsDestination { HOME, THEME }
@Stable
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
var destination: String by mutableStateOf(initialDestination)
var themeBackReturnsHome: Boolean by mutableStateOf(true)
val isHome: Boolean get() = destination == SettingsDestination.HOME.name
fun showHome() {
destination = SettingsDestination.HOME.name
}
fun showTheme(returnToHome: Boolean = true) {
themeBackReturnsHome = returnToHome
destination = SettingsDestination.THEME.name
}
companion object {
val Saver: Saver<SettingsExperienceState, String> = Saver(
save = { it.destination },
restore = { SettingsExperienceState(it) },
save = { "${it.destination}|${it.themeBackReturnsHome}" },
restore = { saved ->
val parts = saved.split('|', limit = 2)
SettingsExperienceState(parts.first()).apply {
themeBackReturnsHome = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: true
}
},
)
}
}
@@ -98,24 +116,31 @@ fun SettingsExperience(
) {
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
.getOrDefault(SettingsDestination.HOME)
BackHandler {
if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name
else onExit()
}
when (destination) {
SettingsDestination.HOME -> SettingsHomeScreen(
Box(Modifier.fillMaxSize()) {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
SettingsHomeScreen(
state = state,
onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name },
onOpenTheme = { experienceState.showTheme(returnToHome = true) },
onBack = onExit,
onAction = onAction,
)
SettingsDestination.THEME -> ThemeSettingsScreen(
}
if (destination == SettingsDestination.THEME) {
val leaveTheme = {
if (experienceState.themeBackReturnsHome) experienceState.showHome() else onExit()
}
ABoxBackLayer(onBack = leaveTheme) {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
ThemeSettingsScreen(
state = state,
onBack = { experienceState.destination = SettingsDestination.HOME.name },
onBack = leaveTheme,
onAction = onAction,
)
}
}
}
}
}
@Composable
fun SettingsScreen(
@@ -218,6 +243,7 @@ private fun ThemeSettingsScreen(
@Composable
private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
@@ -238,6 +264,7 @@ private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (
values = ColorStyle.entries,
label = { colorStyleLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
enabled = manualColorsEnabled,
)
EnumSettingRow(
modifier = Modifier.testTag("settings-color-standard"),
@@ -247,8 +274,8 @@ private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (
values = ColorStandard.entries,
label = { colorStandardLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
enabled = manualColorsEnabled,
)
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@@ -303,26 +330,9 @@ private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (Set
},
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
}
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) {
SettingsSwitchRow(
icon = ABoxIconKey.BACK,
title = stringResource(R.string.settings_predictive_back),
description = stringResource(R.string.settings_predictive_back_description),
checked = state.preferences.predictiveBackEnabled,
tag = "settings-predictive-back-switch",
enabled = supported,
unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetPredictiveBack(it)) }
}
}
@Composable
private fun SettingsPage(
title: String,
@@ -448,15 +458,17 @@ private fun <T> EnumSettingRow(
values: List<T>,
label: @Composable (T) -> String,
onSelected: (T) -> Unit,
enabled: Boolean = true,
) {
var expanded by remember { mutableStateOf(false) }
SettingsGroup(modifier = modifier) {
Box(Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().alpha(if (enabled) 1f else 0.42f)) {
SettingsLinkRow(
icon = icon,
title = title,
description = stringResource(R.string.settings_tap_to_choose),
value = value,
enabled = enabled,
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -478,18 +490,29 @@ private fun <T> EnumSettingRow(
@Composable
private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val dark = when (state.preferences.themeMode) {
AppThemeMode.DARK -> true
AppThemeMode.LIGHT -> false
AppThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
}
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth().alpha(if (state.preferences.dynamicColorEnabled) 0.42f else 1f),
modifier = Modifier.fillMaxWidth().alpha(if (manualColorsEnabled) 1f else 0.42f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
AccentPreference.entries.forEach { accent ->
val color = accentColor(accent)
val color = ABoxAccentPalette.preview(
accent = accent,
style = state.preferences.colorStyle,
standard = state.preferences.colorStandard,
dark = dark,
)
Box(
modifier = Modifier.size(44.dp).background(color, CircleShape)
.then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.clickable(enabled = !state.preferences.dynamicColorEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
.clickable(enabled = manualColorsEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
contentAlignment = Alignment.Center,
) {
if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White)
@@ -518,8 +541,8 @@ private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -
}
@Composable
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, onClick: () -> Unit) {
Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Row(modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsRowHeader(icon, title, description, Modifier.weight(1f))
if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -570,4 +593,3 @@ private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: Str
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) }
@@ -14,7 +14,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -107,7 +107,7 @@ fun AccountProviderDialog(
}
AccountSheetAction(
icon = Icons.Outlined.Settings,
icon = Icons.Outlined.Palette,
label = stringResource(R.string.account_interface_settings),
description = stringResource(R.string.account_interface_settings_desc),
tag = "account_dialog_open_settings",
@@ -265,7 +265,7 @@
<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_material3">Material</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">主题</string>
<string name="settings_theme_label">主题模式</string>
@@ -291,7 +291,7 @@
<string name="settings_theme_entry">主题</string>
<string name="settings_theme_entry_description">配色、明暗、视觉效果与缩放</string>
<string name="settings_theme_detail_title">主题</string>
<string name="settings_renderer_primary">Material 3</string>
<string name="settings_renderer_primary">Material</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">让底部导航独立悬浮在页面内容上方</string>
<string name="settings_accent_label">强调色</string>
@@ -344,8 +344,8 @@
<string name="account_current">当前账户</string>
<string name="account_greeting">%1$s,您好</string>
<string name="account_connection_method">连接方式</string>
<string name="account_interface_settings">界面设置</string>
<string name="account_interface_settings_desc">主题、渲染风格与交互偏好</string>
<string name="account_interface_settings">主题</string>
<string name="account_interface_settings_desc">配色、明暗、视觉效果与缩放</string>
<string name="account_logout">退出当前会话</string>
<string name="account_logout_desc">结束当前连接并返回登录流程</string>
<string name="account_logout_confirm">确定退出当前会话吗?</string>
+4 -4
View File
@@ -265,7 +265,7 @@
<string name="settings_style_section">Style</string>
<string name="settings_renderer_label">Renderer</string>
<string name="settings_renderer_description">Choose the interface renderer used by the app</string>
<string name="settings_renderer_material3">Material 3</string>
<string name="settings_renderer_material3">Material</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">Theme</string>
<string name="settings_theme_label">Theme mode</string>
@@ -291,7 +291,7 @@
<string name="settings_theme_entry">Theme</string>
<string name="settings_theme_entry_description">Colors, brightness, effects and scale</string>
<string name="settings_theme_detail_title">Theme</string>
<string name="settings_renderer_primary">Material 3</string>
<string name="settings_renderer_primary">Material</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">Use a detached navigation surface above page content</string>
<string name="settings_accent_label">Accent color</string>
@@ -344,8 +344,8 @@
<string name="account_current">Current account</string>
<string name="account_greeting">Hello, %1$s</string>
<string name="account_connection_method">Connection method</string>
<string name="account_interface_settings">Interface settings</string>
<string name="account_interface_settings_desc">Theme, renderer and interaction preferences</string>
<string name="account_interface_settings">Theme</string>
<string name="account_interface_settings_desc">Colors, brightness, effects and scale</string>
<string name="account_logout">Sign out</string>
<string name="account_logout_desc">End the current connection and return to sign-in</string>
<string name="account_logout_confirm">Sign out of the current session?</string>
@@ -38,6 +38,7 @@ dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.androidx.activity.compose)
implementation(libs.miuix.ui.android)
debugImplementation(libs.compose.ui.tooling)
screenshotTestImplementation(libs.compose.screenshot.test)
@@ -1,5 +1,7 @@
package com.flagship.abox.manager.designsystem
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -45,6 +47,9 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -677,3 +682,16 @@ fun RowScope.ABoxNavigationBarItem(
),
)
}
@Composable
fun ABoxBackLayer(
onBack: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
BackHandler(enabled = enabled, onBack = onBack)
Box(modifier = modifier.fillMaxSize()) {
content()
}
}
@@ -101,7 +101,7 @@ internal fun ABoxThemeContent(
LocalABoxBlurEnabled provides resolution.blurEnabled,
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled,
LocalABoxPredictiveBackEnabled provides false,
) {
renderer.Render(
resolution = resolution,
@@ -2,10 +2,13 @@ package com.flagship.abox.manager.designsystem.tokens
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@@ -122,7 +125,13 @@ object ABoxThemeResolver {
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
dynamicColorTokens
} else {
brandTokens.withAccent(preferences.accentPreference, isDarkTheme)
ABoxAccentPalette.resolve(
base = brandTokens,
accent = preferences.accentPreference,
style = preferences.colorStyle,
standard = preferences.colorStandard,
dark = isDarkTheme,
)
}
val shapeTokens = when (preferences.renderer) {
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
@@ -139,13 +148,86 @@ object ABoxThemeResolver {
}
}
private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens {
val selected = when (accent) {
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D)
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B)
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800)
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59)
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2)
object ABoxAccentPalette {
fun preview(
accent: AccentPreference,
style: ColorStyle,
standard: ColorStandard,
dark: Boolean,
): Color = resolve(
base = if (dark) ABoxThemeDefaults.darkColors else ABoxThemeDefaults.lightColors,
accent = accent,
style = style,
standard = standard,
dark = dark,
).primary
fun resolve(
base: ABoxColorTokens,
accent: AccentPreference,
style: ColorStyle,
standard: ColorStandard,
dark: Boolean,
): ABoxColorTokens {
val source = source(accent, dark)
val primary = when (style) {
ColorStyle.TONAL_SPOT -> lerp(source, base.surface, if (dark) 0.08f else 0.12f)
ColorStyle.EXPRESSIVE -> expressive(accent, dark)
ColorStyle.FIDELITY -> source
}
val deviceSensor = when (style) {
ColorStyle.EXPRESSIVE -> expressiveSensor(accent, dark)
ColorStyle.FIDELITY -> lerp(source, base.success, 0.38f)
ColorStyle.TONAL_SPOT -> base.deviceSensor
}
val deviceInfrared = when (style) {
ColorStyle.EXPRESSIVE -> expressiveInfrared(accent, dark)
ColorStyle.FIDELITY -> lerp(source, base.deviceInfrared, 0.46f)
ColorStyle.TONAL_SPOT -> base.deviceInfrared
}
val styled = base.copy(
primary = primary,
deviceSocket = primary,
deviceSensor = deviceSensor,
deviceInfrared = deviceInfrared,
)
return when (standard) {
ColorStandard.SPEC_2021 -> styled
ColorStandard.SPEC_2025 -> styled.copy(
surface = if (dark) Color(0xFF0C1114) else Color(0xFFFAF9FC),
surfaceContainer = if (dark) Color(0xFF242C30) else Color(0xFFE9E8EE),
outline = if (dark) Color(0xFFA7B2B7) else Color(0xFF596267),
primary = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
deviceSocket = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
)
}
}
private fun source(accent: AccentPreference, dark: Boolean): Color = when (accent) {
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF4F6FA9)
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF2F7D57)
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFFA66700)
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA94C68)
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF7659A8)
}
private fun expressive(accent: AccentPreference, dark: Boolean): Color = when (accent) {
AccentPreference.BLUE -> if (dark) Color(0xFF72D6FF) else Color(0xFF006B91)
AccentPreference.GREEN -> if (dark) Color(0xFF69E6B1) else Color(0xFF007A54)
AccentPreference.AMBER -> if (dark) Color(0xFFFFC24B) else Color(0xFF9A5C00)
AccentPreference.ROSE -> if (dark) Color(0xFFFFA8C4) else Color(0xFFB12F62)
AccentPreference.VIOLET -> if (dark) Color(0xFFC9B5FF) else Color(0xFF7048B8)
}
private fun expressiveSensor(accent: AccentPreference, dark: Boolean): Color = when (accent) {
AccentPreference.BLUE, AccentPreference.VIOLET -> if (dark) Color(0xFF78DFA2) else Color(0xFF26764A)
AccentPreference.GREEN, AccentPreference.AMBER -> if (dark) Color(0xFF79D6F3) else Color(0xFF006985)
AccentPreference.ROSE -> if (dark) Color(0xFFFFC45F) else Color(0xFF8B5B00)
}
private fun expressiveInfrared(accent: AccentPreference, dark: Boolean): Color = when (accent) {
AccentPreference.BLUE, AccentPreference.GREEN -> if (dark) Color(0xFFD1B8FF) else Color(0xFF7453A5)
AccentPreference.AMBER, AccentPreference.ROSE -> if (dark) Color(0xFF9FCBFF) else Color(0xFF38639A)
AccentPreference.VIOLET -> if (dark) Color(0xFFFFB0C7) else Color(0xFFA43E62)
}
return copy(primary = selected, deviceSocket = selected)
}
@@ -26,8 +26,26 @@ class ABoxThemeResolverTest {
)
assertFalse(light.isDarkTheme)
assertEquals(ABoxThemeDefaults.lightColors, light.colorTokens)
assertEquals(ABoxThemeDefaults.darkColors, dark.colorTokens)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.lightColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
false,
),
light.colorTokens,
)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.darkColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
true,
),
dark.colorTokens,
)
}
@Test
@@ -42,7 +60,16 @@ class ABoxThemeResolverTest {
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.lightColors, resolution.colorTokens)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.lightColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
false,
),
resolution.colorTokens,
)
}
@Test
@@ -57,7 +84,16 @@ class ABoxThemeResolverTest {
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.darkColors, resolution.colorTokens)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.darkColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
true,
),
resolution.colorTokens,
)
}
@Test
@@ -50,7 +50,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SwapVert
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -71,6 +70,7 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxBackLayer
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
@@ -157,8 +157,6 @@ internal fun InfraredHubScreen(
var showCreateDialog by remember { mutableStateOf(false) }
var showManagement by remember { mutableStateOf(false) }
BackHandler(enabled = showManagement) { showManagement = false }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
@@ -188,18 +186,16 @@ internal fun InfraredHubScreen(
ABoxDeviceDetailScaffold(
title = state.savedDeviceName.value,
subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle),
subtitle = stringResource(R.string.infrared_center_subtitle),
icon = ABoxIconKey.INFRARED,
onBack = if (showManagement) ({ showManagement = false }) else onBack,
onBack = onBack,
action = {
if (!showManagement) {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.infrared_manage_action),
onClick = { showManagement = true },
modifier = Modifier.testTag("hub_settings_button"),
)
}
},
) { innerPadding ->
ABoxSurface(
@@ -207,16 +203,7 @@ internal fun InfraredHubScreen(
.fillMaxSize()
.padding(innerPadding),
) {
if (showManagement) {
InfraredManagementScreen(
state = state,
onAddRemote = { showCreateDialog = true },
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
)
} else Column(
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 10.dp),
@@ -299,6 +286,30 @@ internal fun InfraredHubScreen(
}
}
if (showManagement) {
ABoxBackLayer(onBack = { showManagement = false }) {
ABoxDeviceDetailScaffold(
title = state.savedDeviceName.value,
subtitle = stringResource(R.string.infrared_settings_subtitle),
icon = ABoxIconKey.INFRARED,
onBack = { showManagement = false },
) { innerPadding ->
ABoxSurface(
modifier = Modifier.fillMaxSize().padding(innerPadding),
) {
InfraredManagementScreen(
state = state,
onAddRemote = { showCreateDialog = true },
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
)
}
}
}
}
if (showCreateDialog) {
CreateVirtualRemoteDialog(
onDismiss = { showCreateDialog = false },