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

70 KiB
Raw Blame History

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).

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 :

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) :

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
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 :

[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 :

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
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 :

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é) :

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 :

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
./gradlew help :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL (le help valide la configuration ; assembleDebug compile commonMain pour android).

  • Step 8: Commit
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 :

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
./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 :

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 :

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 :

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
./gradlew :sharedUI:jvmTest

Attendu : PASS (6 tests).

  • Step 5: Commit
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 :

package com.livrets.game

import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class GameResultTest {

    private fun record(table: Int, correct: Boolean) = AnswerRecord(
        question = Question.Direct(table, table),
        givenAnswer = if (correct) table * table else table * table + 1,
        correctAnswer = table * table,
        isCorrect = correct,
        durationMs = 1000,
    )

    @Test
    fun `stats par table avec tables sans donnees`() {
        val results = listOf(
            GameResult(
                date = Instant.parse("2026-01-01T00:00:00Z"),
                config = GameConfig(4, 10, 12),
                records = listOf(
                    record(7, true), record(7, true), record(7, false), record(9, false),
                ),
            ),
        )
        val stats = results.tableStats()
        assertEquals(12, stats.size)
        assertEquals(7, stats[6].table)
        assertEquals(3, stats[6].attempts)
        assertEquals(2, stats[6].correctCount)
        assertEquals(1, stats[8].attempts)
        assertEquals(0, stats[3].attempts)
        assertEquals(0.0, stats[3].successRate)
    }

    @Test
    fun `derivees de GameResult`() {
        val result = GameResult(
            date = Instant.parse("2026-01-01T00:00:00Z"),
            config = GameConfig(3, 10, 12),
            records = listOf(
                record(2, true),
                record(3, false),
                AnswerRecord(Question.MissingFactor(4, 6), null, 6, false, 5000),
            ),
        )
        assertEquals(1, result.correctCount)
        assertEquals(1.0 / 3, result.successRate, 1e-9)
        assertTrue(result.averageResponseMs > 0)   // seules les réponses non-nulles comptent
    }

    @Test
    fun `seuil table faible - en dessous de 60 pourcent`() {
        val stats = listOf(
            GameResult(
                date = Instant.parse("2026-01-01T00:00:00Z"),
                config = GameConfig(2, 10, 12),
                records = listOf(record(8, false), record(8, false)),
            ),
        ).tableStats()
        assertTrue(stats[7].successRate < 0.6)
    }

    @Test
    fun `serialisation aller-retour de GameResult`() {
        val result = GameResult(
            date = Instant.parse("2026-08-28T10:00:00Z"),
            config = GameConfig(10, 10, 6),
            records = listOf(
                record(7, true),
                AnswerRecord(
                    question = Question.MissingFactor(3, 8),
                    givenAnswer = null,
                    correctAnswer = 8,
                    isCorrect = false,
                    durationMs = 10_000,
                ),
            ),
        )
        val json = Json.encodeToString(GameResult.serializer(), result)
        val decoded = Json.decodeFromString(GameResult.serializer(), json)
        assertEquals(result, decoded)
    }
}
  • Step 2: Vérifier l'échec
./gradlew :sharedUI:jvmTest

Attendu : FAIL — Unresolved reference 'AnswerRecord'.

  • Step 3: Implémenter

sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt :

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 :

package com.livrets.game

import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable

