feat(ui): complete app experience redesign

This commit is contained in:
2026-08-27 11:45:30 +08:00
parent 816606234b
commit 54aa74126f
70 changed files with 4510 additions and 985 deletions
@@ -17,6 +17,7 @@ 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
@@ -476,13 +477,19 @@ 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().padding(horizontal = 8.dp, vertical = 8.dp),
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ABoxIconButton(
@@ -490,11 +497,38 @@ fun ABoxDeviceDetailScaffold(
contentDescription = "返回",
onClick = onBack,
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp),
)
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,
@@ -12,6 +12,8 @@ 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
@@ -20,6 +22,7 @@ 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
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
@@ -29,6 +32,9 @@ val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shap
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(
@@ -78,8 +84,14 @@ internal fun ABoxThemeContent(
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,
@@ -87,6 +99,9 @@ internal fun ABoxThemeContent(
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled,
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled,
) {
renderer.Render(
resolution = resolution,
@@ -11,6 +11,11 @@ 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
@@ -51,6 +56,11 @@ enum class ABoxIconKey {
WARNING,
ERROR,
UNKNOWN_DEVICE,
SETTINGS,
THEME,
EFFECTS,
FEEDBACK,
SCALE,
}
/** Design-system-owned device categories; this module does not depend on domain models. */
@@ -94,6 +104,11 @@ object ABoxIconMapper {
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
}
}
@@ -6,10 +6,7 @@ import com.flagship.abox.manager.preferences.DesignRenderer
internal interface ABoxRendererAdapter {
@Composable
fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
)
fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
}
internal object ABoxRendererAdapters {
@@ -13,11 +13,11 @@ internal object MiuixRendererAdapter : ABoxRendererAdapter {
content: @Composable () -> Unit,
) {
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
MiuixTheme(
colors = colors,
) {
Material3RendererAdapter.Render(
resolution = resolution,
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,
)
}
@@ -5,6 +5,7 @@ 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.AccentPreference
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@@ -121,7 +122,7 @@ object ABoxThemeResolver {
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
dynamicColorTokens
} else {
brandTokens
brandTokens.withAccent(preferences.accentPreference, isDarkTheme)
}
val shapeTokens = when (preferences.renderer) {
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
@@ -137,3 +138,14 @@ object ABoxThemeResolver {
)
}
}
private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens {
val selected = when (accent) {
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D)
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B)
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800)
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59)
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2)
}
return copy(primary = selected, deviceSocket = selected)
}
@@ -16,4 +16,7 @@ interface ConfiguredDeviceDao {
@Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName")
suspend fun remove(profileId: String, deviceName: String): Int
@Query("UPDATE configured_devices SET id = :newId, deviceName = :newName WHERE profileId = :profileId AND deviceName = :oldName")
suspend fun rename(profileId: String, oldName: String, newName: String, newId: String): Int
}
@@ -15,6 +15,8 @@ interface ConfiguredDeviceRepository {
fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>>
suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor>
suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> =
DomainResult.Failure(DomainError.InvalidRequest(detail = "Device rename is not supported"))
}
class RoomConfiguredDeviceRepository(
@@ -44,6 +46,17 @@ class RoomConfiguredDeviceRepository(
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device"))
}
override suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> {
if (newName.value.isBlank()) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name is required"))
return try {
val changed = dao.rename(profileId.value, oldName.value, newName.value, "${profileId.value}:${newName.value}")
if (changed == 1) DomainResult.Success(Unit)
else DomainResult.Failure(DomainError.InvalidRequest(detail = "Device was not found"))
} catch (error: Exception) {
DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name"))
}
}
}
private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor(
@@ -64,6 +64,16 @@ class DataStoreUiPreferencesRepository internal constructor(
navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD)
?: NavigationPresentation.STANDARD,
hapticsEnabled = preferences[HAPTICS] ?: true,
interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD)
?: InterfaceScale.STANDARD,
accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE)
?: AccentPreference.BLUE,
colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT)
?: ColorStyle.TONAL_SPOT,
colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021)
?: ColorStandard.SPEC_2021,
liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false,
predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false,
)
private fun MutablePreferences.putAll(preferences: UiPreferences) {
@@ -73,6 +83,12 @@ class DataStoreUiPreferencesRepository internal constructor(
this[BLUR] = preferences.blurEnabled
this[NAVIGATION] = preferences.navigationPresentation.name
this[HAPTICS] = preferences.hapticsEnabled
this[INTERFACE_SCALE] = preferences.interfaceScale.name
this[ACCENT] = preferences.accentPreference.name
this[COLOR_STYLE] = preferences.colorStyle.name
this[COLOR_STANDARD] = preferences.colorStandard.name
this[LIQUID_GLASS] = preferences.liquidGlassEnabled
this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled
}
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
@@ -89,5 +105,11 @@ class DataStoreUiPreferencesRepository internal constructor(
val BLUR = booleanPreferencesKey("blur")
val NAVIGATION = stringPreferencesKey("navigation")
val HAPTICS = booleanPreferencesKey("haptics")
val INTERFACE_SCALE = stringPreferencesKey("interface_scale")
val ACCENT = stringPreferencesKey("accent")
val COLOR_STYLE = stringPreferencesKey("color_style")
val COLOR_STANDARD = stringPreferencesKey("color_standard")
val LIQUID_GLASS = booleanPreferencesKey("liquid_glass")
val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back")
}
}
@@ -16,6 +16,31 @@ enum class NavigationPresentation {
FLOATING,
}
enum class InterfaceScale(val percent: Int) {
COMPACT(85),
STANDARD(100),
LARGE(115),
}
enum class AccentPreference {
BLUE,
GREEN,
AMBER,
ROSE,
VIOLET,
}
enum class ColorStyle {
TONAL_SPOT,
EXPRESSIVE,
FIDELITY,
}
enum class ColorStandard {
SPEC_2021,
SPEC_2025,
}
enum class PreferenceField {
RENDERER,
THEME_MODE,
@@ -23,6 +48,12 @@ enum class PreferenceField {
BLUR,
NAVIGATION,
HAPTICS,
INTERFACE_SCALE,
ACCENT,
COLOR_STYLE,
COLOR_STANDARD,
LIQUID_GLASS,
PREDICTIVE_BACK,
}
data class UiPreferences(
@@ -32,4 +63,10 @@ data class UiPreferences(
val blurEnabled: Boolean = false,
val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD,
val hapticsEnabled: Boolean = true,
val interfaceScale: InterfaceScale = InterfaceScale.STANDARD,
val accentPreference: AccentPreference = AccentPreference.BLUE,
val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT,
val colorStandard: ColorStandard = ColorStandard.SPEC_2021,
val liquidGlassEnabled: Boolean = false,
val predictiveBackEnabled: Boolean = false,
)
@@ -45,6 +45,24 @@ class DataStoreUiPreferencesRepositoryTest {
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(hapticsEnabled = false)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(interfaceScale = InterfaceScale.LARGE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(accentPreference = AccentPreference.GREEN)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStyle = ColorStyle.EXPRESSIVE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStandard = ColorStandard.SPEC_2025)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(liquidGlassEnabled = true)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(predictiveBackEnabled = false)
})
assertEquals(
UiPreferences(
@@ -54,6 +72,12 @@ class DataStoreUiPreferencesRepositoryTest {
blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false,
interfaceScale = InterfaceScale.LARGE,
accentPreference = AccentPreference.GREEN,
colorStyle = ColorStyle.EXPRESSIVE,
colorStandard = ColorStandard.SPEC_2025,
liquidGlassEnabled = true,
predictiveBackEnabled = false,
),
repository.observe().first(),
)