# Livrets — jeu de multiplications (KMP) — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Application mobile Kotlin Multiplatform (Android + iOS) de quiz de multiplications : partie paramétrable (nombre de questions, temps par question, table maximum), pavé numérique, feedback immédiat, statistiques de fin de partie, historique persistant et stats par table. **Architecture:** Scaffold généré par le wizard KMP JetBrains (structure 2026 : `androidApp` + `sharedUI` + `iosApp`). Logique de jeu = machine à états pure Kotlin (`GameEngine`) testée en JVM ; persistance JSON via okio ; UI Compose Multiplatform 100 % partagée avec navigation par état simple (sealed interface `Screen`). **Tech Stack:** Kotlin 2.4.10, Compose Multiplatform 1.12.0, AGP 9.1.1, Gradle 9.7.1, kotlinx-serialization-json 1.11.0, kotlinx-coroutines 1.11.0, kotlinx-datetime 0.8.0, okio 3.18.1, kotlin.test + kotlinx-coroutines-test. **Spec:** `docs/superpowers/specs/2026-08-28-livrets-multiplication-game-design.md` ## Global Constraints - UI intégralement en **français** (textes codés en dur, pas d'i18n en v1). - Pas de lib de navigation, pas de DI : injection manuelle par constructeur. - Boucle de test rapide : `./gradlew :sharedUI:jvmTest` (tests logiques en commonTest, ~4 s). - Vérification Android : `./gradlew :androidApp:assembleDebug` (~2 min à chaud). - Vérification iOS : `./gradlew :sharedUI:linkDebugFrameworkIosSimulatorArm64` (~3 min à chaud) ; build app complet via `xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17 Pro" build`. - Environnement validé : JDK 18, Xcode 26.6, SDK Android dans `/Users/luria/Library/Android/sdk` (via `local.properties`, gitignoré). Ce Mac expose un JVM x86_64 : les tests `iosSimulatorArm64Test` sont désactivés — on ne s'en sert pas. - Le module partagé `sharedUI` n'a **pas** de tâche de test Android (nouveau plugin `com.android.kotlin.multiplatform.library`) → tous les tests logiques passent par la cible `jvm()` ajoutée en Tâche 1. - La persistance passe le chemin du dossier de stockage **depuis les plateformes** (paramètre `filesDirPath` de `App()`), sans `expect/actual`. - Tests UI Compose supprimés (échouent en JVM headless) — uniquement des tests logiques. - Commits conventionnels (`feat:`, `test:`, `chore:`), un commit par tâche. --- ### Task 1: Scaffold KMP (wizard) + adaptations + build vérifié **Files:** - Create: tous les fichiers du scaffold (générés, voir étape 1) - Modify: `gradle/libs.versions.toml` (+okio), `sharedUI/build.gradle.kts` (+cible jvm, +okio, -resources sample), `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` (placeholder), `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt` (+filesDirPath), `sharedUI/src/iosMain/kotlin/main.kt` (+filesDirPath) - Delete: `sharedUI/src/commonMain/composeResources/` (entièrement), `sharedUI/src/commonTest/kotlin/com/livrets/ComposeTest.kt` **Interfaces:** - Produces: `App(filesDirPath: String, onThemeChanged: @Composable (isDark: Boolean) -> Unit)` — signature définitive utilisée par Android (`AppActivity`) et iOS (`MainViewController`) jusqu'à la fin du plan. - Produces: catalog `libs.okio`, `libs.okio.fakefilesystem` ; cible `jvm()` avec tâche `:sharedUI:jvmTest`. - [ ] **Step 1: Générer le scaffold depuis le générateur du wizard KMP officiel** Le wizard web (kmp.jetbrains.com) est open-source : https://github.com/terrakok/kmp-web-wizard (commit `fddf6d03cc4cd3b58dc6472ebc0c944eda082894`). On exécute son générateur en local pour produire le projet exact (Android + iOS + Compose + tests). ```bash git clone https://github.com/terrakok/kmp-web-wizard.git /tmp/kmp-web-wizard cd /tmp/kmp-web-wizard && git checkout fddf6d03cc4cd3b58dc6472ebc0c944eda082894 ``` Créer `/tmp/kmp-web-wizard/src/jvmTest/kotlin/wizard/DumpLivretsProjectTest.kt` : ```kotlin package wizard import org.junit.Test import wizard.dependencies.* import wizard.files.Gradlew import wizard.files.GradlewBat import java.io.File class DumpLivretsProjectTest { @Test fun dump() { val info = DefaultComposeAppInfo().copy( packageId = "com.livrets", name = "Livrets", platforms = setOf(ProjectPlatform.Android, ProjectPlatform.Ios), addTests = true, dependencies = buildSet { addAll(DefaultComposeAppInfo().dependencies) add(KotlinxDateTime) add(KotlinxSerializationPlugin) add(KotlinxSerializationJson) add(KotlinxCoroutinesCore) add(KotlinxCoroutinesTest) } ) val dir = File("/tmp/livrets-scaffold") dir.deleteRecursively() dir.mkdirs() info.generateComposeAppFiles().forEach { f -> val file = dir.resolve(f.path) file.parentFile.mkdirs() file.createNewFile() if (f is BinaryFile) { file.outputStream().use { out -> javaClass.getResourceAsStream("/binaries/${f.resourcePath}").use { it?.copyTo(out) ?: error("Resource not found: ${f.resourcePath}") } } if (f is Gradlew) file.setExecutable(true) if (f is GradlewBat) file.setExecutable(true) } else { file.writeText(f.content) } } } } ``` Exécuter (génère `/tmp/livrets-scaffold`, ~2 min au premier lancement) : ```bash cd /tmp/kmp-web-wizard && ./gradlew jvmTest --tests "wizard.DumpLivretsProjectTest" -q ``` Vérifier que `/tmp/livrets-scaffold` contient : `settings.gradle.kts`, `gradle/`, `gradlew`, `androidApp/`, `sharedUI/`, `iosApp/`, `.gitignore`, `build.gradle.kts`, `gradle.properties`, `README.MD`. - [ ] **Step 2: Copier le scaffold dans le repo** ```bash rsync -a --exclude local.properties /tmp/livrets-scaffold/ /Users/luria/orca/projects/livrets/ printf 'sdk.dir=/Users/luria/Library/Android/sdk\n' > /Users/luria/orca/projects/livrets/local.properties ``` (`local.properties` est gitignoré ; adapter `sdk.dir` si le SDK est ailleurs.) - [ ] **Step 3: Ajouter okio au catalogue de versions** Remplacer le contenu de `gradle/libs.versions.toml` par : ```toml [versions] kotlin = "2.4.10" agp = "9.1.1" compose-multiplatform = "1.12.0" material3 = "1.12.0-alpha03" androidx-activityCompose = "1.12.0" kotlinx-datetime = "0.8.0" kotlinx-serialization = "1.11.0" kotlinx-coroutines = "1.11.0" okio = "3.18.1" [libraries] compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } android-application = { id = "com.android.application", version.ref = "agp" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ``` - [ ] **Step 4: Adapter `sharedUI/build.gradle.kts` (cible jvm, okio, retrait des resources)** Remplacer le contenu par : ```kotlin import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.kmp.library) alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlinx.serialization) } kotlin { android { namespace = "com.livrets" compileSdk = 37 minSdk = 23 androidResources.enable = true compilerOptions { jvmTarget = JvmTarget.JVM_17 } } jvm() iosArm64() iosSimulatorArm64() sourceSets { commonMain.dependencies { api(libs.compose.runtime) api(libs.compose.ui) api(libs.compose.foundation) api(libs.compose.ui.tooling.preview) api(libs.compose.material3) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.okio) } commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.okio.fakefilesystem) } } targets .withType() .matching { it.konanTarget.family.isAppleFamily } .configureEach { binaries { framework { baseName = "SharedUI" isStatic = true } } } } dependencies { androidRuntimeClasspath(libs.compose.ui.tooling) } ``` (Retraits vs wizard : `api(libs.compose.resources)`, `implementation(libs.compose.ui.test)`, bloc vide `androidMain.dependencies {}`. Ajouts : `jvm()`, okio. Raison du `jvm()` : le nouveau plugin AGP KMP n'expose pas de tâche de tests unitaires pour la cible android, et ce poste expose une JVM x86_64 qui désactive `iosSimulatorArm64Test` — la cible `jvm()` donne une boucle de test rapide et fiable.) - [ ] **Step 5: Remplacer `App.kt` par un placeholder compilable et supprimer les resources sample** ```bash rm -rf sharedUI/src/commonMain/composeResources rm sharedUI/src/commonTest/kotlin/com/livrets/ComposeTest.kt ``` Remplacer le contenu de `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` par : ```kotlin package com.livrets import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.livrets.theme.AppTheme @Composable fun App( filesDirPath: String, onThemeChanged: @Composable (isDark: Boolean) -> Unit = {}, ) = AppTheme(onThemeChanged) { Box( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing), contentAlignment = Alignment.Center, ) { Text("Livrets") } } ``` (Les fichiers `theme/Color.kt` et `theme/Theme.kt` du wizard sont conservés tels quels.) - [ ] **Step 6: Passer `filesDirPath` depuis les plateformes** `androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt` — remplacer le contenu de `setContent` dans `onCreate` (le reste du fichier est inchangé) : ```kotlin class AppActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { App(filesDirPath = filesDir.absolutePath, onThemeChanged = { ThemeChanged(it) }) } } } ``` `sharedUI/src/iosMain/kotlin/main.kt` — remplacer le contenu par : ```kotlin import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.window.ComposeUIViewController import com.livrets.App import platform.Foundation.NSHomeDirectory import platform.UIKit.UIApplication import platform.UIKit.UIStatusBarStyleDarkContent import platform.UIKit.UIStatusBarStyleLightContent import platform.UIKit.UIViewController import platform.UIKit.setStatusBarStyle fun MainViewController(): UIViewController = ComposeUIViewController { App(filesDirPath = NSHomeDirectory() + "/Documents", onThemeChanged = { ThemeChanged(it) }) } @Composable private fun ThemeChanged(isDark: Boolean) { LaunchedEffect(isDark) { UIApplication.sharedApplication.setStatusBarStyle( if (isDark) UIStatusBarStyleDarkContent else UIStatusBarStyleLightContent ) } } ``` - [ ] **Step 7: Vérifier les builds** ```bash ./gradlew help :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL` (le `help` valide la configuration ; assembleDebug compile commonMain pour android). - [ ] **Step 8: Commit** ```bash git add -A git commit -m "chore: scaffold KMP (wizard JetBrains) avec cible jvm et wiring filesDirPath" ``` --- ### Task 2: Domaine — Question, GameConfig, QuestionGenerator (TDD) **Files:** - Create: `sharedUI/src/commonMain/kotlin/com/livrets/game/GameConfig.kt`, `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt`, `sharedUI/src/commonMain/kotlin/com/livrets/game/QuestionGenerator.kt` - Test: `sharedUI/src/commonTest/kotlin/com/livrets/game/QuestionGeneratorTest.kt` **Interfaces:** - Produces : - `data class GameConfig(val questionCount: Int, val secondsPerQuestion: Int, val maxTable: Int)` (`@Serializable`) - `sealed interface Question` avec `val table: Int`, sous-classes `Question.Direct(a, b)` (réponse `a*b`, affichage `"a × b = ?"`) et `Question.MissingFactor(a, b)` (réponse `b`, affichage `"a × ? = a*b"`), `val Question.correctAnswer: Int` (extension), discriminants JSON `"direct"` / `"missing_factor"`. - [ ] **Step 1: Écrire les tests (échouent : classes inexistantes)** `sharedUI/src/commonTest/kotlin/com/livrets/game/QuestionGeneratorTest.kt` : ```kotlin package com.livrets.game import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertTrue class QuestionGeneratorTest { @Test fun `tous les facteurs restent dans 1..maxTable`() { val generator = QuestionGenerator(Random(42)) repeat(500) { val q = generator.next(previous = null, maxTable = 6) val (a, b) = when (q) { is Question.Direct -> q.a to q.b is Question.MissingFactor -> q.a to q.b } assertTrue(a in 1..6, "a=$a hors bornes") assertTrue(b in 1..6, "b=$b hors bornes") } } @Test fun `les deux formes de question sont produites`() { val generator = QuestionGenerator(Random(7)) val questions = List(200) { generator.next(previous = null, maxTable = 12) } assertTrue(questions.any { it is Question.Direct }) assertTrue(questions.any { it is Question.MissingFactor }) } @Test fun `pas de doublon consecutif`() { val generator = QuestionGenerator(Random(123)) var previous: Question? = null repeat(300) { val q = generator.next(previous, 12) assertNotEquals(previous, q) previous = q } } @Test fun `table vaut le plus grand facteur`() { assertEquals(8, Question.Direct(3, 8).table) assertEquals(9, Question.MissingFactor(9, 4).table) } @Test fun `bonne reponse selon la forme`() { assertEquals(56, Question.Direct(7, 8).correctAnswer) assertEquals(8, Question.MissingFactor(7, 8).correctAnswer) } @Test fun `affichage des deux formes`() { assertEquals("7 × 8 = ?", Question.Direct(7, 8).toString()) assertEquals("7 × ? = 56", Question.MissingFactor(7, 8).toString()) } } ``` - [ ] **Step 2: Vérifier l'échec** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : FAIL — compilation échoue (`Unresolved reference 'game'`). - [ ] **Step 3: Implémenter les types et le générateur** `sharedUI/src/commonMain/kotlin/com/livrets/game/GameConfig.kt` : ```kotlin package com.livrets.game import kotlinx.serialization.Serializable @Serializable data class GameConfig( val questionCount: Int, // 5..50 val secondsPerQuestion: Int, // 3..30 val maxTable: Int, // 1..12 : les deux facteurs sont tirés dans 1..maxTable ) ``` `sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt` : ```kotlin package com.livrets.game import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable sealed interface Question { val table: Int @Serializable @SerialName("direct") data class Direct(val a: Int, val b: Int) : Question { override val table: Int get() = maxOf(a, b) override fun toString(): String = "$a × $b = ?" } @Serializable @SerialName("missing_factor") data class MissingFactor(val a: Int, val b: Int) : Question { override val table: Int get() = maxOf(a, b) override fun toString(): String = "$a × ? = ${a * b}" } } val Question.correctAnswer: Int get() = when (this) { is Question.Direct -> a * b is Question.MissingFactor -> b } ``` `sharedUI/src/commonMain/kotlin/com/livrets/game/QuestionGenerator.kt` : ```kotlin package com.livrets.game import kotlin.random.Random fun interface QuestionSource { fun next(previous: Question?, maxTable: Int): Question } class QuestionGenerator(private val random: Random = Random.Default) : QuestionSource { override fun next(previous: Question?, maxTable: Int): Question { while (true) { val a = random.nextInt(1, maxTable + 1) val b = random.nextInt(1, maxTable + 1) val question = if (random.nextBoolean()) Question.Direct(a, b) else Question.MissingFactor(a, b) if (question != previous) return question } } } ``` - [ ] **Step 4: Vérifier le passage des tests** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : PASS (6 tests). - [ ] **Step 5: Commit** ```bash git add sharedUI/src git commit -m "feat: types de questions et générateur (tables 1..maxTable)" ``` --- ### Task 3: Domaine — AnswerRecord, GameResult, stats par table (TDD) **Files:** - Create: `sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt`, `sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt`, `sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt` - Test: `sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt` **Interfaces:** - Consumes: `Question`, `GameConfig`, `Question.correctAnswer` (Task 2) - Produces : - `@Serializable data class AnswerRecord(question: Question, givenAnswer: Int?, correctAnswer: Int, isCorrect: Boolean, durationMs: Long)` — `givenAnswer = null` ⇒ temps écoulé. - `@Serializable data class GameResult(date: Instant, config: GameConfig, records: List)` avec `correctCount: Int`, `successRate: Double`, `averageResponseMs: Long`. - `data class TableStats(table: Int, attempts: Int, correctCount: Int)` + `successRate: Double` ; `fun List.tableStats(): List` (12 entrées, tables 1..12). - [ ] **Step 1: Écrire les tests (échouent : classes inexistantes)** `sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt` : ```kotlin package com.livrets.game import kotlinx.datetime.Instant import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class GameResultTest { private fun record(table: Int, correct: Boolean) = AnswerRecord( question = Question.Direct(table, table), givenAnswer = if (correct) table * table else table * table + 1, correctAnswer = table * table, isCorrect = correct, durationMs = 1000, ) @Test fun `stats par table avec tables sans donnees`() { val results = listOf( GameResult( date = Instant.parse("2026-01-01T00:00:00Z"), config = GameConfig(4, 10, 12), records = listOf( record(7, true), record(7, true), record(7, false), record(9, false), ), ), ) val stats = results.tableStats() assertEquals(12, stats.size) assertEquals(7, stats[6].table) assertEquals(3, stats[6].attempts) assertEquals(2, stats[6].correctCount) assertEquals(1, stats[8].attempts) assertEquals(0, stats[3].attempts) assertEquals(0.0, stats[3].successRate) } @Test fun `derivees de GameResult`() { val result = GameResult( date = Instant.parse("2026-01-01T00:00:00Z"), config = GameConfig(3, 10, 12), records = listOf( record(2, true), record(3, false), AnswerRecord(Question.MissingFactor(4, 6), null, 6, false, 5000), ), ) assertEquals(1, result.correctCount) assertEquals(1.0 / 3, result.successRate, 1e-9) assertTrue(result.averageResponseMs > 0) // seules les réponses non-nulles comptent } @Test fun `seuil table faible - en dessous de 60 pourcent`() { val stats = listOf( GameResult( date = Instant.parse("2026-01-01T00:00:00Z"), config = GameConfig(2, 10, 12), records = listOf(record(8, false), record(8, false)), ), ).tableStats() assertTrue(stats[7].successRate < 0.6) } @Test fun `serialisation aller-retour de GameResult`() { val result = GameResult( date = Instant.parse("2026-08-28T10:00:00Z"), config = GameConfig(10, 10, 6), records = listOf( record(7, true), AnswerRecord( question = Question.MissingFactor(3, 8), givenAnswer = null, correctAnswer = 8, isCorrect = false, durationMs = 10_000, ), ), ) val json = Json.encodeToString(GameResult.serializer(), result) val decoded = Json.decodeFromString(GameResult.serializer(), json) assertEquals(result, decoded) } } ``` - [ ] **Step 2: Vérifier l'échec** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : FAIL — `Unresolved reference 'AnswerRecord'`. - [ ] **Step 3: Implémenter** `sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt` : ```kotlin package com.livrets.game import kotlinx.serialization.Serializable @Serializable data class AnswerRecord( val question: Question, val givenAnswer: Int?, // null = temps écoulé sans réponse validée val correctAnswer: Int, val isCorrect: Boolean, val durationMs: Long, ) ``` `sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt` : ```kotlin package com.livrets.game import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @Serializable data class GameResult( val date: Instant, val config: GameConfig, val records: List, ) { val correctCount: Int get() = records.count { it.isCorrect } val successRate: Double get() = if (records.isEmpty()) 0.0 else correctCount.toDouble() / records.size val averageResponseMs: Long get() = records.filter { it.givenAnswer != null } .takeIf { it.isNotEmpty() } ?.map { it.durationMs } ?.average() ?.toLong() ?: 0L } ``` `sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt` : ```kotlin package com.livrets.game data class TableStats( val table: Int, val attempts: Int, val correctCount: Int, ) { val successRate: Double get() = if (attempts == 0) 0.0 else correctCount.toDouble() / attempts } fun List.tableStats(): List = (1..12).map { table -> val records = flatMap { it.records }.filter { it.question.table == table } TableStats( table = table, attempts = records.size, correctCount = records.count { it.isCorrect }, ) } ``` - [ ] **Step 4: Vérifier le passage des tests** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : PASS. - [ ] **Step 5: Commit** ```bash git add sharedUI/src git commit -m "feat: résultats de partie, enregistrements de réponses et stats par table" ``` --- ### Task 4: GameEngine — machine à états de partie (TDD) **Files:** - Create: `sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt`, `sharedUI/src/commonMain/kotlin/com/livrets/game/GameEngine.kt` - Test: `sharedUI/src/commonTest/kotlin/com/livrets/game/GameEngineTest.kt` **Interfaces:** - Consumes: `GameConfig`, `Question` (+`correctAnswer`), `AnswerRecord`, `GameResult`, `QuestionSource` (Tasks 2-3) - Produces : - `sealed interface GameState { NotStarted; Playing(question, questionIndex, totalQuestions, remainingMs, entry); Feedback(record, questionIndex); Finished(result) }` - `class GameEngine(config, scope: CoroutineScope, questionSource: QuestionSource = QuestionGenerator(), onFinish: (GameResult) -> Unit = {})` - Méthodes : `start()`, `onDigit(d: Char)`, `onDelete()`, `onValidate()`, `onTimeout()` ; propriété `state: StateFlow`. - [ ] **Step 1: Écrire les tests (échouent : classes inexistantes)** `sharedUI/src/commonTest/kotlin/com/livrets/game/GameEngineTest.kt` : ```kotlin package com.livrets.game import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class GameEngineTest { private val fixedQuestion = Question.Direct(7, 8) // bonne réponse : 56 private fun GameEngine.playing(): GameState.Playing = state.value as GameState.Playing private fun GameEngine.feedback(): GameState.Feedback = state.value as GameState.Feedback @Test fun `bonne réponse - feedback puis question suivante`() = runTest { val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onDigit('5'); engine.onDigit('6'); engine.onValidate() val fb = engine.feedback() assertTrue(fb.record.isCorrect) assertEquals(56, fb.record.givenAnswer) advanceTimeBy(1600) val next = engine.playing() assertEquals(1, next.questionIndex) assertEquals("", next.entry) } @Test fun `mauvaise réponse - enregistre la réponse donnée et la bonne réponse`() = runTest { val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onDigit('9'); engine.onValidate() val fb = engine.feedback() assertFalse(fb.record.isCorrect) assertEquals(9, fb.record.givenAnswer) assertEquals(56, fb.record.correctAnswer) } @Test fun `validation avec saisie vide est ignorée`() = runTest { val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onValidate() assertTrue(engine.state.value is GameState.Playing) engine.onDelete() // suppression sur saisie vide : sans effet assertEquals("", engine.playing().entry) } @Test fun `timeout sans saisie compte comme faux avec réponse nulle`() = runTest { val engine = GameEngine(GameConfig(2, 5, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() advanceTimeBy(5100) val fb = engine.feedback() assertFalse(fb.record.isCorrect) assertEquals(null, fb.record.givenAnswer) } @Test fun `timeout avec saisie en cours auto-valide la saisie`() = runTest { val engine = GameEngine(GameConfig(1, 5, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onDigit('5'); engine.onDigit('6') advanceTimeBy(5100) val fb = engine.feedback() assertEquals(56, fb.record.givenAnswer) assertTrue(fb.record.isCorrect) assertTrue(engine.state.value is GameState.Finished) } @Test fun `fin de partie après N questions avec résultat complet`() = runTest { var finished: GameResult? = null val engine = GameEngine( GameConfig(2, 10, 12), backgroundScope, QuestionSource { fixedQuestion }, ) { finished = it } engine.start() repeat(2) { engine.onDigit('5'); engine.onDigit('6'); engine.onValidate() advanceTimeBy(1600) } val finishedState = engine.state.value as GameState.Finished assertEquals(2, finishedState.result.records.size) assertEquals(2, finishedState.result.correctCount) assertEquals(2, finished?.records?.size) } @Test fun `le chrono est en pause pendant le feedback`() = runTest { val engine = GameEngine(GameConfig(50, 1, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onDigit('5'); engine.onDigit('6'); engine.onValidate() advanceTimeBy(1499) assertTrue(engine.state.value is GameState.Feedback) advanceTimeBy(2) assertTrue(engine.state.value is GameState.Playing) } @Test fun `événements pendant le feedback sont ignorés`() = runTest { val engine = GameEngine(GameConfig(2, 10, 12), backgroundScope, QuestionSource { fixedQuestion }) engine.start() engine.onDigit('5'); engine.onDigit('6'); engine.onValidate() engine.onDigit('1'); engine.onDigit('2'); engine.onValidate() // pendant Feedback : ignorés assertEquals(1, engine.feedback().questionIndex) } } ``` - [ ] **Step 2: Vérifier l'échec** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : FAIL — `Unresolved reference 'GameEngine'`. - [ ] **Step 3: Implémenter GameEngine** `sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt` : ```kotlin package com.livrets.game sealed interface GameState { data object NotStarted : GameState data class Playing( val question: Question, val questionIndex: Int, // à partir de 0 val totalQuestions: Int, val remainingMs: Long, val entry: String, ) : GameState data class Feedback( val record: AnswerRecord, val questionIndex: Int, ) : GameState data class Finished(val result: GameResult) : GameState } ``` `sharedUI/src/commonMain/kotlin/com/livrets/game/GameEngine.kt` : ```kotlin package com.livrets.game import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlin.time.Clock import kotlin.time.TimeSource class GameEngine( private val config: GameConfig, private val scope: CoroutineScope, private val questionSource: QuestionSource = QuestionGenerator(), private val onFinish: (GameResult) -> Unit = {}, ) { private val _state = MutableStateFlow(GameState.NotStarted) val state: StateFlow = _state.asStateFlow() private var timerJob: Job? = null private var questionStartMark: TimeSource.Monotonic.ValueTimeMark? = null private val records = mutableListOf() fun start() { records.clear() beginQuestion(index = 0, previous = null) } fun onDigit(digit: Char) { val playing = _state.value as? GameState.Playing ?: return if (playing.entry.length < MAX_ENTRY_LENGTH) { _state.value = playing.copy(entry = playing.entry + digit) } } fun onDelete() { val playing = _state.value as? GameState.Playing ?: return _state.value = playing.copy(entry = playing.entry.dropLast(1)) } fun onValidate() { val playing = _state.value as? GameState.Playing ?: return if (playing.entry.isEmpty()) return submit(playing.entry.toInt()) } fun onTimeout() { val playing = _state.value as? GameState.Playing ?: return submit(playing.entry.takeIf { it.isNotEmpty() }?.toInt()) } private fun submit(givenAnswer: Int?) { val playing = _state.value as? GameState.Playing ?: return timerJob?.cancel() val record = AnswerRecord( question = playing.question, givenAnswer = givenAnswer, correctAnswer = playing.question.correctAnswer, isCorrect = givenAnswer == playing.question.correctAnswer, durationMs = questionStartMark?.elapsedNow()?.inWholeMilliseconds ?: 0L, ) records += record _state.value = GameState.Feedback(record, playing.questionIndex) scope.launch { delay(FEEDBACK_DURATION_MS) if (_state.value is GameState.Feedback) { val next = playing.questionIndex + 1 if (next >= config.questionCount) finish() else beginQuestion(index = next, previous = playing.question) } } } private fun beginQuestion(index: Int, previous: Question?) { val question = questionSource.next(previous, config.maxTable) questionStartMark = TimeSource.Monotonic.markNow() _state.value = GameState.Playing( question = question, questionIndex = index, totalQuestions = config.questionCount, remainingMs = config.secondsPerQuestion * 1000L, entry = "", ) timerJob?.cancel() timerJob = scope.launch { runTimer(question) } } private suspend fun runTimer(question: Question) { val totalMs = config.secondsPerQuestion * 1000L var elapsedMs = 0L while (elapsedMs < totalMs) { delay(TICK_MS) elapsedMs += TICK_MS val current = _state.value if (current !is GameState.Playing || current.question != question) return _state.value = current.copy(remainingMs = (totalMs - elapsedMs).coerceAtLeast(0L)) } onTimeout() } private fun finish() { val result = GameResult( date = Clock.System.now(), config = config, records = records.toList(), ) _state.value = GameState.Finished(result) onFinish(result) } companion object { const val TICK_MS = 100L const val FEEDBACK_DURATION_MS = 1500L const val MAX_ENTRY_LENGTH = 4 } } ``` - [ ] **Step 4: Vérifier le passage des tests** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : PASS (8 tests GameEngine + 6 + 4 des tâches précédentes). - [ ] **Step 5: Commit** ```bash git add sharedUI/src git commit -m "feat: moteur de jeu machine à états (chrono, feedback auto, fin de partie)" ``` --- ### Task 5: HistoryRepository — persistance JSON (TDD) **Files:** - Create: `sharedUI/src/commonMain/kotlin/com/livrets/data/HistoryRepository.kt` - Test: `sharedUI/src/commonTest/kotlin/com/livrets/data/HistoryRepositoryTest.kt` **Interfaces:** - Consumes: `GameResult` (Task 3) - Produces : `class HistoryRepository(filePath: Path, fileSystem: FileSystem = FileSystem.SYSTEM)` avec `suspend fun load(): List` et `suspend fun save(result: GameResult)` (ajout en fin de liste, réécriture du fichier). - [ ] **Step 1: Écrire les tests (échouent : classe inexistante)** `sharedUI/src/commonTest/kotlin/com/livrets/data/HistoryRepositoryTest.kt` : ```kotlin package com.livrets.data import com.livrets.game.AnswerRecord import com.livrets.game.GameConfig import com.livrets.game.GameResult import com.livrets.game.Question import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import okio.fakefilesystem.FakeFileSystem import okio.Path.Companion.toPath import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class HistoryRepositoryTest { private fun gameResult(date: String, score: Int) = GameResult( date = Instant.parse(date), config = GameConfig(10, 10, 12), records = List(score) { AnswerRecord(Question.Direct(2, 3), 6, 6, true, 900L) } + List(10 - score) { AnswerRecord(Question.Direct(2, 3), null, 6, false, 10_000L) }, ) @Test fun `chargement vide quand aucun fichier`() = runTest { val fs = FakeFileSystem() val repo = HistoryRepository("/docs/history.json".toPath(), fs) assertTrue(repo.load().isEmpty()) } @Test fun `aller-retour sauvegarde puis chargement`() = runTest { val fs = FakeFileSystem() val repo = HistoryRepository("/docs/history.json".toPath(), fs) val first = gameResult("2026-01-01T00:00:00Z", score = 7) val second = gameResult("2026-02-02T00:00:00Z", score = 4) repo.save(first) assertEquals(listOf(first), repo.load()) repo.save(second) assertEquals(listOf(first, second), repo.load()) assertTrue(fs.exists("/docs/history.json".toPath())) } } ``` - [ ] **Step 2: Vérifier l'échec** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : FAIL — `Unresolved reference 'data'`. - [ ] **Step 3: Implémenter HistoryRepository** `sharedUI/src/commonMain/kotlin/com/livrets/data/HistoryRepository.kt` : ```kotlin package com.livrets.data import com.livrets.game.GameResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path class HistoryRepository( private val filePath: Path, private val fileSystem: FileSystem = FileSystem.SYSTEM, ) { private val json = Json { ignoreUnknownKeys = true } suspend fun load(): List = withContext(Dispatchers.IO) { if (!fileSystem.exists(filePath)) return@withContext emptyList() fileSystem.read(filePath) { readUtf8() } .let { json.decodeFromString(ListSerializer(GameResult.serializer()), it) } } suspend fun save(result: GameResult) { val all = load() + result withContext(Dispatchers.IO) { filePath.parent?.let { fileSystem.createDirectories(it) } fileSystem.write(filePath) { writeUtf8(json.encodeToString(ListSerializer(GameResult.serializer()), all)) } } } } ``` - [ ] **Step 4: Vérifier le passage des tests** ```bash ./gradlew :sharedUI:jvmTest ``` Attendu : PASS. - [ ] **Step 5: Commit** ```bash git add sharedUI/src git commit -m "feat: persistance de l'historique des parties en JSON (okio)" ``` --- ### Task 6: UI — navigation App + écran Accueil (configuration) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/App.kt` (remplace le placeholder) - Create: `sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt` **Interfaces:** - Consumes: `GameConfig` (Task 2) - Produces : - `sealed interface Screen { Home; Game(config: GameConfig); Results(result: GameResult); History }` (déclarée dans `App.kt`) - `@Composable fun HomeScreen(onPlay: (GameConfig) -> Unit, onHistory: () -> Unit)` - `App(filesDirPath: String, onThemeChanged)` — signature inchangée, corps remplacé par la navigation. - [ ] **Step 1: Réécrire `App.kt` avec la navigation par état** ```kotlin package com.livrets import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.livrets.data.HistoryRepository import com.livrets.game.GameConfig import com.livrets.game.GameResult import com.livrets.theme.AppTheme import com.livrets.ui.GameScreen import com.livrets.ui.HistoryScreen import com.livrets.ui.HomeScreen import com.livrets.ui.ResultsScreen import kotlinx.coroutines.launch import okio.Path.Companion.toPath sealed interface Screen { data object Home : Screen data class Game(val config: GameConfig) : Screen data class Results(val result: GameResult) : Screen data object History : Screen } @Composable fun App( filesDirPath: String, onThemeChanged: @Composable (isDark: Boolean) -> Unit = {}, ) = AppTheme(onThemeChanged) { val scope = rememberCoroutineScope() val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) } var screen by remember { mutableStateOf(Screen.Home) } when (val current = screen) { Screen.Home -> HomeScreen( onPlay = { config -> screen = Screen.Game(config) }, onHistory = { screen = Screen.History }, ) is Screen.Game -> GameScreen( config = current.config, scope = scope, onFinish = { result -> screen = Screen.Results(result) scope.launch { history.save(result) } }, onQuit = { screen = Screen.Home }, ) is Screen.Results -> ResultsScreen( result = current.result, onReplay = { screen = Screen.Game(current.result.config) }, onHome = { screen = Screen.Home }, ) Screen.History -> HistoryScreen( history = history, onBack = { screen = Screen.Home }, ) } } ``` - [ ] **Step 2: Créer `ui/HomeScreen.kt`** ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.livrets.game.GameConfig @Composable fun HomeScreen( onPlay: (GameConfig) -> Unit, onHistory: () -> Unit, ) { var questionCount by remember { mutableStateOf(10) } var secondsPerQuestion by remember { mutableStateOf(10) } var maxTable by remember { mutableStateOf(12) } Column( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text("Livrets", style = MaterialTheme.typography.displayMedium) Spacer(Modifier.height(32.dp)) StepperRow("Nombre de questions", questionCount, 5..50, step = 5) { questionCount = it } StepperRow("Secondes par question", secondsPerQuestion, 3..30, step = 1) { secondsPerQuestion = it } StepperRow("Jusqu'à la table de", maxTable, 1..12, step = 1) { maxTable = it } Spacer(Modifier.height(32.dp)) Button( onClick = { onPlay(GameConfig(questionCount, secondsPerQuestion, maxTable)) }, modifier = Modifier.fillMaxWidth().height(56.dp), ) { Text("Jouer", fontSize = 20.sp) } TextButton(onClick = onHistory) { Text("Historique") } } } @Composable private fun StepperRow( label: String, value: Int, range: IntRange, step: Int, onChange: (Int) -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), ) { Text(label, modifier = Modifier.weight(1f)) TextButton(onClick = { onChange((value - step).coerceAtLeast(range.first)) }) { Text("−") } Text( text = "$value", modifier = Modifier.width(48.dp), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium, ) TextButton(onClick = { onChange((value + step).coerceAtMost(range.last)) }) { Text("+") } } } ``` - [ ] **Step 3: Créer les écrans manquants temporairement pour compiler** `sharedUI/src/commonMain/kotlin/com/livrets/ui/GameScreen.kt` (stub, remplacé en Task 7) : ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.livrets.game.GameConfig import com.livrets.game.GameResult import kotlinx.coroutines.CoroutineScope @Composable fun GameScreen( config: GameConfig, scope: CoroutineScope, onFinish: (GameResult) -> Unit, onQuit: () -> Unit, ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Jeu") } } ``` `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt` (stub, remplacé en Task 8) : ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.livrets.game.GameResult @Composable fun ResultsScreen(result: GameResult, onReplay: () -> Unit, onHome: () -> Unit) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Résultats") } } ``` `sharedUI/src/commonMain/kotlin/com/livrets/ui/HistoryScreen.kt` (stub, remplacé en Task 9) : ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.livrets.data.HistoryRepository @Composable fun HistoryScreen(history: HistoryRepository, onBack: () -> Unit) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Historique") } } ``` - [ ] **Step 4: Vérifier la compilation et les tests** ```bash ./gradlew :sharedUI:jvmTest :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL`. - [ ] **Step 5: Commit** ```bash git add sharedUI/src git commit -m "feat: navigation par écrans et accueil avec configuration de partie" ``` --- ### Task 7: UI — écran de jeu (question, chrono, pavé numérique, feedback) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/GameScreen.kt` (remplace le stub) **Interfaces:** - Consumes: `GameEngine`, `GameState`, `GameConfig`, `GameResult` (Tasks 2-4) - Produces : `@Composable fun GameScreen(config: GameConfig, scope: CoroutineScope, onFinish: (GameResult) -> Unit, onQuit: () -> Unit)` — signature inchangée. - [ ] **Step 1: Réécrire `GameScreen.kt`** ```kotlin package com.livrets.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.weight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ElevatedButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.livrets.game.GameConfig import com.livrets.game.GameEngine import com.livrets.game.GameResult import com.livrets.game.GameState import kotlinx.coroutines.CoroutineScope @Composable fun GameScreen( config: GameConfig, scope: CoroutineScope, onFinish: (GameResult) -> Unit, onQuit: () -> Unit, ) { val engine = remember(config) { GameEngine(config, scope, onFinish = onFinish) } val state by engine.state.collectAsState() LaunchedEffect(engine) { engine.start() } var showQuitDialog by remember { mutableStateOf(false) } if (showQuitDialog) { AlertDialog( onDismissRequest = { showQuitDialog = false }, title = { Text("Quitter la partie ?") }, text = { Text("La partie en cours sera abandonnée.") }, confirmButton = { TextButton(onClick = onQuit) { Text("Quitter") } }, dismissButton = { TextButton(onClick = { showQuitDialog = false }) { Text("Continuer") } }, ) } when (val s = state) { is GameState.Playing -> PlayingContent( engine = engine, state = s, progress = s.remainingMs.toFloat() / (config.secondsPerQuestion * 1000L), onQuitRequest = { showQuitDialog = true }, ) is GameState.Feedback -> FeedbackContent(s) else -> {} } } @Composable private fun PlayingContent( engine: GameEngine, state: GameState.Playing, progress: Float, onQuitRequest: () -> Unit, ) { Column( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( "Question ${state.questionIndex + 1} / ${state.totalQuestions}", style = MaterialTheme.typography.titleMedium, ) TextButton(onClick = onQuitRequest) { Text("Quitter") } } LinearProgressIndicator( progress = { progress.coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), ) Spacer(Modifier.weight(1f)) Text( text = state.question.toString(), style = MaterialTheme.typography.displayMedium, textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) Text( text = state.entry.ifEmpty { " " }, style = MaterialTheme.typography.displayLarge, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .height(80.dp) .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)), ) Spacer(Modifier.weight(1f)) Keypad( onDigit = engine::onDigit, onDelete = engine::onDelete, onValidate = engine::onValidate, ) } } @Composable private fun Keypad( onDigit: (Char) -> Unit, onDelete: () -> Unit, onValidate: () -> Unit, ) { val rows = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("⌫", "0", "OK"), ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { for (row in rows) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { for (key in row) { ElevatedButton( onClick = { when (key) { "⌫" -> onDelete() "OK" -> onValidate() else -> onDigit(key.first()) } }, modifier = Modifier.size(width = 96.dp, height = 64.dp), ) { Text(key, fontSize = 24.sp) } } } } } } @Composable private fun FeedbackContent(state: GameState.Feedback) { val ok = state.record.isCorrect val green = Color(0xFF2E7D32) val red = Color(0xFFC62828) Column( modifier = Modifier .fillMaxSize() .background(if (ok) green.copy(alpha = 0.15f) else red.copy(alpha = 0.15f)) .windowInsetsPadding(WindowInsets.safeDrawing) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = if (ok) "✓" else "✗", fontSize = 72.sp, color = if (ok) green else red, ) Spacer(Modifier.height(16.dp)) Text( text = state.record.question.toString(), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center, ) if (!ok) { Spacer(Modifier.height(8.dp)) Text( text = if (state.record.givenAnswer == null) "Temps écoulé" else "Votre réponse : ${state.record.givenAnswer}", style = MaterialTheme.typography.titleMedium, color = red, ) Text( text = "Bonne réponse : ${state.record.correctAnswer}", style = MaterialTheme.typography.titleLarge, color = green, ) } } } ``` - [ ] **Step 2: Vérifier la compilation et les tests** ```bash ./gradlew :sharedUI:jvmTest :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL`. - [ ] **Step 3: Commit** ```bash git add sharedUI/src git commit -m "feat: écran de jeu avec pavé numérique, barre de temps et feedback" ``` --- ### Task 8: UI — écran Résultats (score + réponses fausses) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt` (remplace le stub) **Interfaces:** - Consumes: `GameResult` (Task 3) - Produces : `@Composable fun ResultsScreen(result: GameResult, onReplay: () -> Unit, onHome: () -> Unit)` — signature inchangée. - [ ] **Step 1: Réécrire `ResultsScreen.kt`** ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.livrets.game.GameResult @Composable fun ResultsScreen( result: GameResult, onReplay: () -> Unit, onHome: () -> Unit, ) { val wrong = result.records.filterNot { it.isCorrect } val green = Color(0xFF2E7D32) Column( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) .padding(24.dp), ) { Text("Partie terminée", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(16.dp)) Text( text = "${result.correctCount} / ${result.records.size}", style = MaterialTheme.typography.displayLarge, ) Text( text = "Réussite : ${(result.successRate * 100).toInt()} %", style = MaterialTheme.typography.titleMedium, ) Text( text = "Temps moyen de réponse : ${result.averageResponseMs / 1000.0} s", style = MaterialTheme.typography.titleMedium, ) Spacer(Modifier.height(16.dp)) Text( text = "Réponses fausses (${wrong.size})", style = MaterialTheme.typography.titleLarge, ) if (wrong.isEmpty()) { Text( text = "Aucune erreur, parfait !", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 16.dp), ) } else { LazyColumn(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { items(wrong) { record -> Row( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = record.question.toString(), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, ) Column(horizontalAlignment = Alignment.End) { Text( text = if (record.givenAnswer == null) "Temps écoulé" else "Votre réponse : ${record.givenAnswer}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) Text( text = "Bonne réponse : ${record.correctAnswer}", color = green, style = MaterialTheme.typography.bodyMedium, ) } } HorizontalDivider() } } } Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Button(onClick = onReplay, modifier = Modifier.weight(1f).height(52.dp)) { Text("Rejouer") } OutlinedButton(onClick = onHome, modifier = Modifier.weight(1f).height(52.dp)) { Text("Accueil") } } } } ``` - [ ] **Step 2: Vérifier la compilation et les tests** ```bash ./gradlew :sharedUI:jvmTest :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL`. - [ ] **Step 3: Commit** ```bash git add sharedUI/src git commit -m "feat: écran de résultats avec statistiques et résumé des réponses fausses" ``` --- ### Task 9: UI — écran Historique (parties + stats par table) **Files:** - Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/HistoryScreen.kt` (remplace le stub) **Interfaces:** - Consumes: `HistoryRepository` (Task 5), `GameResult`, `TableStats`, `List.tableStats()` (Task 3) - Produces : `@Composable fun HistoryScreen(history: HistoryRepository, onBack: () -> Unit)` — signature inchangée. - [ ] **Step 1: Réécrire `HistoryScreen.kt`** ```kotlin package com.livrets.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.livrets.data.HistoryRepository import com.livrets.game.GameResult import com.livrets.game.TableStats import com.livrets.game.tableStats import kotlinx.datetime.Instant @Composable fun HistoryScreen( history: HistoryRepository, onBack: () -> Unit, ) { var tab by remember { mutableIntStateOf(0) } var games by remember { mutableStateOf>(emptyList()) } LaunchedEffect(Unit) { games = history.load() } Column( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) .padding(24.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = onBack) { Text("←") } Text("Historique", style = MaterialTheme.typography.headlineSmall) } TabRow(selectedTabIndex = tab) { Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Parties") }) Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Tables") }) } when (tab) { 0 -> Box(Modifier.weight(1f)) { GamesTab(games) } else -> Box(Modifier.weight(1f)) { TablesTab(games.tableStats()) } } } } private fun formatDate(date: Instant): String = date.toString().take(16).replace('T', ' ') @Composable private fun GamesTab(games: List) { if (games.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Aucune partie enregistrée") } return } LazyColumn { items(games.asReversed()) { game -> Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Text( text = "${game.correctCount} / ${game.records.size}", style = MaterialTheme.typography.titleLarge, ) Text( text = formatDate(game.date), style = MaterialTheme.typography.bodySmall, ) Text( text = "${game.config.questionCount} questions · ${game.config.secondsPerQuestion} s par question · jusqu'à la table de ${game.config.maxTable}", style = MaterialTheme.typography.bodySmall, ) HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) } } } } @Composable private fun TablesTab(stats: List) { LazyColumn { items(stats) { stat -> Row( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Table de ${stat.table}", modifier = Modifier.width(110.dp), style = MaterialTheme.typography.bodyMedium, ) if (stat.attempts == 0) { LinearProgressIndicator( progress = { 0f }, modifier = Modifier.weight(1f), ) Text("—", modifier = Modifier.padding(start = 8.dp)) } else { LinearProgressIndicator( progress = { stat.successRate.toFloat() }, modifier = Modifier.weight(1f), color = if (stat.successRate < 0.6) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, ) Text( text = "${(stat.successRate * 100).toInt()} %", modifier = Modifier.padding(start = 8.dp), ) } } } } } ``` - [ ] **Step 2: Vérifier la compilation et les tests** ```bash ./gradlew :sharedUI:jvmTest :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL`. - [ ] **Step 3: Commit** ```bash git add sharedUI/src git commit -m "feat: historique des parties et statistiques de réussite par table" ``` --- ### Task 10: Vérification multi-plateformes + README **Files:** - Modify: `README.MD` (mode d'emploi en français) **Interfaces:** - Consomme tout le plan. Aucun nouveau code de production. - [ ] **Step 1: Vérifier les tests et le build Android** ```bash ./gradlew :sharedUI:jvmTest :androidApp:assembleDebug ``` Attendu : `BUILD SUCCESSFUL`, 20 tests au total (6 + 4 + 8 + 2). - [ ] **Step 2: Vérifier le framework iOS** ```bash ./gradlew :sharedUI:linkDebugFrameworkIosSimulatorArm64 ``` Attendu : `BUILD SUCCESSFUL` (~3-4 min au premier lancement). - [ ] **Step 3: Vérifier le build de l'app iOS via xcodebuild** ```bash xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17 Pro" build ``` Attendu : `** BUILD SUCCEEDED **`. - [ ] **Step 4: Mettre à jour `README.MD`** ```markdown # Livrets Jeu d'entraînement aux tables de multiplication (Kotlin Multiplatform, Android + iOS). ## Règles du jeu - Au lancement, on configure : le **nombre de questions** (5–50), le **temps par question** (3–30 s) et la **table maximum** (1–12). - Chaque question est une multiplication tirée au hasard dans les tables 1 à la table maximum : soit `a × b = ?`, soit `a × ? = b`. On répond avec le pavé numérique. - Temps écoulé = réponse comptée comme fausse (une saisie en cours est auto-validée). - Après chaque réponse, un feedback (vert/rouge) s'affiche 1,5 s puis la question suivante arrive. - À la fin : score, taux de réussite, temps moyen et résumé de chaque réponse fausse. - L'**Historique** conserve les parties passées et le taux de réussite cumulé par table. ## Lancer l'application ### Android ```bash ./gradlew :androidApp:installDebug # appareil/émulateur connecté # ou générer l'APK : ./gradlew :androidApp:assembleDebug # androidApp/build/outputs/apk/debug/ ``` ### iOS Ouvrir `iosApp/iosApp.xcodeproj` dans Xcode et lancer le scheme `iosApp` sur un simulateur. ## Tests ```bash ./gradlew :sharedUI:jvmTest # logique de jeu, générateur, persistance ``` ## Structure - `sharedUI/` : logique de jeu (`game/`), persistance (`data/`), UI Compose (`ui/`), thème (`theme/`) - `androidApp/` : hôte Android - `iosApp/` : hôte iOS (Xcode) ``` - [ ] **Step 4: Commit final** ```bash git add -A git commit -m "docs: readme avec règles du jeu et instructions de lancement" ``` --- ## Notes d'exécution - **Première exécution** : les téléchargements Gradle/Deps prennent ~5-10 min (Gradle 9.7.1, Kotlin 2.4.10, CM 1.12.0). Les exécutions suivantes sont rapides grâce au cache. - **local.properties** n'est pas versionné (gitignore). Il doit contenir `sdk.dir` valide pour les tâches Android. - Sur cette machine, la JVM est x86_64 : `iosSimulatorArm64Test` est désactivé — ne pas l'utiliser comme vérification. La liaison du framework (`linkDebugFrameworkIosSimulatorArm64`) et `xcodebuild` fonctionnent. - Le dossier `/tmp/livrets-scaffold` produit en Task 1 est temporaire ; seul son contenu copié dans le repo compte. - Si `xcodebuild` échoue sur le nom du simulateur, lister les destinations disponibles : `xcodebuild -showdestinations -project iosApp/iosApp.xcodeproj -scheme iosApp` et choisir un simulateur iPhone disponible.