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

## Summary

- rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience
- redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content
- add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative
- add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes
- persist real activity events for device commands, infrared actions, and scene execution
- stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option
- update launcher icons and bilingual application name

## Verification

- `:android:app:assembleDebug`
- installed the debug APK on `emulator-5554`
- manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions

## Notes

- no automated test suite was run during the visual iteration cycle
- `docs/superpowers/**` and local reference repositories are excluded from this PR

Reviewed-on: #10
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
+21 -8
View File
@@ -1,11 +1,17 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.screenshot)
}
android {
namespace = "com.flagship.abox.manager.designsystem"
compileSdk = 34
compileSdk = 37
experimentalProperties["android.experimental.enableScreenshotTest"] = true
defaultConfig {
minSdk = 26
@@ -15,25 +21,32 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlinOptions {
jvmTarget = "17"
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
dependencies {
implementation(project(":android:core:preferences"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
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)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.activity.compose)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.designsystem
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
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.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ABoxComponentSemanticsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent {
ABoxTheme {
ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
Text("确定")
}
ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = "添加设备",
onClick = {},
modifier = Modifier.testTag("icon"),
)
ABoxSwitch(
checked = false,
onCheckedChange = {},
contentDescription = "启用设备",
modifier = Modifier.testTag("switch"),
)
}
}
listOf("button", "icon", "switch").forEach { tag ->
composeRule.onNodeWithTag(tag)
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
}
}
@Test
fun operableSwitchExposesChineseLabelRoleStateAndToggleAction() {
var checked = false
composeRule.setContent {
ABoxTheme {
ABoxSwitch(
checked = checked,
onCheckedChange = { checked = it },
contentDescription = "启用设备",
modifier = Modifier.testTag("operable-switch"),
)
}
}
val switchNode = composeRule.onNodeWithTag("operable-switch")
.assertContentDescriptionEquals("启用设备")
.assertIsOff()
.assertHasClickAction()
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
.fetchSemanticsNode()
assertEquals(Role.Switch, switchNode.config[SemanticsProperties.Role])
assertTrue(switchNode.config.contains(SemanticsProperties.ToggleableState))
composeRule.onNodeWithTag("operable-switch").performClick()
composeRule.waitForIdle()
assertEquals(true, checked)
}
@Test
fun cardBodyAndQuickActionExposeIndependentSemanticNodes() {
composeRule.setContent {
ABoxTheme {
ABoxCard(
title = "客厅插座",
subtitle = "在线",
onClick = {},
quickAction = ABoxCardQuickAction(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
)
}
}
composeRule.onNodeWithText("客厅插座").assertIsDisplayed().assertHasClickAction()
composeRule.onNodeWithContentDescription("刷新设备").assertIsDisplayed().assertHasClickAction()
}
@Test
fun renderersExposeSameChineseStateDescription() {
var renderer by mutableStateOf(DesignRenderer.MATERIAL3)
composeRule.setContent {
ABoxTheme(
preferences = UiPreferences(
renderer = renderer,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在处理",
)
}
}
composeRule.onNodeWithContentDescription("正在处理").assertExists()
composeRule.runOnUiThread { renderer = DesignRenderer.MIUIX }
composeRule.waitForIdle()
composeRule.onNodeWithContentDescription("正在处理").assertExists()
}
@Test
fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
composeRule.setContent {
ABoxThemeContent(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = null,
reducedMotion = true,
) {
ABoxLoadingState(contentDescription = "正在加载")
}
}
val loadingNode = composeRule.onNodeWithContentDescription("正在加载")
.assertExists()
.fetchSemanticsNode()
assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription])
}
@Test
fun interactiveIconRequiresNonBlankChineseDescription() {
composeRule.setContent {
ABoxTheme {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = {},
)
}
}
composeRule.onNodeWithContentDescription("返回")
.assertContentDescriptionEquals("返回")
}
}
@@ -1,51 +1,97 @@
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import androidx.compose.material3.Icon as MaterialIcon
@Composable
fun ABoxAppSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val colors = LocalABoxColors.current
val blurEnabled = LocalABoxBlurEnabled.current
Surface(
modifier = modifier,
color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
content = content,
)
}
/** Compatibility surface retained for existing feature modules. */
@Composable
fun ABoxSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(modifier = modifier, content = content)
}
) = ABoxAppSurface(modifier = modifier, content = content)
@Composable
fun ABoxButton(
@@ -56,7 +102,7 @@ fun ABoxButton(
) {
Button(
onClick = onClick,
modifier = modifier,
modifier = modifier.defaultMinSize(minHeight = 48.dp),
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
content = content,
@@ -64,14 +110,437 @@ fun ABoxButton(
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier)
fun ABoxIconButton(
key: ABoxIconKey,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxIconButton contentDescription must not be blank"
}
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp),
) {
ABoxIcon(key = key, contentDescription = contentDescription)
}
}
/**
* Circular user avatar displaying the initial character of username or a default person icon.
* Styled with an accented profile ring.
*/
@Composable
fun ABoxSwitch(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
contentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxSwitch contentDescription must not be blank"
}
Box(
modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.toggleable(
value = checked,
enabled = enabled && onCheckedChange != null,
role = Role.Switch,
onValueChange = { onCheckedChange?.invoke(it) },
)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
@Composable
fun ABoxFilterChip(
selected: Boolean,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
leadingIcon: (@Composable () -> Unit)? = null,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label) },
modifier = modifier.defaultMinSize(minHeight = 48.dp),
leadingIcon = leadingIcon,
colors = FilterChipDefaults.filterChipColors(),
)
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
val reducedMotion = LocalABoxReducedMotion.current
if (reducedMotion) {
CircularProgressIndicator(
progress = { 0.5f },
modifier = modifier,
)
} else {
CircularProgressIndicator(modifier = modifier)
}
}
@Composable
fun ABoxLoadingState(
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxLoadingState contentDescription must not be blank"
}
val reducedMotion = LocalABoxReducedMotion.current
Column(
modifier = modifier
.fillMaxWidth()
.semantics {
this.contentDescription = contentDescription
stateDescription = if (reducedMotion) "静态加载" else "动态加载"
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxLoadingIndicator(modifier = Modifier.size(32.dp))
Text(text = contentDescription)
}
}
enum class ABoxStatus {
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
}
@Composable
fun ABoxStatusBanner(
state: ABoxStatus,
contentDescription: String,
modifier: Modifier = Modifier,
message: String = contentDescription,
) {
require(contentDescription.isNotBlank()) {
"ABoxStatusBanner contentDescription must not be blank"
}
val colors = LocalABoxColors.current
val (iconKey, color) = when (state) {
ABoxStatus.ONLINE -> ABoxIconKey.ONLINE to colors.success
ABoxStatus.OFFLINE -> ABoxIconKey.OFFLINE to colors.offline
ABoxStatus.PENDING -> ABoxIconKey.PENDING to colors.pending
ABoxStatus.WARNING -> ABoxIconKey.WARNING to colors.warning
ABoxStatus.ERROR -> ABoxIconKey.ERROR to colors.error
}
Surface(
modifier = modifier
.fillMaxWidth()
.semantics { this.contentDescription = contentDescription },
color = color.copy(alpha = 0.14f),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ABoxIcon(key = iconKey, contentDescription = "${contentDescription}图标", modifier = Modifier.size(22.dp))
Text(text = message, color = color, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun ABoxEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.DEVICES, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
action?.invoke()
}
}
@Composable
fun ABoxErrorState(
title: String,
description: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.ERROR, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
if (onRetry != null) {
ABoxButton(onClick = onRetry) { Text("重试") }
}
}
}
@Composable
fun ABoxCard(
title: String,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
leadingContent: (@Composable () -> Unit)? = null,
quickAction: ABoxCardQuickAction? = null,
content: (@Composable ColumnScope.() -> Unit)? = null,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(LocalABoxShapes.current.mediumCornerRadius),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingContent?.let {
Box(modifier = Modifier.padding(end = 12.dp), contentAlignment = Alignment.Center) {
it()
}
}
Column(
modifier = Modifier
.weight(1f)
.then(
if (onClick != null) {
Modifier
.clickable(
role = Role.Button,
onClickLabel = "打开$title",
onClick = onClick,
)
.semantics { role = Role.Button }
} else {
Modifier
},
)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
content?.invoke(this)
}
if (quickAction != null) {
Spacer(Modifier.width(8.dp))
ABoxIconButton(
key = quickAction.key,
contentDescription = quickAction.contentDescription,
onClick = quickAction.onClick,
)
}
}
}
}
data class ABoxCardQuickAction(
val key: ABoxIconKey,
val contentDescription: String,
val onClick: () -> Unit,
)
@Composable
fun ABoxDialog(
visible: Boolean,
title: String,
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
confirmText: String = "确定",
dismissText: String = "取消",
content: @Composable () -> Unit,
) {
if (visible) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
title = { Text(title) },
text = content,
confirmButton = {
ABoxButton(onClick = onConfirm) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismissRequest) { Text(dismissText) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ABoxBottomSheet(
visible: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
if (visible) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
content = content,
)
}
}
}
@Composable
fun ABoxPreferenceGroup(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
data class ABoxNavigationItem(
val key: ABoxIconKey,
val label: String,
val contentDescription: String = label,
)
@Composable
fun ABoxNavigationSuite(
items: List<ABoxNavigationItem>,
selectedKey: ABoxIconKey,
onItemSelected: (ABoxIconKey) -> Unit,
modifier: Modifier = Modifier,
) {
ABoxNavigationBar(modifier = modifier) {
items.forEach { item ->
ABoxNavigationBarItem(
selected = item.key == selectedKey,
onClick = { onItemSelected(item.key) },
icon = {
ABoxIcon(
key = item.key,
contentDescription = item.contentDescription,
)
},
label = { Text(item.label) },
)
}
}
}
@Composable
fun ABoxDeviceDetailScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE,
action: (@Composable () -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = onBack,
)
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(start = 4.dp),
) {
ABoxIcon(
key = icon,
contentDescription = subtitle ?: title,
modifier = Modifier.padding(9.dp).size(24.dp),
)
}
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
action?.invoke()
}
},
content = content,
)
}
/** Circular user avatar displaying the initial character of username or a default person icon. */
@Composable
fun ABoxUserAvatar(
name: String?,
@@ -83,73 +552,73 @@ fun ABoxUserAvatar(
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape,
)
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape,
),
)
} else {
Icon(
imageVector = Icons.Default.Person,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f),
)
contentAlignment = Alignment.Center,
) {
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
} else {
MaterialIcon(
imageVector = ABoxIconMapperForAccount,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f),
)
}
}
}
}
/**
* Material 3 pill top bar containing leading icon, title/provider label,
* an optional '+' action button, and user avatar.
*/
private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
@Composable
fun ABoxTopPillBar(
title: String,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
userName: String? = null,
leadingIcon: ImageVector = Icons.Default.LocationOn,
leadingIcon: ImageVector = Icons.Outlined.Bolt,
onAddClick: (() -> Unit)? = null,
addActionContentDescription: String? = "Add",
avatarContentDescription: String? = "Account",
addActionContentDescription: String? = "添加",
avatarContentDescription: String? = "账户",
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth().height(64.dp),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
MaterialIcon(
imageVector = leadingIcon,
contentDescription = null,
contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
modifier = Modifier.padding(start = 8.dp).size(22.dp),
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@@ -158,20 +627,13 @@ fun ABoxTopPillBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onAddClick != null) {
IconButton(
ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = addActionContentDescription ?: "添加",
onClick = onAddClick,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = addActionContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
)
}
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
@@ -182,9 +644,6 @@ fun ABoxTopPillBar(
}
}
/**
* ABox standard Material 3 Navigation Bar with pill indicator styling.
*/
@Composable
fun ABoxNavigationBar(
modifier: Modifier = Modifier,
@@ -198,9 +657,6 @@ fun ABoxNavigationBar(
)
}
/**
* ABox standard Navigation Bar Item.
*/
@Composable
fun RowScope.ABoxNavigationBarItem(
selected: Boolean,
@@ -226,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()
}
}
@@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
val ABoxShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes(
small = RoundedCornerShape(tokens.smallCornerRadius),
medium = RoundedCornerShape(tokens.mediumCornerRadius),
large = RoundedCornerShape(tokens.largeCornerRadius),
)
@@ -1,41 +1,205 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import android.os.Build
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF006780),
onPrimary = Color.White,
primaryContainer = Color(0xFFB8EAFF),
onPrimaryContainer = Color(0xFF001F29),
secondary = Color(0xFF4D616A),
surface = Color(0xFFF8FAFC),
background = Color(0xFFF8FAFC),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD5F8),
onPrimary = Color(0xFF003544),
primaryContainer = Color(0xFF004D61),
onPrimaryContainer = Color(0xFFB8EAFF),
secondary = Color(0xFFB4CBD4),
surface = Color(0xFF101416),
background = Color(0xFF101416),
)
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() }
val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
@Composable
fun ABoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
preferences: UiPreferences,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = ABoxTypography,
shapes = ABoxShapes,
val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
val context = LocalContext.current
val isDarkTheme = when (preferences.themeMode) {
AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
}
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val dynamicColorScheme = if (isDarkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
dynamicColorScheme.toABoxColorTokens()
} else {
null
}
ABoxThemeContent(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = dynamicTokens,
reducedMotion = systemAnimatorDurationScale(context) == 0f,
content = content,
)
}
@Composable
internal fun ABoxThemeContent(
preferences: UiPreferences,
systemInDarkTheme: Boolean,
sdkInt: Int,
dynamicColorTokens: ABoxColorTokens?,
reducedMotion: Boolean,
content: @Composable () -> Unit,
) {
val resolution = ABoxThemeResolver.resolve(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = sdkInt,
dynamicColorTokens = dynamicColorTokens,
)
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
val density = LocalDensity.current
val scaledDensity = Density(
density = density.density * (preferences.interfaceScale.percent / 100f),
fontScale = density.fontScale,
)
CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalABoxColors provides resolution.colorTokens,
LocalABoxSpacing provides ABoxSpacing(),
LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled,
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
LocalABoxPredictiveBackEnabled provides false,
) {
renderer.Render(
resolution = resolution,
content = content,
)
}
}
private fun systemAnimatorDurationScale(context: android.content.Context): Float =
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
}.getOrDefault(1f)
@Composable
fun ABoxTheme(
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
ABoxTheme(
preferences = UiPreferences(
themeMode = if (darkTheme) AppThemeMode.DARK else AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
content = content,
)
}
internal fun materialColorScheme(resolution: ABoxThemeResolution): ColorScheme {
val colors = resolution.colorTokens
val onPrimary = if (resolution.isDarkTheme) Color(0xFF003544) else Color.White
val onSurface = if (resolution.isDarkTheme) Color(0xFFE0E3E5) else Color(0xFF191C1E)
val onSurfaceVariant = if (resolution.isDarkTheme) Color(0xFFC0C8CC) else Color(0xFF40484C)
val primaryContainer = colors.primary.copy(alpha = if (resolution.isDarkTheme) 0.35f else 0.20f)
val secondaryContainer = colors.surfaceContainer
return if (resolution.isDarkTheme) {
darkColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = colors.primary,
secondary = colors.pending,
onSecondary = Color(0xFF3F2E00),
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color(0xFF00391E),
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color(0xFF690005),
)
} else {
lightColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = Color(0xFF001F29),
secondary = colors.pending,
onSecondary = Color.White,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color.White,
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color.White,
)
}
}
private fun ColorScheme.toABoxColorTokens() = ABoxColorTokens(
primary = primary,
surface = surface,
surfaceContainer = surfaceContainer,
outline = outline,
success = tertiary,
warning = secondary,
error = error,
offline = outline,
pending = secondary,
deviceSocket = primary,
deviceSensor = tertiary,
deviceInfrared = secondary,
)
@@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Typography
/** Default typography supplied through [LocalABoxTypography]. */
val ABoxTypography = Typography()
@@ -0,0 +1,129 @@
package com.flagship.abox.manager.designsystem.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Devices
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.outlined.ViewCarousel
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Power
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sensors
import androidx.compose.material.icons.outlined.Thermostat
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon as MaterialIcon
/** Stable semantic identifiers for every icon exposed by the ABox design system. */
enum class ABoxIconKey {
BACK,
CLOSE,
ADD,
EDIT,
REFRESH,
ACCOUNT,
PROVIDER,
DEVICES,
SCENES,
ACTIVITY,
SOCKET,
SENSOR,
INFRARED,
POWER,
TEMPERATURE,
HUMIDITY,
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
UNKNOWN_DEVICE,
SETTINGS,
THEME,
EFFECTS,
FEEDBACK,
SCALE,
}
/** Design-system-owned device categories; this module does not depend on domain models. */
enum class ABoxDeviceType {
SOCKET,
SENSOR,
INFRARED,
POWER,
UNKNOWN,
}
object ABoxIconMapper {
fun forDeviceType(type: ABoxDeviceType?): ABoxIconKey = when (type) {
ABoxDeviceType.SOCKET -> ABoxIconKey.SOCKET
ABoxDeviceType.SENSOR -> ABoxIconKey.SENSOR
ABoxDeviceType.INFRARED -> ABoxIconKey.INFRARED
ABoxDeviceType.POWER -> ABoxIconKey.POWER
null, ABoxDeviceType.UNKNOWN -> ABoxIconKey.UNKNOWN_DEVICE
}
internal fun vectorFor(key: ABoxIconKey): ImageVector = when (key) {
ABoxIconKey.BACK -> Icons.AutoMirrored.Outlined.ArrowBack
ABoxIconKey.CLOSE -> Icons.Outlined.Close
ABoxIconKey.ADD -> Icons.Outlined.Add
ABoxIconKey.EDIT -> Icons.Outlined.Edit
ABoxIconKey.REFRESH -> Icons.Outlined.Refresh
ABoxIconKey.ACCOUNT -> Icons.Outlined.AccountCircle
ABoxIconKey.PROVIDER -> Icons.Outlined.Bolt
ABoxIconKey.DEVICES -> Icons.Outlined.Devices
ABoxIconKey.SCENES -> Icons.Outlined.Layers
ABoxIconKey.ACTIVITY -> Icons.Outlined.History
ABoxIconKey.SOCKET -> Icons.Outlined.Power
ABoxIconKey.SENSOR -> Icons.Outlined.Sensors
ABoxIconKey.INFRARED -> Icons.Outlined.Bolt
ABoxIconKey.POWER -> Icons.Outlined.PowerSettingsNew
ABoxIconKey.TEMPERATURE -> Icons.Outlined.Thermostat
ABoxIconKey.HUMIDITY -> Icons.Outlined.WaterDrop
ABoxIconKey.ONLINE -> Icons.Outlined.Wifi
ABoxIconKey.OFFLINE -> Icons.Outlined.WifiOff
ABoxIconKey.PENDING -> Icons.Outlined.Schedule
ABoxIconKey.WARNING -> Icons.Outlined.Warning
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline
ABoxIconKey.SETTINGS -> Icons.Outlined.Settings
ABoxIconKey.THEME -> Icons.Outlined.Palette
ABoxIconKey.EFFECTS -> Icons.Outlined.ViewCarousel
ABoxIconKey.FEEDBACK -> Icons.Outlined.Vibration
ABoxIconKey.SCALE -> Icons.Outlined.Tune
}
}
@Composable
fun ABoxIcon(
key: ABoxIconKey,
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxIcon contentDescription must not be blank"
}
MaterialIcon(
imageVector = ABoxIconMapper.vectorFor(key),
contentDescription = contentDescription,
modifier = modifier,
)
}
@@ -0,0 +1,17 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.preferences.DesignRenderer
internal interface ABoxRendererAdapter {
@Composable
fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
}
internal object ABoxRendererAdapters {
fun forRenderer(renderer: DesignRenderer): ABoxRendererAdapter = when (renderer) {
DesignRenderer.MATERIAL3 -> Material3RendererAdapter
DesignRenderer.MIUIX -> MiuixRendererAdapter
}
}
@@ -0,0 +1,23 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.ABoxShapes
import com.flagship.abox.manager.designsystem.ABoxTypography
import com.flagship.abox.manager.designsystem.materialColorScheme
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
internal object Material3RendererAdapter : ABoxRendererAdapter {
@Composable
override fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = materialColorScheme(resolution),
typography = ABoxTypography,
shapes = ABoxShapes(resolution.shapeTokens),
content = content,
)
}
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.darkColorScheme
import top.yukonga.miuix.kmp.theme.lightColorScheme
internal object MiuixRendererAdapter : ABoxRendererAdapter {
@Composable
override fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
) {
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
MiuixTheme(colors = colors) {
androidx.compose.material3.MaterialTheme(
colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution),
typography = com.flagship.abox.manager.designsystem.ABoxTypography,
shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens),
content = content,
)
}
}
}
@@ -0,0 +1,233 @@
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
@Immutable
data class ABoxColorTokens(
val primary: Color,
val surface: Color,
val surfaceContainer: Color,
val outline: Color,
val success: Color,
val warning: Color,
val error: Color,
val offline: Color,
val pending: Color,
val deviceSocket: Color,
val deviceSensor: Color,
val deviceInfrared: Color,
)
@Immutable
data class ABoxSpacing(
val dp4: Dp = 4.dp,
val dp8: Dp = 8.dp,
val dp12: Dp = 12.dp,
val dp16: Dp = 16.dp,
val dp20: Dp = 20.dp,
val dp24: Dp = 24.dp,
val dp32: Dp = 32.dp,
val minimumTouchTarget: Dp = 48.dp,
)
@Immutable
data class ABoxComponentState(
val enabled: Boolean = true,
val pending: Boolean = false,
val offline: Boolean = false,
val dangerous: Boolean = false,
)
@Immutable
data class ABoxShapeTokens(
val smallCornerRadius: Dp,
val mediumCornerRadius: Dp,
val largeCornerRadius: Dp,
)
@Immutable
data class ABoxThemeResolution(
val isDarkTheme: Boolean,
val colorTokens: ABoxColorTokens,
val shapeTokens: ABoxShapeTokens,
val blurEnabled: Boolean,
val renderer: DesignRenderer,
)
object ABoxThemeDefaults {
val lightColors = ABoxColorTokens(
primary = Color(0xFF006780),
surface = Color(0xFFF8FAFC),
surfaceContainer = Color(0xFFE2E8F0),
outline = Color(0xFF70797D),
success = Color(0xFF1B7F4B),
warning = Color(0xFF8A5800),
error = Color(0xFFBA1A1A),
offline = Color(0xFF5F6B72),
pending = Color(0xFF8A5800),
deviceSocket = Color(0xFF006E90),
deviceSensor = Color(0xFF476B23),
deviceInfrared = Color(0xFF77536F),
)
val darkColors = ABoxColorTokens(
primary = Color(0xFF5DD5F8),
surface = Color(0xFF101416),
surfaceContainer = Color(0xFF1F292D),
outline = Color(0xFF8A969B),
success = Color(0xFF7EDFA7),
warning = Color(0xFFFFB95B),
error = Color(0xFFFFB4AB),
offline = Color(0xFFBFC9CD),
pending = Color(0xFFFFB95B),
deviceSocket = Color(0xFF5DD5F8),
deviceSensor = Color(0xFFAFD67C),
deviceInfrared = Color(0xFFE6B5D6),
)
val material3Shapes = ABoxShapeTokens(
smallCornerRadius = 8.dp,
mediumCornerRadius = 16.dp,
largeCornerRadius = 24.dp,
)
val miuixShapes = ABoxShapeTokens(
smallCornerRadius = 12.dp,
mediumCornerRadius = 20.dp,
largeCornerRadius = 28.dp,
)
}
object ABoxThemeResolver {
fun resolve(
preferences: UiPreferences,
systemInDarkTheme: Boolean,
sdkInt: Int,
dynamicColorTokens: ABoxColorTokens?,
): ABoxThemeResolution {
val isDarkTheme = when (preferences.themeMode) {
AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
}
val brandTokens = if (isDarkTheme) {
ABoxThemeDefaults.darkColors
} else {
ABoxThemeDefaults.lightColors
}
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
dynamicColorTokens
} else {
ABoxAccentPalette.resolve(
base = brandTokens,
accent = preferences.accentPreference,
style = preferences.colorStyle,
standard = preferences.colorStandard,
dark = isDarkTheme,
)
}
val shapeTokens = when (preferences.renderer) {
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
DesignRenderer.MIUIX -> ABoxThemeDefaults.miuixShapes
}
return ABoxThemeResolution(
isDarkTheme = isDarkTheme,
colorTokens = colorTokens,
shapeTokens = shapeTokens,
blurEnabled = preferences.blurEnabled && preferences.renderer == DesignRenderer.MIUIX,
renderer = preferences.renderer,
)
}
}
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)
}
}
@@ -0,0 +1,123 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest
@Preview(name = "M3 light", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMaterialLight() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "M3 dark", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMaterialDark() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.DARK)
}
@PreviewTest
@Preview(name = "Miuix light", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMiuixLight() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "Miuix dark", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMiuixDark() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.DARK)
}
@PreviewTest
@Preview(name = "Large font", widthDp = 360, heightDp = 760, fontScale = 1.3f)
@Composable
fun ABoxThemeScreenshotLargeFont() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "Blur fallback", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotBlurFallback() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT, blurEnabled = true)
}
@Composable
private fun ABoxScreenshotFixture(
renderer: DesignRenderer,
themeMode: AppThemeMode,
blurEnabled: Boolean = false,
) {
ABoxTheme(
preferences = UiPreferences(
renderer = renderer,
themeMode = themeMode,
dynamicColorEnabled = false,
blurEnabled = blurEnabled,
),
) {
ABoxAppSurface(modifier = Modifier.padding(16.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ABoxTopPillBar(
title = "我的设备",
onAvatarClick = {},
userName = "小明",
onAddClick = {},
addActionContentDescription = "添加设备",
avatarContentDescription = "账户",
)
ABoxCard(
title = "客厅插座",
subtitle = "在线 · 正常",
onClick = {},
quickAction = ABoxCardQuickAction(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
)
ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在处理",
)
ABoxStatusBanner(
state = ABoxStatus.OFFLINE,
contentDescription = "设备离线",
)
ABoxLoadingState(contentDescription = "正在加载")
ABoxEmptyState(
title = "暂无设备",
description = "添加设备后会显示在这里。",
)
ABoxErrorState(
title = "加载失败",
description = "请稍后重试。",
onRetry = {},
)
ABoxFilterChip(
selected = true,
label = "全部",
onClick = {},
)
}
}
}
}
@@ -0,0 +1,24 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest
@Preview
@Composable
fun DesignSystemSmokePreview() {
ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("截图基线")
}
}
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.designsystem.icon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ABoxIconMappingTest {
@Test
fun unknownDeviceNeverMapsToPower() {
assertNotEquals(ABoxIconKey.POWER, ABoxIconMapper.forDeviceType(null))
assertEquals(ABoxIconKey.UNKNOWN_DEVICE, ABoxIconMapper.forDeviceType(null))
assertEquals(
ABoxIconKey.UNKNOWN_DEVICE,
ABoxIconMapper.forDeviceType(ABoxDeviceType.UNKNOWN),
)
}
@Test
fun knownDeviceCategoriesMapToTheirTypedIcons() {
assertEquals(
ABoxIconKey.SOCKET,
ABoxIconMapper.forDeviceType(ABoxDeviceType.SOCKET),
)
assertEquals(
ABoxIconKey.SENSOR,
ABoxIconMapper.forDeviceType(ABoxDeviceType.SENSOR),
)
assertEquals(
ABoxIconKey.INFRARED,
ABoxIconMapper.forDeviceType(ABoxDeviceType.INFRARED),
)
assertEquals(
ABoxIconKey.POWER,
ABoxIconMapper.forDeviceType(ABoxDeviceType.POWER),
)
}
}
@@ -0,0 +1,19 @@
package com.flagship.abox.manager.designsystem.renderer
import com.flagship.abox.manager.preferences.DesignRenderer
import org.junit.Assert.assertSame
import org.junit.Test
class ABoxRendererAdapterTest {
@Test
fun selectsMaterial3AndMiuixAdaptersFromRendererPreference() {
assertSame(
Material3RendererAdapter,
ABoxRendererAdapters.forRenderer(DesignRenderer.MATERIAL3),
)
assertSame(
MiuixRendererAdapter,
ABoxRendererAdapters.forRenderer(DesignRenderer.MIUIX),
)
}
}
@@ -0,0 +1,152 @@
package com.flagship.abox.manager.designsystem.tokens
import androidx.compose.ui.graphics.Color
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ABoxThemeResolverTest {
@Test
fun systemLightAndDarkResolveDeterministically() {
val light = ABoxThemeResolver.resolve(
preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM),
systemInDarkTheme = false,
sdkInt = 30,
dynamicColorTokens = null,
)
val dark = ABoxThemeResolver.resolve(
preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM),
systemInDarkTheme = true,
sdkInt = 30,
dynamicColorTokens = null,
)
assertFalse(light.isDarkTheme)
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
fun dynamicColorDisabledUsesBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = dynamicTokens,
)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.lightColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
false,
),
resolution.colorTokens,
)
}
@Test
fun pre31DynamicColorFallsBackToBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = true,
),
systemInDarkTheme = false,
sdkInt = 30,
dynamicColorTokens = dynamicTokens,
)
assertEquals(
ABoxAccentPalette.resolve(
ABoxThemeDefaults.darkColors,
UiPreferences().accentPreference,
UiPreferences().colorStyle,
UiPreferences().colorStandard,
true,
),
resolution.colorTokens,
)
}
@Test
fun blurDisabledResolvesOpaqueSurface() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(blurEnabled = false),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
assertFalse(resolution.blurEnabled)
assertEquals(1f, resolution.colorTokens.surface.alpha)
assertEquals(1f, resolution.colorTokens.surfaceContainer.alpha)
}
@Test
fun rendererChangesShapeButNotSemanticStateRoles() {
val material = ABoxThemeResolver.resolve(
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
val miuix = ABoxThemeResolver.resolve(
preferences = UiPreferences(renderer = DesignRenderer.MIUIX),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
assertEquals(material.colorTokens.success, miuix.colorTokens.success)
assertEquals(material.colorTokens.warning, miuix.colorTokens.warning)
assertEquals(material.colorTokens.error, miuix.colorTokens.error)
assertEquals(material.colorTokens.offline, miuix.colorTokens.offline)
assertEquals(material.colorTokens.pending, miuix.colorTokens.pending)
assertNotEquals(material.shapeTokens, miuix.shapeTokens)
}
private companion object {
val dynamicTokens = ABoxColorTokens(
primary = Color(0xFF111111),
surface = Color(0xFF222222),
surfaceContainer = Color(0xFF333333),
outline = Color(0xFF444444),
success = Color(0xFF555555),
warning = Color(0xFF666666),
error = Color(0xFF777777),
offline = Color(0xFF888888),
pending = Color(0xFF999999),
deviceSocket = Color(0xFFAAAAAA),
deviceSensor = Color(0xFFBBBBBB),
deviceInfrared = Color(0xFFCCCCCC),
)
}
}