new: replace in-memory record repository with JSON-backed implementation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-121
@@ -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 }
|
||||
}
|
||||
+61
@@ -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"
|
||||
}
|
||||
}
|
||||
+19
@@ -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,
|
||||
)
|
||||
+28
@@ -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>() }
|
||||
}
|
||||
|
||||
+118
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user