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