feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
3 changed files with 106 additions and 23 deletions
Showing only changes of commit b24ca98e14 - Show all commits
@@ -1,35 +1,38 @@
package com.flagship.abox.manager.designsystem package com.flagship.abox.manager.designsystem
import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.material3.Text
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag 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.assertContentDescriptionEquals
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 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.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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) @RunWith(AndroidJUnit4::class)
class ABoxComponentSemanticsTest { class ABoxComponentSemanticsTest {
@get:Rule @get:Rule
@@ -39,27 +42,58 @@ class ABoxComponentSemanticsTest {
fun allInteractiveControlsHaveAtLeast48DpBounds() { fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent { composeRule.setContent {
ABoxTheme { ABoxTheme {
ABoxButton(onClick = {}, modifier = androidx.compose.ui.Modifier.testTag("button")) { ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
androidx.compose.material3.Text("确定") Text("确定")
} }
ABoxIconButton( ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.ADD, key = ABoxIconKey.ADD,
contentDescription = "添加设备", contentDescription = "添加设备",
onClick = {}, onClick = {},
modifier = androidx.compose.ui.Modifier.testTag("icon"), modifier = Modifier.testTag("icon"),
) )
ABoxSwitch( ABoxSwitch(
checked = false, checked = false,
onCheckedChange = {}, onCheckedChange = {},
contentDescription = "启用设备", contentDescription = "启用设备",
modifier = androidx.compose.ui.Modifier.testTag("switch"), modifier = Modifier.testTag("switch"),
) )
} }
} }
composeRule.onNodeWithTag("button").assertHeightIsAtLeast(48.dp) listOf("button", "icon", "switch").forEach { tag ->
composeRule.onNodeWithTag("icon").assertHeightIsAtLeast(48.dp) composeRule.onNodeWithTag(tag)
composeRule.onNodeWithTag("switch").assertHeightIsAtLeast(48.dp) .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 @Test
@@ -71,7 +105,7 @@ class ABoxComponentSemanticsTest {
subtitle = "在线", subtitle = "在线",
onClick = {}, onClick = {},
quickAction = ABoxCardQuickAction( quickAction = ABoxCardQuickAction(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.REFRESH, key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备", contentDescription = "刷新设备",
onClick = {}, onClick = {},
), ),
@@ -108,9 +142,19 @@ class ABoxComponentSemanticsTest {
} }
@Test @Test
fun reducedMotionExposesStaticLoadingState() { fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
composeRule.setContent { composeRule.setContent {
CompositionLocalProvider(LocalABoxReducedMotion provides true) { ABoxThemeContent(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = null,
reducedMotion = true,
) {
ABoxLoadingState(contentDescription = "正在加载") ABoxLoadingState(contentDescription = "正在加载")
} }
} }
@@ -126,7 +170,7 @@ class ABoxComponentSemanticsTest {
composeRule.setContent { composeRule.setContent {
ABoxTheme { ABoxTheme {
ABoxIconButton( ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.BACK, key = ABoxIconKey.BACK,
contentDescription = "返回", contentDescription = "返回",
onClick = {}, onClick = {},
) )
@@ -54,6 +54,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -135,6 +137,12 @@ fun ABoxSwitch(
Box( Box(
modifier = modifier modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.toggleable(
value = checked,
enabled = enabled && onCheckedChange != null,
role = Role.Switch,
onValueChange = { onCheckedChange?.invoke(it) },
)
.semantics { .semantics {
this.contentDescription = contentDescription this.contentDescription = contentDescription
}, },
@@ -142,8 +150,9 @@ fun ABoxSwitch(
) { ) {
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = null,
enabled = enabled, enabled = enabled,
modifier = Modifier.clearAndSetSemantics { },
) )
} }
} }
@@ -1,6 +1,7 @@
package com.flagship.abox.manager.designsystem package com.flagship.abox.manager.designsystem
import android.os.Build import android.os.Build
import android.provider.Settings
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@@ -35,12 +36,12 @@ fun ABoxTheme(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
val context = LocalContext.current
val isDarkTheme = when (preferences.themeMode) { val isDarkTheme = when (preferences.themeMode) {
AppThemeMode.SYSTEM -> systemInDarkTheme AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true AppThemeMode.DARK -> true
} }
val context = LocalContext.current
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val dynamicColorScheme = if (isDarkTheme) { val dynamicColorScheme = if (isDarkTheme) {
dynamicDarkColorScheme(context) dynamicDarkColorScheme(context)
@@ -51,11 +52,30 @@ fun ABoxTheme(
} else { } else {
null null
} }
val resolution = ABoxThemeResolver.resolve( ABoxThemeContent(
preferences = preferences, preferences = preferences,
systemInDarkTheme = systemInDarkTheme, systemInDarkTheme = systemInDarkTheme,
sdkInt = Build.VERSION.SDK_INT, sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = dynamicTokens, 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 renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
@@ -65,7 +85,7 @@ fun ABoxTheme(
LocalABoxTypography provides ABoxTypography, LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens, LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer, LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides false, LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled, LocalABoxBlurEnabled provides resolution.blurEnabled,
) { ) {
renderer.Render( renderer.Render(
@@ -75,6 +95,16 @@ fun ABoxTheme(
} }
} }
private fun systemAnimatorDurationScale(context: android.content.Context): Float =
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
}.getOrDefault(1f)
@Composable @Composable
fun ABoxTheme( fun ABoxTheme(
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(), darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),