Files
livrets/docs/superpowers/plans/2026-08-28-swipe-back.md
T
2026-08-28 16:11:39 +02:00

17 KiB
Raw Blame History

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é dans App.kt

  • Step 1: Compléter SwipeBack.kt avec 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) :

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

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

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

  • 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