--- 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 dispatch(WatchCommandEnvelope command)` - `WatchProjectionPublisher` - `Future publish(WatchSessionProjection projection)` - `WatchProjectionSource` - `Future 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 secondaryTimers; final WatchPrimaryAction primaryAction; final List 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`