feat(ui): complete ABox app experience redesign #10
+63
-19
@@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-1
@@ -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 { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user