feat: add semantic ABox UI components

This commit is contained in:
2026-08-26 11:38:32 +08:00
parent cae2ea9ce0
commit 063a3f8dab
13 changed files with 900 additions and 71 deletions
@@ -42,4 +42,10 @@ dependencies {
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
screenshotTestImplementation(libs.compose.screenshot.test) screenshotTestImplementation(libs.compose.screenshot.test)
testImplementation(libs.junit) 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,139 @@
package com.flagship.abox.manager.designsystem
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.testTag
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.semantics.SemanticsProperties
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.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
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.Rule
import org.junit.Test
import org.junit.runner.RunWith
import androidx.compose.ui.Modifier
import androidx.compose.material3.Text
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
@RunWith(AndroidJUnit4::class)
class ABoxComponentSemanticsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent {
ABoxTheme {
ABoxButton(onClick = {}, modifier = androidx.compose.ui.Modifier.testTag("button")) {
androidx.compose.material3.Text("确定")
}
ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.ADD,
contentDescription = "添加设备",
onClick = {},
modifier = androidx.compose.ui.Modifier.testTag("icon"),
)
ABoxSwitch(
checked = false,
onCheckedChange = {},
contentDescription = "启用设备",
modifier = androidx.compose.ui.Modifier.testTag("switch"),
)
}
}
composeRule.onNodeWithTag("button").assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("icon").assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("switch").assertHeightIsAtLeast(48.dp)
}
@Test
fun cardBodyAndQuickActionExposeIndependentSemanticNodes() {
composeRule.setContent {
ABoxTheme {
ABoxCard(
title = "客厅插座",
subtitle = "在线",
onClick = {},
quickAction = ABoxCardQuickAction(
key = com.flagship.abox.manager.designsystem.icon.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 reducedMotionExposesStaticLoadingState() {
composeRule.setContent {
CompositionLocalProvider(LocalABoxReducedMotion provides true) {
ABoxLoadingState(contentDescription = "正在加载")
}
}
val loadingNode = composeRule.onNodeWithContentDescription("正在加载")
.assertExists()
.fetchSemanticsNode()
assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription])
}
@Test
fun interactiveIconRequiresNonBlankChineseDescription() {
composeRule.setContent {
ABoxTheme {
ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.BACK,
contentDescription = "返回",
onClick = {},
)
}
}
composeRule.onNodeWithContentDescription("返回")
.assertContentDescriptionEquals("返回")
}
}
@@ -5,47 +5,85 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Row
import androidx.compose.foundation.layout.RowScope 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 @Composable
fun ABoxSurface( fun ABoxSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) = ABoxAppSurface(modifier = modifier, content = content)
Surface(modifier = modifier, content = content)
}
@Composable @Composable
fun ABoxButton( fun ABoxButton(
@@ -56,7 +94,7 @@ fun ABoxButton(
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier.defaultMinSize(minHeight = 48.dp),
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.buttonColors(), colors = ButtonDefaults.buttonColors(),
content = content, content = content,
@@ -64,14 +102,397 @@ fun ABoxButton(
} }
@Composable @Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { fun ABoxIconButton(
CircularProgressIndicator(modifier = modifier) 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)
}
} }
/** @Composable
* Circular user avatar displaying the initial character of username or a default person icon. fun ABoxSwitch(
* Styled with an accented profile ring. 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)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}
@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,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = onBack,
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
},
content = content,
)
}
/** Circular user avatar displaying the initial character of username or a default person icon. */
@Composable @Composable
fun ABoxUserAvatar( fun ABoxUserAvatar(
name: String?, name: String?,
@@ -83,6 +504,12 @@ fun ABoxUserAvatar(
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
Box( Box(
modifier = modifier modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer) .background(MaterialTheme.colorScheme.primaryContainer)
@@ -90,8 +517,7 @@ fun ABoxUserAvatar(
width = 2.dp, width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape, shape = CircleShape,
) ),
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (initial != null) { if (initial != null) {
@@ -103,8 +529,8 @@ fun ABoxUserAvatar(
), ),
) )
} else { } else {
Icon( MaterialIcon(
imageVector = Icons.Default.Person, imageVector = ABoxIconMapperForAccount,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f), modifier = Modifier.size(size * 0.6f),
@@ -112,44 +538,39 @@ fun ABoxUserAvatar(
} }
} }
} }
}
/** private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
* Material 3 pill top bar containing leading icon, title/provider label,
* an optional '+' action button, and user avatar. /** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
*/
@Composable @Composable
fun ABoxTopPillBar( fun ABoxTopPillBar(
title: String, title: String,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
userName: String? = null, userName: String? = null,
leadingIcon: ImageVector = Icons.Default.LocationOn, leadingIcon: ImageVector = Icons.Outlined.Bolt,
onAddClick: (() -> Unit)? = null, onAddClick: (() -> Unit)? = null,
addActionContentDescription: String? = "Add", addActionContentDescription: String? = "添加",
avatarContentDescription: String? = "Account", avatarContentDescription: String? = "账户",
) { ) {
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxWidth().height(64.dp),
.fillMaxWidth() shape = RoundedCornerShape(32.dp),
.height(56.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp, tonalElevation = 2.dp,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( MaterialIcon(
imageVector = leadingIcon, imageVector = leadingIcon,
contentDescription = null, contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp), modifier = Modifier.padding(start = 8.dp).size(22.dp),
) )
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@@ -158,20 +579,13 @@ fun ABoxTopPillBar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (onAddClick != null) { if (onAddClick != null) {
IconButton( ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = addActionContentDescription ?: "添加",
onClick = onAddClick, onClick = onAddClick,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = addActionContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
ABoxUserAvatar( ABoxUserAvatar(
name = userName, name = userName,
onClick = onAvatarClick, onClick = onAvatarClick,
@@ -182,9 +596,6 @@ fun ABoxTopPillBar(
} }
} }
/**
* ABox standard Material 3 Navigation Bar with pill indicator styling.
*/
@Composable @Composable
fun ABoxNavigationBar( fun ABoxNavigationBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -198,9 +609,6 @@ fun ABoxNavigationBar(
) )
} }
/**
* ABox standard Navigation Bar Item.
*/
@Composable @Composable
fun RowScope.ABoxNavigationBarItem( fun RowScope.ABoxNavigationBarItem(
selected: Boolean, selected: Boolean,
@@ -27,6 +27,7 @@ val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes } val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
val LocalABoxReducedMotion = staticCompositionLocalOf { false } val LocalABoxReducedMotion = staticCompositionLocalOf { false }
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
@Composable @Composable
fun ABoxTheme( fun ABoxTheme(
@@ -65,6 +66,7 @@ fun ABoxTheme(
LocalABoxShapes provides resolution.shapeTokens, LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer, LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides false, LocalABoxReducedMotion provides false,
LocalABoxBlurEnabled provides resolution.blurEnabled,
) { ) {
renderer.Render( renderer.Render(
resolution = resolution, resolution = resolution,
@@ -0,0 +1,114 @@
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.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,
}
/** 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
}
}
@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,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,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),
)
}
}