From 828e29dc9873bdfee3c8142e4051451d9061b52c Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Fri, 28 Aug 2026 14:41:50 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20du=20jeu?= =?UTF-8?q?=20de=20multiplications=20KMP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-28-livrets-jeu-multiplications.md | 2080 +++++++++++++++++ 1 file changed, 2080 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-livrets-jeu-multiplications.md diff --git a/docs/superpowers/plans/2026-08-28-livrets-jeu-multiplications.md b/docs/superpowers/plans/2026-08-28-livrets-jeu-multiplications.md new file mode 100644 index 0000000..9b3a454 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-livrets-jeu-multiplications.md @@ -0,0 +1,2080 @@ +# 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.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 8: 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 9: 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.