The App
Just3 is a daily planner I'm building for myself โ Brain Dump โ Big 3 โ Time Box โ Google Calendar โ Review. The idea is to force prioritization: you can only have three real tasks for the day.
The Problem with Dynamic Color
Jetpack Compose's Material 3 gives you dynamic color out of the box. You call dynamicLightColorScheme(context) and the system derives your entire palette from the user's wallpaper.
It sounds great until you open your app on a device with an orange wallpaper and everything turns pumpkin. For a productivity tool, that's not a neutral experience โ the visual identity of the app should stay consistent regardless of what's on the home screen.
The original scaffold in Just3Theme.kt defaulted to dynamicColor = true:
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}On Android 12+ this would pull a random palette from the wallpaper. On older devices it fell back to M3's default baseline โ a purple/violet scheme that didn't feel like a deliberate choice either.
First Attempt: Indigo
My first custom palette was Indigo-based, roughly matching what tools like Linear and Raycast use.
private val LightColors = lightColorScheme(
primary = Color(0xFF4F46E5), // Indigo 600
primaryContainer = Color(0xFFEEF2FF), // Indigo 50
secondary = Color(0xFF6366F1),
tertiary = Color(0xFF8B5CF6), // Violet 500
background = Color(0xFFF8FAFC), // Slate 50
...
)Installed it, looked at it on the device for about thirty seconds, and decided it was too purple. The violet tertiary pulled everything in a direction that feels more like a social or creative app than a task manager.
Second Attempt: Deep Blue (Navy)
Switched to a blue palette anchored at Blue 800 (#1E40AF) with Sky blue as the secondary.
// Light
private val LightColors = lightColorScheme(
primary = Color(0xFF1E40AF), // Blue 800
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFDBEAFE), // Blue 100
onPrimaryContainer = Color(0xFF1E3A8A),
secondary = Color(0xFF0EA5E9), // Sky 500
background = Color(0xFFF0F9FF), // Sky 50
surface = Color(0xFFFFFFFF),
...
)
// Dark
private val DarkColors = darkColorScheme(
primary = Color(0xFF60A5FA), // Blue 400
background = Color(0xFF0C1A2E), // Midnight Navy
surface = Color(0xFF111F3A),
...
)The light background (#F0F9FF) sits somewhere between white and a very pale sky โ enough to feel intentional without being aggressive. The dark background (#0C1A2E) is a deep navy rather than flat black, so dark mode still has depth.
What Changed in the Code
The theme function got simpler. No more Build.VERSION.SDK_INT check, no more dynamicColor parameter, no more LocalContext.current call:
@Composable
fun Just3Theme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}All the call sites pass no arguments to Just3Theme, so removing dynamicColor was a clean removal.
What I Learned
Dynamic color is opt-out, not opt-in. The scaffold generates it enabled by default. If you care about visual identity โ and for a product app you should โ you need to replace it on day one before components accumulate and color decisions get baked in.
Iterate on device, not in preview. The Indigo scheme looked fine in Android Studio's preview. On the physical device under room lighting it read as purple immediately. The preview doesn't lie, but the device tells you faster whether a color feels right.
Dark mode background is not just #000000. A true black background flattens everything. A deep navy like #0C1A2E gives the surface colors somewhere to sit, so elevation and card differentiation actually show up.