feat: add dual-renderer design tokens

This commit is contained in:
2026-08-26 09:56:58 +08:00
parent 686176b148
commit 4055c53362
13 changed files with 521 additions and 33 deletions
@@ -5,10 +5,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.designsystem.ABoxTheme
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 AppSmokePreview() {
ABoxTheme(darkTheme = false) { Text("ABox Manager") }
ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("ABox Manager")
}
}
@@ -32,11 +32,14 @@ kotlin {
}
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.miuix.ui.android)
debugImplementation(libs.compose.ui.tooling)
screenshotTestImplementation(libs.compose.screenshot.test)
testImplementation(libs.junit)
}
@@ -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,158 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import android.os.Build
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 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.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 }
@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 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)
} else {
dynamicLightColorScheme(context)
}
dynamicColorScheme.toABoxColorTokens()
} else {
null
}
val resolution = ABoxThemeResolver.resolve(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = dynamicTokens,
)
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
CompositionLocalProvider(
LocalABoxColors provides resolution.colorTokens,
LocalABoxSpacing provides ABoxSpacing(),
LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides false,
) {
renderer.Render(
resolution = resolution,
content = content,
)
}
}
@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,20 @@
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
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,
) {
Material3RendererAdapter.Render(
resolution = resolution,
content = content,
)
}
}
}
@@ -0,0 +1,139 @@
package com.flagship.abox.manager.designsystem.tokens
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
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.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 {
brandTokens
}
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,
)
}
}
@@ -4,10 +4,21 @@ 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(darkTheme = false) { Text("截图基线") }
ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("截图基线")
}
}
@@ -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,116 @@
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(ABoxThemeDefaults.lightColors, light.colorTokens)
assertEquals(ABoxThemeDefaults.darkColors, dark.colorTokens)
}
@Test
fun dynamicColorDisabledUsesBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.lightColors, resolution.colorTokens)
}
@Test
fun pre31DynamicColorFallsBackToBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = true,
),
systemInDarkTheme = false,
sdkInt = 30,
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.darkColors, 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),
)
}
}
+2
View File
@@ -15,6 +15,7 @@ adaptive = "1.3.0"
adaptiveNavigationSuite = "1.4.0"
window = "1.5.1"
composeScreenshot = "0.0.1-alpha16"
miuix = "0.9.3"
junit = "4.13.2"
androidxJunit = "1.2.1"
espresso = "3.6.1"
@@ -40,6 +41,7 @@ compose-material-icons-extended = { module = "androidx.compose.material:material
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" }
miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }