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>
18 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| gametime-architecture-watch-companion | memory note gametime-architecture-watch-companion |
|
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 parMethodChannel/EventChannelouPigeon.
Justification :
- fonctionne offline/local via le lien téléphone-montre existant, sans cloud ;
MessageClientest adapté aux intentions impératives basse latence ;DataClientest 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
WatchCommandEnvelopedepuis l'adapter Wear ; - sérialiser l'exécution des commandes montre ;
- router chaque commande vers les use cases existants (
ActiveWorkoutSessionUseCases,ActiveExerciseStepUseCaseset lecture repository) ; - construire une
WatchSessionProjectioncompacte à partir de l'état persistant téléphone ; - publier cette projection à chaque mutation d'exécution pertinente.
Ports recommandés côté application :
WatchCommandIngressFuture<WatchCommandAck> dispatch(WatchCommandEnvelope command)
WatchProjectionPublisherFuture<void> publish(WatchSessionProjection projection)
WatchProjectionSourceFuture<WatchSessionProjection> currentProjection()
Important :
WatchCompanionUseCasesest 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.
- appelle la même commande applicative que le téléphone pour
-
pauseSession- route vers
ActiveWorkoutSessionUseCases.pause(...).
- route vers
-
resumeSession- route vers
ActiveWorkoutSessionUseCases.resume(...).
- route vers
-
startPreparedTimedStep- route vers
ActiveExerciseStepUseCases.startTimer(...). - réservé au cas
Chrono suivant prêt.
- route vers
-
skipCurrentStep- route vers
ActiveExerciseStepUseCases.skipCurrentStep(...).
- route vers
-
skipCurrentPassage- route vers
ActiveExerciseStepUseCases.skipCurrentPassage(...).
- route vers
-
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.
- route vers le même enchaînement applicatif que le bouton téléphone
-
skipCurrentSet- route vers le même enchaînement applicatif que
Passer la série, avec skip des chronos/séquence et progression.
- route vers le même enchaînement applicatif que
-
skipCurrentRest- route vers
ActiveWorkoutSessionUseCases.skipRest(...).
- route vers
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é depuisexpectedRevision; 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
revisionentiè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
expectedRevisionest rejetérejectedStaleRevisionet 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 + commandIdsuffit 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/seriesTotalsont 1-based pour éviter toute logique de mapping montre.passageIndex,stepIndexet leurs totals sont omis si non applicables.dominantTimersuit strictement la priorité UX :- repos ;
- étape temps ou
nextTimerReady; - score chrono ;
- temps de série.
secondaryTimerscontient seulement les autres chronos utiles à l'affichage compact, ordonnés.nextExerciseNamen'est renseigné que pendantrestRunning/restPaused.statusLabelsert aux libellés compacts typeChrono é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,accumulatedMsettargetMs; - 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çumais 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
WatchSessionProjectioncomplète courante viaDataClient; - 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émentdataSyncseulement si requis par l'implémentation exacte du bridge ; - arrêt du service quand la séance passe en
completed,abandonedousavedExitet 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
ackou 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/acceptedNoOppour 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
- créer le package
-
#91-B
[DevBackend] Projection compacte d'exécution téléphone -> montre- dériver
WatchSessionProjectiondepuis l'état persistant d'exécution - gérer
revision, priorisation du chrono dominant, actions autorisées - dépend de
#91-A
- dériver
-
#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-Aet#91-B
- mapper toutes les commandes watch vers
-
#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-Bet#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
- créer
-
#91-F
[DevFrontend] Client watch bridge + états pending/latence/reconnexion + haptiques- consommer
acketWatchSessionProjection - gérer interpolation locale, timeouts UX, désactivation actions, resync complet
- déclencher haptiques montre
- dépend de
#91-Det#91-E
- consommer
-
#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-Bet#91-Een parallèle#91-C#91-D#91-F#91-G