diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index d28fb6b..7b06b9b 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -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) } diff --git a/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt new file mode 100644 index 0000000..7451c90 --- /dev/null +++ b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt @@ -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() + + @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("返回") + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index 8f3fb5a..614e7a6 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -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, + 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,73 +504,73 @@ fun ABoxUserAvatar( val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() Box( modifier = modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - shape = CircleShape, - ) + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) .clickable(onClickLabel = contentDescription, onClick = onClick), contentAlignment = Alignment.Center, ) { - if (initial != null) { - Text( - text = initial, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + shape = CircleShape, ), - ) - } else { - Icon( - imageVector = Icons.Default.Person, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(size * 0.6f), - ) + contentAlignment = Alignment.Center, + ) { + if (initial != null) { + Text( + text = initial, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else { + MaterialIcon( + imageVector = ABoxIconMapperForAccount, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(size * 0.6f), + ) + } } } } -/** - * Material 3 pill top bar containing leading icon, title/provider label, - * an optional '+' action button, and user avatar. - */ +private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle + +/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */ @Composable fun ABoxTopPillBar( title: String, onAvatarClick: () -> Unit, modifier: Modifier = Modifier, userName: String? = null, - leadingIcon: ImageVector = Icons.Default.LocationOn, + leadingIcon: ImageVector = Icons.Outlined.Bolt, onAddClick: (() -> Unit)? = null, - addActionContentDescription: String? = "Add", - avatarContentDescription: String? = "Account", + addActionContentDescription: String? = "添加", + avatarContentDescription: String? = "账户", ) { Surface( - modifier = modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth().height(64.dp), + shape = RoundedCornerShape(32.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), tonalElevation = 2.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( + MaterialIcon( imageVector = leadingIcon, - contentDescription = null, + contentDescription = title, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), + modifier = Modifier.padding(start = 8.dp).size(22.dp), ) - Text( text = title, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), @@ -158,20 +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, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index 4f68164..ad6eb22 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -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, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt new file mode 100644 index 0000000..217b822 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt @@ -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, + ) +} diff --git a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt new file mode 100644 index 0000000..3541208 --- /dev/null +++ b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png new file mode 100644 index 0000000..70919a9 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png new file mode 100644 index 0000000..957bff8 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png new file mode 100644 index 0000000..276aac9 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png new file mode 100644 index 0000000..f4f03da Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png new file mode 100644 index 0000000..0288ec6 Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png differ diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png new file mode 100644 index 0000000..cb2ea5f Binary files /dev/null and b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png differ diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt new file mode 100644 index 0000000..2a0b9d9 --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt @@ -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), + ) + } +}