Dark mode and dynamic colour
Many people run their phone in dark mode all the time, and a trader checking prices before a
pre-dawn auction is exactly the person who will. Module 3's preview lesson put @PreviewLightDark
on a card and got two identical pictures, because the app had no dark theme. This lesson adds one,
fixes two dark-mode bugs the app already has, and settles a design question that every brand app
has to answer: should the colours come from the user's wallpaper?
What the module 3 build does in the dark
Switch the emulator to dark mode from a terminal:
adb shell cmd uimode night yes
Night mode: yes
The module 3 build of Mandi Bhav, launched like that, showed two bugs.
The app stayed light, which you expected: a bare MaterialTheme always uses its light scheme.
The status bar nearly disappeared. The clock, the Wi-Fi and the battery icons were drawn in
white on the app's near-white background. enableEdgeToEdge() makes the bars transparent and
picks their icon colour from the system theme: dark system, so light icons. The app ignored the
system theme, so the icons and the background disagreed. This is the most common dark-mode bug in
edge-to-edge apps, and the fix is not to change the icons but to make the app honour dark mode.
The launch screen was white. For the moment between tapping the icon and Compose drawing its
first frame, Android shows a splash built from the activity's window theme in the manifest,
not from Compose. Module 1's manifest uses Theme.Material.Light.NoActionBar, so every launch in
dark mode began with a flash of white.
The dark scheme
The dark values went into Color.kt in lesson 4. Add the scheme to Theme.kt, below
LightColors:
private val DarkColors = darkColorScheme(
primary = Green80,
onPrimary = Green20,
primaryContainer = Green30,
onPrimaryContainer = Green90,
inversePrimary = Green40,
secondary = Sage80,
onSecondary = Sage20,
secondaryContainer = Sage30,
onSecondaryContainer = Sage90,
tertiary = Turmeric80,
onTertiary = Turmeric20,
tertiaryContainer = Turmeric30,
onTertiaryContainer = Turmeric90,
background = NeutralDark,
onBackground = OnNeutralDark,
surface = NeutralDark,
onSurface = OnNeutralDark,
surfaceVariant = NeutralVariantDark,
onSurfaceVariant = OnNeutralVariantDark,
inverseSurface = OnNeutralDark,
inverseOnSurface = ContainerHighDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark,
surfaceBright = SurfaceBrightDark,
surfaceDim = NeutralDark,
surfaceContainerLowest = ContainerLowestDark,
surfaceContainerLow = ContainerLowDark,
surfaceContainer = ContainerDark,
surfaceContainerHigh = ContainerHighDark,
surfaceContainerHighest = ContainerHighestDark,
)
A dark scheme is not the light one inverted. Three choices in it are deliberate:
- The primary gets lighter, not darker. The light theme's green on a dark card measured
2.41:1 contrast, unreadable; the dark theme's
Green80on the same card measures 7.35:1. Tone 80 for primary in dark, tone 40 in light, is the Material convention for this reason. - The background is not pure black.
#101510is a very dark green-grey. Pure black behind light text is harsher to read, and on OLED screens dark-grey content scrolling over true black can smear. The battery saving of true black over near-black is small. - Surfaces get lighter as they rise. In the dark, a card is lighter than the background
(
surfaceContainerHighestis#313630), the way light catches things closer to you.
Now the theme function, with both schemes and the switch between them:
/**
* Mandi Bhav's theme. Follows the system's dark setting.
*
* Dynamic colour (Android 12+) is off by default: a farmer who knows the app
* by its green should find the same green on every phone. Pass
* dynamicColor = true to try the wallpaper-based scheme.
*/
@Composable
fun MandiBhavTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = MandiBhavTypography,
content = content,
)
}
New imports: android.os.Build, androidx.compose.foundation.isSystemInDarkTheme,
androidx.compose.material3.darkColorScheme, dynamicDarkColorScheme,
dynamicLightColorScheme (both androidx.compose.material3), and
androidx.compose.ui.platform.LocalContext.
isSystemInDarkTheme() reads the current configuration. When the user flips dark mode, that is a
configuration change, which module 2's resources lesson showed recreates the activity, so the new
value arrives without any code of yours. The darkTheme parameter has it as a default, so a
preview or a test can force either.
The window theme, for the moment before Compose
Create res/values/themes.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
The window theme, used for the instant before Compose draws: the
launch splash and the window background. Colours inside the app come
from MandiBhavTheme in Kotlin, not from here.
-->
<style name="Theme.MandiBhav" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
and res/values-night/themes.xml, with the dark parent:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MandiBhav" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
Add the colour to res/values/colors.xml:
<!-- Same as the Compose surface colour, so the launch does not flash. -->
<color name="window_background">#F7FBF2</color>
and create res/values-night/colors.xml:
<resources>
<color name="window_background">#101510</color>
</resources>
Finally, in AndroidManifest.xml, change the application's theme to
android:theme="@style/Theme.MandiBhav". This is module 2's resource qualifiers at work:
values-night wins when the phone is in dark mode. The two colours are the same as NeutralLight
and NeutralDark in Color.kt, written twice because XML cannot read Kotlin. That duplication
is the cost: change the surface colour in one place and forget the other, and the flash comes
back as a slightly wrong shade.
Run it in dark mode. On the emulator, a screenshot taken 0.3 seconds after launch caught the splash:
the green launcher icon on #101510, not on white. Once Compose drew, the screen was the dark
theme: near-black background, dark grey-green cards, light text, light green prices, and white
status bar icons that are now readable because the background really is dark.
Change PriceCardPreview's @Preview(showBackground = true) to @PreviewLightDark (import
androidx.compose.ui.tooling.preview.PreviewLightDark). Module 3's promise is kept: the two
previews now differ.
Restore the emulator when you finish:
adb shell cmd uimode night no
Dynamic colour, and whether to use it
On Android 12 (API 31) and later, the system derives a colour scheme from the user's wallpaper.
dynamicLightColorScheme(context) and dynamicDarkColorScheme(context) return it, and every role
is filled in with pairings Material guarantees to be readable. The code above uses it only when
dynamicColor is true and the phone is Android 12 or later.
Set dynamicColor = true in MainActivity and run it. On the emulator, with its default
wallpaper, Mandi Bhav turned blue-grey: slate-blue prices, a pale lavender-blue selected chip,
cool grey cards. Nothing is broken. It is not Mandi Bhav any more.
The check matters. Remove the Build.VERSION.SDK_INT condition and the app still builds, but
lint reports it:
Error: Call requires API level 31 (current min is 26): dynamicLightColorScheme [NewApi]
On a phone older than Android 12 that call fails at run time, and Mandi Bhav supports phones back to Android 8.0.
The trade-off, argued both ways:
| For dynamic colour | Against it, for Mandi Bhav |
|---|---|
| The app feels part of the user's phone | The green is how the app is recognised; the user's wallpaper decides what replaces it |
| Material guarantees the generated pairings are readable | Phones below Android 12 show the brand colours anyway, so there are two looks to support and test |
| No design work for each new phone | The price colour carries meaning; "the green number is the price" breaks when the green is blue |
| Google's own apps do it | Support calls get harder: "the price is the purple one" on one phone is "the orange one" on the next |
The decision: Mandi Bhav keeps its own colours, so dynamicColor defaults to false. A
utility app with no brand to protect, such as a calculator or a notes app, should usually choose
the other way. The parameter stays, because trying it costs one argument and the decision deserves
revisiting when there is a real designer and real users to ask.
Check your work
Why the module 3 build's status bar icons almost vanished in dark mode. enableEdgeToEdge()
chose light icons for the dark system theme, but the app stayed light behind them.
Why the launch screen was white. The splash comes from the manifest's window theme, which was a light theme with no night variant.
Why dark primary is a lighter green. Dark green on a dark card has too little contrast,
2.41:1 measured; the tone-80 green reaches 7.35:1.
How the app learns that dark mode changed. It is a configuration change; the activity is
recreated and isSystemInDarkTheme() returns the new value.
What values-night does. Supplies resources used when the phone is in dark mode.
Why dynamicLightColorScheme needs the version check. It exists only from API 31; the app's
minSdk is 26.
Whether Mandi Bhav uses dynamic colour, and why. No, by default: the brand green is how users recognise the app and the price, and older phones would show different colours anyway.
Practice 2. Color.Gray measured 2.75:1 on the light card and 3.48:1 on the dark one, below the
4.5:1 that body text needs in both. onSurfaceVariant measured 7.21:1 and 7.28:1. A fixed colour
is a compromise between two backgrounds; a role is chosen for each.
Practice 6. @PreviewLightDark sets the preview's night mode, so it tests the whole path:
the configuration, isSystemInDarkTheme() and the scheme. Passing darkTheme = true skips the
system setting and tests only that the dark scheme renders correctly, which is also how you would
force a theme in a screenshot test.
Practice
- Add
DarkColors, the newMandiBhavTheme, the window themes and the colours. Switch the emulator to dark mode, run the app, and check the status bar, the cards and the launch. - If you still have lesson 4's practice 5,
Color.Grayon "per quintal", look at it in both modes and compare it withonSurfaceVariant. Then remove it. - Put
@PreviewLightDarkonPricesScreenPreviewas well, and compare the two screens. - Try
dynamicColor = truewith two different wallpapers on the emulator. Write down what the price colour was each time. Then set it back tofalse. - Break it on purpose: delete
res/values-night/themes.xml, run in dark mode, and watch the launch closely. Put it back. - Harder: write a preview that calls
MandiBhavTheme(darkTheme = true)directly instead of using@PreviewLightDark. Both render dark. Say what each one tests that the other does not. Restore the emulator withadb shell cmd uimode night nowhen you finish.
Official documentation
- Dark theme — How the system setting works, force dark, and configuration changes.
- Dynamic colour — Material's guidance on when dynamic colour suits a product and when a brand scheme is better.
- Material Design 3 in Compose —
dynamicLightColorScheme,dynamicDarkColorSchemeand the version check.
Next: phones, foldables and tablets, and a two-column layout when there is room for one.
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