2080 lines
70 KiB
Markdown
2080 lines
70 KiB
Markdown
# 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.
|