| name | ui-ux-design |
| description | UI/UX design patterns and Material 3 best practices for Petit Android app. Use when: creating new screens, building Compose layouts, choosing Material 3 components, designing forms, implementing loading/empty/error states, adding animations and transitions, working with typography hierarchy, spacing and sizing, card and list designs, navigation patterns, visual feedback, color usage, dark theme support, skeleton loaders, confirmation feedback, swipe actions. |
UI/UX Design — Petit Android
Based on Material Design 3 guidelines, Android design patterns, and industry best practices for health tracking apps.
When to Use
- Creating new screens or composables
- Choosing between Material 3 component variants
- Laying out content (spacing, alignment, visual hierarchy)
- Designing forms with good UX
- Implementing loading, empty, and error states
- Adding animations and micro-interactions
- Improving visual polish and user feedback
Design Principles
Based on Material Design 3 core principles:
- Personal — Dynamic Color (Material You) makes the app feel like the user's own
- Adaptive — Layouts adapt to screen size, orientation, and input type
- Expressive — Motion, color, and typography create visual hierarchy without clutter
- Accessible — WCAG AA contrast, 48dp touch targets, semantic structure for TalkBack
App Personality
Petit is a multi-pet health tracker. Following Google's emotional design guidance:
- Warm, not clinical — round shapes, soft containers, friendly copy
- Scannable, not dense — generous whitespace, clear grouping, progressive disclosure
- Reliable, not flashy — consistent patterns, predictable navigation, clear feedback
- Delightful, not distracting — purposeful animations, subtle transitions
No Emojis in UI
Never use emoji characters (Unicode emoji) in layouts, cards, list items, or section headers. Always use Material Icons (Icons.Default.*, Icons.Outlined.*) instead. Emojis render inconsistently across Android versions and manufacturers, cannot be tinted to match the theme, don't adapt to dark mode, and lack proper semantic accessibility.
Text(text = "💉", style = MaterialTheme.typography.headlineMedium)
Text(text = "🔔 UPCOMING", style = MaterialTheme.typography.titleMedium)
Icon(
Icons.Default.Vaccines,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(text = "UPCOMING", style = MaterialTheme.typography.titleMedium)
Exception: Emojis are acceptable in system notifications (NotificationCompat.Builder) where Material Icons are not available and emojis improve glanceability in the notification shade.
Color System
Material 3 Color Roles
Follow the M3 color system. Never hardcode hex values for semantic elements — always use MaterialTheme.colorScheme tokens.
| Role | Use For | NOT For |
|---|
primary | FAB, key buttons, active nav, links | Body text, backgrounds |
primaryContainer | Selected states, emphasis cards | All cards indiscriminately |
secondary / secondaryContainer | Filter chips, secondary info, tags | Primary actions |
tertiary / tertiaryContainer | Complementary accents, category badges | Anything that secondary covers |
error / errorContainer | Validation errors, overdue alerts, destructive actions | Warnings (use custom color) |
surface | Base background | — |
surfaceContainerLowest | Behind scrolling content | — |
surfaceContainerLow | Standard cards | — |
surfaceContainer | App bars, navigation | — |
surfaceContainerHigh | Dialogs, search bars, elevated cards | — |
surfaceContainerHighest | Text fields, highest emphasis | — |
onSurface | Primary text on surface | — |
onSurfaceVariant | Secondary/supporting text, icons | Primary text |
outline | Borders, dividers | — |
outlineVariant | Subtle dividers | Prominent borders |
Dynamic Color (Material You)
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
Color Rules
Text(text, color = MaterialTheme.colorScheme.onSurface)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow))
Text(text, color = Color.Black)
Card(colors = CardDefaults.cardColors(containerColor = Color.White))
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)))
Status/Semantic Colors
Per M3 custom colors guidance, define custom semantic colors alongside the scheme:
val HealthGreen = Color(0xFF2E7D32)
val HealthAmber = Color(0xFFF57F17)
val HealthRed = Color(0xFFC62828)
val HealthGreenDark = Color(0xFF66BB6A)
val HealthAmberDark = Color(0xFFFFCA28)
val HealthRedDark = Color(0xFFEF5350)
Always convey status with icon + text + color (never color alone):
@Composable
fun HealthStatusBadge(status: HealthStatus, modifier: Modifier = Modifier) {
val (icon, color, label) = when (status) {
HealthStatus.OK -> Triple(Icons.Default.CheckCircle, HealthGreen, "Em dia")
HealthStatus.ATTENTION -> Triple(Icons.Default.Schedule, HealthAmber, "Atenção")
HealthStatus.OVERDUE -> Triple(Icons.Default.Warning, HealthRed, "Atrasado")
}
Row(
modifier = modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(14.dp))
Text(label, style = MaterialTheme.typography.labelSmall, color = color, fontWeight = FontWeight.Medium)
}
}
Typography
Material 3 Type Scale
Follow the M3 type scale. Define ALL roles in your Typography() — don't rely on defaults:
| Role | Size/Weight | Petit Usage |
|---|
displayLarge | 57sp / Regular | Not used (reserved for tablets) |
displayMedium | 45sp / Regular | Hero weight numbers ("4.5 kg") |
displaySmall | 36sp / Regular | Dashboard greeting, large stats |
headlineLarge | 32sp / Regular | — |
headlineMedium | 28sp / Regular | Screen titles in expanded top bars |
headlineSmall | 24sp / Regular | Section headers, prominent card titles |
titleLarge | 22sp / Regular | Top app bar title, dialog titles |
titleMedium | 16sp / Medium | Card titles, list item primary text |
titleSmall | 14sp / Medium | Sub-section headers, tab labels |
bodyLarge | 16sp / Regular | Primary body text |
bodyMedium | 14sp / Regular | Secondary body text, descriptions |
bodySmall | 12sp / Regular | Captions, timestamps, supporting text |
labelLarge | 14sp / Medium | Button text |
labelMedium | 12sp / Medium | Form labels, chip text, navigation labels |
labelSmall | 11sp / Medium | Badges, footnotes, helper text |
Hierarchy Rules
Per M3 typography guidance:
- Use at least 3 distinct type styles per screen to create visual hierarchy
- Never use raw
fontSize — always use MaterialTheme.typography.*
- Use
fontWeight for emphasis within a style, not a different style
- Use
onSurfaceVariant for secondary text — not a lighter font weight
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Luna", style = MaterialTheme.typography.titleMedium)
Text("Fêmea • 2 anos", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Peso: 4.5 kg", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Column {
Text("Luna", fontSize = 16.sp)
Text("Fêmea • 2 anos", fontSize = 14.sp)
Text("Peso: 4.5 kg", fontSize = 12.sp, color = Color.Gray)
}
Spacing & Layout
8dp Grid System
Follow the M3 spacing system:
| Value | Name | Usage |
|---|
| 4.dp | Extra-small | Icon-to-label gap, tight inline spacing |
| 8.dp | Small | Between related items in a group, chip gaps |
| 12.dp | Medium-small | Inner card padding (compact), list divider insets |
| 16.dp | Medium | Default — screen padding, card padding, list gaps |
| 24.dp | Large | Between sections, generous card padding |
| 32.dp | Extra-large | Empty state padding, major visual breaks |
| 48.dp | 2x-large | Top/bottom page margins on sparse screens |
Screen Padding Standards
val ScreenHorizontalPadding = 16.dp
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 88.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
)
Card Internal Layout
Per M3 card anatomy:
Card(shape = RoundedCornerShape(12.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(8.dp))
}
}
Canonical Shape Values
Per M3 shape system:
| Shape | Value | Usage |
|---|
| None | 0.dp | — |
| Extra-small | 4.dp | Badges, tooltips |
| Small | 8.dp | Chips, small buttons |
| Medium | 12.dp | Cards, text fields, dialogs |
| Large | 16.dp | FAB, navigation drawers |
| Extra-large | 28.dp | Bottom sheets, large FABs |
| Full | 50% | Avatar circles, pills |
val CardShape = RoundedCornerShape(12.dp)
val TextFieldShape = RoundedCornerShape(12.dp)
val ChipShape = RoundedCornerShape(8.dp)
val AvatarShape = CircleShape
val BottomSheetShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
val FABShape = RoundedCornerShape(16.dp)
Component Selection
| Component | Role | Usage Rule |
|---|
Button (Filled) | Highest emphasis — final action on screen | Max 1 per visible area. "Salvar", "Confirmar" |
FilledTonalButton | Medium emphasis — important but not primary | "Ver todos", "Filtrar" |
OutlinedButton | Medium emphasis — alternative/back action | "Cancelar", "Voltar" |
TextButton | Lowest emphasis — tertiary, inline | Dialog dismiss, "Pular", "Saiba mais" |
IconButton | Icon-only action | Toolbar buttons, inline row actions |
FloatingActionButton | Primary creation/action for the screen | 1 per screen maximum |
ExtendedFloatingActionButton | FAB with label when action isn't obvious | Use if icon alone is ambiguous |
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = onSave, modifier = Modifier.weight(1f)) {
Text("Salvar")
}
}
| Variant | Use For | Surface |
|---|
Card (Filled) | Primary content: pet profile, health summary | surfaceContainerHighest |
ElevatedCard | Prominent call-out: dashboard highlight, stat | Elevated surfaceContainerLow |
OutlinedCard | Sequential lists: history entries, timeline items | surface with outline |
| Variant | Use For |
|---|
TopAppBar (Small) | Standard inner screens with title |
MediumTopAppBar | Detail screens with collapsible title |
LargeTopAppBar | Dashboard/home with prominent greeting |
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text("Luna") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Voltar") } },
scrollBehavior = scrollBehavior,
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { }
Rules:
- 3-5 destinations only (M3 guideline)
- Use filled icons for active, outlined for inactive (M3 standard)
- Always show labels — icon-only is discouraged by M3
- Persist across screens — only hide for immersive/full-screen flows
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.DeleteForever, contentDescription = null) },
title = { Text("Excluir ${catName}?") },
text = { Text("Esta ação é irreversível. Todos os registros de saúde serão removidos.") },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Excluir") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancelar") }
},
)
Rules per M3:
- Dismiss button always on the left, confirm on the right
- Destructive confirm uses
error color
- Icon for context (optional but recommended for destructive actions)
- Title: concise question. Text: explains consequences
val snackbarHostState = remember { SnackbarHostState() }
snackbarHostState.showSnackbar("Pet salvo com sucesso", duration = SnackbarDuration.Short)
val result = snackbarHostState.showSnackbar(
message = "Registro excluído",
actionLabel = "Desfazer",
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) { viewModel.undoDelete() }
Rules:
- Short (4s) for confirmations: "Salvo", "Enviado"
- Long (10s) for actions with undo: "Excluído" + "Desfazer"
- Never Snackbar for errors — use inline error or full-screen error state
- Max 1 Snackbar at a time — they queue automatically
Screen Patterns
List Screen (Master)
Per M3 Lists guidance:
Scaffold(
topBar = { TopAppBar(title = { Text("Meus Pets") }) },
floatingActionButton = {
ExtendedFloatingActionButton(onClick = onAdd, icon = { Icon(Icons.Default.Add, null) }, text = { Text("Novo Pet") })
}
) { padding ->
when {
isLoading -> SkeletonList(modifier = Modifier.padding(padding))
cats.isEmpty() -> EmptyState(modifier = Modifier.padding(padding), )
else -> LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(pets, key = { it.id }) { pet -> PetCard(pet, onClick = { onPetClick(pet.id) }) }
}
}
}
Detail Screen
Per M3 layout guidelines — use vertical scrolling with clear sections:
Scaffold(
topBar = {
MediumTopAppBar(
title = { Text(pet.name) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Voltar") } },
actions = { IconButton(onClick = onEdit) { Icon(Icons.Default.Edit, "Editar") } },
scrollBehavior = scrollBehavior,
)
}
) { padding ->
Column(
modifier = Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
}
}
Form Screen
Per M3 text field guidelines and form design best practices:
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) "Editar Pet" else "Novo Pet") },
navigationIcon = { IconButton(onClick = onClose) { Icon(Icons.Default.Close, "Fechar") } },
actions = { TextButton(onClick = onSave, enabled = isValid) { Text("Salvar") } },
)
}
) { padding ->
Column(
modifier = Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
FormSection("Informações Básicas") { }
FormSection("Aparência") { }
FormSection("Identificação") { }
FormSection("Observações") { }
}
}
Dashboard / Home
Per M3 canonical layouts:
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
}
State Handling
Loading — Skeleton Screens
Per industry best practice — content-shaped placeholders reduce perceived wait time vs. spinners:
@Composable
fun SkeletonCard(modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Box(Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest))
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
Box(Modifier.fillMaxWidth(0.6f).height(16.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest))
Box(Modifier.fillMaxWidth(0.4f).height(12.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest))
}
}
}
}
@Composable
fun SkeletonList(modifier: Modifier = Modifier, count: Int = 4) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(count) { SkeletonCard() }
}
}
When to use which:
| Pattern | When |
|---|
| Skeleton/shimmer | Initial page load, switching tabs/categories |
CircularProgressIndicator | Form submit, short operations (<2s) |
LinearProgressIndicator | Upload/download, measurable progress |
| Pull-to-refresh spinner | User-triggered refresh on existing content |
Empty State
Per Material guidelines for empty states:
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
description: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
Spacer(Modifier.height(24.dp))
Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp))
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
Spacer(Modifier.height(32.dp))
FilledTonalButton(onClick = onAction) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(actionLabel)
}
}
}
Error State
@Composable
fun ErrorState(message: String, onRetry: (() -> Unit)? = null, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Outlined.ErrorOutline, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
if (onRetry != null) {
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onRetry) { Text("Tentar novamente") }
}
}
}
Animations & Motion
M3 Motion Principles
Per M3 motion system:
- Informative — motion shows spatial relationships and result of actions
- Focused — draws attention to what matters without creating distraction
- Expressive — celebrates moments (success, achievement, delight)
M3 Duration & Easing Tokens
val DurationShort1 = 50
val DurationShort2 = 100
val DurationMedium1 = 200
val DurationMedium2 = 300
val DurationLong1 = 450
val DurationLong2 = 500
val EasingEmphasized = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val EasingEmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
val EasingEmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)
val EasingStandard = CubicBezierEasing(0.2f, 0f, 0f, 1f)
Navigation Transitions
Per M3 navigation transitions:
enterTransition = {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 300, easing = EasingEmphasizedDecelerate)
) + fadeIn(animationSpec = tween(150))
}
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 250, easing = EasingEmphasizedAccelerate)
) + fadeOut(animationSpec = tween(100))
}
Content Transitions
LazyColumn {
itemsIndexed(items) { index, item ->
val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
AnimatedVisibility(
visibleState = visibleState,
enter = fadeIn(tween(200, delayMillis = index * 40)) + slideInVertically(initialOffsetY = { 24 }),
) {
ItemCard(item)
}
}
}
Shared Element Concepts
Column(modifier = Modifier.animateContentSize(animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow))) {
HeaderRow(onClick = { expanded = !expanded })
if (expanded) {
DetailContent()
}
}
Animated Counters
val animatedWeight by animateFloatAsState(
targetValue = weightKg,
animationSpec = tween(600, easing = FastOutSlowInEasing),
)
Text(
text = "%.1f".format(animatedWeight),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
)
Text("kg", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
FAB Motion
Per M3 FAB guidelines:
val rotation by animateFloatAsState(if (expanded) 45f else 0f, animationSpec = tween(200))
FloatingActionButton(onClick = { expanded = !expanded }) {
Icon(Icons.Default.Add, contentDescription = "Ações rápidas", modifier = Modifier.rotate(rotation))
}
Form Design
Use OutlinedTextField (not filled) for forms with multiple fields — it provides clearer field boundaries.
Field Grouping
Group related fields with clear sections (per form design best practices):
@Composable
fun FormSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
content()
}
}
Field Ordering (UX standard)
- Visual fields (photo picker)
- Required fields — most important first (
name)
- Frequently used fields (
birthDate, sex)
- Optional fields (
breed, color, microchip)
- Free text last (
notes)
Keyboard Optimization
Per Android input docs:
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done)
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Default)
Inline Validation
Per M3 text field states:
OutlinedTextField(
value = name,
onValueChange = { onNameChange(it) },
label = { Text("Nome do gato") },
placeholder = { Text("Ex: Luna") },
isError = nameError != null,
supportingText = {
if (nameError != null) {
Text(nameError, color = MaterialTheme.colorScheme.error)
} else if (name.length > 40) {
Text("${name.length}/50")
}
},
leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) },
shape = RoundedCornerShape(12.dp),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Rules:
- Show errors after first interaction (blur or submit), not immediately
supportingText for hints, counter, or errors — never use a separate Text() below
leadingIcon for context (optional — don't overuse)
placeholder for examples — never duplicate the label
Card Design Patterns
List Item Card
Per M3 list items:
@Composable
fun PetListCard(pet: Pet, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = pet.photoUri,
contentDescription = "Foto de ${pet.name}",
modifier = Modifier.size(56.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(pet.name, style = MaterialTheme.typography.titleMedium)
Text("${pet.age} • ${cat.sex.displayName}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Stat/Metric Card
Per dashboard design patterns:
@Composable
fun StatCard(label: String, value: String, unit: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier, shape = RoundedCornerShape(12.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(value, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.width(4.dp))
Text(unit, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp))
}
}
}
}
Action Card (Tappable Section)
@Composable
fun ActionCard(icon: ImageVector, iconContainerColor: Color, title: String, subtitle: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(onClick = onClick, modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(12.dp), color = iconContainerColor.copy(alpha = 0.12f), modifier = Modifier.size(48.dp)) {
Icon(icon, contentDescription = null, tint = iconContainerColor, modifier = Modifier.padding(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Dark Theme
Rules:
- Never hardcode
Color.White, Color.Black, or hex colors for text/backgrounds
- Always use
MaterialTheme.colorScheme tokens — they auto-adapt
- Use
.copy(alpha = ...) for de-emphasis — not separate "light" color values
- Test both themes — every screen must look correct in light AND dark
- Elevated surfaces in dark mode are tinted (not lighter) — M3 handles this via tonal elevation
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { }
Text(text, color = MaterialTheme.colorScheme.onSurface)
Text(secondary, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(color = Color(0xFFF5F5F5)) { }
Text(text, color = Color(0xFF333333)) { }
Visual Polish Checklist
Before shipping any new screen, verify:
Layout & Hierarchy
Components
Color & Theme
States
Motion
Forms
Accessibility (cross-reference with accessibility skill)