17 KiB
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 :
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) :
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
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é dansApp.kt -
Step 1: Compléter
SwipeBack.ktavec le composable
Ajouter sous l'objet SwipeBackGesture (le fichier final complet) :
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) :
-
snapTosur 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 -
snapToannule toute animationanimateToen cours via laMutatorMutexd'Animatable— un nouveau grab pendant un settle reprend proprement -
Le drag vertical (scroll de la
LazyColumndes erreurs) est laissé aux enfants : ils consomment sur le pass Main (enfant d'abord) et lechange.isConsumedfait 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
-
showBackgroundviaderivedStateOf: recomposition uniquement au passage 0 ↔ >0, letranslationXanimé 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) :
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 :
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 ...) :
val homePreview: @Composable () -> Unit = {
HomeScreen(onPlay = {}, onHistory = {})
}
ainsi que l'import com.livrets.ui.SwipeBack (le bloc d'imports devient) :
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) :
- Lancer une partie rapide (5 questions, répondre faux/vite pour finir) → écran scores
- Glisser avec la souris depuis le bord gauche : l'écran suit, Home visible derrière
- Relâcher avant 40 % → l'écran revient en place
- Relâcher après 40 % (ou geste rapide/fling) → navigation à l'accueil sans saut
- Même vérification via "Historique"
- Vérifier que le scroll de la liste d'erreurs et les clics boutons fonctionnent toujours
- Vérifier qu'un swipe vertical dans la liste ne déclenche pas le retour
- Step 6: Commit
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)
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)
BackHandlerAndroid (le bouton système ferme l'activité — préexistant)systemGestureExclusionAndroid pour cohabitation avec le geste système de navigation (suite possible)- Animation parallaxe du fond, backstack, geste sur Game