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>
453 lines
18 KiB
Markdown
453 lines
18 KiB
Markdown
---
|
|
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`
|