chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

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>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -0,0 +1,452 @@
---
name: gametime-architecture-watch-companion
description: memory note gametime-architecture-watch-companion
metadata:
type: project
---
# GameTime — Architecture interface montre synchronisée
Décision d'architecture pour la feature #91, basée sur `gametime-ux-watch-companion`, `gametime-architecture-initial-stack-data-model`, `gametime-session-execution-timer-refactor`, `gametime-architecture-step-chaining-override` et `gametime-architecture-exercise-steps`.
## Décision structurante
La montre est un **companion stateless côté domaine** :
- le **téléphone** reste l'unique source de vérité d'exécution ;
- la **montre** n'exécute aucune logique métier de séance, ne persiste aucun état métier et ne calcule aucun enchaînement ;
- toute action montre est une **commande adressée au téléphone** ;
- la montre ne se recale que sur l'**état confirmé** renvoyé par le téléphone.
Conséquence non négociable : le cas clé "première étape chrono + exercice chrono = démarrage commun" reste implémenté uniquement dans `ActiveWorkoutSessionUseCases.startCurrentExerciseTimers(...)`. La montre invoque ce même point métier ; elle ne recompose jamais ce démarrage elle-même.
## Structure projet retenue
Option retenue : **app Wear OS Flutter dédiée dans le mono-dépôt**, pas un module compagnon embarqué dans l'app téléphone.
Structure recommandée :
```text
/
lib/ // app téléphone existante
android/ // app téléphone existante
watch_app/ // nouvelle app Flutter Wear OS dédiée
lib/
android/
pubspec.yaml
packages/
watch_bridge_contract/ // package Dart pur partagé (DTO + enums + codecs)
```
Justification :
- l'app téléphone actuelle est un package Flutter unique déjà câblé pour Android/iOS ; y greffer une surface Wear OS dans le même target Android mélangerait trop la config mobile phone, la config watch et le bridge natif ;
- une app Wear OS dédiée isole les manifestes, permissions, icônes, navigation et cadence de release montre sans polluer l'app téléphone ;
- le mono-dépôt reste simple : un second package Flutter avec dépendance locale vers un package Dart partagé suffit ; pas besoin d'introduire un nouveau runtime, ni de dupliquer le domaine ;
- l'architecture hexagonale reste propre : le package partagé ne contient que des **contrats de transport**, jamais du métier.
Option écartée : module compagnon dans l'app téléphone.
- elle réduit légèrement le nombre de packages, mais couple trop fort les couches Android et rend plus fragile la maintenance du bridge Wearable/Data Layer et des variantes téléphone/montre.
## Canal téléphone ↔ montre retenu
Canal retenu : **Wearable Data Layer natif Android** exposé à Flutter via un adapter d'infrastructure fin.
Répartition :
- **MessageClient** pour les **commandes montre -> téléphone** et les **acks**.
- **DataClient** pour la **projection d'état téléphone -> montre** sous forme de "latest state".
- **CapabilityClient** pour la **découverte de nœud** et la reprise de connexion.
Décision d'implémentation :
- ne pas rendre le domaine/application dépendants d'un plugin tiers ;
- encapsuler le Data Layer dans un adapter Android dédié (`infrastructure/watch_bridge`) exposé à Flutter par `MethodChannel`/`EventChannel` ou `Pigeon`.
Justification :
- fonctionne **offline/local** via le lien téléphone-montre existant, sans cloud ;
- `MessageClient` est adapté aux intentions impératives basse latence ;
- `DataClient` est adapté au **dernier état compact** à rejouer après reconnexion, sans devoir rejouer un historique d'événements ;
- le couple Message/Data est plus robuste qu'un flux message-only : la montre peut toujours se réaligner sur le dernier snapshot autoritaire.
## Architecture hexagonale cible
### Côté téléphone
Ajouter une façade applicative dédiée, additive et non invasive :
`WatchCompanionUseCases`
Responsabilités :
- recevoir une `WatchCommandEnvelope` depuis l'adapter Wear ;
- sérialiser l'exécution des commandes montre ;
- router chaque commande vers les **use cases existants** (`ActiveWorkoutSessionUseCases`, `ActiveExerciseStepUseCases` et lecture repository) ;
- construire une `WatchSessionProjection` compacte à partir de l'état persistant téléphone ;
- publier cette projection à chaque mutation d'exécution pertinente.
Ports recommandés côté application :
- `WatchCommandIngress`
- `Future<WatchCommandAck> dispatch(WatchCommandEnvelope command)`
- `WatchProjectionPublisher`
- `Future<void> publish(WatchSessionProjection projection)`
- `WatchProjectionSource`
- `Future<WatchSessionProjection> currentProjection()`
Important :
- `WatchCompanionUseCases` est une **façade d'orchestration**, pas une seconde logique métier ;
- les règles d'exécution restent dans les use cases existants ;
- les adapters Wear n'appellent jamais directement Drift ni la présentation Flutter téléphone.
### Côté montre
L'app Wear OS a trois couches :
- `presentation/` : écrans UX montre, état local de connexion/pending ;
- `application/` : interprétation minimale des DTO et orchestration UI ;
- `infrastructure/` : adapter Data Layer.
La montre peut persister seulement :
- préférences UI locales ;
- dernier état reçu pour reprise visuelle courte durée si l'app montre est recréée.
Elle ne persiste jamais :
- session métier ;
- timers métier ;
- résultats ;
- historique de commandes comme source de vérité.
## Sémantique des flux
## 1. Montre -> téléphone : contrat de commande
### Enveloppe
```dart
enum WatchCommandType {
startCurrentExercise,
pauseSession,
resumeSession,
startPreparedTimedStep,
skipCurrentStep,
skipCurrentPassage,
finishCurrentSet,
skipCurrentSet,
skipCurrentRest,
}
final class WatchCommandEnvelope {
final int schemaVersion;
final String commandId;
final WatchCommandType type;
final String sessionId;
final int expectedRevision;
final int sentAtEpochMs;
}
```
Décisions :
- `commandId` : UUID généré côté montre, unique par tentative utilisateur.
- `sessionId` : session téléphone visée ; empêche l'application d'une commande à une autre séance après reconnexion.
- `expectedRevision` : révision de projection sur laquelle l'utilisateur a agi.
- pas de payload métier supplémentaire en v1 : toutes les commandes portent implicitement sur la **position courante** de la séance active.
### Mapping métier obligatoire
- `startCurrentExercise`
- appelle la même commande applicative que le téléphone pour `Démarrer l'exercice`.
- route vers `ActiveWorkoutSessionUseCases.startCurrentExerciseTimers(...)`.
- couvre explicitement le démarrage commun timer de série + première étape chrono + score chrono.
- `pauseSession`
- route vers `ActiveWorkoutSessionUseCases.pause(...)`.
- `resumeSession`
- route vers `ActiveWorkoutSessionUseCases.resume(...)`.
- `startPreparedTimedStep`
- route vers `ActiveExerciseStepUseCases.startTimer(...)`.
- réservé au cas `Chrono suivant prêt`.
- `skipCurrentStep`
- route vers `ActiveExerciseStepUseCases.skipCurrentStep(...)`.
- `skipCurrentPassage`
- route vers `ActiveExerciseStepUseCases.skipCurrentPassage(...)`.
- `finishCurrentSet`
- route vers le même enchaînement applicatif que le bouton téléphone `Terminer la série` :
- arrêt/enregistrement des chronos actifs via les use cases existants ;
- création/mise à jour du résultat de série ;
- démarrage éventuel du repos ;
- progression de curseur.
- `skipCurrentSet`
- route vers le même enchaînement applicatif que `Passer la série`, avec skip des chronos/séquence et progression.
- `skipCurrentRest`
- route vers `ActiveWorkoutSessionUseCases.skipRest(...)`.
### Ack
```dart
enum WatchCommandAckStatus {
accepted,
acceptedNoOp,
rejectedStaleRevision,
rejectedNotApplicable,
rejectedNoActiveSession,
rejectedSessionMismatch,
rejectedPhoneBusy,
}
final class WatchCommandAck {
final int schemaVersion;
final String commandId;
final WatchCommandAckStatus status;
final String sessionId;
final int revisionAtAck;
final int ackedAtEpochMs;
final String? reasonCode;
}
```
Règles :
- `accepted` : la commande a été appliquée ; une projection mise à jour doit suivre immédiatement.
- `acceptedNoOp` : la commande était déjà satisfaite ou doublonnée sans effet métier.
- `rejectedStaleRevision` : l'état téléphone a avancé depuis `expectedRevision` ; la commande n'est pas rejouée sur le nouvel état. La montre doit attendre le snapshot courant et se recaler.
- `rejectedNotApplicable` : action impossible dans l'état courant.
- `rejectedPhoneBusy` : réservé au cas exceptionnel où le téléphone n'a pas pu sérialiser immédiatement ; la montre ne rejoue pas en boucle sans nouvel état.
### Garantie d'ordre et d'idempotence
Décision :
- les commandes montre sont traitées **séquentiellement** côté téléphone, via une file mono-consommateur dans `WatchCompanionUseCases` ;
- le téléphone incrémente une `revision` entière de projection à chaque mutation visible montre ;
- une commande n'est appliquée que si `expectedRevision == currentRevision` ;
- après succès, la nouvelle projection porte `revision + 1` ;
- un duplicate/retry avec ancien `expectedRevision` est rejeté `rejectedStaleRevision` et ne peut donc pas skipper une étape supplémentaire par accident.
Conséquence :
- **pas besoin** d'une persistance métier de reçus de commandes sur la montre ;
- la combinaison `sessionId + expectedRevision + commandId` suffit pour obtenir un comportement effectivement idempotent côté UX ;
- l'ordre réel retenu est toujours celui du téléphone, jamais celui reconstruit par la montre.
## 2. Téléphone -> montre : DTO de projection d'état
### DTO racine
```dart
enum WatchSessionPhase {
noActiveSession,
ready,
running,
paused,
nextTimerReady,
restRunning,
restPaused,
betweenSetsReady,
}
enum WatchPrimaryAction {
none,
startCurrentExercise,
pauseSession,
resumeSession,
startPreparedTimedStep,
skipCurrentRest,
}
enum WatchSecondaryAction {
skipCurrentStep,
skipCurrentPassage,
finishCurrentSet,
skipCurrentSet,
skipCurrentRest,
}
final class WatchSessionProjection {
final int schemaVersion;
final String deviceSessionId;
final int revision;
final int projectedAtEpochMs;
final WatchSessionPhase phase;
final bool phoneReachable;
final int seriesIndex;
final int seriesTotal;
final String exerciseName;
final int? passageIndex;
final int? passageTotal;
final int? stepIndex;
final int? stepTotal;
final String? stepName;
final WatchTimerProjection? dominantTimer;
final List<WatchTimerProjection> secondaryTimers;
final WatchPrimaryAction primaryAction;
final List<WatchSecondaryAction> secondaryActions;
final String? nextExerciseName;
final String? statusLabel;
}
```
### DTO timer
```dart
enum WatchTimerKind {
rest,
step,
scoreStopwatch,
setTimer,
}
enum WatchTimerDisplayMode {
countdown,
elapsed,
}
enum WatchTimerRunState {
stopped,
running,
paused,
}
final class WatchTimerProjection {
final WatchTimerKind kind;
final String label;
final WatchTimerDisplayMode displayMode;
final WatchTimerRunState runState;
final int referenceEpochMs;
final int accumulatedMs;
final int? startedAtEpochMs;
final int? targetMs;
}
```
### Règles de calcul
- `seriesIndex` / `seriesTotal` sont **1-based** pour éviter toute logique de mapping montre.
- `passageIndex`, `stepIndex` et leurs totals sont omis si non applicables.
- `dominantTimer` suit strictement la priorité UX :
1. repos ;
2. étape temps ou `nextTimerReady` ;
3. score chrono ;
4. temps de série.
- `secondaryTimers` contient seulement les autres chronos utiles à l'affichage compact, ordonnés.
- `nextExerciseName` n'est renseigné que pendant `restRunning` / `restPaused`.
- `statusLabel` sert aux libellés compacts type `Chrono étape`, `Séance en pause`, `Prêt pour la série suivante`.
### Interpolation locale du chrono
Décision :
- la montre **interpole localement l'affichage du chrono** à partir de `referenceEpochMs`, `startedAtEpochMs`, `accumulatedMs` et `targetMs` ;
- le téléphone envoie une projection immédiatement à chaque transition métier et un **heartbeat de resynchronisation léger toutes les 5 secondes** tant qu'au moins un chrono est `running`.
Justification :
- réduit fortement le trafic et la batterie par rapport à un push haute fréquence ;
- exploite le modèle téléphone déjà persistant par horodatages ;
- garde la montre lisible même avec une brève latence ;
- la montre n'utilise cette interpolation que pour **l'affichage**, jamais pour décider d'un changement métier.
## États de connexion, latence et resync
Décision de seuils v1 :
- après tap sur la montre : état local `Envoi...` immédiat ;
- si pas d'ack après **500 ms** : afficher `En attente du téléphone` ;
- si pas d'ack après **2 s** : commande considérée en timeout UX ;
- si aucun ack ni projection fraîche depuis **10 s** : état `Connexion perdue`, actions désactivées ;
- si une projection reçue date de plus de **6 s** pendant une séance active, la montre la marque `dernier état reçu` mais garde encore l'écran.
Stratégie de reprise :
- à reconnexion d'un nœud téléphone, la montre demande un `resync` ;
- le téléphone republie la `WatchSessionProjection` complète courante via `DataClient` ;
- la projection complète remplace toujours l'état montre en entier, jamais patch par patch.
## Service premier plan téléphone
Décision : quand une séance est active côté téléphone (`running`, `paused` ou repos actif), le bridge montre doit vivre dans un **foreground service Android** dédié au companion.
Responsabilités du service :
- garder le process téléphone vivant pendant la séance ;
- écouter les commandes Wear Data Layer ;
- invoquer `WatchCompanionUseCases` ;
- publier les projections et heartbeats ;
- exposer une notification persistante `Séance en cours`.
Contraintes :
- le service ne porte **aucune logique métier** ; il orchestre uniquement le bridge et les use cases existants ;
- il doit redémarrer à partir de l'état persistant téléphone si Android recrée le process pendant une séance ;
- type Android recommandé : `connectedDevice`, avec complément `dataSync` seulement si requis par l'implémentation exacte du bridge ;
- arrêt du service quand la séance passe en `completed`, `abandoned` ou `savedExit` et qu'aucune synchronisation montre n'est encore en vol.
## Conflits et source de vérité
Confirmation de la règle UX :
- une action montre n'est **jamais appliquée localement** sur la montre ;
- la montre ne fait qu'afficher un pending local puis attend `ack + projection` ;
- si téléphone et montre agissent presque simultanément, l'ordre retenu est celui appliqué par le téléphone ;
- une commande fondée sur une révision périmée est rejetée `rejectedStaleRevision`, puis remplacée visuellement par l'état réel courant.
## Haptiques
Décision :
- déclenchement **côté montre**, à réception d'un `ack` ou d'une projection franchissant un jalon ;
- jamais côté téléphone pour la montre ;
- aucun son requis au MVP ;
- si un son est ajouté plus tard, il doit être configuré sans prise de focus audio.
Mapping v1 :
- `accepted` / `acceptedNoOp` pour start/pause/reprise : impulsion courte ;
- projection entrant en `nextTimerReady`, fin de repos ou fin de chrono visible : double impulsion ;
- perte de connexion après action : impulsion lourde unique optionnelle.
Invariant #92 :
- aucune API haptique/son montre ne doit prendre le focus audio ni interrompre la musique du téléphone.
## Invariants à préserver
- le domaine d'exécution reste centralisé sur le téléphone ;
- aucune logique d'enchaînement d'étapes, de repos ou de timers n'est dupliquée sur la montre ;
- toute commande montre passe par les mêmes use cases applicatifs que l'UI téléphone ;
- la projection montre reste compacte et dérivée, jamais source de vérité ;
- la reconnexion remplace intégralement l'état montre par le dernier snapshot téléphone ;
- aucune nouvelle base métier n'est introduite sur la montre.
## Sous-tickets recommandés
- #91-A `[DevBackend] Contrats watch bridge partagés + façade applicative WatchCompanionUseCases`
- créer le package `packages/watch_bridge_contract`
- définir `WatchCommandEnvelope`, `WatchCommandAck`, `WatchSessionProjection`
- créer la façade applicative téléphone et la file séquentielle
- dépendances : aucune
- #91-B `[DevBackend] Projection compacte d'exécution téléphone -> montre`
- dériver `WatchSessionProjection` depuis l'état persistant d'exécution
- gérer `revision`, priorisation du chrono dominant, actions autorisées
- dépend de `#91-A`
- #91-C `[DevBackend] Routing des commandes montre vers les use cases d'exécution existants`
- mapper toutes les commandes watch vers `ActiveWorkoutSessionUseCases` / `ActiveExerciseStepUseCases`
- appliquer contrôle `sessionId + expectedRevision`
- produire `WatchCommandAck`
- dépend de `#91-A` et `#91-B`
- #91-D `[DevBackend] Adapter Android Wear Data Layer + foreground service téléphone`
- implémenter l'adapter natif MessageClient/DataClient/CapabilityClient
- brancher le service premier plan, réception commandes, publication projections/heartbeats
- dépend de `#91-B` et `#91-C`
- #91-E `[DevFrontend] App Wear OS Flutter dédiée + navigation UX montre`
- créer `watch_app/`
- implémenter écrans `pas de séance`, `séance active`, `actions`, `repos`, `connexion perdue`
- dépend de `#91-A`
- #91-F `[DevFrontend] Client watch bridge + états pending/latence/reconnexion + haptiques`
- consommer `ack` et `WatchSessionProjection`
- gérer interpolation locale, timeouts UX, désactivation actions, resync complet
- déclencher haptiques montre
- dépend de `#91-D` et `#91-E`
- #91-G `[QA] Validation companion watch offline/local`
- vérifier ordre/idempotence, rejet de révision périmée, reconnexion, écran verrouillé/téléphone en arrière-plan, absence d'interruption audio
- dépend de `#91-F`
Ordre recommandé :
- `#91-A`
- `#91-B` et `#91-E` en parallèle
- `#91-C`
- `#91-D`
- `#91-F`
- `#91-G`