new: persist preferences across sessions

This commit is contained in:
2026-07-03 13:48:29 +02:00
parent beb1973978
commit d2283ae347
12 changed files with 212 additions and 24 deletions
+20
View File
@@ -3,7 +3,27 @@ plugins {
}
kotlin {
// Adding custom `dependsOn` edges below disables the automatic default hierarchy, so re-apply it
// explicitly to keep the standard commonMain/appleMain/iosMain/webMain wiring.
applyDefaultHierarchyTemplate()
androidLibrary {
namespace = "fr.ajaury.gwenedeg.preferences"
}
sourceSets {
// Persistence via DataStore for every target that has an artifact for it (Android, iOS,
// Desktop). Web (JS/Wasm) has no DataStore artifact, so it keeps commonMain's in-memory
// implementation.
val dataStoreMain by creating {
dependsOn(commonMain.get())
dependencies {
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.okio)
}
}
androidMain.get().dependsOn(dataStoreMain)
jvmMain.get().dependsOn(dataStoreMain)
iosMain.get().dependsOn(dataStoreMain)
}
}
@@ -0,0 +1,20 @@
package fr.ajaury.gwenedeg.preferences.di
import android.content.Context
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.dsl.module
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
val context = get<Context>()
DataStorePreferencesRepository(
createPreferencesDataStore {
context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath
},
)
}
}
@@ -8,22 +8,26 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
internal class PreferencesRepositoryImpl : PreferencesRepository {
/**
* In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do
* not survive an app restart / page reload.
*/
internal class InMemoryPreferencesRepository : PreferencesRepository {
private val subtitleState = MutableStateFlow(SubtitlePreferences())
override val subtitlePreferences: StateFlow<SubtitlePreferences> = subtitleState.asStateFlow()
private val playbackState = MutableStateFlow(PlaybackPreferences())
override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow()
override fun setShowTranscription(enabled: Boolean) {
override suspend fun setShowTranscription(enabled: Boolean) {
subtitleState.update { it.copy(showTranscription = enabled) }
}
override fun setShowTranslation(enabled: Boolean) {
override suspend fun setShowTranslation(enabled: Boolean) {
subtitleState.update { it.copy(showTranslation = enabled) }
}
override fun setPlaybackSpeed(speed: Float) {
override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) }
}
}
@@ -1,12 +1,9 @@
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.PreferencesRepositoryImpl
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val preferencesModule: Module = module {
singleOf(::PreferencesRepositoryImpl) { bind<PreferencesRepository>() }
}
/**
* Binds [fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository]. Persisted with DataStore on
* every target that ships it (Android, iOS, Desktop); web falls back to an in-memory implementation.
*/
expect val preferencesModule: Module
@@ -2,16 +2,16 @@ package fr.ajaury.gwenedeg.preferences.domain
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
interface PreferencesRepository {
val subtitlePreferences: StateFlow<SubtitlePreferences>
val subtitlePreferences: Flow<SubtitlePreferences>
val playbackPreferences: StateFlow<PlaybackPreferences>
val playbackPreferences: Flow<PlaybackPreferences>
fun setShowTranscription(enabled: Boolean)
suspend fun setShowTranscription(enabled: Boolean)
fun setShowTranslation(enabled: Boolean)
suspend fun setShowTranslation(enabled: Boolean)
fun setPlaybackSpeed(speed: Float)
suspend fun setPlaybackSpeed(speed: Float)
}
@@ -0,0 +1,59 @@
package fr.ajaury.gwenedeg.preferences.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
/**
* [PreferencesRepository] persisting to a Preferences [DataStore]. Reads are exposed as hot
* [StateFlow]s seeded with the defaults; writes are fire-and-forget on a background scope.
*/
internal class DataStorePreferencesRepository(
private val dataStore: DataStore<Preferences>,
) : PreferencesRepository {
override val subtitlePreferences: Flow<SubtitlePreferences> =
dataStore.data
.map { it.toSubtitlePreferences() }
override val playbackPreferences: Flow<PlaybackPreferences> =
dataStore.data
.map { it.toPlaybackPreferences() }
override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled }
override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled }
override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform)
}
private fun Preferences.toSubtitlePreferences(): SubtitlePreferences {
val defaults = SubtitlePreferences()
return SubtitlePreferences(
showTranscription = this[ShowTranscriptionKey] ?: defaults.showTranscription,
showTranslation = this[ShowTranslationKey] ?: defaults.showTranslation,
)
}
private fun Preferences.toPlaybackPreferences(): PlaybackPreferences =
PlaybackPreferences(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
)
private companion object {
val ShowTranscriptionKey = booleanPreferencesKey("show_transcription")
val ShowTranslationKey = booleanPreferencesKey("show_translation")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
}
}
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.preferences.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath
internal const val DATA_STORE_FILE_NAME = "gwenedeg.preferences_pb"
/** Builds a Preferences [DataStore] backed by the file at [producePath]. */
internal fun createPreferencesDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
@@ -0,0 +1,32 @@
@file:OptIn(ExperimentalForeignApi::class)
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import kotlinx.cinterop.ExperimentalForeignApi
import org.koin.core.module.Module
import org.koin.dsl.module
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
DataStorePreferencesRepository(
createPreferencesDataStore {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
val path = requireNotNull(documentDirectory?.path) { "Missing documents directory" }
"$path/$DATA_STORE_FILE_NAME"
},
)
}
}
@@ -0,0 +1,20 @@
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.DATA_STORE_FILE_NAME
import fr.ajaury.gwenedeg.preferences.data.DataStorePreferencesRepository
import fr.ajaury.gwenedeg.preferences.data.createPreferencesDataStore
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.dsl.module
import java.io.File
actual val preferencesModule: Module = module {
single<PreferencesRepository> {
DataStorePreferencesRepository(
createPreferencesDataStore {
val directory = File(System.getProperty("user.home"), ".gwenedeg").apply { mkdirs() }
File(directory, DATA_STORE_FILE_NAME).absolutePath
},
)
}
}
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.preferences.di
import fr.ajaury.gwenedeg.preferences.data.InMemoryPreferencesRepository
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val preferencesModule: Module = module {
singleOf(::InMemoryPreferencesRepository) { bind<PreferencesRepository>() }
}