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

@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -17,14 +18,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
/**
* Shared Scaffold wrapper that standardizes the drawer-aware TopAppBar.
* Shared Scaffold wrapper that standardizes the TopAppBar.
*
* Eliminates the per-screen duplication of:
* - Menu icon (drawer toggle)
* - Title
* - Optional Refresh / actions slot
* Improvements (v2):
* - Scroll-aware top bar (pinned collapse on scroll)
* - Uses Spacing tokens internally
* - Simplified API: onOpenDrawer OR showBack (mutually exclusive)
* - Safe-area aware via Scaffold insets
*
* Screens that still need a Back arrow instead of a Menu icon can set
* `showBack = true` and pass an `onBack` lambda.
@@ -38,11 +41,15 @@ fun HermesScaffold(
onBack: (() -> Unit)? = null,
showBack: Boolean = false,
actions: @Composable () -> Unit = {},
content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
when {
showBack && onBack != null -> {
@@ -57,13 +64,12 @@ fun HermesScaffold(
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Open Drawer",
contentDescription = "Open navigation drawer",
)
}
}
}
},
title = { Text(title) },
actions = {
if (onRefresh != null) {
IconButton(onClick = onRefresh) {
@@ -82,6 +88,7 @@ fun HermesScaffold(
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
scrollBehavior = scrollBehavior,
)
},
containerColor = MaterialTheme.colorScheme.background,
@@ -0,0 +1,322 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
// ── SectionHeader — sticky-ish section label for grouped content ────────
@Composable
fun SectionHeader(
title: String,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
trailing?.invoke()
}
}
// ── StatusBadge — coloured pill with optional leading dot ──────────────
@Composable
fun StatusBadge(
text: String,
status: StatusBadgeType = StatusBadgeType.NEUTRAL,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val (bgColor, fgColor) =
when (status) {
StatusBadgeType.SUCCESS -> statusColors.successContainer to statusColors.success
StatusBadgeType.WARNING -> statusColors.warningContainer to statusColors.warning
StatusBadgeType.ERROR -> statusColors.errorContainer to statusColors.error
StatusBadgeType.INFO -> statusColors.infoContainer to statusColors.info
StatusBadgeType.NEUTRAL ->
MaterialTheme.colorScheme.surfaceContainerHigh to
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = bgColor,
) {
Row(
modifier =
Modifier.padding(
horizontal = spacing.sm + 2.dp,
vertical = spacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Box(
modifier =
Modifier
.size(6.dp)
.background(fgColor, RoundedCornerShape(50)),
)
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = fgColor,
fontWeight = FontWeight.Medium,
)
}
}
}
enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL }
// ── ToggleRow — switch row with label + description ────────────────────
@Composable
fun ToggleRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
icon: ImageVector? = null,
) {
val spacing = LocalSpacing.current
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(spacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm + 4.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
}
// ── SearchBar — M3 search field with clear button ──────────────────────
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Search…",
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text(placeholder) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear search",
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
)
}
// ── FilterChipRow — horizontally scrollable filter chips ───────────────
@Composable
fun FilterChipRow(
chips: List<String>,
selectedChip: String?,
onChipSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = spacing.md,
vertical = spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
label = { Text(chip) },
)
}
}
}
// ── StatCard — metric display card for dashboard screens ───────────────
@Composable
fun StatCard(
label: String,
value: String,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
accentColor: Color = MaterialTheme.colorScheme.primary,
) {
val spacing = LocalSpacing.current
Card(
modifier = modifier,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(
modifier = Modifier.padding(spacing.md),
verticalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(20.dp),
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
}
}
// ── InfoRow — label/value row for detail screens ──────────────────────
@Composable
fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -1,33 +1,49 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.theme.Motion
/**
* Unified loading / error / empty placeholders so every list screen
* shares the same visual language.
*/
// Unified loading / error / empty / skeleton placeholders so every screen
// shares the same visual language.
// ── Loading ───────────────────────────────────────────────────────────
@Composable
fun LoadingState(modifier: Modifier = Modifier) {
@@ -43,59 +59,136 @@ fun LoadingState(modifier: Modifier = Modifier) {
}
}
// ── Skeleton Shimmer ──────────────────────────────────────────────────
/**
* Shimmer placeholder for list-based loading states.
* Shows animated placeholder cards while real data loads.
*
* @param itemCount number of skeleton rows to show
*/
@Composable
fun SkeletonList(
itemCount: Int = 6,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 0.7f,
animationSpec =
infiniteRepeatable(
animation = tween(Motion.NORMAL * 2),
repeatMode = RepeatMode.Reverse,
),
label = "shimmerAlpha",
)
val skeletonColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = shimmerAlpha)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = spacing.md, vertical = spacing.sm),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(itemCount) {
SkeletonCard(skeletonColor)
}
}
}
@Composable
private fun SkeletonCard(baseColor: Color) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(baseColor)
.padding(16.dp),
) {
Box(
modifier =
Modifier
.width(180.dp)
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.background(baseColor.copy(alpha = baseColor.alpha + 0.1f)),
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier =
Modifier
.fillMaxWidth(0.7f)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(baseColor.copy(alpha = baseColor.alpha + 0.05f)),
)
}
}
// ── Error ──────────────────────────────────────────────────────────────
@Composable
fun ErrorState(
message: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
.padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "⚠",
fontSize = MaterialTheme.typography.headlineMedium.fontSize,
color = MaterialTheme.colorScheme.error,
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = onRetry) {
Spacer(modifier = Modifier.height(spacing.md))
Button(onClick = onRetry) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Spacer(modifier = Modifier.size(spacing.sm))
Text("Retry")
}
}
}
}
// ── Empty ────────────────────────────────────────────────────────────
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
icon: ImageVector = Icons.Filled.Inbox,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val spacing = LocalSpacing.current
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
.padding(spacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -103,16 +196,16 @@ fun EmptyState(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp),
modifier = Modifier.size(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
@@ -120,12 +213,17 @@ fun EmptyState(
textAlign = TextAlign.Center,
)
}
if (actionLabel != null && onAction != null) {
Spacer(modifier = Modifier.height(spacing.md))
OutlinedButton(onClick = onAction) {
Text(actionLabel)
}
}
}
}
/**
* Padding convention for List screens — outer 16, gaps of 8 between items.
*/
// ── Padding conventions ──────────────────────────────────────────────
val listContentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
val listItemSpacing = Arrangement.spacedBy(8.dp)