@Serializable
data class GameResult(
    val date: Instant,
    val config: GameConfig,
    val records: List<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 :

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
./gradlew :sharedUI:jvmTest

Attendu : PASS.

  • Step 5: Commit
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 :

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
./gradlew :sharedUI:jvmTest

Attendu : FAIL — Unresolved reference 'GameEngine'.

  • Step 3: Implémenter GameEngine

sharedUI/src/commonMain/kotlin/com/livrets/game/GameState.kt :

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 :

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
./gradlew :sharedUI:jvmTest

Attendu : PASS (8 tests GameEngine + 6 + 4 des tâches précédentes).

  • Step 5: Commit
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 :

package com.livrets.data

import com.livrets.game.AnswerRecord
import com.livrets.game.GameConfig
import com.livrets.game.GameResult
import com.livrets.game.Question
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import okio.fakefilesystem.FakeFileSystem
import okio.Path.Companion.toPath
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class HistoryRepositoryTest {

    private fun gameResult(date: String, score: Int) = GameResult(
        date = Instant.parse(date),
        config = GameConfig(10, 10, 12),
        records = List(score) {
            AnswerRecord(Question.Direct(2, 3), 6, 6, true, 900L)
        } + List(10 - score) {
            AnswerRecord(Question.Direct(2, 3), null, 6, false, 10_000L)
        },
    )

    @Test
    fun `chargement vide quand aucun fichier`() = runTest {
        val fs = FakeFileSystem()
        val repo = HistoryRepository("/docs/history.json".toPath(), fs)
        assertTrue(repo.load().isEmpty())
    }

    @Test
    fun `aller-retour sauvegarde puis chargement`() = runTest {
        val fs = FakeFileSystem()
        val repo = HistoryRepository("/docs/history.json".toPath(), fs)
        val first = gameResult("2026-01-01T00:00:00Z", score = 7)
        val second = gameResult("2026-02-02T00:00:00Z", score = 4)

        repo.save(first)
        assertEquals(listOf(first), repo.load())

        repo.save(second)
        assertEquals(listOf(first, second), repo.load())
        assertTrue(fs.exists("/docs/history.json".toPath()))
    }
}
  • Step 2: Vérifier l'échec
./gradlew :sharedUI:jvmTest

Attendu : FAIL — Unresolved reference 'data'.

  • Step 3: Implémenter HistoryRepository

sharedUI/src/commonMain/kotlin/com/livrets/data/HistoryRepository.kt :

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
./gradlew :sharedUI:jvmTest

Attendu : PASS.

  • Step 5: Commit
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

package com.livrets

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.livrets.data.HistoryRepository
import com.livrets.game.GameConfig
import com.livrets.game.GameResult
import com.livrets.theme.AppTheme
import com.livrets.ui.GameScreen
import com.livrets.ui.HistoryScreen
import com.livrets.ui.HomeScreen
import com.livrets.ui.ResultsScreen
import kotlinx.coroutines.launch
import okio.Path.Companion.toPath

sealed interface Screen {
    data object Home : Screen
    data class Game(val config: GameConfig) : Screen
    data class Results(val result: GameResult) : Screen
    data object History : Screen
}

@Composable
fun App(
    filesDirPath: String,
    onThemeChanged: @Composable (isDark: Boolean) -> Unit = {},
) = AppTheme(onThemeChanged) {
    val scope = rememberCoroutineScope()
    val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) }
    var screen by remember { mutableStateOf<Screen>(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
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) :

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) :

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) :

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
./gradlew :sharedUI:jvmTest :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL.

  • Step 5: Commit
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

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
./gradlew :sharedUI:jvmTest :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL.

  • Step 3: Commit
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

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
./gradlew :sharedUI:jvmTest :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL.

  • Step 3: Commit
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

package com.livrets.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.livrets.data.HistoryRepository
import com.livrets.game.GameResult
import com.livrets.game.TableStats
import com.livrets.game.tableStats
import kotlinx.datetime.Instant

@Composable
fun HistoryScreen(
    history: HistoryRepository,
    onBack: () -> Unit,
) {
    var tab by remember { mutableIntStateOf(0) }
    var games by remember { mutableStateOf<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
./gradlew :sharedUI:jvmTest :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL.

  • Step 3: Commit
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

./gradlew :sharedUI:jvmTest :androidApp:assembleDebug

Attendu : BUILD SUCCESSFUL, 20 tests au total (6 + 4 + 8 + 2).

  • Step 2: Vérifier le framework iOS
./gradlew :sharedUI:linkDebugFrameworkIosSimulatorArm64

Attendu : BUILD SUCCESSFUL (~3-4 min au premier lancement).

  • Step 3: Vérifier le build de l'app iOS via xcodebuild
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
# 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

./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.