From 61dc9f1d59fde1b2939bf556245bf04557e06ed4 Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Fri, 28 Aug 2026 16:11:39 +0200 Subject: [PATCH] docs: plan swipe-back --- .../plans/2026-08-28-swipe-back.md | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-swipe-back.md diff --git a/docs/superpowers/plans/2026-08-28-swipe-back.md b/docs/superpowers/plans/2026-08-28-swipe-back.md new file mode 100644 index 0000000..d8de1e9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-swipe-back.md @@ -0,0 +1,430 @@ +# Plan d'implémentation : swipe-back vers l'accueil + +> **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 :** Permettre de revenir au menu principal par un swipe depuis le bord gauche (style iOS) sur les écrans Results et History, avec preview interactive (l'écran suit le doigt/souris, Home révélé derrière). + +**Architecture :** Nouveau composable réutilisable `SwipeBack(onBack, background, content)` dans `sharedUI/ui/` : détection de drag par `pointerInput` custom (zone de bord 24 dp, slop horizontal uniquement), offset suivi par `Animatable` appliqué via `graphicsLayer.translationX`, décision de validation au relâchement par une fonction pure testable `SwipeBackGesture.shouldCommit`. `App.kt` wrappe Results et History avec `HomeScreen` en fond (callbacks no-op). + +**Tech Stack :** Kotlin 2.4.10, Compose Multiplatform 1.12.0, Material3 1.12.0-alpha03, kotlinx-coroutines, kotlin.test (JVM). + +Spec : `docs/superpowers/specs/2026-08-28-swipe-back-design.md` + +## Global Constraints + +- Code exclusivement dans `commonMain` (aucune API platform-specific) — cibles : android (minSdk 23), jvm desktop, iosArm64/iosSimulatorArm64 +- Textes UI en français ; **aucun commentaire de code** +- Pattern projet : écrans sans état de navigation interne, pilotés par callbacks `onXxx: () -> Unit` +- Chaque écran gère déjà ses `WindowInsets.safeDrawing` — ne pas doubler les insets dans SwipeBack +- Tests : kotlin.test dans `commonTest`, exécution via `./gradlew :sharedUI:jvmTest` +- Commits conventional en français, un commit par tâche +- Constantes du geste : `EdgeZone = 24.dp`, `CommitRatio = 0.4f`, `CommitVelocityDpPerSec = 1000f` + +--- + +### Task 1 : Logique de décision `SwipeBackGesture` + +**Files:** +- Create: `sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt` +- Test: `sharedUI/src/commonTest/kotlin/com/livrets/ui/SwipeBackGestureTest.kt` + +**Interfaces:** +- Consumes: rien (fonction pure) +- Produces: `SwipeBackGesture.EdgeZone: Dp`, `SwipeBackGesture.CommitRatio: Float`, `SwipeBackGesture.CommitVelocityDpPerSec: Float`, `SwipeBackGesture.shouldCommit(offsetPx: Float, containerWidthPx: Float, velocityDpPerSec: Float): Boolean` — consommés par le Task 2 + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `sharedUI/src/commonTest/kotlin/com/livrets/ui/SwipeBackGestureTest.kt` : + +```kotlin +package com.livrets.ui + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SwipeBackGestureTest { + + @Test + fun `commit si l offset depasse 40 pourcent de la largeur`() { + assertTrue( + SwipeBackGesture.shouldCommit( + offsetPx = 401f, + containerWidthPx = 1000f, + velocityDpPerSec = 0f, + ) + ) + } + + @Test + fun `pas de commit si offset et velocite sous les seuils`() { + assertFalse( + SwipeBackGesture.shouldCommit( + offsetPx = 399f, + containerWidthPx = 1000f, + velocityDpPerSec = 999f, + ) + ) + } + + @Test + fun `commit par velocite seule meme avec offset nul`() { + assertTrue( + SwipeBackGesture.shouldCommit( + offsetPx = 0f, + containerWidthPx = 1000f, + velocityDpPerSec = 1000f, + ) + ) + } + + @Test + fun `pas de commit si la largeur n est pas connue`() { + assertFalse( + SwipeBackGesture.shouldCommit( + offsetPx = 500f, + containerWidthPx = 0f, + velocityDpPerSec = 2000f, + ) + ) + } +} +``` + +- [ ] **Step 2: Vérifier que le test échoue** + +Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.ui.SwipeBackGestureTest"` +Expected: FAIL — compilation error `unresolved reference: SwipeBackGesture` + +- [ ] **Step 3: Implémenter la logique minimale** + +Créer `sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt` (le composable viendra au Task 2, ce fichier ne contient que l'objet pour l'instant) : + +```kotlin +package com.livrets.ui + +import androidx.compose.ui.unit.dp + +object SwipeBackGesture { + val EdgeZone = 24.dp + const val CommitRatio = 0.4f + const val CommitVelocityDpPerSec = 1000f + + fun shouldCommit(offsetPx: Float, containerWidthPx: Float, velocityDpPerSec: Float): Boolean { + if (containerWidthPx <= 0f) return false + return offsetPx / containerWidthPx > CommitRatio || + velocityDpPerSec >= CommitVelocityDpPerSec + } +} +``` + +- [ ] **Step 4: Vérifier que le test passe** + +Run: `./gradlew :sharedUI:jvmTest --tests "com.livrets.ui.SwipeBackGestureTest"` +Expected: PASS — 4 tests + +- [ ] **Step 5: Commit** + +```bash +git add sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt sharedUI/src/commonTest/kotlin/com/livrets/ui/SwipeBackGestureTest.kt +git commit -m "feat: logique de décision du swipe-back" +``` + +--- + +### Task 2 : Composable `SwipeBack` + branchement dans `App.kt` + +**Files:** +- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt` (ajout du composable sous l'objet existant) +- Modify: `sharedUI/src/commonMain/kotlin/com/livrets/App.kt:51-60` (wrapping Results et History) + +**Interfaces:** +- Consumes: `SwipeBackGesture` (Task 1) +- Produces: `@Composable fun SwipeBack(onBack: () -> Unit, background: @Composable () -> Unit, content: @Composable () -> Unit)` — utilisé dans `App.kt` + +- [ ] **Step 1: Compléter `SwipeBack.kt` avec le composable** + +Ajouter sous l'objet `SwipeBackGesture` (le fichier final complet) : + +```kotlin +package com.livrets.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlinx.coroutines.launch + +object SwipeBackGesture { + val EdgeZone = 24.dp + const val CommitRatio = 0.4f + const val CommitVelocityDpPerSec = 1000f + + fun shouldCommit(offsetPx: Float, containerWidthPx: Float, velocityDpPerSec: Float): Boolean { + if (containerWidthPx <= 0f) return false + return offsetPx / containerWidthPx > CommitRatio || + velocityDpPerSec >= CommitVelocityDpPerSec + } +} + +@Composable +fun SwipeBack( + onBack: () -> Unit, + background: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val offset = remember { Animatable(0f) } + val edgeZonePx = with(density) { SwipeBackGesture.EdgeZone.toPx() } + var widthPx by remember { mutableFloatStateOf(0f) } + val showBackground by remember { derivedStateOf { offset.value > 0f } } + + Box( + Modifier + .fillMaxSize() + .onSizeChanged { widthPx = it.width.toFloat() }, + ) { + if (showBackground) { + Box(Modifier.fillMaxSize()) { background() } + } + Box( + Modifier + .fillMaxSize() + .graphicsLayer { translationX = offset.value } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + if (down.position.x > edgeZonePx) return@awaitEachGesture + + val tracker = VelocityTracker() + tracker.addPosition(down.uptimeMillis, down.position) + var dragging = false + var accumulatedX = 0f + var accumulatedY = 0f + var dragged = 0f + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (event.type == PointerEventType.Cancel) { + if (dragging) { + scope.launch { offset.animateTo(0f, tween(200)) } + } + break + } + + if (!dragging && change.isConsumed) break + + val delta = change.positionChange() + accumulatedX += delta.x + accumulatedY += delta.y + + if (dragging) { + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + dragged = (dragged + delta.x).coerceAtLeast(0f) + scope.launch { offset.snapTo(dragged) } + } else { + val slop = viewConfiguration.touchSlop + if (abs(accumulatedX) > slop || abs(accumulatedY) > slop) { + if (abs(accumulatedX) > abs(accumulatedY)) { + dragging = true + change.consume() + tracker.resetTracking() + tracker.addPosition(change.uptimeMillis, change.position) + dragged = accumulatedX.coerceAtLeast(0f) + scope.launch { offset.snapTo(dragged) } + } else { + break + } + } + } + + if (change.changedToUpIgnoreConsumed) { + if (dragging) { + val velocityDpPerSec = + tracker.calculateVelocity().x / density.density + val commit = SwipeBackGesture.shouldCommit( + offsetPx = dragged, + containerWidthPx = widthPx, + velocityDpPerSec = velocityDpPerSec, + ) + scope.launch { + if (commit) { + offset.snapTo(dragged) + offset.animateTo(widthPx, tween(200)) + onBack() + } else { + offset.snapTo(dragged) + offset.animateTo(0f, tween(200)) + } + } + } + break + } + } + } + }, + ) { + content() + } + } +} +``` + +Points de conception (pour le relecteur) : +- `snapTo` sur valeur **absolue** (`dragged`) et non incrémentale : plusieurs événements pointeur peuvent arriver dans la même frame, les coroutines FIFO sur le dispatcher composition donnent le même résultat final +- `snapTo` annule toute animation `animateTo` en cours via la `MutatorMutex` d'`Animatable` — un nouveau grab pendant un settle reprend proprement +- Le drag vertical (scroll de la `LazyColumn` des erreurs) est laissé aux enfants : ils consomment sur le pass Main (enfant d'abord) et le `change.isConsumed` fait abandonner le geste +- Les taps sur boutons ne sont pas consommés (pas de slop franchi) → clics normaux ; un drag horizontal consommé annule le clic +- `showBackground` via `derivedStateOf` : recomposition uniquement au passage 0 ↔ >0, le `translationX` animé ne recompose pas le layout +- Le fond n'est composé que pendant un drag (aucun coût hors geste) + +- [ ] **Step 2: Vérifier la compilation et les tests existants** + +Run: `./gradlew :sharedUI:jvmTest` +Expected: PASS — les 20 tests existants + 4 nouveaux (24 tests) + +- [ ] **Step 3: Brancher Results et History dans `App.kt`** + +Dans `sharedUI/src/commonMain/kotlin/com/livrets/App.kt`, remplacer (lignes 51-60) : + +```kotlin + 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 }, + ) +``` + +par : + +```kotlin + is Screen.Results -> SwipeBack( + onBack = { screen = Screen.Home }, + background = homePreview, + ) { + ResultsScreen( + result = current.result, + onReplay = { screen = Screen.Game(current.result.config) }, + onHome = { screen = Screen.Home }, + ) + } + + Screen.History -> SwipeBack( + onBack = { screen = Screen.Home }, + background = homePreview, + ) { + HistoryScreen( + history = history, + onBack = { screen = Screen.Home }, + ) + } +``` + +et ajouter avant le `when` (après la ligne 34 `var screen by ...`) : + +```kotlin + val homePreview: @Composable () -> Unit = { + HomeScreen(onPlay = {}, onHistory = {}) + } +``` + +ainsi que l'import `com.livrets.ui.SwipeBack` (le bloc d'imports devient) : + +```kotlin +import com.livrets.ui.GameScreen +import com.livrets.ui.HistoryScreen +import com.livrets.ui.HomeScreen +import com.livrets.ui.ResultsScreen +import com.livrets.ui.SwipeBack +``` + +- [ ] **Step 4: Recompiler et relancer les tests** + +Run: `./gradlew :sharedUI:jvmTest` +Expected: PASS — 24 tests + +- [ ] **Step 5: Vérification manuelle desktop du geste** + +Run: `./gradlew :desktopApp:run` +Vérifier (fenêtre 480×800) : +1. Lancer une partie rapide (5 questions, répondre faux/vite pour finir) → écran scores +2. Glisser avec la souris depuis le bord gauche : l'écran suit, Home visible derrière +3. Relâcher avant 40 % → l'écran revient en place +4. Relâcher après 40 % (ou geste rapide/fling) → navigation à l'accueil sans saut +5. Même vérification via "Historique" +6. Vérifier que le scroll de la liste d'erreurs et les clics boutons fonctionnent toujours +7. Vérifier qu'un swipe vertical dans la liste ne déclenche pas le retour + +- [ ] **Step 6: Commit** + +```bash +git add sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt sharedUI/src/commonMain/kotlin/com/livrets/App.kt +git commit -m "feat: swipe-back vers l'accueil depuis les scores et l'historique" +``` + +--- + +### Task 3 : Vérification des 3 cibles + +**Files:** aucun nouveau fichier (gate de vérification) + +**Interfaces:** +- Consumes: tout le Task 2 +- Produces: validation build complet (pré-requis merge) + +- [ ] **Step 1: Build complet multi-cibles** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL (jvmTest 24/24, assembleDebug Android, targets iOS, desktop) + +Si l'outillage iOS natif n'est pas disponible sur la machine, fallback équivalent : +`./gradlew :sharedUI:compileKotlinIosSimulatorArm64 :androidApp:assembleDebug :desktopApp:jar` + +- [ ] **Step 2: Vérifier les tests une dernière fois** + +Run: `./gradlew :sharedUI:jvmTest` +Expected: 24/24 PASS + +- [ ] **Step 3: Commit (seulement si des ajustements ont été nécessaires)** + +```bash +git add sharedUI/src docs/superpowers/plans/2026-08-28-swipe-back.md +git commit -m "fix: ajustements suite à la vérification multi-cibles" +``` + +--- + +## Hors périmètre (rappels du spec) + +- `BackHandler` Android (le bouton système ferme l'activité — préexistant) +- `systemGestureExclusion` Android pour cohabitation avec le geste système de navigation (suite possible) +- Animation parallaxe du fond, backstack, geste sur Game