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:
M57 authored and GitHub committed 2026-06-18 16:34:44 +03:00
1 parent f833005bec
commit d90e36974c
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,
)
}
}