feat: Material You design system rewrite — Phases 1–4
Complete UI/UX rewrite of hermes-mobile with Material You design system. Phase 1: Color/Theme/Type/Shapes/Spacing/Motion tokens Phase 2: HermesScaffold, StateViews (skeleton/empty/error), SearchBar, StatusBadge, ToggleRow Phase 3: Bottom nav + drawer navigation with NavigationController Phase 4: All 17 screens rewritten with HermesScaffold and Material You patterns Closes PR #3.
This commit is contained in:
29 files changed
+2669
-2377
No files matched your search
@@ -2,17 +2,16 @@ package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Hermes Control brand palette — v2 (modern redesign).
|
||||
*
|
||||
* Design intent:
|
||||
* - Primary (voltage purple) is the single accent; surfaces stay calm.
|
||||
* - Secondary (plasma amber) is reserved for opt-in highlights only.
|
||||
* - Surfaces use a 3-step elevation ladder with a slight blue tint so dark
|
||||
* mode no longer reads as flat black.
|
||||
* - Semantic colors (success/warning/error/info) share the same saturation
|
||||
* band so they don't fight the brand.
|
||||
*/
|
||||
// Hermes Control brand palette — v3 (Material You rewrite).
|
||||
//
|
||||
// On Android 12+ (API 31+) the app uses dynamic colour derived from the user's
|
||||
// wallpaper — this palette is NOT used for primary/surface roles on those
|
||||
// devices (see HermesControlTheme). On API 26–30 it serves as the full colour
|
||||
// scheme. Semantic status colours and chat-bubble colours are ALWAYS
|
||||
// brand-defined. All pairs verified for WCAG AA contrast (4.5:1 text, 3:1
|
||||
// large text / icons).
|
||||
|
||||
// ── Brand: Voltage Purple (primary) ──────────────────────────────────────
|
||||
|
||||
val HermesPurple = Color(0xFF7C5CFF)
|
||||
val HermesPurpleLight = Color(0xFFAC93FF)
|
||||
@@ -20,27 +19,45 @@ val HermesPurpleDark = Color(0xFF5A3FE0)
|
||||
val HermesPurpleContainer = Color(0xFF2B2159)
|
||||
val HermesPurpleOnContainer = Color(0xFFD9CCFF)
|
||||
|
||||
// ── Brand: Plasma Amber (secondary) ─────────────────────────────────────
|
||||
|
||||
val HermesAmber = Color(0xFFFFB627)
|
||||
val HermesAmberLight = Color(0xFFFFE082)
|
||||
val HermesAmberDark = Color(0xFFC68400)
|
||||
|
||||
// ── Dark surfaces (5-step elevation ladder) ─────────────────────────────
|
||||
|
||||
val DarkBackground = Color(0xFF0B0B11)
|
||||
val DarkSurface = Color(0xFF121218)
|
||||
val DarkSurfaceVariant = Color(0xFF1C1C26)
|
||||
val DarkSurfaceHigh = Color(0xFF262633)
|
||||
val DarkSurfaceContainer = Color(0xFF1C1C26)
|
||||
val DarkSurfaceContainerHigh = Color(0xFF262633)
|
||||
val DarkSurfaceContainerHighest = Color(0xFF30303D)
|
||||
val DarkOnSurface = Color(0xFFE8E6EE)
|
||||
val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
|
||||
val DarkOutline = Color(0xFF4A4A5A)
|
||||
val DarkOutlineVariant = Color(0xFF2F2F3D)
|
||||
val DarkInverseSurface = Color(0xFFE8E6EE)
|
||||
val DarkInverseOnSurface = Color(0xFF1A1A24)
|
||||
val DarkScrim = Color(0xFF000000)
|
||||
|
||||
// ── Light surfaces (5-step elevation ladder) ────────────────────────────
|
||||
|
||||
val LightBackground = Color(0xFFF7F6FB)
|
||||
val LightSurface = Color(0xFFFFFFFF)
|
||||
val LightSurfaceVariant = Color(0xFFEFEDF4)
|
||||
val LightSurfaceHigh = Color(0xFFE6E3EE)
|
||||
val LightSurfaceContainer = Color(0xFFEFEDF4)
|
||||
val LightSurfaceContainerHigh = Color(0xFFE6E3EE)
|
||||
val LightSurfaceContainerHighest = Color(0xFFDDD9EA)
|
||||
val LightOnSurface = Color(0xFF1A1A24)
|
||||
val LightOnSurfaceVariant = Color(0xFF56536A)
|
||||
val LightOutline = Color(0xFF79748E)
|
||||
val LightOutlineVariant = Color(0xFFC9C5D6)
|
||||
val LightInverseSurface = Color(0xFF1A1A24)
|
||||
val LightInverseOnSurface = Color(0xFFE8E6EE)
|
||||
val LightScrim = Color(0xFF000000)
|
||||
|
||||
// ── Semantic status colours (always brand-defined) ──────────────────────
|
||||
|
||||
val StatusGreen = Color(0xFF3DDC84)
|
||||
val StatusGreenContainer = Color(0xFF143A23)
|
||||
@@ -51,6 +68,8 @@ val StatusYellowContainer = Color(0xFF3D2F0F)
|
||||
val StatusBlue = Color(0xFF4DA8FF)
|
||||
val StatusBlueContainer = Color(0xFF0F2A3D)
|
||||
|
||||
// ── Chat-specific colours (always brand-defined for consistency) ───────
|
||||
|
||||
val UserBubble = HermesPurple
|
||||
val UserBubbleDark = HermesPurpleDark
|
||||
val AssistantBubble = Color(0xFF1C1C26)
|
||||
@@ -58,3 +77,10 @@ val AssistantBubbleLight = Color(0xFFEDEAF4)
|
||||
val SystemMessageColor = Color(0xFF8B879A)
|
||||
val ToolChipColor = Color(0xFF262633)
|
||||
val ToolChipColorLight = Color(0xFFE6E3EE)
|
||||
|
||||
// ── Skeleton shimmer colours ───────────────────────────────────────────
|
||||
|
||||
val SkeletonBase = Color(0xFF1C1C26)
|
||||
val SkeletonHighlight = Color(0xFF2F2F3D)
|
||||
val SkeletonBaseLight = Color(0xFFE6E3EE)
|
||||
val SkeletonHighlightLight = Color(0xFFEFEDF4)
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Semantic status colour set — always brand-defined (never dynamic).
|
||||
*
|
||||
* Material You changes the *primary* / *surface* palette based on the user's
|
||||
* wallpaper, but success / warning / error / info colours must stay
|
||||
* semantically correct (green = good, red = bad) regardless of what the
|
||||
* wallpaper-derived palette would choose. This struct is provided via
|
||||
* [LocalHermesStatusColors] and should be used for all status indicators,
|
||||
* badges, and semantic feedback colours.
|
||||
*/
|
||||
data class HermesStatusColors(
|
||||
val success: Color,
|
||||
val successContainer: Color,
|
||||
val warning: Color,
|
||||
val warningContainer: Color,
|
||||
val error: Color,
|
||||
val errorContainer: Color,
|
||||
val info: Color,
|
||||
val infoContainer: Color,
|
||||
)
|
||||
|
||||
val LocalHermesStatusColors =
|
||||
staticCompositionLocalOf {
|
||||
HermesStatusColors(
|
||||
success = StatusGreen,
|
||||
successContainer = StatusGreenContainer,
|
||||
warning = StatusYellow,
|
||||
warningContainer = StatusYellowContainer,
|
||||
error = StatusRed,
|
||||
errorContainer = StatusRedContainer,
|
||||
info = StatusBlue,
|
||||
infoContainer = StatusBlueContainer,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/**
|
||||
* Motion tokens — duration, easing, and spring specs.
|
||||
*
|
||||
* Design intent (ui-ux-pro-max §7):
|
||||
* - Micro-interactions: 150–300 ms
|
||||
* - Complex transitions: ≤ 400 ms
|
||||
* - Never exceed 500 ms
|
||||
* - Enter: ease-out (decelerated), Exit: ease-in (accelerated)
|
||||
* - Exit shorter than enter (~60–70 %)
|
||||
* - Spring physics for interactive elements
|
||||
* - All animations must be interruptible
|
||||
*
|
||||
* Access via `LocalMotion.current` in composables.
|
||||
*/
|
||||
object Motion {
|
||||
// ── Duration tokens (ms) ─────────────────────────────────────────
|
||||
|
||||
/** Instant — state changes with no perceptible delay. */
|
||||
const val INSTANT = 0
|
||||
|
||||
/** Fast — micro-interactions, ripples, opacity toggles. */
|
||||
const val FAST = 150
|
||||
|
||||
/** Normal — standard UI transitions, sheet slides, expansion. */
|
||||
const val NORMAL = 250
|
||||
|
||||
/** Slow — complex multi-element transitions, screen navigation. */
|
||||
const val SLOW = 400
|
||||
|
||||
// ── Easing curves ────────────────────────────────────────────────
|
||||
|
||||
/** Emphasized decelerate — for ENTERING elements (ease-out). */
|
||||
val emphasizedDecelerate: Easing =
|
||||
CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
|
||||
|
||||
/** Emphasized accelerate — for EXITING elements (ease-in). */
|
||||
val emphasizedAccelerate: Easing =
|
||||
CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
|
||||
|
||||
/** Standard — default M3 easing for neutral transitions. */
|
||||
val standard: Easing = FastOutSlowInEasing
|
||||
|
||||
// ── Spring specs ─────────────────────────────────────────────────
|
||||
|
||||
/** Bouncy spring for interactive elements (press, release, drag). */
|
||||
fun <T> bouncySpring(): SpringSpec<T> =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
)
|
||||
|
||||
/** Snappy spring for state toggles (switch, selection). */
|
||||
fun <T> snappySpring(): SpringSpec<T> =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessHigh,
|
||||
)
|
||||
|
||||
/** Gentle spring for layout animations (expand, collapse). */
|
||||
fun <T> gentleSpring(): SpringSpec<T> =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
)
|
||||
}
|
||||
|
||||
val MotionDefaults = Motion
|
||||
|
||||
val LocalMotion = staticCompositionLocalOf { Motion }
|
||||
@@ -5,19 +5,21 @@ import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Unified shape system for Hermes Control.
|
||||
* Unified shape system — aligned to 8 dp rhythm (4/8/12/16/28).
|
||||
*
|
||||
* - xs: chips, small badges
|
||||
* - sm: text fields, small cards
|
||||
* - md: standard cards, list rows
|
||||
* - lg: large cards, bottom sheets
|
||||
* - xl: feature cards, full-bleed surfaces
|
||||
* | Token | Radius | Use case |
|
||||
* |--------------|--------|---------------------------------------|
|
||||
* | extraSmall | 4 dp | Chips, badges, inline toggles |
|
||||
* | small | 8 dp | Text fields, small cards, small FAB |
|
||||
* | medium | 12 dp | Standard cards, list rows, dialogs |
|
||||
* | large | 16 dp | Large cards, bottom sheets, FAB |
|
||||
* | extraLarge | 28 dp | Feature cards, full-bleed surfaces |
|
||||
*/
|
||||
val HermesShapes =
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(10.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(24.dp),
|
||||
extraLarge = RoundedCornerShape(32.dp),
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(28.dp),
|
||||
)
|
||||
@@ -1,24 +1,32 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Spacing tokens — use these instead of ad-hoc dp values so every screen
|
||||
* shares the same rhythm.
|
||||
* Spacing tokens — the 8 dp rhythm that every screen shares.
|
||||
*
|
||||
* Convention (Material 3 spacing scale, condensed):
|
||||
* xs = 4 hairline / chip gaps
|
||||
* sm = 8 default inner padding
|
||||
* md = 12 card content
|
||||
* lg = 16 screen edge, section gaps
|
||||
* xl = 24 hero spacing
|
||||
* xxl = 32 full-feature spacing
|
||||
* | Token | Value | Use case |
|
||||
* |-------|--------|-------------------------------------------|
|
||||
* | xs | 4 dp | Hairline gaps, chip inner padding |
|
||||
* | sm | 8 dp | Default inner padding, icon-to-text gap |
|
||||
* | md | 16 dp | Card content, screen edge, section gaps |
|
||||
* | lg | 24 dp | Hero spacing, between major sections |
|
||||
* | xl | 32 dp | Full-feature spacing, large section break |
|
||||
* | xxl | 48 dp | Maximum spacing, rare |
|
||||
*
|
||||
* Access in composables via `LocalSpacing.current` or the [Spacing] object.
|
||||
*/
|
||||
object Spacing {
|
||||
val xs = 4.dp
|
||||
val sm = 8.dp
|
||||
val md = 12.dp
|
||||
val lg = 16.dp
|
||||
val xl = 24.dp
|
||||
val xxl = 32.dp
|
||||
val md = 16.dp
|
||||
val lg = 24.dp
|
||||
val xl = 32.dp
|
||||
val xxl = 48.dp
|
||||
}
|
||||
|
||||
/** Alias for backwards-compatibility — existing `Spacing.xs` calls still work. */
|
||||
val SpacingDefaults = Spacing
|
||||
|
||||
val LocalSpacing = staticCompositionLocalOf { Spacing }
|
||||
@@ -8,6 +8,7 @@ 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.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -18,9 +19,17 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||
|
||||
/**
|
||||
* Set to true to opt into Material You dynamic color (Android 12+).
|
||||
* Off by default — brand identity (Voltage Purple) is the priority.
|
||||
*
|
||||
* On API 31+ the app derives its entire colour scheme from the user's
|
||||
* wallpaper — primary, secondary, tertiary, surfaces, the lot. On older
|
||||
* devices the [HermesDarkColorScheme] / [HermesLightColorScheme] brand
|
||||
* palette is used instead.
|
||||
*
|
||||
* Semantic status colours (success / warning / error / info) and chat-bubble
|
||||
* colours are ALWAYS brand-defined regardless of dynamic colour — see
|
||||
* [Color.kt] and the `LocalHermesStatusColors` provider below.
|
||||
*/
|
||||
private const val ENABLE_DYNAMIC_COLOR = false
|
||||
private const val ENABLE_DYNAMIC_COLOR = true
|
||||
|
||||
private val HermesDarkColorScheme =
|
||||
darkColorScheme(
|
||||
@@ -33,6 +42,7 @@ private val HermesDarkColorScheme =
|
||||
secondaryContainer = Color(0xFF3D2F0F),
|
||||
onSecondaryContainer = HermesAmberLight,
|
||||
tertiary = HermesAmberLight,
|
||||
onTertiary = Color.Black,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnSurface,
|
||||
surface = DarkSurface,
|
||||
@@ -40,17 +50,18 @@ private val HermesDarkColorScheme =
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
surfaceTint = HermesPurple,
|
||||
surfaceContainer = DarkSurfaceVariant,
|
||||
surfaceContainerHigh = DarkSurfaceHigh,
|
||||
surfaceContainerHighest = DarkSurfaceHigh,
|
||||
inverseSurface = Color(0xFFE8E6EE),
|
||||
inverseOnSurface = Color(0xFF1A1A24),
|
||||
surfaceContainer = DarkSurfaceContainer,
|
||||
surfaceContainerHigh = DarkSurfaceContainerHigh,
|
||||
surfaceContainerHighest = DarkSurfaceContainerHighest,
|
||||
inverseSurface = DarkInverseSurface,
|
||||
inverseOnSurface = DarkInverseOnSurface,
|
||||
error = StatusRed,
|
||||
onError = Color.White,
|
||||
errorContainer = StatusRedContainer,
|
||||
onErrorContainer = Color(0xFFFFB4B4),
|
||||
outline = DarkOutline,
|
||||
outlineVariant = DarkOutlineVariant,
|
||||
scrim = DarkScrim,
|
||||
)
|
||||
|
||||
private val HermesLightColorScheme =
|
||||
@@ -64,6 +75,7 @@ private val HermesLightColorScheme =
|
||||
secondaryContainer = HermesAmberLight,
|
||||
onSecondaryContainer = Color(0xFF3D2F0F),
|
||||
tertiary = HermesAmberDark,
|
||||
onTertiary = Color.White,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnSurface,
|
||||
surface = LightSurface,
|
||||
@@ -71,17 +83,18 @@ private val HermesLightColorScheme =
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
surfaceTint = HermesPurple,
|
||||
surfaceContainer = LightSurfaceVariant,
|
||||
surfaceContainerHigh = LightSurfaceHigh,
|
||||
surfaceContainerHighest = LightSurfaceHigh,
|
||||
inverseSurface = Color(0xFF1A1A24),
|
||||
inverseOnSurface = Color(0xFFE8E6EE),
|
||||
surfaceContainer = LightSurfaceContainer,
|
||||
surfaceContainerHigh = LightSurfaceContainerHigh,
|
||||
surfaceContainerHighest = LightSurfaceContainerHighest,
|
||||
inverseSurface = LightInverseSurface,
|
||||
inverseOnSurface = LightInverseOnSurface,
|
||||
error = Color(0xFFB3261E),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF410E0B),
|
||||
outline = LightOutline,
|
||||
outlineVariant = LightOutlineVariant,
|
||||
scrim = LightScrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -107,10 +120,41 @@ fun HermesControlTheme(
|
||||
else -> HermesLightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = HermesShapes,
|
||||
content = content,
|
||||
)
|
||||
val statusColors =
|
||||
if (darkTheme) {
|
||||
HermesStatusColors(
|
||||
success = StatusGreen,
|
||||
successContainer = StatusGreenContainer,
|
||||
warning = StatusYellow,
|
||||
warningContainer = StatusYellowContainer,
|
||||
error = StatusRed,
|
||||
errorContainer = StatusRedContainer,
|
||||
info = StatusBlue,
|
||||
infoContainer = StatusBlueContainer,
|
||||
)
|
||||
} else {
|
||||
HermesStatusColors(
|
||||
success = Color(0xFF1B873A),
|
||||
successContainer = Color(0xFFD7F5E0),
|
||||
warning = HermesAmberDark,
|
||||
warningContainer = Color(0xFFFFEAB3),
|
||||
error = Color(0xFFB3261E),
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
info = Color(0xFF2E6FBD),
|
||||
infoContainer = Color(0xFFD4E7FF),
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalHermesStatusColors provides statusColors,
|
||||
LocalSpacing provides SpacingDefaults,
|
||||
LocalMotion provides MotionDefaults,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = HermesShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user