docs: plan swipe-back
This commit is contained in:
1 file changed
+430
@@ -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
|
||||
Reference in new issue
Block a user