RizTech Academy logo
RizTech Academy
Layout and ThemingLesson 4 of 730 min

Material theming, colour and typography

Mandi Bhav has been wearing someone else's clothes. The bare MaterialTheme from module 1 uses Material's baseline scheme, and on the emulator the module 3 build showed it: a faintly lavender background, greyish-lilac cards, and the app bar's title in the same plain black as everything else. A farmer who uses the app every morning should recognise it at a glance, and a price should be the first thing the eye lands on. This lesson gives the app its own theme in three files, Color.kt, Type.kt and Theme.kt, and changes every composable to take its colours and text styles from it.

What MaterialTheme holds

MaterialTheme provides three things to everything inside it:

Part Type What reads it
colorScheme ColorScheme, a few dozen named colour roles Every Material component, and your code through MaterialTheme.colorScheme
typography Typography, the 15 styles of the type scale Text defaults, components, and MaterialTheme.typography
shapes Shapes, corner sizes from extra small to extra large Cards, chips, buttons, dialogs

The values are passed down the tree invisibly, as a composition local: any composable inside the theme can read MaterialTheme.colorScheme.primary and gets the value from the nearest MaterialTheme above it. That is why a theme change reaches every screen without a single parameter being added.

Components do not take "a colour"; they take a role. On the emulator the cards are surfaceContainerHighest, the selected filter chip is secondaryContainer, the search field's border is outline, and the screen behind everything is surface. You change the role's value once, and every component using it follows.

The colour roles you will use

Role Meaning In Mandi Bhav
primary / onPrimary The brand colour, and text or icons drawn on it The modal price; filled buttons later
primaryContainer / onPrimaryContainer A softer tint of the brand for larger areas Highlighted cards later
secondary, secondaryContainer and their on colours A quieter accent The selected filter chip
tertiary, tertiaryContainer and their on colours A contrasting accent Badges such as "Lowest"
surface / onSurface The background of the screen and its main text Screen background, titles
onSurfaceVariant Secondary text on a surface Market, date, "per quintal"
surfaceContainerLowest … surfaceContainerHighest Five steps of surface for things that sit on the background Cards use the highest
outline / outlineVariant Borders and dividers Chip and text field borders
error / onError Errors The networking module's failure messages

The rule that matters: every on colour is designed for the role it is named after. onPrimary text on a primary background is guaranteed readable; onPrimary on surface is not. Lesson 7 measures what happens when that pairing is wrong.

The palette

A Material scheme is built from tonal palettes: one hue taken from very dark (tone 10) to very light (tone 90 and above). A light scheme uses tone 40 for primary and tone 90 for its container; a dark scheme flips to tone 80 and tone 30. The names below follow that convention, so Green40 is the light scheme's primary.

The values here were picked by hand around the launcher icon's green, #2E7D32, with a muted sage as the secondary, turmeric as the tertiary, and neutrals with a slight green tint so the backgrounds belong to the same family. For a client's brand, generate the whole set from their colour with Material Theme Builder instead: it computes every tone and exports a Color.kt and Theme.kt you can paste in.

Create the package ui.theme (right-click ui, New > Package) and in it Color.kt:

package com.riztechacademy.mandibhav.ui.theme

import androidx.compose.ui.graphics.Color

// Mandi Bhav's palette: a field green for the brand, a turmeric accent, and
// neutrals with a slight green tint so surfaces belong to the same family.

// Light scheme
val Green40 = Color(0xFF2E7D32)
val Green90 = Color(0xFFB8F0B0)
val Green10 = Color(0xFF002106)
val Sage40 = Color(0xFF52634F)
val Sage90 = Color(0xFFD5E8CF)
val Sage10 = Color(0xFF101F0F)
val Turmeric40 = Color(0xFF8A5100)
val Turmeric90 = Color(0xFFFFDCBE)
val Turmeric10 = Color(0xFF2C1600)

val NeutralLight = Color(0xFFF7FBF2)
val OnNeutralLight = Color(0xFF181D17)
val NeutralVariantLight = Color(0xFFDEE5D8)
val OnNeutralVariantLight = Color(0xFF424940)
val OutlineLight = Color(0xFF72796F)
val OutlineVariantLight = Color(0xFFC2C9BD)
val ContainerLowestLight = Color(0xFFFFFFFF)
val ContainerLowLight = Color(0xFFF1F5EC)
val ContainerLight = Color(0xFFEBEFE6)
val ContainerHighLight = Color(0xFFE6E9E1)
val ContainerHighestLight = Color(0xFFE0E4DB)
val SurfaceDimLight = Color(0xFFD7DBD3)

