MaterialTheme & Color Schemes

Every screen we've built for RecipeBox so far has been using Material 3's default purple color scheme, because every one of those composables sits inside a MaterialTheme { } wrapper that Android Studio generated for us. This lesson is about taking control of that wrapper — understanding exactly what MaterialTheme provides to everything inside it, what a ColorScheme's semantic roles actually mean, and building RecipeBox its own warm, food-app-appropriate color identity instead of the generic template purple.

What MaterialTheme Actually Provides

MaterialTheme is a composable function that takes three pieces of design data — colorScheme, typography, and shapes — and makes them available to every composable nested inside its content lambda, no matter how deep. It does this through CompositionLocal under the hood, which is why you can call MaterialTheme.colorScheme.primary from any composable in the tree without passing colors down as parameters manually.

The MaterialTheme signature (simplified)

@Composable
fun MaterialTheme(
    colorScheme: ColorScheme = MaterialTheme.colorScheme,
    typography: Typography = MaterialTheme.typography,
    shapes: Shapes = MaterialTheme.shapes,
    content: @Composable () -> Unit
)

Every Material 3 component — Button, Card, TopAppBar, TextField — reads its default colors and text styles straight from whichever MaterialTheme wraps it. Change the colorScheme passed at the top of the app, and every one of those components updates everywhere, with zero changes to the screens themselves.

ColorScheme: Semantic Roles, Not Raw Hex Values

The single most important habit this lesson teaches is that a ColorScheme is made of roles, not colors. You never write Color(0xFFEF6C00) inside a composable to color a button — you write MaterialTheme.colorScheme.primary, and you decide what hex value primary actually is exactly once, in the theme setup. The role name describes the color's purpose; only the theme definition decides its actual value.

RoleMeaning
primaryThe app's main brand color — FABs, filled buttons, active states
onPrimaryContent (usually text/icons) drawn on top of a primary surface
secondaryA supporting accent color for less prominent components (filter chips, secondary actions)
surfaceThe background color of components like Card and Sheet
backgroundThe color behind scrollable content — the screen's base color
errorReserved for error states — invalid form fields, destructive-action confirmations
onSurface / onBackgroundDefault text/icon color on top of surface / background

Because components always reference roles, swapping primary from orange to green never requires touching a single screen — every composable that asked for "the primary color" simply receives the new value the next time it recomposes.

Building RecipeBox's Light ColorScheme

lightColorScheme() is a builder function that returns a complete ColorScheme, filling in any role you don't specify with Material 3's sensible defaults. RecipeBox is a food app, so a warm orange makes a much better primary than the default purple:

Theme/Color.kt

val RecipeOrange = Color(0xFFEF6C00)
val RecipeOrangeLight = Color(0xFFFFB74D)
val RecipeGreen = Color(0xFF558B2F)
val RecipeCream = Color(0xFFFFF8F0)
val RecipeErrorRed = Color(0xFFBA1A1A)

val RecipeBoxLightColors = lightColorScheme(
    primary = RecipeOrange,
    onPrimary = Color.White,
    secondary = RecipeGreen,
    onSecondary = Color.White,
    background = RecipeCream,
    onBackground = Color(0xFF1B1B1B),
    surface = Color.White,
    onSurface = Color(0xFF1B1B1B),
    error = RecipeErrorRed,
    onError = Color.White
)

Building RecipeBox's Dark ColorScheme

darkColorScheme() follows the same shape, but dark themes need more than just inverting light and dark — a saturated orange that looks great on a cream background can be harsh on near-black surfaces, so dark schemes usually lighten and slightly desaturate the primary color instead:

Theme/Color.kt (continued)

val RecipeBoxDarkColors = darkColorScheme(
    primary = RecipeOrangeLight,
    onPrimary = Color(0xFF4A2800),
    secondary = Color(0xFFA5D6A7),
    onSecondary = Color(0xFF1B3200),
    background = Color(0xFF121212),
    onBackground = Color(0xFFECECEC),
    surface = Color(0xFF1E1E1E),
    onSurface = Color(0xFFECECEC),
    error = Color(0xFFFFB4AB),
    onError = Color(0xFF690005)
)

Wiring the ColorScheme into RecipeBoxTheme

These two ColorScheme values are only useful once something decides which one to use and passes it into MaterialTheme. RecipeBox wraps that decision in its own RecipeBoxTheme composable, which every screen calls at its root:

Theme/Theme.kt

@Composable
fun RecipeBoxTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) RecipeBoxDarkColors else RecipeBoxLightColors
    MaterialTheme(
        colorScheme = colorScheme,
        typography = RecipeBoxTypography,
        content = content
    )
}
Note: RecipeBoxTypography is built in the next lesson — for now, know that MaterialTheme always takes a Typography alongside the ColorScheme, even a default one.

Using Roles Instead of Hardcoded Colors

With RecipeBoxTheme wrapping RecipeApp() once at the top level, every composable inside reads colors through MaterialTheme.colorScheme — never through a literal Color(...) value:

RecipeListItem.kt — correct vs. wrong

// Correct: reads the current theme's role
@Composable
fun RecipeListItem(title: String, category: String) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface
        )
    ) {
        Text(
            text = title,
            color = MaterialTheme.colorScheme.onSurface
        )
        Text(
            text = category,
            color = MaterialTheme.colorScheme.secondary
        )
    }
}

// Wrong: hardcoded hex ignores dark mode and any future re-theme
Text(text = title, color = Color(0xFF1B1B1B))

The hardcoded version happens to look fine in light mode, but flip to RecipeBoxDarkColors and that near-black text sits on a near-black card — invisible. Reading through MaterialTheme.colorScheme means RecipeListItem never needs to know or care which theme is active; it just asks for the role it needs.

Next up: MaterialTheme also carries a Typography object — Typography Customization covers giving RecipeBox its own named text styles.