DEV Community

A0mineTV
A0mineTV

Posted on

Building Dark Mode & Dynamic Theming with Kotlin & Jetpack Compose: Advanced Settings, DataStore & Color Management

When building dark mode and dynamic theming with Kotlin and Jetpack Compose, seamless user experience and color management are essential. But how do you create a theming system that not only adapts beautifully between light/dark modes, but also leverages Android 12+'s Material You dynamic colors and advanced Kotlin features like Flows, DataStore, and type-safe theme management ?

In this comprehensive guide, I'll walk you through a production-ready theming system that showcases Kotlin's reactive programming with Compose's Material Design 3. We'll explore patterns like dynamic color extraction, theme persistence with DataStore, Flow-based theme switching, platform-specific feature detection, and font scalingβ€”all while maintaining perfect UX across different Android versions.

By the end of this article, you'll master building sophisticated theming systems that seamlessly switch between light/dark modes, integrate Material You dynamic colors, and provide users with complete control over their app's visual experience.

πŸ—οΈ Architecture: MVVM + Flow Reactive Excellence

Our Settings screen demonstrates complete MVVM architecture with reactive data persistence using Kotlin's most advanced features:

class SettingsViewModel(app: Application) : AndroidViewModel(app) {
    private val repo = SettingsRepository(app)

    val ui = repo.settings.stateIn(
        scope = viewModelScope,
        started = SharingStarted.Lazily,
        initialValue = UiSettings()
    )

    fun setTheme(mode: ThemeMode) = viewModelScope.launch { repo.setThemeMode(mode) }
    fun setDynamic(enabled: Boolean) = viewModelScope.launch { repo.setDynamicColor(enabled) }
    fun setFontScale(scale: Float) = viewModelScope.launch { repo.setFontScale(scale) }
}
Enter fullscreen mode Exit fullscreen mode

Why This Kotlin-First Architecture Excels:

🌊 Reactive Flow Architecture:

val ui = repo.settings.stateIn(
    scope = viewModelScope,
    started = SharingStarted.Lazily,
    initialValue = UiSettings()
)
Enter fullscreen mode Exit fullscreen mode
  • Flow to State: stateIn converts cold Flow to hot StateFlow
  • Lifecycle Awareness: viewModelScope ensures proper cleanup
  • Lazy Loading: SharingStarted.Lazily optimizes resource usage
  • Initial Value: Type-safe default prevents null states

⚑ Coroutine-Based Actions:

fun setTheme(mode: ThemeMode) = viewModelScope.launch { repo.setThemeMode(mode) }
Enter fullscreen mode Exit fullscreen mode
  • Single Expression Functions: Kotlin's concise syntax
  • Scope Management: viewModelScope handles cancellation automatically
  • Suspend Delegation: Repository handles async persistence
  • Type Safety: ThemeMode enum prevents invalid values

πŸ”’ Repository Pattern with DataStore:

  • Clean Separation: ViewModel doesn't know about DataStore implementation
  • Testable: Repository can be mocked with fake implementations
  • Type-Safe: Kotlin's type system prevents preference key mistakes

πŸ“Š DataStore Integration: Kotlin's Modern Persistence

Our settings demonstrate DataStore Preferences with advanced Kotlin patterns:

// Extension property for clean DataStore access
private val Context.userPrefsDataStore by preferencesDataStore(name = "user_prefs")

