---
issueRef: "#119"
version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 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` (dans `watch_bridge_contract`) n'a **aucun champ payload générique** — chaque commande existante est un `WatchCommandType` sans paramètre, tout l'effet dérivant de l'état serveur + du type. On reste cohérent avec ce pattern : pas de champ `delta` gé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 simple `TextEditingController` cô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` :
```dart
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` / `deleteManualScoreState` sur `ActiveSessionRepository`, en parallèle des méthodes `*ScoreStopwatchState` existantes.
- Suppression de l'état à la fin/passage de la série (même cycle de vie que le chrono score).
- **Plancher** : `0` fixe pour le MVP. Il n'existe aujourd'hui aucun champ "score minimum" configurable sur `Exercise`/`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` :
```dart
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`
```dart
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 :
```dart
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`/`decrementScore` applicables ssi phase de série active **et** `hasManualScore == true` sur 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 de `expectedRevision` — seulement de la phase/du mode. Chaque tap génère un nouveau `commandId` (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** produire `rejectedStaleRevision` pour 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 test` sur les deux nouveaux `WatchCommandType`, même fichier `packages/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, ack `acceptedNoOp`), 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.dart` pour les 3 nouveaux champs.
- Use case : test `recordCurrentSetResult` avec é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`/`onEditingComplete` câblés sur `_persistManualScoreInput` (lignes 257-259, 309-311) ;
- juste avant `recordCurrentSetResult` dans `_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.
