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 index 114d3eb..8a5d630 100644 --- 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 @@ -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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c934de3..508eb01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ 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 = { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 2f6d5db..c32b8f3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -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 - if (savedSelection != selection) { - savedSelection = selection - } - when (detailPresentation(windowSizeClass, selection)) { DetailPresentation.PANE -> { + if (savedSelection != selection) { + savedSelection = selection + } 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,60 +186,25 @@ 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 -> - savedSelection = null - if (tab == currentTab) { - navController.popBackStack(tab.route, inclusive = false) - } else { - navController.navigate(tab.route) { - launchSingleTop = true - restoreState = true - popUpTo(navController.graph.startDestinationId) { - saveState = true - } + content( + snapshot, + { tab -> + savedSelection = null + if (tab == currentTab) { + navController.popBackStack(tab.route, inclusive = false) + } else { + navController.navigate(tab.route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true } } - }, - { 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() - } + } + }, + { deviceName, type -> savedSelection = DeviceSelection(profileId, deviceName, type) }, + { savedSelection = null }, + ) } @Composable @@ -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() - } -} 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 index be914b8..415439d 100644 --- 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 @@ -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 = 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,22 +116,29 @@ 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( - state = state, - onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name }, - onBack = onExit, - onAction = onAction, - ) - SettingsDestination.THEME -> ThemeSettingsScreen( - state = state, - onBack = { experienceState.destination = SettingsDestination.HOME.name }, - onAction = onAction, - ) + Box(Modifier.fillMaxSize()) { + ABoxSurface(modifier = Modifier.fillMaxSize()) { + SettingsHomeScreen( + state = state, + onOpenTheme = { experienceState.showTheme(returnToHome = true) }, + onBack = onExit, + onAction = onAction, + ) + } + if (destination == SettingsDestination.THEME) { + val leaveTheme = { + if (experienceState.themeBackReturnsHome) experienceState.showHome() else onExit() + } + ABoxBackLayer(onBack = leaveTheme) { + ABoxSurface(modifier = Modifier.fillMaxSize()) { + ThemeSettingsScreen( + state = state, + onBack = leaveTheme, + onAction = onAction, + ) + } + } + } } } @@ -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 EnumSettingRow( values: List, 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 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) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt index 45de938..d792c1f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -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", diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 22d18d4..adcff17 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -265,7 +265,7 @@ 样式 渲染器 选择应用使用的界面渲染方式 - Material 3 + Material Miuix 主题 主题模式 @@ -291,7 +291,7 @@ 主题 配色、明暗、视觉效果与缩放 主题 - Material 3 + Material Miuix 让底部导航独立悬浮在页面内容上方 强调色 @@ -344,8 +344,8 @@ 当前账户 %1$s,您好 连接方式 - 界面设置 - 主题、渲染风格与交互偏好 + 主题 + 配色、明暗、视觉效果与缩放 退出当前会话 结束当前连接并返回登录流程 确定退出当前会话吗? diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a1216ed..677acab 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -265,7 +265,7 @@ Style Renderer Choose the interface renderer used by the app - Material 3 + Material Miuix Theme Theme mode @@ -291,7 +291,7 @@ Theme Colors, brightness, effects and scale Theme - Material 3 + Material Miuix Use a detached navigation surface above page content Accent color @@ -344,8 +344,8 @@ Current account Hello, %1$s Connection method - Interface settings - Theme, renderer and interaction preferences + Theme + Colors, brightness, effects and scale Sign out End the current connection and return to sign-in Sign out of the current session? diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index 7b06b9b..8f3340d 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -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) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index 9b38645..f8dda1d 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -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() + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index 0a1b683..e8e9518 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -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, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt index a21e57f..8b7d73c 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -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) } diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt index e0a23ce..53ee0d6 100644 --- a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt @@ -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 diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index ef9d92f..71a9957 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -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"), - ) - } + 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 },