new: replace in-memory record repository with JSON-backed implementation

This commit is contained in:
2026-07-07 22:45:41 +02:00
parent 7fc9011d78
commit c0d73f86c0
12 changed files with 324 additions and 125 deletions
+12
View File
@@ -1,5 +1,6 @@
plugins {
id("gwenedeg.kmp.library")
alias(libs.plugins.kotlinSerialization)
}
kotlin {
@@ -11,6 +12,17 @@ kotlin {
commonMain.dependencies {
// Core modules
implementation(projects.core.model)
implementation(projects.core.coroutines)
implementation(projects.core.logging)
// Reads the un-versioned records.json bundled with the app resources
implementation(projects.data.resources)
// JSON parsing
implementation(libs.kotlinx.serializationJson)
}
commonTest.dependencies {
implementation(libs.kotlinx.coroutinesTest)
}
}
}
@@ -1,121 +0,0 @@
package bzh.ajaury.chombev.records.data
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.records.domain.RecordRepository
import bzh.ajaury.chombev.records.model.Record
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
internal class InMemoryRecordRepository : RecordRepository {
private val records =
listOf(
"🙏" to Phrase(
transcription = "An onestiz",
translation = "La politesse",
),
"👋" to Phrase(
transcription = "En em brezantiñ",
translation = "Se présenter",
),
"🌦️" to Phrase(
transcription = "An amzer a ra",
translation = "Le temps qu'il fait",
),
"💬" to Phrase(
transcription = "E komz get unan arall",
translation = "Parler avec quelqu'un d'autre",
),
"📖" to Phrase(
transcription = "Me hag ar brezhoneg",
translation = "Moi et le breton",
),
"🩺" to Phrase(
transcription = "Ar yec'hed",
translation = "La santé",
),
"🔍" to Phrase(
transcription = "Deskriviñ",
translation = "Décrire",
),
"❤️" to Phrase(
transcription = "Me 'gar / Ne garan ket",
translation = "J'aime / Je n'aime pas",
),
"" to Phrase(
transcription = "C'hoantoù",
translation = "Les envies",
),
"🤔" to Phrase(
transcription = "Penaos 'peus kavet...?",
translation = "Comment as-tu trouvé... ?",
),
"🎨" to Phrase(
transcription = "Oberiantizoù",
translation = "Les activités",
),
"🍽️" to Phrase(
transcription = "Debriñ hag evet",
translation = "Manger et boire",
),
"🚶🏻" to Phrase(
transcription = "Bale, pourmen ha beajiñ",
translation = "Marcher, se promener et voyager",
),
"🧍🏼‍♀️" to Phrase(
transcription = "Ma c'horf (1)",
translation = "Mon corps (1)",
),
"👦🏾" to Phrase(
transcription = "Ma c'horf (2)",
translation = "Mon corps (2)",
),
"" to Phrase(
transcription = "Dle eo / ret eo / dav eo / mall eo...",
translation = "Il faut que... / Il est temps que (de) ...",
),
"📅" to Phrase(
transcription = "Deizioù ar sizhun",
translation = "Les jours de la semaine",
),
"🍁" to Phrase(
transcription = "Mizioù ha kourzoù ar blez",
translation = "Les mois et les saisons de l'année",
),
"📦" to Phrase(
transcription = "Bez'zo / N'eus ket...",
translation = "Il y a / Il n'y a pas...",
),
"🤏" to Phrase(
transcription = "Banne.../Tamm...",
translation = "Un peu...",
),
"🏠" to Phrase(
transcription = "Stad a vuhez (1)",
translation = "Situation de vie (1)",
),
"🧑🏻‍❤️‍💋‍🧑🏽" to Phrase(
transcription = "Stad a vuhez (2)",
translation = "Situation de vie (2)",
),
"🧭" to Phrase(
transcription = "Lec'hiiñ",
translation = "Situer",
),
).mapIndexed { index, (emoji, phrase) ->
val number = index + 1
val paddedNumber = number.toString().padStart(2, '0')
Record(
id = number,
index = number,
emoji = emoji,
title = phrase,
audioResourcePath = "files/Chom_bev_$paddedNumber.m4a",
subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc",
translationSubtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_fr.lrc",
)
}
override fun getRecords(): Flow<List<Record>> = flowOf(records)
override suspend fun getRecord(id: Int): Record? = records.firstOrNull { it.id == id }
}
@@ -0,0 +1,61 @@
package bzh.ajaury.chombev.records.data
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.records.data.local.model.RecordDataModel
import bzh.ajaury.chombev.records.data.mappers.RecordMapper
import bzh.ajaury.chombev.records.domain.RecordRepository
import bzh.ajaury.chombev.records.model.Record
import bzh.ajaury.chombev.resources.domain.ResourceReader
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
/**
* Loads the record catalogue from the un-versioned `files/records.json` data file (read through
* [ResourceReader], the same mechanism used for subtitle files). The parsed list is cached after the
* first read so the file is only read and decoded once.
*
* A read or parse failure is logged and degrades to an empty list rather than crashing the app.
*/
internal class JsonRecordRepository(
private val resourceReader: ResourceReader,
private val json: Json,
private val recordMapper: RecordMapper,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger,
) : RecordRepository {
private val mutex = Mutex()
private var cachedRecords: List<Record>? = null
override fun getRecords(): Flow<List<Record>> = flow { emit(loadRecords()) }
override suspend fun getRecord(id: Int): Record? = loadRecords().firstOrNull { it.id == id }
private suspend fun loadRecords(): List<Record> =
mutex.withLock {
cachedRecords ?: readRecords().also { cachedRecords = it }
}
private suspend fun readRecords(): List<Record> =
try {
val content = resourceReader.read(resourcePath = RECORDS_RESOURCE_PATH)
withContext(dispatcherProvider.default) {
val dataModels = json.decodeFromString<List<RecordDataModel>>(content)
recordMapper.map(dataModels)
}
} catch (exception: Exception) {
logger.error(
message = "Failed to load records from $RECORDS_RESOURCE_PATH",
throwable = exception,
)
emptyList()
}
private companion object {
const val RECORDS_RESOURCE_PATH = "files/records.json"
}
}
@@ -0,0 +1,19 @@
package bzh.ajaury.chombev.records.data.local.model
import kotlinx.serialization.Serializable
/**
* A single record entry as stored in the un-versioned `files/records.json` data file.
*
* This mirrors the JSON schema exactly; it is mapped to the domain [bzh.ajaury.chombev.records.model.Record]
* by [bzh.ajaury.chombev.records.data.mappers.RecordMapper].
*/
@Serializable
internal data class RecordDataModel(
val emoji: String,
val transcription: String,
val translation: String? = null,
val audio: String,
val subtitle: String,
val translationSubtitle: String? = null,
)
@@ -0,0 +1,28 @@
package bzh.ajaury.chombev.records.data.mappers
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.records.data.local.model.RecordDataModel
import bzh.ajaury.chombev.records.model.Record
/**
* Maps the serialized [RecordDataModel]s from the data file to domain [Record]s, assigning a
* 1-based [Record.id]/[Record.index] from each entry's position in the file.
*/
internal class RecordMapper {
fun map(dataModels: List<RecordDataModel>): List<Record> =
dataModels.mapIndexed { position, dataModel ->
val number = position + 1
Record(
id = number,
index = number,
emoji = dataModel.emoji,
title = Phrase(
transcription = dataModel.transcription,
translation = dataModel.translation,
),
audioResourcePath = dataModel.audio,
subtitleResourcePath = dataModel.subtitle,
translationSubtitleResourcePath = dataModel.translationSubtitle,
)
}
}
@@ -1,12 +1,16 @@
package bzh.ajaury.chombev.records.di
import bzh.ajaury.chombev.records.data.InMemoryRecordRepository
import bzh.ajaury.chombev.records.data.JsonRecordRepository
import bzh.ajaury.chombev.records.data.mappers.RecordMapper
import bzh.ajaury.chombev.records.domain.RecordRepository
import kotlinx.serialization.json.Json
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val recordsModule: Module = module {
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
single { Json { ignoreUnknownKeys = true } }
singleOf(::RecordMapper)
singleOf(::JsonRecordRepository) { bind<RecordRepository>() }
}
@@ -0,0 +1,118 @@
package bzh.ajaury.chombev.records.data
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.records.data.mappers.RecordMapper
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class JsonRecordRepositoryTest {
private val validJson =
"""
[
{
"emoji": "🙏",
"transcription": "An onestiz",
"translation": "La politesse",
"audio": "files/Chom_bev_01.m4a",
"subtitle": "files/Chom_bev_01_st_bzh.lrc",
"translationSubtitle": "files/Chom_bev_01_st_fr.lrc"
},
{
"emoji": "👋",
"transcription": "En em brezantiñ",
"translation": "Se présenter",
"audio": "files/Chom_bev_02.m4a",
"subtitle": "files/Chom_bev_02_st_bzh.lrc"
}
]
""".trimIndent()
@Test
fun maps_json_entries_to_records_with_one_based_ids() = runTest {
val repository = repository(content = validJson)
val records = repository.getRecords().first()
assertEquals(2, records.size)
val first = records[0]
assertEquals(1, first.id)
assertEquals(1, first.index)
assertEquals("🙏", first.emoji)
assertEquals("An onestiz", first.title.transcription)
assertEquals("La politesse", first.title.translation)
assertEquals("files/Chom_bev_01.m4a", first.audioResourcePath)
assertEquals("files/Chom_bev_01_st_bzh.lrc", first.subtitleResourcePath)
assertEquals("files/Chom_bev_01_st_fr.lrc", first.translationSubtitleResourcePath)
val second = records[1]
assertEquals(2, second.id)
// translationSubtitle is optional and absent in the second entry.
assertNull(second.translationSubtitleResourcePath)
}
@Test
fun getRecord_returns_the_entry_with_the_matching_id() = runTest {
val repository = repository(content = validJson)
assertEquals("👋", repository.getRecord(id = 2)?.emoji)
assertNull(repository.getRecord(id = 99))
}
@Test
fun malformed_json_degrades_to_an_empty_list() = runTest {
val repository = repository(content = "{ not valid json ")
assertEquals(emptyList(), repository.getRecords().first())
}
@Test
fun a_read_failure_degrades_to_an_empty_list() = runTest {
val repository = repository(readError = IllegalStateException("missing resource"))
assertEquals(emptyList(), repository.getRecords().first())
}
private fun repository(
content: String = "[]",
readError: Throwable? = null,
): JsonRecordRepository =
JsonRecordRepository(
resourceReader = FakeResourceReader(content = content, readError = readError),
json = Json { ignoreUnknownKeys = true },
recordMapper = RecordMapper(),
dispatcherProvider = UnconfinedDispatcherProvider,
logger = NoOpLogger,
)
private class FakeResourceReader(
private val content: String,
private val readError: Throwable?,
) : bzh.ajaury.chombev.resources.domain.ResourceReader {
override suspend fun read(resourcePath: String): String {
readError?.let { throw it }
return content
}
override fun uri(resourcePath: String): String = resourcePath
}
private object UnconfinedDispatcherProvider : DispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Unconfined
override val default: CoroutineDispatcher = Dispatchers.Unconfined
override val io: CoroutineDispatcher = Dispatchers.Unconfined
}
private object NoOpLogger : Logger {
override fun debug(message: String, throwable: Throwable?) = Unit
override fun info(message: String, throwable: Throwable?) = Unit
override fun warning(message: String, throwable: Throwable?) = Unit
override fun error(message: String, throwable: Throwable?) = Unit
}
}
+49
View File
@@ -0,0 +1,49 @@
# `:data:resources`
Owns the app's Compose Multiplatform resources (`Res`) and exposes them to other modules through the
`ResourceReader` interface, so consumer modules stay free of any platform/resource dependency.
## Un-versioned content
Some content is **not** checked into git (it is large and/or licensed course material). It must be
present locally under `src/commonMain/composeResources/files/` for the app to work, but is excluded
via `.gitignore`:
| Content | Files | Git-ignored by |
|------------------|-----------------------|-------------------------------|
| Audio recordings | `Chom_bev_*.m4a` | `*.m4a` |
| Subtitles | `Chom_bev_*_st_*.lrc` | `*.lrc` |
| Record catalogue | `records.json` | explicit path in `.gitignore` |
A fresh checkout will not contain these files; obtain them from another team member / the asset store
and drop them into `src/commonMain/composeResources/files/`.
## Records data (`records.json`)
The list of lessons shown in the app is loaded at runtime from
`src/commonMain/composeResources/files/records.json` (read via `ResourceReader`, then parsed with
`kotlinx.serialization` in `:data:records`). It is a JSON array of objects:
| Field | Type | Notes |
|-----------------------|----------|----------------------------------------------|
| `emoji` | string | Shown next to the record to illustrate it. |
| `transcription` | string | Breton title. |
| `translation` | string? | French translation (optional). |
| `audio` | string | Resource path, e.g. `files/Chom_bev_01.m4a`. |
| `subtitle` | string | Breton subtitle path (`.lrc`). |
| `translationSubtitle` | string? | Translation subtitle path (optional). |
The record `id`/`index` are assigned from each entry's 1-based position in the array.
**Getting started on a fresh checkout:** copy the versioned template and fill it in (or replace it
with the real catalogue):
```bash
cp data/resources/src/commonMain/composeResources/files/records.example.json \
data/resources/src/commonMain/composeResources/files/records.json
```
`records.example.json` is versioned and documents the schema. `records.json` is git-ignored.
Adding/reordering a record is a pure data edit in `records.json` — no code change needed. Make sure
the referenced `.m4a`/`.lrc` files exist in the same `files/` directory.
@@ -0,0 +1,18 @@
[
{
"emoji": "🙏",
"transcription": "An onestiz",
"translation": "La politesse",
"audio": "files/Chom_bev_01.m4a",
"subtitle": "files/Chom_bev_01_st_bzh.lrc",
"translationSubtitle": "files/Chom_bev_01_st_fr.lrc"
},
{
"emoji": "👋",
"transcription": "En em brezantiñ",
"translation": "Se présenter",
"audio": "files/Chom_bev_02.m4a",
"subtitle": "files/Chom_bev_02_st_bzh.lrc",
"translationSubtitle": "files/Chom_bev_02_st_fr.lrc"
}
]