Dark Mode in Compose
RecipeBoxTheme already accepts a darkTheme boolean and picks between
RecipeBoxLightColors and RecipeBoxDarkColors — the previous two lessons
glossed over where that boolean actually comes from. This lesson covers detecting the system's dark
mode setting properly, letting RecipeBox users override it from a Settings screen, and previewing
both themes side by side without ever running the app.
Detecting the System Setting: isSystemInDarkTheme()
isSystemInDarkTheme() is a composable function that reads the device's current
Configuration and returns whether dark mode is active — and because it's observed as
Compose state internally, the whole UI recomposes automatically if the user flips the system setting
while RecipeBox is open, no restart required.
Theme/Theme.kt — the default parameter from lesson one
@Composable
fun RecipeBoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) RecipeBoxDarkColors else RecipeBoxLightColors
MaterialTheme(
colorScheme = colorScheme,
typography = RecipeBoxTypography,
content = content
)
}
Because darkTheme has a default value, most call sites simply write
RecipeBoxTheme { RecipeApp() } and get correct system-following behavior for free — the
parameter only needs to be passed explicitly when something wants to override it.
Letting the User Override the Default
Following the system is the right default, but a Settings screen "Dark Mode" switch that overrides it
is a common, welcome feature. That choice needs somewhere to live — RecipeBox holds it as state at the
app root, above RecipeBoxTheme, so the override can reach the theme decision:
MainActivity.kt — app root holding the override
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// null = follow the system, true/false = explicit override
var darkModeOverride by remember { mutableStateOf<Boolean?>(null) }
RecipeBoxTheme(darkTheme = darkModeOverride ?: isSystemInDarkTheme()) {
RecipeApp(
darkModeOverride = darkModeOverride,
onDarkModeOverrideChange = { darkModeOverride = it }
)
}
}
}
}
SettingsScreen.kt — the Dark Mode switch
@Composable
fun SettingsScreen(
darkModeOverride: Boolean?,
onDarkModeOverrideChange: (Boolean?) -> Unit
) {
val systemDark = isSystemInDarkTheme()
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Dark Mode", style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.weight(1f))
Switch(
checked = darkModeOverride ?: systemDark,
onCheckedChange = { isChecked -> onDarkModeOverrideChange(isChecked) }
)
}
}
Toggling the switch stores an explicit true/false, which
darkModeOverride ?: isSystemInDarkTheme() then prefers over the system value. A
"Reset to System Default" action elsewhere in Settings would simply set the override back to
null.
Previewing Both Themes with Multiple @Preview
Compose supports stacking multiple @Preview annotations on the same composable, each with
its own configuration — this is the fastest way to catch a color that looks fine in light mode but
disappears in dark mode, without touching an emulator:
RecipeListItemPreview.kt
@Preview(name = "Light", showBackground = true)
@Preview(
name = "Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun RecipeListItemPreview() {
RecipeBoxTheme(darkTheme = isSystemInDarkTheme()) {
RecipeListItem(title = "Tomato Basil Soup", category = "Soup")
}
}
Android Studio renders both previews side by side in the Preview pane. The
uiMode = Configuration.UI_MODE_NIGHT_YES flag is what forces that specific preview into
dark mode regardless of the IDE's own theme, so isSystemInDarkTheme() reports correctly
for each one independently.