Files
GameTime/.ideai/memory/gametime-architecture-watch-companion.md
Blomios 917777e18b 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>
2026-07-28 16:48:54 +02:00

18 KiB

name, description, metadata
name description metadata
gametime-architecture-watch-companion memory note gametime-architecture-watch-companion
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 :

/
  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

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

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

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

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