feat(ui): complete app experience redesign
This commit is contained in:
+40
-6
@@ -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,
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+22
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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,
|
||||
)
|
||||
|
||||
+24
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user