Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
issueRef, version, updatedBy, updatedAt
| issueRef | version | updatedBy | updatedAt | ||||
|---|---|---|---|---|---|---|---|
| #119 | 6 |
|
1785137408716 |
#119 — Extension watch bridge pour score +/- (Architect, 2026-07-26)
Statut : cadrage exploitable, prêt pour DevBackend/DevFrontend, pas de blocage. Basé sur le cadrage UX #118 et l'existant réel (packages/watch_bridge_contract, WatchCompanionCommandHandler, cf. gametime-watch-companion-implementation).
Portée fonctionnelle
Ce ticket couvre le score en mode manual (ScoreInputMode.manual) uniquement. Le score chronométré (stopwatch, cf. gametime-architecture-score-chrono) ne s'"incrémente" pas — il se démarre/arrête ; il n'est pas concerné par ce +/- et reste hors périmètre ici.
Constat sur l'existant
WatchCommandEnvelope(danswatch_bridge_contract) n'a aucun champ payload générique — chaque commande existante est unWatchCommandTypesans paramètre, tout l'effet dérivant de l'état serveur + du type. On reste cohérent avec ce pattern : pas de champdeltagénérique, deux types discrets.- Aucun état vivant n'existe aujourd'hui pour le score manuel pendant une série — contrairement au score chrono qui a
ActiveScoreStopwatchState(persistant, clésessionId+programIndex+exerciseIndex+setIndex). Le score manuel est aujourd'hui un simpleTextEditingControllercôté UI, lu une seule fois au moment de "Terminer la série" (workout_execution_screen.dart). Ce n'est pas suffisant : le +/- montre doit être visible en direct, y compris côté téléphone si l'app est au premier plan.
Décision structurante : le score manuel devient un état de séance vivant
Créer une nouvelle table Drift/entité ActiveManualScoreState, sur exactement le même principe que ActiveScoreStopwatchState :
class ActiveManualScoreState {
final int programIndex, exerciseIndex, setIndex; // même clé logique que ActiveScoreStopwatchState
final double value; // valeur courante, plancher 0
final DateTime updatedAt;
}
- Absence de ligne = valeur par défaut
0. - Port repository :
findManualScoreState/saveManualScoreState/deleteManualScoreStatesurActiveSessionRepository, en parallèle des méthodes*ScoreStopwatchStateexistantes. - Suppression de l'état à la fin/passage de la série (même cycle de vie que le chrono score).
- Plancher :
0fixe pour le MVP. Il n'existe aujourd'hui aucun champ "score minimum" configurable surExercise/ProgramExercise— en ajouter un serait un cadrage spéculatif hors demande explicite. Si un plancher par exercice est réellement voulu, c'est un ticket séparé, pas une extension implicite ici. - Pas de plafond au MVP (cohérent avec le champ libre actuel qui n'a pas de borne haute).
- Pas de palier configurable : incrément fixe
+1/-1(cas d'usage dominant : tally de points/répétitions comptés un par un). Pas de champ "step" à inventer sans besoin exprimé.
Use cases (application layer)
Deux nouvelles méthodes sur ActiveWorkoutSessionUseCases, miroir de startScoreStopwatch/stopScoreStopwatch :
Future<void> incrementManualScore(sessionId, ...position...);
Future<void> decrementManualScore(sessionId, ...position...); // no-op si déjà à 0
recordCurrentSetResult(...) doit lire ActiveManualScoreState.value comme valeur par défaut de actualScore pour la série courante — mais rester surchageable par une édition manuelle explicite côté téléphone au moment de terminer la série (même mécanique que "Modifier le temps" déjà en place pour le score chrono, cf. gametime-architecture-score-chrono) : le paramètre existant de recordCurrentSetResult prend priorité s'il est explicitement fourni par l'UI, sinon on retombe sur l'état vivant.
Pivot UI téléphone (important, à transmettre à DevFrontend)
Le score manuel n'est plus "un champ texte soumis une fois à la fin" — c'est désormais un état de séance vivant, au même titre que le chrono score. Le champ texte actuel (_scoreController) doit être re-branché en lecture/écriture sur ActiveManualScoreState (via le flux de projection/session déjà utilisé pour le reste de l'écran d'exécution), pour que toute modification faite depuis la montre soit visible immédiatement à l'écran si le téléphone est au premier plan.
Extension du contrat watch_bridge_contract
enum WatchCommandType {
..., // types existants inchangés
incrementScore,
decrementScore,
}
Ajout par extension d'enum (Open/Closed) — aucune modification des types/cas existants.
WatchSessionProjection (phone→watch) doit exposer deux champs supplémentaires pour que la montre sache afficher/masquer le bloc et l'état du bouton − sans dupliquer la logique métier :
final bool hasManualScore; // bloc absent sur la montre si false (cf. #118 UX)
final double? currentManualScoreValue; // valeur affichée, null si hasManualScore == false
final bool canDecrementScore; // false si déjà au plancher — logique du plancher reste calculée côté téléphone, jamais dupliquée sur la montre (montre = satellite, cf. [[gametime-watch-companion-implementation]])
Idempotence et applicabilité (WatchCompanionCommandHandler)
_isApplicable():incrementScore/decrementScoreapplicables ssi phase de série active ethasManualScore == truesur la projection courante. Ajouter cette entrée sans toucher aux cas existants.- Point d'attention explicite pour l'implémentation : contrairement à des commandes ponctuelles (ex.
finishCurrentSet), le +/- est tapé rapidement et répétitivement (cf. UX #118 : "usage répété rapide"). L'applicabilité de ces deux types ne doit pas dépendre de l'exactitude deexpectedRevision— seulement de la phase/du mode. Chaque tap génère un nouveaucommandId(idempotence par commande individuelle, déjà gérée par_handledCommands), mais un léger décalage de révision entre deux taps rapprochés (le premier tap ayant déjà fait avancer la révision avant que le second parte) ne doit jamais produirerejectedStaleRevisionpour ces deux types — sinon l'usage répété rapide voulu par l'UX serait cassé. C'est un cas différent des commandes de transition d'étape, qui elles peuvent légitimement dépendre de la révision exacte. - Chaque commande acceptée déclenche
_emitProjectionAfterCommand()comme les autres — republie vers la montre (valeur +canDecrementScoreà jour) et vers le futur coordinateur de notification (#109) sans code spécifique supplémentaire.
Comportement en cas de latence/échec (UX #118, déjà cadré, rappel technique)
Aucun contrat supplémentaire requis ici : le mécanisme optimiste "tap local → état atténué → confirmation" est entièrement côté montre/présentation (watch_app), consommant currentManualScoreValue/révision déjà remontés. Pas de nouvel état serveur à créer pour ça.
Migration
Nouvelle table Drift ActiveManualScoreState ⇒ bump du schemaVersion local (vérifier la valeur courante au moment de l'implémentation, ne pas supposer un numéro figé ici).
Tests
- Contrat :
dart testsur les deux nouveauxWatchCommandType, même fichierpackages/watch_bridge_contract/test/. - Handler : étendre
test/application/watch_companion_command_handler_test.dart— cas incrément/décrément normal, décrément au plancher (no-op, ackacceptedNoOp), applicabilité refusée hors mode manuel, rafale de commandes avec révisions décalées (non rejetées). - Projection : étendre
test/application/watch_companion_projection_test.dartpour les 3 nouveaux champs. - Use case : test
recordCurrentSetResultavec état vivant présent + override manuel explicite.
Audit Architect — implémentation constatée dans le worktree (2026-07-27)
Statut mis à jour : implémenté de bout en bout, conforme au cadrage, un garde-fou de cohérence à fermer.
Vérification du code réel : ActiveManualScoreState (lib/domain/entities.dart, plancher enforced en plus dans le constructeur via _requireNullableNonNegativeDouble), CRUD complet sur ActiveSessionRepository/DriftActiveSessionRepository, incrementManualScore/decrementManualScore/_updateManualScore (use_cases.dart, clamp [0, +∞), ManualScoreUpdateResult.changed pour distinguer accepté/no-op), _allowsStaleRevision(type) dans WatchCompanionCommandHandler qui exempte précisément incrementScore/decrementScore du rejet rejectedStaleRevision — exactement le garde-fou cadré ci-dessus pour l'usage répété rapide. Table Drift active_manual_score_states créée avec CHECK(value >= 0) (plancher renforcé aussi en base), schemaVersion bumpé à 20 avec migration dédiée, table enregistrée dans _syncableTableNames. Contrat watch_bridge_contract étendu exactement comme cadré (hasManualScore/currentManualScoreValue/canDecrementScore, fromJson avec défauts sûrs). Côté montre : incrementScore/decrementScore avec mise à jour optimiste instantanée, opacité atténuée pendant l'attente, recalage silencieux sur échec/timeout (requestResync(), aucun message d'erreur) — conforme au cadrage UX #118.
Garde-fou à fermer avant merge : le pivot téléphone n'est qu'à moitié fait. L'écran d'exécution (workout_execution_screen.dart) lit bien ActiveManualScoreState en direct (_syncManualScoreInput, polling via le ticker 1s existant, ignoré si le champ a le focus) — donc une correction faite depuis la montre remonte bien à l'écran téléphone. Mais l'inverse est manquant : taper une valeur dans le champ texte du téléphone n'écrit jamais dans ActiveManualScoreState ; le texte tapé n'est consommé qu'au moment de finishCurrentSet, en paramètre explicite qui écrase la valeur vivante seulement à cet instant précis. Conséquence concrète : si l'utilisateur corrige le score sur le téléphone puis tape encore une fois sur la montre avant de terminer la série, le tap montre repart de l'ancienne valeur persistée (pas de la correction tapée), et à la validation de la série le champ téléphone (dernière source lue) écrase silencieusement ce que la montre vient d'appliquer — un des deux côtés perd sa modification sans avertissement, alors que l'UX #118 attend explicitement que "la correction plus lourde se fait sur le téléphone, source de vérité complète" en cohérence avec ce que voit la montre à ce moment.
Contrat à fermer pour DevFrontend : toute édition explicite du champ texte téléphone (onSubmitted/onEditingComplete/perte de focus) doit écrire à travers vers ActiveManualScoreState (ex. un setManualScore(sessionId, position, value) sur ActiveWorkoutSessionUseCases, à ajouter en miroir des deux méthodes existantes, avec le même clamp [0, +∞)), pas seulement être lue en fin de série. Cela rend les deux sens de synchronisation (montre→téléphone déjà fait, téléphone→montre à fermer) symétriques et élimine la fenêtre de divergence silencieuse. Sans ce correctif, ne pas considérer le lot #119 comme fonctionnellement complet du point de vue de l'invariant "le téléphone reste toujours la source de vérité complète" posé par UX #118.
Vérification Architect — gap résiduel fermé (2026-07-27, second passage)
Le gap ci-dessus est résolu dans le worktree actuel : ActiveWorkoutSessionUseCases.setManualScore (lib/application/use_cases.dart:2730) existe et applique le même clamp [0, +∞) que increment/decrementManualScore. Côté écran (lib/presentation/workout_execution_screen.dart), _persistManualScoreInputOnce (ligne 702) écrit à travers vers setManualScore à chaque édition explicite validée, appelée par :
- perte de focus (
_handleScoreFocusChange, ligne 139-140) ; onScoreSubmitted/onEditingCompletecâblés sur_persistManualScoreInput(lignes 257-259, 309-311) ;- juste avant
recordCurrentSetResultdans_recordAndAdvance(ligne 1426).
Les deux sens de synchronisation sont désormais symétriques. L'invariant UX #118 ("le téléphone reste la source de vérité complète") est respecté. Aucune action supplémentaire requise sur #119 ; le lot est fonctionnellement complet.