// Dark scheme
val Green80 = Color(0xFF9CD69A)
val Green20 = Color(0xFF00390F)
val Green30 = Color(0xFF1B5E20)
val Sage80 = Color(0xFFB9CCB4)
val Sage20 = Color(0xFF253423)
val Sage30 = Color(0xFF3B4B38)
val Turmeric80 = Color(0xFFFFB870)
val Turmeric20 = Color(0xFF4A2800)
val Turmeric30 = Color(0xFF693C00)

val NeutralDark = Color(0xFF101510)
val OnNeutralDark = Color(0xFFE0E4DB)
val NeutralVariantDark = Color(0xFF424940)
val OnNeutralVariantDark = Color(0xFFC2C9BD)
val OutlineDark = Color(0xFF8C9388)
val OutlineVariantDark = Color(0xFF424940)
val ContainerLowestDark = Color(0xFF0B0F0A)
val ContainerLowDark = Color(0xFF181D17)
val ContainerDark = Color(0xFF1C211B)
val ContainerHighDark = Color(0xFF272B25)
val ContainerHighestDark = Color(0xFF313630)
val SurfaceBrightDark = Color(0xFF363A34)

The dark values are here now so the file is written once; lesson 5 uses them. This is the only file in the app allowed to contain a hex colour.

Typography

The Material type scale is fifteen styles: display, headline, title, body and label, each large, medium and small. The default values are sensible. Change only what has a reason. Type.kt:

package com.riztechacademy.mandibhav.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontWeight

private val Base = Typography()

/**
 * The Material type scale with three changes. The system font is kept: it is
 * already on the phone, covers Devanagari, and costs nothing to download.
 */
val MandiBhavTypography = Base.copy(
    // Card titles: a little heavier, so the commodity is the first thing read.
    titleLarge = Base.titleLarge.copy(fontWeight = FontWeight.SemiBold),
    // The modal price. Tabular figures give every digit the same width,
    // so prices on cards one above the other line up digit for digit.
    headlineSmall = Base.headlineSmall.copy(
        fontWeight = FontWeight.SemiBold,
        fontFeatureSettings = "tnum",
    ),
    // Min and max under the modal price use the same figures.
    bodyMedium = Base.bodyMedium.copy(fontFeatureSettings = "tnum"),
)

Typography and TextStyle are immutable, so you change them with copy, which returns a new value with the named fields replaced.

"tnum" asks the font for tabular figures, digits that all have the same width, so that ₹1,111 and ₹8,888 are the same length and prices stacked on cards line up. Many system fonts, Roboto among them, already draw equal-width digits; some manufacturers' fonts do not. Where the font already does it, the setting changes nothing and costs nothing.

Why no custom font. A bundled font in res/font adds its file size to every install, and a font with full Devanagari coverage is large; a downloadable font needs the network on first launch, which is the thing Mandi Bhav's users have least of. The system font is already on the phone, is the one the user's eyes are used to, and has Devanagari for when the app is translated. A brand that insists on its own typeface can have it; know that this is the price.

The theme

Theme.kt, light scheme only for now:

package com.riztechacademy.mandibhav.ui.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color

private val LightColors = lightColorScheme(
    primary = Green40,
    onPrimary = Color.White,
    primaryContainer = Green90,
    onPrimaryContainer = Green10,
    inversePrimary = Green80,
    secondary = Sage40,
    onSecondary = Color.White,
    secondaryContainer = Sage90,
    onSecondaryContainer = Sage10,
    tertiary = Turmeric40,
    onTertiary = Color.White,
    tertiaryContainer = Turmeric90,
    onTertiaryContainer = Turmeric10,
    background = NeutralLight,
    onBackground = OnNeutralLight,
    surface = NeutralLight,
    onSurface = OnNeutralLight,
    surfaceVariant = NeutralVariantLight,
    onSurfaceVariant = OnNeutralVariantLight,
    inverseSurface = OnNeutralLight,
    inverseOnSurface = ContainerLight,
    outline = OutlineLight,
    outlineVariant = OutlineVariantLight,
    surfaceBright = NeutralLight,
    surfaceDim = SurfaceDimLight,
    surfaceContainerLowest = ContainerLowestLight,
    surfaceContainerLow = ContainerLowLight,
    surfaceContainer = ContainerLight,
    surfaceContainerHigh = ContainerHighLight,
    surfaceContainerHighest = ContainerHighestLight,
)

