feat(prefs): app preferences over the kit's PrefStore

SettingsPrefs exposes the appearance slice; DataModule builds the DataStore on
the kit's @IoDispatcher. The store is created with a corruption handler that
replaces an unreadable file with empty preferences, so a truncated
clockula_prefs.preferences_pb cannot leave the app crashing on every launch
with no recovery short of clearing app data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
makiolajandClaude Opus 5 committed 2026-09-11 12:47:26 +02:00
1 parent 2f2a2c529d
commit fc615e4622
4 files changed
+265

No files matched your search

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.clockula.data.di
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
/**
* The store as `DataModule` builds it. The preference file feeds the theme
* before the first frame, so an unreadable one must degrade to defaults rather
* than throw `CorruptionException` out of every launch.
*/
class DataModuleTest {
private fun TestScope.newStore(file: File) = createDataStore(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { file },
)
@Test
fun `a corrupted preference file reads back as the defaults`(@TempDir tempDir: Path) = runTest {
val file = tempDir.resolve("clockula_prefs.preferences_pb").toFile()
file.writeBytes(byteArrayOf(0x07, 0x2A, 0x13, 0x37, 0x00, 0x42))
val prefs = SettingsPrefs(PrefStore(newStore(file)))
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
assertThat(prefs.dynamicColor.first()).isTrue()
}
@Test
fun `the store is writable again after replacing a corrupted file`(@TempDir tempDir: Path) = runTest {
val file = tempDir.resolve("clockula_prefs.preferences_pb").toFile()
file.writeBytes(byteArrayOf(0x07, 0x2A, 0x13, 0x37, 0x00, 0x42))
val prefs = SettingsPrefs(PrefStore(newStore(file)))
prefs.setThemeMode(ThemeMode.DARK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
// ...and the replacement is a real file, so the next launch reads it back.
assertThat(file.length()).isGreaterThan(0L)
}
}
@@ -0,0 +1,108 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
/**
* Clockula's appearance preferences over a real DataStore, one temp file per
* test. These values feed the theme before the first frame, so a missing or
* corrupted one must resolve rather than throw.
*/
class SettingsPrefsTest {
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
)
@Test
fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
val appearance = listOf(prefs.themeMode.first(), prefs.dynamicColor.first())
assertThat(appearance).containsExactly(ThemeMode.SYSTEM, true).inOrder()
}
@Test
fun `the theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setThemeMode(ThemeMode.DARK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `the dynamic colour choice round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setDynamicColor(false)
assertThat(prefs.dynamicColor.first()).isFalse()
}
@Test
fun `appearance carries both stored choices`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setThemeMode(ThemeMode.DARK)
prefs.setDynamicColor(false)
val appearance = prefs.appearance.first()
assertThat(appearance).isEqualTo(Appearance(ThemeMode.DARK, dynamicColor = false))
}
@Test
fun `the theme mode is written under the family's key name`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
prefs.setThemeMode(ThemeMode.LIGHT)
assertThat(dataStore.data.first()[stringPreferencesKey("theme_mode")]).isEqualTo("LIGHT")
}
@Test
fun `a corrupted theme mode falls back to following the system`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences().apply { this[stringPreferencesKey("theme_mode")] = "FUNDAY" }
}
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
}
@Test
fun `the theme mode does not re-emit when an unrelated preference is written`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.themeMode.test {
assertThat(awaitItem()).isEqualTo(ThemeMode.SYSTEM)
prefs.setDynamicColor(true)
expectNoEvents()
cancel()
}
}
}