Files
livrets/docs/superpowers/plans/2026-08-28-livrets-jeu-multiplications.md
T

2080 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<KotlinNativeTarget>()
.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<AnswerRecord>)` avec `correctCount: Int`, `successRate: Double`, `averageResponseMs: Long`.
- `data class TableStats(table: Int, attempts: Int, correctCount: Int)` + `successRate: Double` ; `fun List<GameResult>.tableStats(): List<TableStats>` (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 kotlin.time.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 kotlin.time.Instant
import kotlinx.serialization.Serializable
@Serializable
data class GameResult(
val date: Instant,
val config: GameConfig,
val records: List<AnswerRecord>,
) {
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<GameResult>.tableStats(): List<TableStats> =
(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<GameState>`.
- [ ] **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>(GameState.NotStarted)
val state: StateFlow<GameState> = _state.asStateFlow()
private var timerJob: Job? = null
private var questionStartMark: TimeSource.Monotonic.ValueTimeMark? = null
private val records = mutableListOf<AnswerRecord>()
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<GameResult>` 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 kotlin.time.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<GameResult> = 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.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>(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<GameResult>.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 kotlin.time.Instant
@Composable
fun HistoryScreen(
history: HistoryRepository,
onBack: () -> Unit,
) {
var tab by remember { mutableIntStateOf(0) }
var games by remember { mutableStateOf<List<GameResult>>(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<GameResult>) {
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<TableStats>) {
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.