My dark mode toggle was lying to users
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 ThemeMode that 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"
# 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"
# 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"
plugins {
alias(libs.plugins.kotlin.serialization)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.multiplatform.settings)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.multiplatform.settings.test
plugins {
alias(libs.plugins.kotlin.serialization)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.multiplatform.settings)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.multiplatform.settings.test
plugins {
alias(libs.plugins.kotlin.serialization)
}
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.
enum class ThemeMode { System, Light, Dark }
data class SettingsItem(
val themeMode: ThemeMode = ThemeMode
enum class ThemeMode { System, Light, Dark }
data class SettingsItem(
val themeMode: ThemeMode = ThemeMode
enum class ThemeMode { System, Light, Dark }
data class SettingsItem(
val themeMode: ThemeMode = ThemeMode
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:
@Serializable
data class SettingsLocalItem(
val themeMode: String = ThemeMode.System.name
@Serializable
data class SettingsLocalItem(
val themeMode: String = ThemeMode.System.name
@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
interface SettingsLocalDataSource {
val settingsItem: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem
interface SettingsLocalDataSource {
val settingsItem: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem
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 ->
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"
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 ->
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"
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 ->
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.
transform instead of setX() 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.
Settings is injected, not defaulted. That's what makes the test in section 7 possible.
4. Repository
interface SettingsRepository {
val settings: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem)
}
class SettingsRepositoryImpl(
private val localDataSource: SettingsLocalDataSource,
) : SettingsRepository {
override val settings = localDataSource.settingsItem
override suspend fun update(transform: (SettingsItem) -> SettingsItem) =
localDataSource.update(transform
interface SettingsRepository {
val settings: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem)
}
class SettingsRepositoryImpl(
private val localDataSource: SettingsLocalDataSource,
) : SettingsRepository {
override val settings = localDataSource.settingsItem
override suspend fun update(transform: (SettingsItem) -> SettingsItem) =
localDataSource.update(transform
interface SettingsRepository {
val settings: StateFlow<SettingsItem>
suspend fun update(transform: (SettingsItem) -> SettingsItem)
}
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>(
AppUiState(themeMode = settingsRepository.settings.value.themeMode)
) {
init {
settingsRepository.settings
.map { it.themeMode }
.distinctUntilChanged()
.collectToState { mode -> copy(themeMode = mode
class AppViewModel(
settingsRepository: SettingsRepository,
) : BaseViewModel<AppUiState>(
AppUiState(themeMode = settingsRepository.settings.value.themeMode)
) {
init {
settingsRepository.settings
.map { it.themeMode }
.distinctUntilChanged()
.collectToState { mode -> copy(themeMode = mode
class AppViewModel(
settingsRepository: SettingsRepository,
) : BaseViewModel<AppUiState>(
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
@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
@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
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
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:
<resources>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
</resources>
<resources>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
</resources>
<resources>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
</resources>
@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
@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
@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
SingleChoiceSegmentedButtonRow {
ThemeMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = state.themeMode == mode,
onClick = { onChangeTheme(mode) },
shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
) { Text(mode.label
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
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
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.
