fix: correct ABox switch semantics and motion

This commit is contained in:
2026-08-26 11:38:32 +08:00
parent 063a3f8dab
commit b24ca98e14
3 changed files with 106 additions and 23 deletions
@@ -1,35 +1,38 @@
package com.flagship.abox.manager.designsystem
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
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.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.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
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
@@ -39,27 +42,58 @@ class ABoxComponentSemanticsTest {
fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent {
ABoxTheme {
ABoxButton(onClick = {}, modifier = androidx.compose.ui.Modifier.testTag("button")) {
androidx.compose.material3.Text("确定")
ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
Text("确定")
}
ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.ADD,
key = ABoxIconKey.ADD,
contentDescription = "添加设备",
onClick = {},
modifier = androidx.compose.ui.Modifier.testTag("icon"),
modifier = Modifier.testTag("icon"),
)
ABoxSwitch(
checked = false,
onCheckedChange = {},
contentDescription = "启用设备",
modifier = androidx.compose.ui.Modifier.testTag("switch"),
modifier = Modifier.testTag("switch"),
)
}
}
composeRule.onNodeWithTag("button").assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("icon").assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("switch").assertHeightIsAtLeast(48.dp)
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
@@ -71,7 +105,7 @@ class ABoxComponentSemanticsTest {
subtitle = "在线",
onClick = {},
quickAction = ABoxCardQuickAction(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.REFRESH,
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
@@ -108,9 +142,19 @@ class ABoxComponentSemanticsTest {
}
@Test
fun reducedMotionExposesStaticLoadingState() {
fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
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 = "正在加载")
}
}
@@ -126,7 +170,7 @@ class ABoxComponentSemanticsTest {
composeRule.setContent {
ABoxTheme {
ABoxIconButton(
key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.BACK,
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = {},
)
@@ -54,6 +54,8 @@ 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
@@ -135,6 +137,12 @@ fun ABoxSwitch(
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
},
@@ -142,8 +150,9 @@ fun ABoxSwitch(
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
@@ -1,6 +1,7 @@
package com.flagship.abox.manager.designsystem
import android.os.Build
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
@@ -35,12 +36,12 @@ fun ABoxTheme(
content: @Composable () -> Unit,
) {
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 context = LocalContext.current
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val dynamicColorScheme = if (isDarkTheme) {
dynamicDarkColorScheme(context)
@@ -51,11 +52,30 @@ fun ABoxTheme(
} else {
null
}
val resolution = ABoxThemeResolver.resolve(
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)
@@ -65,7 +85,7 @@ fun ABoxTheme(
LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides false,
LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled,
) {
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
fun ABoxTheme(
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),