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:
2026-09-11 12:47:26 +02:00
co-authored by Claude Opus 5
parent 2f2a2c529d
commit fc615e4622
4 changed files with 265 additions and 0 deletions
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.clockula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.io.File
import javax.inject.Singleton
/**
* Clockula's storage bindings. M1 wires the preference half only; the Room
* database and its DAOs join this module in M2.
*/
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
/** App-local: the store's name stays out of floret-kit, by its roadmap's rule. */
private const val PREFS_NAME = "clockula_prefs"
/**
* Built explicitly rather than through the `preferencesDataStore` delegate so
* the store's own coroutine scope runs on the kit's IO dispatcher.
* `@Singleton` gives the same one-instance-per-file guarantee the delegate
* exists to provide.
*/
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
@IoDispatcher io: CoroutineDispatcher,
): DataStore<Preferences> = createDataStore(
scope = CoroutineScope(io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile(PREFS_NAME) },
)
@Provides
@Singleton
fun providePrefStore(dataStore: DataStore<Preferences>): PrefStore = PrefStore(dataStore)
}
/**
* The store's construction, free of [Context] so it can be exercised from a
* plain JVM unit test.
*
* A half-written `clockula_prefs.preferences_pb` — a kill during a write, a
* bad restore — otherwise throws `CorruptionException` out of
* `dataStore.data` on every single launch, and nothing between here and the
* theme catches it. Replacing an unreadable file with empty preferences
* costs the user their two appearance choices and keeps the app startable;
* the alternative is an app that can only be fixed by clearing its data.
*/
internal fun createDataStore(
scope: CoroutineScope,
produceFile: () -> File,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
scope = scope,
produceFile = produceFile,
)
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.appearance
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Clockula's app preferences. M1 covers the appearance slice only; M2 adds the
* clock defaults and the stopwatch running state to this same class.
*/
@Singleton
class SettingsPrefs @Inject constructor(private val store: PrefStore) {
// Built once, not per access: a collector keyed on the flow instance (as
// `collectAsStateWithLifecycle` is) would otherwise tear down and restart
// the DataStore collection on every recomposition.
val appearance: Flow<Appearance> = store.appearance()
val themeMode: Flow<ThemeMode> = store.flow(AppearancePrefs.themeMode)
val dynamicColor: Flow<Boolean> = store.flow(AppearancePrefs.dynamicColor)
suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode)
suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled)
}