Persisting a light, dark or system theme in Compose Multiplatform with multiplatform-settings, Koin and StateFlow
Most dark mode tutorials store one Boolean. That works until you ask what "false" means. Is it "the user chose light" or "the user never chose anything"? With a plain boolean, a user on a dark system who never touched the setting sees a dark app and a toggle that says "off".
This post builds a small, testable settings foundation that fixes that with three states (System, Light, Dark). It's also a starting point for any other setting you add later.
What you'll get
- A persisted
ThemeModethat survives restarts, on Android and iOS - One source of truth exposed as a
StateFlow - Safe handling of corrupted or outdated stored data
- Localizable labels that don't depend on enum names
- A test for the part most likely to break
Full source: Github. Note: I use a BaseViewModel with setState and a collectToState helper, which are not covered here.
1. Dependencies
We use multiplatform-settings for key-value storage and kotlinx.serialization to store the settings as one JSON blob.
# libs.versions.toml
[versions]
multiplatform-settings = "1.3.0"
kotlinx-serialization = "1.11.0"
[libraries]
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.serialization) // easy to forget, and the build fails without it
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.multiplatform.settings)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.multiplatform.settings.test)
}
}
}
2. Models
The domain model is what the app uses. The local model is what we write to disk. Keeping them separate means you can change storage without touching the UI, and vice versa.
// domain/model
enum class ThemeMode { System, Light, Dark }
data class SettingsItem(
val themeMode: ThemeMode = ThemeMode.System,
)
The local model stores the enum as a String on purpose. If you rename or remove an enum value in a later release, an old stored value won't crash decoding. It falls back to a default in the mapper:
// data/local/model
@Serializable
data class SettingsLocalItem(
val themeMode: String = ThemeMode.System.name,
)
Because the enum's name is now a storage format, renaming a constant is a data migration. That's one more reason to keep user-facing text out of the enum (see section 6).
3. The data source: one source of truth
// data/local/source
interface SettingsLocalDataSource {
val settingsItem: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem)
}
class SettingsLocalDataSourceImpl(
private val settings: Settings,
private val json: Json = Json { ignoreUnknownKeys = true },
) : SettingsLocalDataSource {
private val mutex = Mutex()
private val _settingsItem = MutableStateFlow(read())
override val settingsItem: StateFlow<SettingsItem> = _settingsItem.asStateFlow()
private fun read(): SettingsItem {
val raw = settings.getStringOrNull(KEY_SETTINGS) ?: return SettingsItem()
return runCatching { json.decodeFromString<SettingsLocalItem>(raw) }
.map { it.asDomainModel() }
.getOrElse { error ->
// Corrupt data must never crash startup, but it should leave a trace.
// Replace println with your logger.
println("Settings: failed to decode stored settings, using defaults: $error")
SettingsItem()
}
}
override suspend fun update(transform: (SettingsItem) -> SettingsItem) = mutex.withLock {
val updated = transform(_settingsItem.value)
settings.putString(KEY_SETTINGS, json.encodeToString(updated.asLocalModel()))
_settingsItem.value = updated
}
private companion object {
const val KEY_SETTINGS = "settings"
}
}
A few decisions worth explaining:
- Synchronous initial read. The state is populated when the object is created, so the first frame already has the right theme. No loading state is needed.
- A mutex around read-modify-write. Two quick updates can't overwrite each other, and the stored JSON always matches the in-memory value.
-
transforminstead ofsetX()methods. Adding a new setting later needs no interface change. - One JSON blob instead of one key per setting. It's simpler to evolve and migrate. The trade-off is that every update rewrites the whole blob, which is fine for a handful of small settings and wrong for large data. For that, use a database.
-
Settingsis injected, not defaulted. That's what makes the test in section 7 possible.
4. Repository
// domain
interface SettingsRepository {
val settings: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem)
}
// data/source
class SettingsRepositoryImpl(
private val localDataSource: SettingsLocalDataSource,
) : SettingsRepository {
override val settings = localDataSource.settingsItem
override suspend fun update(transform: (SettingsItem) -> SettingsItem) =
localDataSource.update(transform)
}
No use case here, deliberately. A use case that only reads one field adds a file and a layer without adding any logic. I add use cases when there's real logic to hold, like combining sources or validating input. For simple reads, ViewModels talk to the repository directly.
6. UI
Applying the theme (app root)
class AppViewModel(
settingsRepository: SettingsRepository,
) : BaseViewModel<AppUiState>(
// Seeding from the current value means the first frame is already correct.
AppUiState(themeMode = settingsRepository.settings.value.themeMode)
) {
init {
settingsRepository.settings
.map { it.themeMode }
.distinctUntilChanged()
.collectToState { mode -> copy(themeMode = mode) }
}
}
@Composable
private fun AppContent() {
val viewModel: AppViewModel = koinViewModel()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val darkTheme = when (state.themeMode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
AppTheme(darkTheme = darkTheme) {
NavigationComponent(rememberNavController())
}
}
collectAsStateWithLifecycle() stops collecting while the app is in the background, which collectAsState() doesn't.
Changing the theme (settings screen)
class SettingsViewModel(
private val settingsRepository: SettingsRepository,
) : BaseViewModel<SettingsUiState>(
SettingsUiState(themeMode = settingsRepository.settings.value.themeMode)
) {
init {
settingsRepository.settings.collectToState { copy(themeMode = it.themeMode) }
}
fun onChangeTheme(mode: ThemeMode) {
viewModelScope.launch {
settingsRepository.update { it.copy(themeMode = mode) }
}
}
}
Labels live in the UI layer
The enum's name is a code identifier, and we just made it a storage format too. Showing it to users (Text(mode.name)) can't be localized, and renaming a constant would silently change what users see. So the label lives in a UI-layer extension, with the strings in Compose Multiplatform resources:
<!-- composeResources/values/strings.xml -->
<resources>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
</resources>
// ui/settings/ThemeModeLabel.kt (UI layer only)
@Composable
fun ThemeMode.label(): String = when (this) {
ThemeMode.System -> stringResource(Res.string.theme_system)
ThemeMode.Light -> stringResource(Res.string.theme_light)
ThemeMode.Dark -> stringResource(Res.string.theme_dark)
}
SingleChoiceSegmentedButtonRow {
ThemeMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = state.themeMode == mode,
onClick = { onChangeTheme(mode) },
shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
) { Text(mode.label()) }
}
}
The when is exhaustive, so adding a new mode to the domain breaks the build until the UI handles it. The selected segment always matches what the app is actually showing, because "System" is a real, visible choice.
Why there's no ThemeModeUi
The UI uses the domain enum directly. The dependency direction is correct (UI depends on domain, never the reverse), and a mirror enum would add two mapping functions without adding any information. Presentation details such as labels live in a UI-layer extension, so the domain stays free of strings and resources. A separate UI model starts to pay off when the screen needs data the domain doesn't have, like icons, formatted text or selection state. I'd use one for a list of devices, but not for a three-value enum.
7. Test the part that breaks
Storage code fails quietly, so test the failure paths. MapSettings is an in-memory Settings, so the tests run on every platform with no device.
class SettingsLocalDataSourceTest {
@Test
fun `defaults to System when nothing is stored`() {
val source = SettingsLocalDataSourceImpl(MapSettings())
assertEquals(ThemeMode.System, source.settingsItem.value.themeMode)
}
@Test
fun `update persists and is read back by a new instance`() = runTest {
val storage = MapSettings()
SettingsLocalDataSourceImpl(storage).update { it.copy(themeMode = ThemeMode.Dark) }
val restored = SettingsLocalDataSourceImpl(storage)
assertEquals(ThemeMode.Dark, restored.settingsItem.value.themeMode)
}
@Test
fun `corrupt JSON falls back to defaults`() {
val storage = MapSettings().apply { putString("settings", "{not valid json") }
val source = SettingsLocalDataSourceImpl(storage)
assertEquals(ThemeMode.System, source.settingsItem.value.themeMode)
}
@Test
fun `unknown stored theme value falls back to System`() {
val storage = MapSettings().apply { putString("settings", """{"themeMode":"Sepia"}""") }
val source = SettingsLocalDataSourceImpl(storage)
assertEquals(ThemeMode.System, source.settingsItem.value.themeMode)
}
}
Wrapping up
The result is small but solid: one source of truth, a state that can't be misrepresented, localizable labels, safe handling of bad data, and tests for the failure paths. Adding a new setting (language, units, onboarding completed) means adding a field to two models and a mapper line, with no changes to the plumbing.
Top comments (0)