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
@@ -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)
|
||||
Reference in new issue
Block a user