Files
livrets/docs/superpowers/plans/2026-08-28-swipe-back.md
T

431 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
- [x] **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`
- [x] **Step 2: Vérifier les tests une dernière fois**
Run: `./gradlew :sharedUI:jvmTest`
Expected: 24/24 PASS
- [x] **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