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