new: replace in-memory record repository with JSON-backed implementation
This commit is contained in:
@@ -25,6 +25,9 @@ node_modules/
|
|||||||
# Firebase config (App Distribution) — per-developer, not tracked (see google-services.example.json)
|
# Firebase config (App Distribution) — per-developer, not tracked (see google-services.example.json)
|
||||||
androidApp/google-services.json
|
androidApp/google-services.json
|
||||||
|
|
||||||
|
# Records data — course content kept out of Version Control (see records.example.json for the schema).
|
||||||
|
data/resources/src/commonMain/composeResources/files/records.json
|
||||||
|
|
||||||
# Android signing — must NOT be checked into Version Control
|
# Android signing — must NOT be checked into Version Control
|
||||||
keystore.properties
|
keystore.properties
|
||||||
*.jks
|
*.jks
|
||||||
|
|||||||
@@ -77,3 +77,8 @@ is shorter on iOS with OGG. Therefore, subtitles are not correctly synchronized.
|
|||||||
## Subtitles
|
## Subtitles
|
||||||
|
|
||||||
To know more about subtitles, see: [SUBTITLES.md](docs/SUBTITLES.md)
|
To know more about subtitles, see: [SUBTITLES.md](docs/SUBTITLES.md)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
See [Resource's README.md](data/resources/README.md) to handle the resource files (list of records, audio files,
|
||||||
|
and subtitles).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("gwenedeg.kmp.library")
|
id("gwenedeg.kmp.library")
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -11,6 +12,17 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Core modules
|
// Core modules
|
||||||
implementation(projects.core.model)
|
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
|
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 bzh.ajaury.chombev.records.domain.RecordRepository
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.core.module.dsl.bind
|
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
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val recordsModule: Module = 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -17,6 +17,7 @@ kotlinWrappers = "2026.7.0"
|
|||||||
kotlinxBrowser = "0.5.0"
|
kotlinxBrowser = "0.5.0"
|
||||||
kotlinxCoroutines = "1.11.0"
|
kotlinxCoroutines = "1.11.0"
|
||||||
kotlinSerialization = "2.4.0"
|
kotlinSerialization = "2.4.0"
|
||||||
|
kotlinxSerializationJson = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
kermit = "2.1.0"
|
kermit = "2.1.0"
|
||||||
@@ -55,6 +56,8 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl
|
|||||||
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" }
|
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" }
|
||||||
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
|
lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
|
||||||
lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
|
lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
|
||||||
lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycle" }
|
lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user