feat(ui): complete ABox app experience redesign #10
@@ -42,4 +42,10 @@ dependencies {
|
||||
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)
|
||||
}
|
||||
|
||||
+139
@@ -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("返回")
|
||||
}
|
||||
}
|
||||
+460
-52
@@ -5,47 +5,85 @@ 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.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.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.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 +94,7 @@ fun ABoxButton(
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
modifier = modifier.defaultMinSize(minHeight = 48.dp),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
content = content,
|
||||
@@ -64,14 +102,397 @@ 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)
|
||||
.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
|
||||
fun ABoxUserAvatar(
|
||||
name: String?,
|
||||
@@ -83,6 +504,12 @@ fun ABoxUserAvatar(
|
||||
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
||||
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
@@ -90,8 +517,7 @@ fun ABoxUserAvatar(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (initial != null) {
|
||||
@@ -103,53 +529,48 @@ fun ABoxUserAvatar(
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
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 +579,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 +596,6 @@ fun ABoxTopPillBar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ABox standard Material 3 Navigation Bar with pill indicator styling.
|
||||
*/
|
||||
@Composable
|
||||
fun ABoxNavigationBar(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -198,9 +609,6 @@ fun ABoxNavigationBar(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ABox standard Navigation Bar Item.
|
||||
*/
|
||||
@Composable
|
||||
fun RowScope.ABoxNavigationBarItem(
|
||||
selected: Boolean,
|
||||
|
||||
+2
@@ -27,6 +27,7 @@ val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
|
||||
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
|
||||
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
|
||||
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
|
||||
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
|
||||
|
||||
@Composable
|
||||
fun ABoxTheme(
|
||||
@@ -65,6 +66,7 @@ fun ABoxTheme(
|
||||
LocalABoxShapes provides resolution.shapeTokens,
|
||||
LocalABoxRenderer provides resolution.renderer,
|
||||
LocalABoxReducedMotion provides false,
|
||||
LocalABoxBlurEnabled provides resolution.blurEnabled,
|
||||
) {
|
||||
renderer.Render(
|
||||
resolution = resolution,
|
||||
|
||||
+114
@@ -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,
|
||||
)
|
||||
}
|
||||
+123
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+37
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user