class SettingsRepository(private val context: Context) {
    val settings: Flow<UiSettings> = context.userPrefsDataStore.data.map { preferences ->
        UiSettings(
            themeMode = ThemeMode.values()
                .getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System },
            dynamicColor = preferences[Keys.DYNAMIC_COLOR] ?: true,
            fontScale = preferences[Keys.FONT_SCALE] ?: 1.0f
        )
    }

    suspend fun setThemeMode(mode: ThemeMode) {
        context.userPrefsDataStore.edit { preferences ->
            preferences[Keys.THEME_MODE] = mode.ordinal
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Kotlin DataStore Patterns:

🎯 Extension Properties for Clean Access:

private val Context.userPrefsDataStore by preferencesDataStore(name = "user_prefs")
Enter fullscreen mode Exit fullscreen mode
  • Property Delegation: by preferencesDataStore creates singleton instance
  • Extension Properties: Clean API without static factory methods
  • Lazy Initialization: DataStore created only when first accessed
  • Thread Safety: Built-in synchronization across app components

πŸ”§ Type-Safe Preference Keys:

private object Keys {
    val THEME_MODE = intPreferencesKey("theme_mode")
    val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
    val FONT_SCALE = floatPreferencesKey("font_scale")
}
Enter fullscreen mode Exit fullscreen mode
  • Object Declaration: Singleton pattern for preference keys
  • Typed Keys: intPreferencesKey prevents type mismatches
  • Compile-Time Safety: Wrong types caught at compile time
  • Private Keys: Encapsulated implementation details

🌊 Flow Transformation Excellence:

val settings: Flow<UiSettings> = context.userPrefsDataStore.data.map { preferences ->
    UiSettings(
        themeMode = ThemeMode.values().getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System },
        dynamicColor = preferences[Keys.DYNAMIC_COLOR] ?: true,
        fontScale = preferences[Keys.FONT_SCALE] ?: 1.0f
    )
}
Enter fullscreen mode Exit fullscreen mode

πŸ’Ž Kotlin Features in Action:

  • Flow Operators: map transforms raw preferences to typed data
  • Safe Navigation: getOrElse provides fallbacks for invalid enum values
  • Elvis Operator: ?: provides defaults for missing preferences
  • Data Classes: UiSettings auto-generates useful methods

🎨 State Management: Enum Classes & Type Safety

Our theme system showcases sealed classes and enum excellence:

enum class ThemeMode { System, Light, Dark }

data class UiSettings(
    val themeMode: ThemeMode = ThemeMode.System,
    val dynamicColor: Boolean = true,
    val fontScale: Float = 1.0f
)
Enter fullscreen mode Exit fullscreen mode

Kotlin Type Safety Advantages:

🎯 Enum for Discrete States:

enum class ThemeMode { System, Light, Dark }
Enter fullscreen mode Exit fullscreen mode
  • Exhaustive Checking: when expressions must handle all cases
  • Type Safety: Impossible to pass invalid theme modes
  • Ordinal Persistence: mode.ordinal for efficient DataStore storage
  • String Conversion: Automatic toString() for debugging

πŸ”’ Immutable Data Classes:

data class UiSettings(
    val themeMode: ThemeMode = ThemeMode.System,
    val dynamicColor: Boolean = true,
    val fontScale: Float = 1.0f
)
Enter fullscreen mode Exit fullscreen mode
  • Default Values: Sensible defaults reduce boilerplate
  • Immutability: val properties prevent accidental mutations
  • Copy Function: Auto-generated for state updates
  • Structural Equality: Automatic equals() and hashCode()

⚑ Safe Enum Conversion:

ThemeMode.values().getOrElse(preferences[Keys.THEME_MODE] ?: 0) { ThemeMode.System }
Enter fullscreen mode Exit fullscreen mode
  • Array Access: ThemeMode.values()[index] with safety
  • Bounds Checking: getOrElse prevents index out of bounds
  • Fallback Logic: Returns default when invalid ordinal found

🎭 Dynamic UI: Platform Detection & Conditional Logic

Our settings screen demonstrates platform-aware features with Kotlin's expressive conditionals:

// Top-level property for platform detection
val dynamicSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

@Composable
fun SettingsScreen(vm: SettingsViewModel) {
    val ui by vm.ui.collectAsStateWithLifecycle()

    // Dynamic color section with platform awareness
    Row {
        Column(Modifier.weight(1f)) {
            Text("Dynamic Colors", style = MaterialTheme.typography.titleMedium)
            Text(
                "Adapts palette to wallpaper (Android 12+)",
                style = MaterialTheme.typography.bodySmall
            )
        }
        Switch(
            checked = ui.dynamicColor && dynamicSupported,
            onCheckedChange = { vm.setDynamic(it) },
            enabled = dynamicSupported
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Kotlin UI Patterns:

🎯 Smart Platform Detection:

val dynamicSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
Enter fullscreen mode Exit fullscreen mode
  • Top-Level Properties: Computed once, reused throughout app
  • Boolean Logic: Simple comparison for feature availability
  • SDK Constants: Type-safe API level checking

πŸ”§ Conditional State Logic:

checked = ui.dynamicColor && dynamicSupported,
enabled = dynamicSupported
Enter fullscreen mode Exit fullscreen mode
  • Boolean Combinations: Logical AND for complex conditions
  • State Dependency: Switch reflects both user preference and platform capability
  • UX Consistency: Disabled state when feature unavailable

⚑ Reactive State Collection:

val ui by vm.ui.collectAsStateWithLifecycle()
Enter fullscreen mode Exit fullscreen mode
  • Property Delegation: by creates reactive property
  • Lifecycle Awareness: Collection stops when screen hidden
  • Automatic Recomposition: UI updates when settings change

🎚️ Filter Chips: Kotlin's Declarative UI Excellence

Our theme selector demonstrates declarative UI patterns with Kotlin's expressive syntax:

Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
    FilterChip(
        selected = ui.themeMode == ThemeMode.System,
        onClick = { vm.setTheme(ThemeMode.System) },
        label = { Text("System") }
    )
    FilterChip(
        selected = ui.themeMode == ThemeMode.Light,
        onClick = { vm.setTheme(ThemeMode.Light) },
        label = { Text("Light") }
    )
    FilterChip(
        selected = ui.themeMode == ThemeMode.Dark,
        onClick = { vm.setTheme(ThemeMode.Dark) },
        label = { Text("Dark") }
    )
}
Enter fullscreen mode Exit fullscreen mode

Declarative UI Patterns:

🎯 Enum-Driven Selection Logic:

selected = ui.themeMode == ThemeMode.System,
onClick = { vm.setTheme(ThemeMode.System) }
Enter fullscreen mode Exit fullscreen mode
  • Equality Comparison: Direct enum comparison for selection state
  • Lambda Expressions: Clean event handling
  • Type Safety: Impossible to pass wrong enum values
  • Consistent Pattern: Same structure for all theme options

⚑ Potential Kotlin Enhancement:

// Could be refactored with Kotlin collections
@Composable
fun ThemeSelector(currentTheme: ThemeMode, onThemeChange: (ThemeMode) -> Unit) {
    val themes = listOf(
        ThemeMode.System to "System",
        ThemeMode.Light to "Light",
        ThemeMode.Dark to "Dark"
    )

    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        themes.forEach { (mode, label) ->
            FilterChip(
                selected = currentTheme == mode,
                onClick = { onThemeChange(mode) },
                label = { Text(label) }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸŽ›οΈ Advanced Slider: Value Formatting & Bounds Checking

Our font scale slider showcases numerical precision and Kotlin formatting:

Column {
    Slider(
        value = ui.fontScale,
        onValueChange = vm::setFontScale,  // Method reference syntax
        valueRange = 0.85f..1.30f,        // Range operator
        steps = 8,
        modifier = Modifier.fillMaxWidth()
    )
    Text("Scale: ${"%.2f".format(ui.fontScale)}Γ—")  // String formatting

    // Live preview
    Text(
        "Sample paragraph with current text size.",
        style = MaterialTheme.typography.bodyLarge
    )
}
Enter fullscreen mode Exit fullscreen mode

Kotlin Numerical Excellence:

🎯 Method Reference Syntax:

onValueChange = vm::setFontScale
Enter fullscreen mode Exit fullscreen mode
  • Method References: ::setFontScale cleaner than lambda
  • Type Safety: Compiler ensures correct signature
  • Performance: Slightly more efficient than lambda creation
  • Readability: Intent is crystal clear

πŸ”’ Range Operators for Bounds:

valueRange = 0.85f..1.30f
Enter fullscreen mode Exit fullscreen mode
  • Range Syntax: Inclusive range with .. operator
  • Float Precision: Explicit float literals prevent double conversion
  • Type Inference: Compiler infers ClosedFloatingPointRange<Float>

πŸ’‘ String Formatting with Templates:

Text("Γ‰chelle: ${"%.2f".format(ui.fontScale)}Γ—")
Enter fullscreen mode Exit fullscreen mode
  • String Templates: ${} for expressions in strings
  • Format Strings: Java-style formatting with Kotlin syntax
  • Precision Control: %.2f for 2 decimal places

πŸ”’ Repository-Level Bounds Checking:

suspend fun setFontScale(scale: Float) {
    context.userPrefsDataStore.edit {
        it[Keys.FONT_SCALE] = scale.coerceIn(0.85f, 1.30f)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Extension Function: coerceIn ensures valid range
  • Data Integrity: Invalid values clamped at persistence layer
  • Defense in Depth: UI and repository both validate bounds

πŸ§ͺ Testing Strategies: Flow Testing & Coroutine Excellence

While our current settings don't include tests, here's how Kotlin would make testing elegant:

Repository Testing with Flows

@Test
fun settingsFlow_emitsCorrectDefaults() = runTest {
    val fakeContext = mockContext()
    val repository = SettingsRepository(fakeContext)

    val settings = repository.settings.first()

    assertEquals(ThemeMode.System, settings.themeMode)
    assertEquals(true, settings.dynamicColor)
    assertEquals(1.0f, settings.fontScale, 0.01f)
}

@Test
fun setThemeMode_updatesFlow() = runTest {
    val repository = SettingsRepository(mockContext())

    repository.setThemeMode(ThemeMode.Dark)

    val settings = repository.settings.first()
    assertEquals(ThemeMode.Dark, settings.themeMode)
}
Enter fullscreen mode Exit fullscreen mode

ViewModel Testing with TestDispatcher

@Test
fun setTheme_updatesRepository() = runTest {
    val fakeRepo = FakeSettingsRepository()
    val viewModel = SettingsViewModel(fakeRepo)

    viewModel.setTheme(ThemeMode.Light)
    advanceUntilIdle()

    assertEquals(ThemeMode.Light, fakeRepo.lastThemeMode)
}
Enter fullscreen mode Exit fullscreen mode

Kotlin Testing Advantages:

πŸ§ͺ Coroutine Test Support:

  • Virtual Time: runTest for deterministic async testing
  • Flow Testing: first(), toList() for Flow assertions
  • Fake Repositories: Kotlin makes clean test doubles easy

⚑ Type-Safe Assertions:

  • Enum Equality: Direct comparison without string matching
  • Float Precision: Proper delta for floating-point assertions
  • Null Safety: No null pointer exceptions in tests

πŸš€ Performance & Production Considerations

DataStore Optimization Patterns

⚑ Efficient Preference Updates:

suspend fun updateMultipleSettings(theme: ThemeMode, dynamic: Boolean) {
    context.userPrefsDataStore.edit { preferences ->
        preferences[Keys.THEME_MODE] = theme.ordinal
        preferences[Keys.DYNAMIC_COLOR] = dynamic
    }  // Single transaction for atomicity
}
Enter fullscreen mode Exit fullscreen mode

πŸ”„ Flow Transformation Efficiency:

val settings: Flow<UiSettings> = context.userPrefsDataStore.data
    .distinctUntilChanged()  // Avoid unnecessary recomposition
    .map { preferences ->
        // Expensive transformations here
    }
Enter fullscreen mode Exit fullscreen mode

Kotlin Memory Optimization

🎯 Object Reuse:

// Companion object for constants
companion object {
    private val DEFAULT_SETTINGS = UiSettings()
}
Enter fullscreen mode Exit fullscreen mode

⚑ Lazy Initialization:

val repository by lazy { SettingsRepository(applicationContext) }
Enter fullscreen mode Exit fullscreen mode

πŸŽ“ Key Takeaways & Kotlin Insights

πŸ—οΈ Architecture Lessons

  1. MVVM with Flows: Reactive architecture scales beautifully with complexity
  2. DataStore Integration: Modern persistence with type-safe APIs
  3. Repository Pattern: Clean separation enables testing and flexibility
  4. Sealed Classes: Type-safe state management prevents runtime errors

⚑ Kotlin Language Features

  1. Flow Operators: map, distinctUntilChanged for reactive transformations
  2. Property Delegation: by preferencesDataStore for clean APIs
  3. Extension Properties: Context extensions for elegant DataStore access
  4. Method References: vm::setFontScale for concise event handling
  5. Range Operators: 0.85f..1.30f for expressive value bounds

🌊 Reactive Programming

  1. StateFlow: Hot streams that survive configuration changes
  2. collectAsStateWithLifecycle: Lifecycle-aware UI state collection
  3. Flow Transformation: Clean data mapping from preferences to UI models
  4. Coroutine Scoping: Proper async lifecycle management

🎨 UI Excellence

  1. Declarative Settings: State-driven UI that updates automatically
  2. Platform Awareness: Dynamic feature detection with clean conditionals
  3. Type-Safe Interactions: Enums prevent invalid state transitions
  4. Live Previews: Immediate feedback for user preferences

πŸ’­ Final Thoughts

This Settings screen demonstrates how Kotlin's reactive programming model transforms preference management from a chore into an elegant experience. By leveraging Flow APIs, DataStore integration, and MVVM architecture, we've built a settings interface that's not only beautiful and responsive but also maintainable and testable.

The combination of Kotlin's type safety, reactive streams, and Compose's declarative paradigm creates settings screens that feel native to both the platform and the development experience. From enum-driven theme selection to Flow-based persistence, every pattern scales to meet production requirements.

Top comments (0)