@Composable
fun MandiBhavTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = LightColors,
        typography = MandiBhavTypography,
        content = content,
    )
}

lightColorScheme takes every role as a parameter with a default, so you name the ones you set. MandiBhavTheme is an ordinary composable that wraps MaterialTheme; the rest of the app never mentions MaterialTheme(…) again, only reads from MaterialTheme.colorScheme and MaterialTheme.typography.

Using it

In MainActivity, replace MaterialTheme { with MandiBhavTheme {, import com.riztechacademy.mandibhav.ui.theme.MandiBhavTheme, and remove the MaterialTheme import. Do the same in every preview in PriceCard.kt and PricesScreen.kt, so previews show the real theme; module 3 warned that a preview in the wrong theme stops matching the app.

Then make the price the brand colour. In PriceCard, the modal price Text gains one line:

Text(
    text = formatRupees(price.modalPrice),
    style = MaterialTheme.typography.headlineSmall,
    color = MaterialTheme.colorScheme.primary,
    modifier = Modifier.alignByBaseline(),
)

Run it. The background is a pale green-white, the cards a soft grey-green, the selected "All" chip a light sage, and each modal price a semibold green. Nothing in PriceCard or PricesScreen names a colour value; they name roles.

The trap: a hex colour in a composable

Text(text = formatRupees(price.modalPrice), color = Color(0xFF2E7D32))

It compiles, it looks identical today, and it is a bug waiting for lesson 5. A hard-coded colour does not know about the theme, so when dark mode arrives it stays the same dark green on a near-black card. The same goes for Color.Black text and Color.White backgrounds: they are the commonest cause of an app that looks right in light mode and broken in dark mode. Lint does not flag it. Code review has to, which is why the rule above is worth holding to: hex values live in Color.kt, roles everywhere else.

The other trap is the half-set scheme. Set only primary and leave the rest to defaults, and the defaults are Material's baseline, lavender-tinted values: green prices on lilac cards. Set every surface and container role, or none.

What a theme costs: about a hundred lines you maintain, and a decision every time a designer asks for "a slightly different green here". The answer is almost always a different role, not a new colour.

Check your work

The three parts of MaterialTheme. Colour scheme, typography and shapes.

How a composable deep in the tree finds the theme. It reads a composition local that the nearest MaterialTheme above it provides.

Which role the cards use, and which the selected chip uses. surfaceContainerHighest and secondaryContainer.

Which tone a light scheme uses for primary. Tone 40; the dark scheme uses 80.

What "tnum" does. Asks for equal-width digits so numbers in a column line up.

Why the app keeps the system font. No extra download or install size, and it already covers Devanagari.

Why Color(0xFF2E7D32) inside a composable is a bug. It ignores the theme, so it does not change in dark mode or with any future theme change.

Practice 2. The cards and background go back to Material's baseline lavender-grey while the prices stay green, because every role you did not name takes the baseline default.

Practice 4. MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) at the call site works, but it puts a style decision in one composable; change headlineSmall in Type.kt instead if every price should be bold.

Practice

  1. Create the three theme files, switch MainActivity and the previews to MandiBhavTheme, and run it. Put a module 3 screenshot next to it.
  2. In LightColors, comment out everything except primary and onPrimary. Run it, describe what you see, then restore it.
  3. Change titleLarge in Type.kt to FontWeight.Bold and look at every preview at once. Decide whether you prefer it, and put it back if not.
  4. Make the modal price bold for one card only, without touching Type.kt. Then explain why you would usually not do it that way.
  5. Break it on purpose: give the "per quintal" text color = Color.Gray. Keep it until lesson 5 and see what dark mode does to it.
  6. Harder: open Material Theme Builder, enter #2E7D32 as the source colour, and export for Jetpack Compose. Compare its primary, surfaceContainerHighest and onSurfaceVariant with the values here, and say which you would ship and why.

Official documentation

Next: dark mode, and whether a brand app should let the wallpaper choose its colours.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship