feat(ui): complete ABox app experience redesign #10
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.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,53 +529,48 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+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