Young Devs Bin
๐Ÿ”ง Dev Log

Just3: Replacing Dynamic Color with a Custom Theme

ยท4 min readยท#android#kotlin#compose#material3#just3

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.