Nine lessons in, every screen has used the same red, the same rounded cards, the same 16dp gaps and the same button. None of those values appear in a screen's code. They come from a design system — a small set of files that every screen draws from, so that changing the brand is a change in one place rather than a search across forty.
Material 3, Android's design language, provides much of a design system out of the box. This lesson is how to build on it rather than around it: where Material's colour scheme fits, where it runs out, and how to fill the gap without fighting the framework.
Three layers
The structure is the same one the iOS version of this app uses, and most mature design systems converge on it:
- Tokens — raw values with no meaning: this red, this grey, 16dp.
- Semantic roles — what each value is for: the primary colour, the text colour, the colour of a successful order.
- Components — the button, the card, the badge, built only from semantic roles.
The point of the middle layer is that features never name a token. A screen asks for "the primary colour", not "red", and the day the brand red changes — or a dark palette arrives — the change happens in the semantic layer and nowhere else.
Layer 1: tokens
object Palette {
// `Red` and `Black` are lifted straight from the web tokens so all five apps match.
val Red = Color(0xFFD8102A)
val RedDark = Color(0xFFAB0D21)
val RedSoft = Color(0xFFFDEAEC)
val Black = Color(0xFF231F20)
val Cream = Color(0xFFFFF8F0)
val White = Color(0xFFFFFFFF)
val Grey900 = Color(0xFF231F20)
val Grey700 = Color(0xFF4A4746)
val Grey600 = Color(0xFF6C6A68)
val Grey400 = Color(0xFFA9A5A1)
val Grey300 = Color(0xFFD5D1CD)
val Grey200 = Color(0xFFECEAE7)
val Grey100 = Color(0xFFF5F3F0)
// …
The red and black are copied from the web app's token file, so all five apps against this backend
match. Colours are written as 0xAARRGGBB — the leading FF is full
opacity.
Spacing and shape are tokens too:
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 xxxl = 48.dp
}
A 4dp grid. Every gap in the app is one of these, never a literal 13.dp. That sounds
like bureaucracy until you look at a screen built without it: thirteen slightly different paddings,
none of them chosen, all of them visible as a vague sense that the layout is off.
val MinTouchTarget = 48.dp
The minimum touch target is a token rather than a magic number in each component, because it is a rule — 48dp, from Material's accessibility guidance — that every tappable thing must honour.
Layer 2: semantic roles, and where Material fits
Material 3 already has a semantic layer: the ColorScheme, with roles like
primary, onPrimary (text drawn on primary), surface,
onSurfaceVariant, outline and error. Every Material
component reads its colours from those roles. Fill them with your brand and every stock button,
text field, chip and navigation bar picks it up with no further work:
val scheme = lightColorScheme(
primary = colors.primary,
onPrimary = colors.onPrimary,
primaryContainer = colors.primarySoft,
onPrimaryContainer = colors.primaryDark,
secondary = colors.surfaceInverse,
onSecondary = colors.onSurfaceInverse,
background = colors.background,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceAlt,
onSurfaceVariant = colors.textMuted,
surfaceContainerLow = colors.background,
outline = colors.border,
outlineVariant = colors.borderSubtle,
error = colors.danger,
)
This is the part people skip, and then spend weeks overriding colours component by component. Map the scheme first; override only what the scheme cannot express.
Where Material runs out
The ColorScheme has no role for "success" or "warning". An order badge needs both —
green for completed, amber for awaiting payment. Inventing meanings for unused Material roles
(tertiary as success?) works until someone else reads the code. Instead the app defines
its own semantic set:
@Immutable
data class PizzaColors(
val primary: Color = Palette.Red,
val primaryDark: Color = Palette.RedDark,
val primarySoft: Color = Palette.RedSoft,
// …
…and makes it available the same way Material makes its own:
private val LocalPizzaColors = staticCompositionLocalOf { PizzaColors() }
object PizzaTheme {
val colors: PizzaColors
@Composable @ReadOnlyComposable
get() = LocalPizzaColors.current
}
A CompositionLocal is a value provided by an ancestor and readable by any descendant,
without being passed through every function in between. MaterialTheme.colorScheme is
itself a CompositionLocal read through an object; PizzaTheme.colors copies the pattern
exactly, so a screen reads PizzaTheme.colors.success the way it reads
MaterialTheme.colorScheme.primary.
Two details. staticCompositionLocalOf rather than compositionLocalOf: the
static variant does not track who read it, so changing it recomposes everything beneath the provider —
expensive if it changed often, cheaper for a theme that changes approximately never. And
@ReadOnlyComposable on the getter tells the compiler it only reads, which lets it skip some
bookkeeping on every call.
CompositionLocals are easy to overuse. They are right for ambient, app-wide values — theme, locale, density — and wrong for a screen's data, which should be passed explicitly so a reader can see where it comes from.
The theme composable
fun PizzaTheme(content: @Composable () -> Unit) {
val colors = PizzaColors()
val scheme = lightColorScheme(
primary = colors.primary,
onPrimary = colors.onPrimary,
primaryContainer = colors.primarySoft,
onPrimaryContainer = colors.primaryDark,
secondary = colors.surfaceInverse,
onSecondary = colors.onSurfaceInverse,
background = colors.background,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceAlt,
onSurfaceVariant = colors.textMuted,
surfaceContainerLow = colors.background,
outline = colors.border,
outlineVariant = colors.borderSubtle,
error = colors.danger,
)
CompositionLocalProvider(LocalPizzaColors provides colors) {
MaterialTheme(colorScheme = scheme, typography = PizzaTypography, content = content)
}
}
// …
One composable provides both layers. MainActivity wraps the whole app in it, and every
@Preview wraps its content in it too — which is why a preview looks exactly like the app.
Typography is mapped the same way as colour: Material's type scale (titleMedium,
bodySmall) filled with the app's sizes and weights, in sp so the customer's
font-size setting scales all of it.
Dynamic colour and dark mode
Android 12 can derive a whole colour scheme from the user's wallpaper —
dynamicLightColorScheme(context). It is lovely for a utility app and wrong for a brand: a
pizza brand is red whatever the wallpaper is, so this app does not use it.
The app is also light-only, matching the three web frontends and the iOS app. That is a scope
decision, and the structure makes undoing it cheap: a second PizzaColors and a second
lightColorScheme mapping (a darkColorScheme), chosen with
isSystemInDarkTheme() inside PizzaTheme. No screen changes, because no screen
names a colour.
Two themes: the window and Compose
There is a second, older theme that Compose does not replace: the XML theme the window uses before Compose draws its first frame.
<style name="Theme.Pizza" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/pizza_background</item>
</style>
Its only job is the window background. Without it, the app flashes white between the splash
screen and the first Compose frame — a visible stutter on every launch. Everything else the customer
sees is styled by PizzaTheme. (The same file also defines the splash screen theme; that
is the lifecycle lesson.)
Layer 3: components
A component is built from semantic roles only, and encodes behaviour that every use of it would otherwise have to remember. The button is the clearest example:
fun PizzaButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: PizzaButtonStyle = PizzaButtonStyle.PRIMARY,
enabled: Boolean = true,
isLoading: Boolean = false,
fullWidth: Boolean = true,
) {
val sizing = modifier
.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
.heightIn(min = MinTouchTarget)
// TalkBack hears "Place order, busy" rather than an unlabelled spinner.
.semantics { if (isLoading) stateDescription = "Busy" }
val shape = RoundedCornerShape(Radius.md)
val isEnabled = enabled && !isLoading
val content: @Composable () -> Unit = { ButtonContent(text, isLoading) }
// …
Three decisions are built in:
- A
style, not a colour. Callers say what the button is — the primary action, a secondary one, a quiet text link — and the component maps that onto Material's three button types and the brand colours. - A loading state that does not move the layout. The label stays in place at zero alpha with a spinner on top, so the button keeps its size instead of shrinking to the width of a spinner and shoving the screen around mid-tap.
- A loading button is disabled. The classic double-submitted order — a customer tapping "Place order" twice on a slow network — is prevented here, once, instead of in every screen that has a submit button.
private fun ButtonContent(text: String, isLoading: Boolean) {
Box(contentAlignment = Alignment.Center) {
Text(text, modifier = Modifier.alpha(if (isLoading) 0f else 1f))
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
}
Components that encode a rule
The order badge maps every status to a pair of semantic colours, with an exhaustive
when:
val (foreground, background) = when (status) {
OrderStatus.PENDING_PAYMENT -> colors.warning to colors.warningSoft
OrderStatus.PAID -> colors.info to colors.infoSoft
OrderStatus.PREPARING -> colors.info to colors.infoSoft
OrderStatus.COMPLETED -> colors.success to colors.successSoft
OrderStatus.CANCELLED -> colors.danger to colors.dangerSoft
}
Add a sixth order status to the domain and this file stops compiling until the new status has a colour. A design system that is checked by the compiler does not drift.
The segmented picker is generic over what it picks, so the same component chooses a size in the builder and delivery-or-pickup in the cart, with the compiler checking that each callback gets the right type:
data class Option<T>(val value: T, val label: String, val subtitle: String? = null)
// ...
fun <T> OptionPicker(
options: List<Option<T>>,
selected: T,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
) {
And the shared state views make a rule impossible to forget: an error screen offers "Try again" only when retrying could work, because the button is part of the component, not of each screen:
fun ErrorState(
message: String,
isRetryable: Boolean,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
EmptyState(
emoji = "😕",
title = "Something went wrong",
message = message,
modifier = modifier,
// A 400 will fail identically on retry, so offering the button would be a lie.
action = if (isRetryable) {
{ PizzaButton(text = "Try again", onClick = onRetry, style = PizzaButtonStyle.SECONDARY) }
} else {
null
},
)
}
The rules that keep it a system
- Features never import
Palette. If a screen needs a colour the semantic layer does not have, add the role; do not reach past it. - No literal dp in features. Spacing comes from
Spacing; sizes that are genuinely one-off (a 64dp product tile) stay in the component that owns them. - Components know nothing about pizza.
CardContainer,PizzaButtonandOptionPickerwould work unchanged in a different app. The moment a component takes aProduct, it has become a feature. - Wrap Material; do not replace it. Every component here is a thin layer over a Material one, so it inherits Material's accessibility, ripple, focus handling and state layers for free.
Next
The menu, the cart and the orders have all come from somewhere. The next lesson is that somewhere: Retrofit and OkHttp, an interceptor that attaches the token, kotlinx.serialization and its three dangerous defaults, and why your first request to a dev server fails with a cleartext error.