chore(wip): lot #165-169 validé QA + rework foreground #163 (KO - preuve device/toolchain insuffisante)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:59:43 +02:00
parent 917777e18b
commit bc533d6c45
32 changed files with 898 additions and 123 deletions

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#136" issueRef: "#136"
version: 8 version: 9
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1785244375293 updatedAt: 1785252649210
--- ---
## #136 — Cadrage UX (2026-07-28) ## #136 — Cadrage UX (2026-07-28)

View File

@ -2,15 +2,15 @@
id: "cd125c5f-5e37-4100-8b61-58cbaab5eac1" id: "cd125c5f-5e37-4100-8b61-58cbaab5eac1"
number: 136 number: 136
title: "[Bug] \"passer le repos\" sur la montre ne fonctionne pas" title: "[Bug] \"passer le repos\" sur la montre ne fonctionne pas"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [] agentRefs: []
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
createdAt: 1785141207792 createdAt: 1785141207792
updatedAt: 1785244375293 updatedAt: 1785252649210
version: 8 version: 9
--- ---
Le bouton "passer le repos" sur la montre ne fonctionne pas, il me renvoie sur l'écran de la séance sur la montre mais ne passe pas le repos sur le téléphone Le bouton "passer le repos" sur la montre ne fonctionne pas, il me renvoie sur l'écran de la séance sur la montre mais ne passe pas le repos sur le téléphone

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#157" issueRef: "#157"
version: 4 version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1785248859176 updatedAt: 1785252668623
--- ---

View File

@ -2,15 +2,15 @@
id: "4ba5c5bc-c575-4afc-bb55-93feb7e7ad9c" id: "4ba5c5bc-c575-4afc-bb55-93feb7e7ad9c"
number: 157 number: 157
title: "[DevFrontend] Distance live montre sur séance en cours" title: "[DevFrontend] Distance live montre sur séance en cours"
status: "qa" status: "open"
priority: "medium" priority: "medium"
sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea"
links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
createdAt: 1785180656723 createdAt: 1785180656723
updatedAt: 1785248859176 updatedAt: 1785252668623
version: 4 version: 5
--- ---
Ajouter la distance parcourue live remontée par la montre compatible, avec affichage léger côté téléphone pendant la séance et réutilisation côté montre dans le panneau secondaire. Dégradation silencieuse si donnée indisponible. Ajouter la distance parcourue live remontée par la montre compatible, avec affichage léger côté téléphone pendant la séance et réutilisation côté montre dans le panneau secondaire. Dégradation silencieuse si donnée indisponible.

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#160" issueRef: "#160"
version: 4 version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785248859257 updatedAt: 1785254327417
--- ---

View File

@ -2,7 +2,7 @@
id: "b5b4fe80-952f-4376-9271-de98817add32" id: "b5b4fe80-952f-4376-9271-de98817add32"
number: 160 number: 160
title: "[DevFrontend] Graphiques historiques statistiques montre" title: "[DevFrontend] Graphiques historiques statistiques montre"
status: "inProgress" status: "qa"
priority: "medium" priority: "medium"
sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea"
links: [{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] links: [{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: []
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785180656778 createdAt: 1785180656778
updatedAt: 1785248859257 updatedAt: 1785254327417
version: 4 version: 5
--- ---
Implémenter les graphiques et écrans d'historique pour les statistiques montre conservées : fréquence cardiaque avec détail par étape/série/exercice, distance parcourue, calories consommées. S'appuie sur la persistance historique et reste silencieux si certaines séries de données sont absentes. Implémenter les graphiques et écrans d'historique pour les statistiques montre conservées : fréquence cardiaque avec détail par étape/série/exercice, distance parcourue, calories consommées. S'appuie sur la persistance historique et reste silencieux si certaines séries de données sont absentes.

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#163" issueRef: "#163"
version: 5 version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785248859271 updatedAt: 1785254327435
--- ---

View File

@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785242433447 createdAt: 1785242433447
updatedAt: 1785248859271 updatedAt: 1785254327435
version: 5 version: 6
--- ---
Quand l'écran de la montre est éteint, la fréquence cardiaque n'est aps actualisée (l'affichage est assombris sur le téléphone et la fréquance ne change pas). Pour les statistiques il fauit que la fréquence cardiaque continue d'être enregistrée Quand l'écran de la montre est éteint, la fréquence cardiaque n'est aps actualisée (l'affichage est assombris sur le téléphone et la fréquance ne change pas). Pour les statistiques il fauit que la fréquence cardiaque continue d'être enregistrée

View File

@ -0,0 +1,6 @@
---
issueRef: "#165"
version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785254327333
---

View File

@ -0,0 +1,16 @@
---
id: "35ff6501-b5fd-4dd3-8b88-dc20e3c6c07d"
number: 165
title: "[Bug] impossible de lancer un chrono depuis la montre"
status: "qa"
priority: "high"
sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785250763662
updatedAt: 1785254327333
version: 5
---
J'ai encore des cas ou je ne peux pas lancer de chrono depuis al montre. Par exemple j'avais deux étapes qui s'enchainaient et qui avaient un chrono, quand j'ai fais passer l'étape depuis la montre, je ne pouvais pas lancer le chrono de l'étape suivante

View File

@ -0,0 +1,67 @@
---
issueRef: "#166"
version: 8
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785254327361
---
## #166/#167/#169 — Cadrage UX coordonné : slot "donnée dominante sans chrono" (UX, 2026-07-28)
Statut : **cadrage exploitable, à traiter comme une seule mini-refonte**, pas trois patchs indépendants (voir point 4). Détail complet ici sur #166, #167 et #169 pointent vers ce carnet + leur delta propre.
Base : écran `Séance active` déjà révisé par #161 (fermé) — un chrono/donnée dominante + un bouton d'action unique sur la même ligne, un seul libellé, FC en icône en pied d'écran. Ce lot traite les **cas où il n'y a pas de chrono actif**, laissés de côté par #161.
### Constat commun
Quand il n'y a pas de chrono actif, l'écran retombe aujourd'hui sur des solutions par défaut peu utiles : le compteur `SÉRIE X/Y` se répète en donnée dominante alors qu'il est déjà affiché en en-tête (#167), aucune action rapide n'est proposée pour une étape à répétitions alors que c'est justement l'action la plus probable à cet instant (#166), et un sous-titre `Prêt pour la série suivante` occupe une ligne entière pour une information déjà déductible du reste de l'écran (#169).
### 1. Comportement attendu par ticket
**#166** : sur une étape à répétitions (sans chrono), un bouton de validation/passage à l'étape suivante est visible directement sur l'écran principal — pas besoin de swiper vers `Actions`. Ce bouton déclenche la même commande que `Étape suivante` (valide l'étape, avance), pas un `Passer l'étape` (qui reste une action de contournement, toujours dans `Actions`).
**#167** : sur une étape à répétitions (sans chrono), la donnée dominante centrale devient le nombre de répétitions à faire pour cette étape, et non plus le nombre de séries de l'exercice (déjà visible en en-tête `SÉRIE X/Y`, donc redondant).
**#169** : le sous-titre `Prêt pour la série suivante` est supprimé, sans remplacement — l'écran (nom d'exercice + bouton `Démarrer l'exercice`) reste compréhensible sans lui.
### 2. Disposition recommandée — écran principal, cas étape reps-only
Réutilise **exactement** le template ligne chrono+bouton posé par #161, appliqué à la donnée reps au lieu du chrono :
```text
SÉRIE 2 / 5
Pompes tempo
Passage 1 / 3 · Étape 2 / 4
Répétitions
10 [✓]
♥ 132
```
- Légende `Répétitions` une seule fois, au-dessus de la valeur — même règle que le libellé de chrono (#161).
- Bouton icône `[✓]` (valider/étape suivante) à droite de la valeur, même ligne, même cible tactile ≥48dp que le bouton pause/play (#128/#161) — pas un nouveau composant, le même gabarit rejoue avec une icône différente.
- Un seul bouton d'action visible à la fois sur cette ligne : jamais `[✓]` et `[⏸]` en même temps. Si l'étape a aussi un chrono (score chrono d'étape, cas #140), ce cas reste géré par le layout dédié `_ManualScoreContent` existant, non touché par ce lot.
- Cœur FC inchangé, en pied d'écran (#161).
### 3. Disposition recommandée — écran `Prêt pour la série suivante`
```text
SÉRIE 3 / 5
Pompes tempo
[Démarrer l'exercice]
```
Le sous-titre disparaît, l'espace libéré n'est **pas** réutilisé pour un nouvel élément (contrainte explicite : ne pas ajouter de bruit) — juste un espacement plus respirant entre le nom d'exercice et le bouton.
### 4. Garde-fous de lisibilité / priorité visuelle
- Un seul élément "action" par écran, toujours à droite de la donnée dominante quand il y en a une (chrono → `[⏸]`, reps → `[✓]`) — jamais empilé en dessous, jamais dupliqué ailleurs. C'est la même règle que #161, appliquée maintenant au cas reps.
- Ne jamais afficher deux fois la même information sous deux formes (le principe qui motive #167) : si une donnée est déjà visible ailleurs sur l'écran (ex. `SÉRIE X/Y` en en-tête), elle n'a pas sa place une seconde fois dans le slot dominant.
- Suppression de texte (#169) : ne jamais compenser par un ajout ailleurs sauf besoin explicite ; le vide contrôlé est préférable au bruit, conformément à la contrainte utilisateur de ce lot.
- Icône `[✓]` distincte visuellement de `[⏸]`/`[▶]` pour éviter toute ambiguïté entre "valider l'étape" et "mettre en pause la séance" — même style (or sur fond `#141824`, anneau crimson au tap, cf. #128) mais pictogramme différent.
### 5. Coordination des 3 tickets
**Oui, à traiter comme une seule mini-refonte cohérente**, pas trois patchs séparés :
- #166 et #167 modifient la **même zone d'écran** (slot donnée dominante + bouton d'action pour une étape reps-only) — les livrer séparément risquerait qu'un commit écrase la mise en page de l'autre, ou que le bouton `[✓]` soit ajouté sans que la redondance `SÉRIE X/Y` soit corrigée en même temps (ou l'inverse).
- #169 touche un état adjacent du même écran (`Séance active` sans chrono actif) et applique le même principe (pas de texte qui ne porte pas d'action ni de donnée nécessaire) — le traiter isolément ferait revivre exactement la dérive de densité déjà diagnostiquée et corrigée par #161 (accumulation de correctifs isolés qui perdent la cohérence d'ensemble).
- Recommandation : un seul lot Architect/DevFrontend pour les trois, avec cette section comme spec unique de référence.

View File

@ -0,0 +1,16 @@
---
id: "71f99d03-7ac8-435f-92ef-087e61c0f873"
number: 166
title: "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape"
status: "qa"
priority: "medium"
sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea"
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785250931982
updatedAt: 1785254327361
version: 8
---
Quand on a une étape qui ne demande qu'un nombre de répétitions, il faudrait que le bouton pour calider l'étape directement sur l'écran principal de la montre.

View File

@ -0,0 +1,10 @@
---
issueRef: "#167"
version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785254327381
---
## #167 — Cadrage UX (2026-07-28)
Spec complète et coordination avec #166/#169 : voir carnet #166 (« Cadrage UX coordonné : slot "donnée dominante sans chrono" »). Ce ticket = le remplacement de la donnée dominante (série → répétitions de l'étape) dans le template partagé décrit là-bas. Ne pas traiter séparément de #166 : même zone d'écran, même livraison.

View File

@ -0,0 +1,16 @@
---
id: "a321767d-8f07-45fc-aff6-935540009aa5"
number: 167
title: "[UI] Afficher le nombre de répétition à faire pour une étape sur la montre"
status: "qa"
priority: "medium"
sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea"
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785251047775
updatedAt: 1785254327381
version: 7
---
Il est important que l'utilisateur sache ce qu'il a a faire directement depuis sa montre. Dans le cas d'une étape qui demande un nombre de répétitions, il faut que l'utilisateur ai le nombre de répétition à faire sur la montre. Actuellement, si l'étape n'a que des répétition, on affiche le nombre de série de l'exercice, je préfèrerais avoir le nombre de répétition à faire plutot à la place

View File

@ -0,0 +1,10 @@
---
issueRef: "#169"
version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785254327400
---
## #169 — Cadrage UX (2026-07-28)
Spec complète et coordination avec #166/#167 : voir carnet #166 (« Cadrage UX coordonné : slot "donnée dominante sans chrono" »), section 3. Suppression du sous-titre `Prêt pour la série suivante`, sans remplacement — l'espace libéré n'est pas réutilisé (contrainte explicite : pas d'ajout de bruit visuel). Peut être livré avec #166/#167 dans le même lot pour cohérence, mais son changement est indépendant techniquement (écran différent : `Prêt pour la série suivante`, pas l'écran étape reps-only).

View File

@ -0,0 +1,16 @@
---
id: "7e5b1ba4-5f5c-46d1-b05a-6d6f5af73f23"
number: 169
title: "[UI] Enlever \"Prêt pour la série suivante\" sur la montre"
status: "qa"
priority: "medium"
sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea"
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1785251491623
updatedAt: 1785254327400
version: 6
---
Je pense que le titre "Prêt pour la série suivante" est a enlever de la montre, il prend de la place pour pas grand chose

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 165 "nextNumber": 170
} }

View File

@ -1461,11 +1461,11 @@
"issueRef": "#136", "issueRef": "#136",
"path": "136", "path": "136",
"title": "[Bug] \"passer le repos\" sur la montre ne fonctionne pas", "title": "[Bug] \"passer le repos\" sur la montre ne fonctionne pas",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1785244375293 "updatedAt": 1785252649210
}, },
{ {
"issueRef": "#137", "issueRef": "#137",
@ -1693,11 +1693,11 @@
"issueRef": "#157", "issueRef": "#157",
"path": "157", "path": "157",
"title": "[DevFrontend] Distance live montre sur séance en cours", "title": "[DevFrontend] Distance live montre sur séance en cours",
"status": "qa", "status": "open",
"priority": "medium", "priority": "medium",
"sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1785248859176 "updatedAt": 1785252668623
}, },
{ {
"issueRef": "#158", "issueRef": "#158",
@ -1723,11 +1723,11 @@
"issueRef": "#160", "issueRef": "#160",
"path": "160", "path": "160",
"title": "[DevFrontend] Graphiques historiques statistiques montre", "title": "[DevFrontend] Graphiques historiques statistiques montre",
"status": "inProgress", "status": "qa",
"priority": "medium", "priority": "medium",
"sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1785248859257 "updatedAt": 1785254327417
}, },
{ {
"issueRef": "#161", "issueRef": "#161",
@ -1763,7 +1763,7 @@
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1785248859271 "updatedAt": 1785254327435
}, },
{ {
"issueRef": "#164", "issueRef": "#164",
@ -1774,6 +1774,54 @@
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1785244375351 "updatedAt": 1785244375351
},
{
"issueRef": "#165",
"path": "165",
"title": "[Bug] impossible de lancer un chrono depuis la montre",
"status": "qa",
"priority": "high",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1785254327333
},
{
"issueRef": "#166",
"path": "166",
"title": "[UI] mettre un bouton sur l'écran principal de la montre pour passer à l'étape suivante dans le cas de répétitions dans une étape",
"status": "qa",
"priority": "medium",
"sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1785254327361
},
{
"issueRef": "#167",
"path": "167",
"title": "[UI] Afficher le nombre de répétition à faire pour une étape sur la montre",
"status": "qa",
"priority": "medium",
"sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1785254327381
},
{
"issueRef": "#169",
"path": "169",
"title": "[UI] Enlever \"Prêt pour la série suivante\" sur la montre",
"status": "qa",
"priority": "medium",
"sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1785254327400
} }
] ]
} }

View File

@ -3670,6 +3670,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
projection.primaryAction == WatchPrimaryAction.resumeSession, projection.primaryAction == WatchPrimaryAction.resumeSession,
WatchCommandType.startPreparedTimedStep => WatchCommandType.startPreparedTimedStep =>
projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep,
WatchCommandType.completeCurrentStep =>
projection.stepType == WatchStepType.reps &&
projection.stepTargetValue != null,
WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( WatchCommandType.skipCurrentStep => projection.secondaryActions.contains(
WatchSecondaryAction.skipCurrentStep, WatchSecondaryAction.skipCurrentStep,
), ),
@ -3712,6 +3715,7 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep( WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep(
session, session,
), ),
WatchCommandType.completeCurrentStep => _completeCurrentStep(session),
WatchCommandType.skipCurrentStep => _skipCurrentStep(session), WatchCommandType.skipCurrentStep => _skipCurrentStep(session),
WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session), WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session),
WatchCommandType.finishCurrentSet => _finishCurrentSet( WatchCommandType.finishCurrentSet => _finishCurrentSet(
@ -3776,6 +3780,18 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
return WatchCommandAck.accepted; return WatchCommandAck.accepted;
} }
Future<WatchCommandAck> _completeCurrentStep(
ActiveWorkoutSession session,
) async {
await _stepUseCases.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _skipCurrentPassage( Future<WatchCommandAck> _skipCurrentPassage(
ActiveWorkoutSession session, ActiveWorkoutSession session,
) async { ) async {
@ -4151,6 +4167,12 @@ final class WatchSessionProjectionProjector {
: (stepState?.currentStepIndex ?? 0) + 1, : (stepState?.currentStepIndex ?? 0) + 1,
stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length, stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length,
stepName: currentStep?.name, stepName: currentStep?.name,
stepType: switch (currentStep?.type) {
ExerciseStepType.time => WatchStepType.time,
ExerciseStepType.reps => WatchStepType.reps,
null => null,
},
stepTargetValue: currentStep?.defaultTargetValue,
dominantTimer: dominantTimer, dominantTimer: dominantTimer,
secondaryTimers: secondaryTimers, secondaryTimers: secondaryTimers,
primaryAction: _primaryAction(phase), primaryAction: _primaryAction(phase),
@ -4389,7 +4411,7 @@ List<WatchSecondaryAction> _secondaryActions({
]; ];
} }
String _statusLabel( String? _statusLabel(
WatchSessionPhase phase, WatchSessionPhase phase,
WatchTimerProjection? dominantTimer, WatchTimerProjection? dominantTimer,
) { ) {
@ -4401,7 +4423,7 @@ String _statusLabel(
WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt', WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt',
WatchSessionPhase.restRunning => 'Repos en cours', WatchSessionPhase.restRunning => 'Repos en cours',
WatchSessionPhase.restPaused => 'Repos en pause', WatchSessionPhase.restPaused => 'Repos en pause',
WatchSessionPhase.betweenSetsReady => 'Prêt pour la série suivante', WatchSessionPhase.betweenSetsReady => null,
}; };
} }
@ -4502,8 +4524,7 @@ bool _isNextTimerReady(
if (state == null || if (state == null ||
currentStep == null || currentStep == null ||
state.status != ActiveExerciseStepProgressStatus.stoppedTimer || state.status != ActiveExerciseStepProgressStatus.stoppedTimer ||
currentStep.type != ExerciseStepType.time || currentStep.type != ExerciseStepType.time) {
snapshot.autoStartNextTimedStepEffective) {
return false; return false;
} }
final previous = _previousStep(snapshot, state); final previous = _previousStep(snapshot, state);

View File

@ -162,13 +162,9 @@ final class HistoryDetailScreen extends StatelessWidget {
Text(_formatDateTime(history.startedAt)), Text(_formatDateTime(history.startedAt)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'), Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (history.averageHeartRateBpm != null && if (_hasWatchStats(history)) ...[
history.maxHeartRateBpm != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_HistoryHeartRateSummary( _HistoryWatchStatsSummary(history: history),
averageHeartRateBpm: history.averageHeartRateBpm!,
maxHeartRateBpm: history.maxHeartRateBpm!,
),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
for (final program in detail.programs) ...[ for (final program in detail.programs) ...[
@ -315,52 +311,163 @@ final class HistoryDetailScreen extends StatelessWidget {
} }
} }
final class _HistoryHeartRateSummary extends StatelessWidget { final class _HistoryWatchStatsSummary extends StatelessWidget {
const _HistoryHeartRateSummary({ const _HistoryWatchStatsSummary({required this.history});
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final double averageHeartRateBpm; final WorkoutHistory history;
final int maxHeartRateBpm;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final minHeartRate = history.minHeartRateBpm;
final averageHeartRate = history.averageHeartRateBpm;
final maxHeartRate = history.maxHeartRateBpm;
final distance = history.totalDistanceMeters;
final calories = history.totalCaloriesKcal;
return CourtBlazerAccentPanel( return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text('Stats montre', style: Theme.of(context).textTheme.titleSmall),
'Fréquence cardiaque', const SizedBox(height: 8),
style: Theme.of(context).textTheme.titleSmall, if (minHeartRate != null ||
), averageHeartRate != null ||
maxHeartRate != null) ...[
Text('Fréquence cardiaque'),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
children: [ children: [
if (minHeartRate != null)
Expanded( Expanded(
child: _HistoryHeartRateMetric( child: _HistoryWatchMetric(
label: 'Min',
value: '$minHeartRate bpm',
),
),
if (minHeartRate != null && averageHeartRate != null)
const SizedBox(width: 8),
if (averageHeartRate != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Moyenne', label: 'Moyenne',
value: '${averageHeartRateBpm.round()}', value: '${averageHeartRate.round()} bpm',
), ),
), ),
const SizedBox(width: 12), if ((minHeartRate != null || averageHeartRate != null) &&
maxHeartRate != null)
const SizedBox(width: 8),
if (maxHeartRate != null)
Expanded( Expanded(
child: _HistoryHeartRateMetric( child: _HistoryWatchMetric(
label: 'Max', label: 'Max',
value: '$maxHeartRateBpm', value: '$maxHeartRate bpm',
), ),
), ),
], ],
), ),
if (averageHeartRate != null && maxHeartRate != null) ...[
const SizedBox(height: 10),
_HistoryWatchStatsBars(
minHeartRateBpm: minHeartRate,
averageHeartRateBpm: averageHeartRate,
maxHeartRateBpm: maxHeartRate,
),
],
],
if (distance != null || calories != null) ...[
const SizedBox(height: 12),
Row(
children: [
if (distance != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Distance',
value: _formatHistoryDistance(distance),
),
),
if (distance != null && calories != null)
const SizedBox(width: 8),
if (calories != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Calories',
value: '${calories.round()} kcal',
),
),
],
),
],
], ],
), ),
); );
} }
} }
final class _HistoryHeartRateMetric extends StatelessWidget { final class _HistoryWatchStatsBars extends StatelessWidget {
const _HistoryHeartRateMetric({required this.label, required this.value}); const _HistoryWatchStatsBars({
required this.minHeartRateBpm,
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final int? minHeartRateBpm;
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
final max = maxHeartRateBpm.toDouble().clamp(1, double.infinity);
final values = [
if (minHeartRateBpm != null) ('Min', minHeartRateBpm!.toDouble()),
('Moy', averageHeartRateBpm),
('Max', maxHeartRateBpm.toDouble()),
];
return Semantics(
label: 'Graphique stats montre',
key: const ValueKey('history-watch-stats-graph'),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final item in values) ...[
Expanded(
child: Column(
children: [
SizedBox(
height: 52,
child: Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
heightFactor: (item.$2 / max).clamp(0.08, 1),
widthFactor: 0.62,
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFC9A24A),
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
const SizedBox(height: 4),
Text(
item.$1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
if (item != values.last) const SizedBox(width: 8),
],
],
),
);
}
}
final class _HistoryWatchMetric extends StatelessWidget {
const _HistoryWatchMetric({required this.label, required this.value});
final String label; final String label;
final String value; final String value;
@ -373,14 +480,8 @@ final class _HistoryHeartRateMetric extends StatelessWidget {
Text(label, style: Theme.of(context).textTheme.labelMedium), Text(label, style: Theme.of(context).textTheme.labelMedium),
RichText( RichText(
text: TextSpan( text: TextSpan(
style: AppTextStyles.scoreNumber(context), style: AppTextStyles.scoreNumber(context).copyWith(fontSize: 24),
children: [ children: [TextSpan(text: value)],
TextSpan(text: value),
TextSpan(
text: ' bpm',
style: Theme.of(context).textTheme.bodySmall,
),
],
), ),
), ),
], ],
@ -388,6 +489,21 @@ final class _HistoryHeartRateMetric extends StatelessWidget {
} }
} }
bool _hasWatchStats(WorkoutHistory history) {
return history.minHeartRateBpm != null ||
history.averageHeartRateBpm != null ||
history.maxHeartRateBpm != null ||
history.totalDistanceMeters != null ||
history.totalCaloriesKcal != null;
}
String _formatHistoryDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
return '${meters.round()} m';
}
final class HistoryDetailData { final class HistoryDetailData {
const HistoryDetailData({required this.programs}); const HistoryDetailData({required this.programs});

View File

@ -5,6 +5,7 @@ enum WatchCommandType {
pauseSession, pauseSession,
resumeSession, resumeSession,
startPreparedTimedStep, startPreparedTimedStep,
completeCurrentStep,
skipCurrentStep, skipCurrentStep,
skipCurrentPassage, skipCurrentPassage,
finishCurrentSet, finishCurrentSet,
@ -60,6 +61,8 @@ enum WatchTimerRunState { stopped, running, paused }
enum WatchManualScoreScope { series, step } enum WatchManualScoreScope { series, step }
enum WatchStepType { time, reps }
final class WatchCommandEnvelope { final class WatchCommandEnvelope {
const WatchCommandEnvelope({ const WatchCommandEnvelope({
this.schemaVersion = watchBridgeSchemaVersion, this.schemaVersion = watchBridgeSchemaVersion,
@ -150,6 +153,8 @@ final class WatchSessionProjection {
this.stepIndex, this.stepIndex,
this.stepTotal, this.stepTotal,
this.stepName, this.stepName,
this.stepType,
this.stepTargetValue,
this.dominantTimer, this.dominantTimer,
this.secondaryTimers = const [], this.secondaryTimers = const [],
required this.primaryAction, required this.primaryAction,
@ -190,6 +195,8 @@ final class WatchSessionProjection {
stepIndex: _nullableIntFromJson(json['stepIndex']), stepIndex: _nullableIntFromJson(json['stepIndex']),
stepTotal: _nullableIntFromJson(json['stepTotal']), stepTotal: _nullableIntFromJson(json['stepTotal']),
stepName: _nullableStringFromJson(json['stepName']), stepName: _nullableStringFromJson(json['stepName']),
stepType: _nullableEnumFromJson(json['stepType'], WatchStepType.values),
stepTargetValue: _nullableIntFromJson(json['stepTargetValue']),
dominantTimer: _timerFromJson(json['dominantTimer']), dominantTimer: _timerFromJson(json['dominantTimer']),
secondaryTimers: _timerListFromJson(json['secondaryTimers']), secondaryTimers: _timerListFromJson(json['secondaryTimers']),
primaryAction: _enumFromJson( primaryAction: _enumFromJson(
@ -238,6 +245,8 @@ final class WatchSessionProjection {
final int? stepIndex; final int? stepIndex;
final int? stepTotal; final int? stepTotal;
final String? stepName; final String? stepName;
final WatchStepType? stepType;
final int? stepTargetValue;
final WatchTimerProjection? dominantTimer; final WatchTimerProjection? dominantTimer;
final List<WatchTimerProjection> secondaryTimers; final List<WatchTimerProjection> secondaryTimers;
final WatchPrimaryAction primaryAction; final WatchPrimaryAction primaryAction;
@ -270,6 +279,8 @@ final class WatchSessionProjection {
'stepIndex': stepIndex, 'stepIndex': stepIndex,
'stepTotal': stepTotal, 'stepTotal': stepTotal,
'stepName': stepName, 'stepName': stepName,
'stepType': stepType?.name,
'stepTargetValue': stepTargetValue,
'dominantTimer': dominantTimer?.toJson(), 'dominantTimer': dominantTimer?.toJson(),
'secondaryTimers': secondaryTimers 'secondaryTimers': secondaryTimers
.map((timer) => timer.toJson()) .map((timer) => timer.toJson())
@ -310,6 +321,8 @@ final class WatchSessionProjection {
stepIndex == other.stepIndex && stepIndex == other.stepIndex &&
stepTotal == other.stepTotal && stepTotal == other.stepTotal &&
stepName == other.stepName && stepName == other.stepName &&
stepType == other.stepType &&
stepTargetValue == other.stepTargetValue &&
dominantTimer == other.dominantTimer && dominantTimer == other.dominantTimer &&
_listEquals(secondaryTimers, other.secondaryTimers) && _listEquals(secondaryTimers, other.secondaryTimers) &&
primaryAction == other.primaryAction && primaryAction == other.primaryAction &&
@ -344,6 +357,8 @@ final class WatchSessionProjection {
stepIndex, stepIndex,
stepTotal, stepTotal,
stepName, stepName,
stepType,
stepTargetValue,
dominantTimer, dominantTimer,
Object.hashAll(secondaryTimers), Object.hashAll(secondaryTimers),
primaryAction, primaryAction,

View File

@ -161,6 +161,8 @@ void main() {
stepIndex: 2, stepIndex: 2,
stepTotal: 4, stepTotal: 4,
stepName: 'Descente', stepName: 'Descente',
stepType: WatchStepType.reps,
stepTargetValue: 12,
dominantTimer: _stepTimer(), dominantTimer: _stepTimer(),
secondaryTimers: [_scoreStopwatchTimer(), _setTimer()], secondaryTimers: [_scoreStopwatchTimer(), _setTimer()],
primaryAction: primaryAction, primaryAction: primaryAction,
@ -203,6 +205,8 @@ void main() {
expect(projection.phase, WatchSessionPhase.restPaused); expect(projection.phase, WatchSessionPhase.restPaused);
expect(projection.passageIndex, isNull); expect(projection.passageIndex, isNull);
expect(projection.stepIndex, isNull); expect(projection.stepIndex, isNull);
expect(projection.stepType, isNull);
expect(projection.stepTargetValue, isNull);
expect(projection.dominantTimer, isNull); expect(projection.dominantTimer, isNull);
expect(projection.secondaryTimers, isEmpty); expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.resumeSession); expect(projection.primaryAction, WatchPrimaryAction.resumeSession);
@ -233,6 +237,8 @@ void main() {
expect(projection.exerciseName, ''); expect(projection.exerciseName, '');
expect(projection.primaryAction, WatchPrimaryAction.none); expect(projection.primaryAction, WatchPrimaryAction.none);
expect(projection.secondaryActions, isEmpty); expect(projection.secondaryActions, isEmpty);
expect(projection.stepType, isNull);
expect(projection.stepTargetValue, isNull);
expect(projection.hasManualScore, isFalse); expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull); expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse); expect(projection.canDecrementScore, isFalse);

View File

@ -98,6 +98,44 @@ void main() {
expect(env.repository.stepResults, hasLength(1)); expect(env.repository.stepResults, hasLength(1));
}); });
test('routes completeCurrentStep to completed reps step', () async {
final session = _session(
steps: [_step(type: ExerciseStepType.reps, defaultTargetValue: 12)],
);
final env = _env(
session: session,
projection: _projection(
stepType: WatchStepType.reps,
stepTargetValue: 12,
),
);
env.repository.stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
);
final ack = await env.dispatch(WatchCommandType.completeCurrentStep);
expect(ack, WatchCommandAck.accepted);
expect(env.repository.stepResults.single.status, SetResultStatus.completed);
expect(env.repository.stepResults.single.actualReps, 12);
});
test(
'rejects completeCurrentStep when current projection is not reps',
() async {
final session = _session(steps: [_step()]);
final env = _env(session: session, projection: _projection());
env.repository.stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
);
final ack = await env.dispatch(WatchCommandType.completeCurrentStep);
expect(ack, WatchCommandAck.rejectedNotApplicable);
expect(env.repository.stepResults, isEmpty);
},
);
test('routes skipCurrentPassage to step use case', () async { test('routes skipCurrentPassage to step use case', () async {
final session = _session( final session = _session(
targetReps: 2, targetReps: 2,
@ -542,6 +580,8 @@ WatchSessionProjection _projection({
double? currentManualScoreValue, double? currentManualScoreValue,
bool canDecrementScore = false, bool canDecrementScore = false,
WatchManualScoreScope? manualScoreScope, WatchManualScoreScope? manualScoreScope,
WatchStepType? stepType,
int? stepTargetValue,
}) { }) {
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: deviceSessionId, deviceSessionId: deviceSessionId,
@ -557,6 +597,8 @@ WatchSessionProjection _projection({
hasManualScore: hasManualScore, hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue, currentManualScoreValue: currentManualScoreValue,
canDecrementScore: canDecrementScore, canDecrementScore: canDecrementScore,
stepType: stepType,
stepTargetValue: stepTargetValue,
manualScoreScope: manualScoreScope:
manualScoreScope ?? manualScoreScope ??
(hasManualScore ? WatchManualScoreScope.series : null), (hasManualScore ? WatchManualScoreScope.series : null),
@ -617,6 +659,8 @@ ActiveWorkoutSession _session({
ExerciseStep _step({ ExerciseStep _step({
String id = 'step-1', String id = 'step-1',
int position = 0, int position = 0,
ExerciseStepType type = ExerciseStepType.time,
int defaultTargetValue = 1,
bool hasScore = false, bool hasScore = false,
String? scoreLabel, String? scoreLabel,
String? scoreUnit, String? scoreUnit,
@ -626,8 +670,8 @@ ExerciseStep _step({
id: id, id: id,
position: position, position: position,
name: 'Step ${position + 1}', name: 'Step ${position + 1}',
type: ExerciseStepType.time, type: type,
defaultTargetValue: 1, defaultTargetValue: defaultTargetValue,
hasScore: hasScore, hasScore: hasScore,
scoreLabel: scoreLabel, scoreLabel: scoreLabel,
scoreUnit: scoreUnit, scoreUnit: scoreUnit,
@ -701,6 +745,8 @@ final class _FakeProjectionSource implements WatchProjectionSource {
seriesIndex: projection.seriesIndex, seriesIndex: projection.seriesIndex,
seriesTotal: projection.seriesTotal, seriesTotal: projection.seriesTotal,
exerciseName: projection.exerciseName, exerciseName: projection.exerciseName,
stepType: projection.stepType,
stepTargetValue: projection.stepTargetValue,
primaryAction: projection.primaryAction, primaryAction: projection.primaryAction,
secondaryActions: projection.secondaryActions, secondaryActions: projection.secondaryActions,
hasManualScore: projection.hasManualScore, hasManualScore: projection.hasManualScore,

View File

@ -40,6 +40,20 @@ void main() {
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
}); });
test('projects reps step target for direct watch validation', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
steps: [_step(type: ExerciseStepType.reps, defaultTargetValue: 12)],
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.stepType, WatchStepType.reps);
expect(projection.stepTargetValue, 12);
expect(projection.dominantTimer, isNull);
});
test('projects running with step timer before stopwatch score', () async { test('projects running with step timer before stopwatch score', () async {
final now = DateTime.utc(2026, 7, 25, 12); final now = DateTime.utc(2026, 7, 25, 12);
final session = _session( final session = _session(
@ -299,6 +313,42 @@ void main() {
}, },
); );
test(
'projects nextTimerReady for a stopped timed step after watch skip with chaining enabled',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
autoStartNextTimedStepSnapshot: true,
steps: [
_step(id: 'step-1'),
_step(id: 'step-2', position: 1),
],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
stepId: 'step-2',
stepIndex: 1,
status: ActiveExerciseStepProgressStatus.stoppedTimer,
lastTransitionAt: now,
);
final projector = _projector(repository, _clock(now));
final projection = await projector.project(revision: 4);
expect(projection.phase, WatchSessionPhase.nextTimerReady);
expect(projection.stepIndex, 2);
expect(projection.stepName, 'Step 2');
expect(projection.statusLabel, 'Chrono suivant prêt');
expect(
projection.primaryAction,
WatchPrimaryAction.startPreparedTimedStep,
);
expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped);
},
);
test('projects restRunning after finishing a set with rest', () async { test('projects restRunning after finishing a set with rest', () async {
final now = DateTime.utc(2026, 7, 25, 12); final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(currentSetIndex: 1); final session = _session(currentSetIndex: 1);
@ -406,7 +456,7 @@ void main() {
final projection = await projector.project(revision: 6); final projection = await projector.project(revision: 6);
expect(projection.phase, WatchSessionPhase.betweenSetsReady); expect(projection.phase, WatchSessionPhase.betweenSetsReady);
expect(projection.statusLabel, 'Prêt pour la série suivante'); expect(projection.statusLabel, isNull);
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
}, },
); );
@ -594,6 +644,7 @@ ActiveWorkoutSession _session({
ExerciseStep _step({ ExerciseStep _step({
String id = 'step-1', String id = 'step-1',
int position = 0, int position = 0,
ExerciseStepType type = ExerciseStepType.time,
int defaultTargetValue = 1, int defaultTargetValue = 1,
bool hasScore = false, bool hasScore = false,
String? scoreLabel, String? scoreLabel,
@ -604,7 +655,7 @@ ExerciseStep _step({
id: id, id: id,
position: position, position: position,
name: 'Step ${position + 1}', name: 'Step ${position + 1}',
type: ExerciseStepType.time, type: type,
defaultTargetValue: defaultTargetValue, defaultTargetValue: defaultTargetValue,
hasScore: hasScore, hasScore: hasScore,
scoreLabel: scoreLabel, scoreLabel: scoreLabel,

View File

@ -71,7 +71,7 @@ void main() {
); );
}); });
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', ( testWidgets('le détail affiche les stats montre quand elles existent', (
tester, tester,
) async { ) async {
await tester.pumpWidget( await tester.pumpWidget(
@ -79,8 +79,11 @@ void main() {
home: HistoryDetailScreen( home: HistoryDetailScreen(
history: _history( history: _history(
id: 'history-1', id: 'history-1',
minHeartRateBpm: 88,
averageHeartRateBpm: 126.4, averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171, maxHeartRateBpm: 171,
totalDistanceMeters: 1234,
totalCaloriesKcal: 83,
), ),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(), workoutTemplateUseCases: _workoutTemplateUseCases(),
@ -90,11 +93,22 @@ void main() {
), ),
); );
expect(find.text('Stats montre'), findsOneWidget);
expect(find.text('Fréquence cardiaque'), findsOneWidget); expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Min'), findsAtLeastNWidgets(1));
expect(find.text('Moyenne'), findsOneWidget); expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsOneWidget); expect(find.text('Max'), findsAtLeastNWidgets(1));
expect(find.text('Distance'), findsOneWidget);
expect(find.text('Calories'), findsOneWidget);
expect(
find.byKey(const ValueKey('history-watch-stats-graph')),
findsOneWidget,
);
expect(find.text('88 bpm', findRichText: true), findsOneWidget);
expect(find.text('126 bpm', findRichText: true), findsOneWidget); expect(find.text('126 bpm', findRichText: true), findsOneWidget);
expect(find.text('171 bpm', findRichText: true), findsOneWidget); expect(find.text('171 bpm', findRichText: true), findsOneWidget);
expect(find.text('1.23 km', findRichText: true), findsOneWidget);
expect(find.text('83 kcal', findRichText: true), findsOneWidget);
}); });
testWidgets('le détail affiche le score chrono comme temps réalisé', ( testWidgets('le détail affiche le score chrono comme temps réalisé', (
@ -256,8 +270,11 @@ WorkoutHistory _history({
bool stopwatchScore = false, bool stopwatchScore = false,
bool withStepResults = false, bool withStepResults = false,
bool emptySnapshot = false, bool emptySnapshot = false,
int? minHeartRateBpm,
double? averageHeartRateBpm, double? averageHeartRateBpm,
int? maxHeartRateBpm, int? maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
}) { }) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory( return WorkoutHistory(
@ -268,8 +285,11 @@ WorkoutHistory _history({
startedAt: start, startedAt: start,
endedAt: start.add(const Duration(minutes: 30)), endedAt: start.add(const Duration(minutes: 30)),
totalActiveMs: 1800000, totalActiveMs: 1800000,
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: averageHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: totalDistanceMeters,
totalCaloriesKcal: totalCaloriesKcal,
completed: true, completed: true,
historySnapshotJson: jsonEncode({ historySnapshotJson: jsonEncode({
'sessionId': 'session-1', 'sessionId': 'session-1',

View File

@ -3,7 +3,8 @@
android:name="android.hardware.type.watch" android:name="android.hardware.type.watch"
android:required="true" /> android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission <uses-permission
@ -63,5 +64,9 @@
android:scheme="wear" /> android:scheme="wear" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".bridge.WatchHeartRateForegroundService"
android:exported="false"
android:foregroundServiceType="health" />
</application> </application>
</manifest> </manifest>

View File

@ -55,6 +55,7 @@ object WatchBridgePlugin {
private var sensorPermissionRequestInFlight = false private var sensorPermissionRequestInFlight = false
private var lastSensorPermissionRequestEpochMs = 0L private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false private var pendingSensorPermissionRequest = false
private var lastSensorProjection: Map<String, Any?>? = null
fun attachApplicationContext(context: Context) { fun attachApplicationContext(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
@ -142,7 +143,13 @@ object WatchBridgePlugin {
if (granted) { if (granted) {
pendingSensorPermissionRequest = false pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted") Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) } appContext?.let { context ->
val projection = lastSensorProjection
if (projection != null) {
WatchHeartRateForegroundService.start(context, projection)
}
heartRateCollector.onBodySensorsGranted(context)
}
} else { } else {
val denied = permissions.filterIndexed { index, _ -> val denied = permissions.filterIndexed { index, _ ->
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
@ -315,11 +322,15 @@ object WatchBridgePlugin {
val phase = projection["phase"] as? String ?: "noActiveSession" val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: "" val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) { if (phase == "noActiveSession" || sessionId.isBlank()) {
lastSensorProjection = null
WatchHeartRateForegroundService.stop(context)
heartRateCollector.finishCurrentSession(context) heartRateCollector.finishCurrentSession(context)
return return
} }
lastSensorProjection = projection
val shouldAggregate = phase == "running" val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) { if (!hasRequiredSensorPermissions(context)) {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession( heartRateCollector.noteActiveSession(
sessionId, sessionId,
shouldAggregate = false, shouldAggregate = false,
@ -336,8 +347,10 @@ object WatchBridgePlugin {
executionContext = telemetryContext(projection), executionContext = telemetryContext(projection),
) )
if (shouldAggregate) { if (shouldAggregate) {
WatchHeartRateForegroundService.start(context, projection)
heartRateCollector.start(context) heartRateCollector.start(context)
} else { } else {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.pause(context) heartRateCollector.pause(context)
} }
} }

View File

@ -1,8 +1,6 @@
package com.gametime.watch.bridge package com.gametime.watch.bridge
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.health.services.client.HealthServices import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient import androidx.health.services.client.MeasureClient
@ -39,7 +37,6 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>() private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false private var shouldAggregate = false
private var appContext: Context? = null private var appContext: Context? = null
private var wakeLock: PowerManager.WakeLock? = null
private val callback = object : MeasureCallback { private val callback = object : MeasureCallback {
override fun onAvailabilityChanged( override fun onAvailabilityChanged(
@ -104,7 +101,6 @@ internal class WatchHeartRateCollector(
return return
} }
appContext = context.applicationContext appContext = context.applicationContext
acquireWakeLock(context)
val measureClient = HealthServices.getClient(context).measureClient val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
@ -221,7 +217,6 @@ internal class WatchHeartRateCollector(
} }
registeredDataTypes.clear() registeredDataTypes.clear()
appContext = null appContext = null
releaseWakeLock()
} }
private fun registerMeasureCallbackIfNeeded( private fun registerMeasureCallbackIfNeeded(
@ -240,34 +235,6 @@ internal class WatchHeartRateCollector(
} }
} }
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock(context: Context) {
val existing = wakeLock
if (existing?.isHeld == true) {
return
}
val powerManager = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as? PowerManager
?: return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GameTime:HeartRateCollection",
).apply {
setReferenceCounted(false)
acquire()
}
Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId")
}
private fun releaseWakeLock() {
val lock = wakeLock
wakeLock = null
if (lock?.isHeld == true) {
lock.release()
Log.d(TAG, "heart rate collection wake lock released")
}
}
private fun reset(nextSessionId: String?) { private fun reset(nextSessionId: String?) {
sessionId = nextSessionId sessionId = nextSessionId
sampleCount = 0 sampleCount = 0

View File

@ -0,0 +1,108 @@
package com.gametime.watch.bridge
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.gametime.watch.MainActivity
import com.gametime.watch.R
internal class WatchHeartRateForegroundService : Service() {
companion object {
const val CHANNEL_ID = "gametime_watch_heart_rate"
const val CHANNEL_NAME = "Collecte cardio GameTime"
const val NOTIFICATION_ID = 9102
const val EXTRA_EXERCISE_NAME = "exerciseName"
fun start(context: Context, projection: Map<String, Any?>) {
val sessionId = projection["deviceSessionId"] as? String ?: ""
val phase = projection["phase"] as? String ?: "noActiveSession"
if (sessionId.isBlank() || phase != "running") {
stop(context)
return
}
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ContextCompat.startForegroundService(
context,
Intent(context, WatchHeartRateForegroundService::class.java)
.putExtra(EXTRA_EXERCISE_NAME, exerciseName),
)
}
fun stop(context: Context) {
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
WatchBridgePlugin.attachApplicationContext(applicationContext)
val exerciseName = intent
?.getStringExtra(EXTRA_EXERCISE_NAME)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(exerciseName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
} else {
0
},
)
return START_STICKY
}
private fun buildNotification(exerciseName: String): Notification {
ensureNotificationChannel()
val touchIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime")
.setContentText(exerciseName)
.setContentIntent(touchIntent)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
),
)
}
}

View File

@ -187,6 +187,10 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
return _sendScoreCommand(WatchCommandType.decrementScore, -1); return _sendScoreCommand(WatchCommandType.decrementScore, -1);
} }
Future<void> completeCurrentStep() {
return _sendCommand(WatchCommandType.completeCurrentStep);
}
@override @override
void dispose() { void dispose() {
_waitingTimer?.cancel(); _waitingTimer?.cancel();
@ -288,8 +292,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
value = value.copyWith(scoreWaitingForPhone: true); value = value.copyWith(scoreWaitingForPhone: true);
}); });
_scoreCommandTimeoutTimer = Timer(_commandTimeout, () { _scoreCommandTimeoutTimer = Timer(_commandTimeout, () {
_clearScorePending(recalibrate: true); value = value.copyWith(scoreWaitingForPhone: true);
unawaited(HapticFeedback.heavyImpact());
unawaited(_nativeClient.requestResync()); unawaited(_nativeClient.requestResync());
}); });
try { try {

View File

@ -77,6 +77,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
onTogglePause: widget.viewModel.sendPrimaryAction, onTogglePause: widget.viewModel.sendPrimaryAction,
onIncrementScore: widget.viewModel.incrementScore, onIncrementScore: widget.viewModel.incrementScore,
onDecrementScore: widget.viewModel.decrementScore, onDecrementScore: widget.viewModel.decrementScore,
onCompleteStep: widget.viewModel.completeCurrentStep,
), ),
), ),
_RoundScaffold( _RoundScaffold(
@ -399,6 +400,7 @@ final class _SessionMainView extends StatelessWidget {
required this.onTogglePause, required this.onTogglePause,
required this.onIncrementScore, required this.onIncrementScore,
required this.onDecrementScore, required this.onDecrementScore,
required this.onCompleteStep,
}); });
final WatchSessionUiState state; final WatchSessionUiState state;
@ -407,6 +409,7 @@ final class _SessionMainView extends StatelessWidget {
final VoidCallback onTogglePause; final VoidCallback onTogglePause;
final VoidCallback onIncrementScore; final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore; final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -456,6 +459,7 @@ final class _SessionMainView extends StatelessWidget {
onTogglePause: canToggleTimer ? onTogglePause : null, onTogglePause: canToggleTimer ? onTogglePause : null,
onIncrementScore: onIncrementScore, onIncrementScore: onIncrementScore,
onDecrementScore: onDecrementScore, onDecrementScore: onDecrementScore,
onCompleteStep: onCompleteStep,
), ),
), ),
), ),
@ -703,12 +707,14 @@ final class _ActiveContent extends StatelessWidget {
required this.onTogglePause, required this.onTogglePause,
required this.onIncrementScore, required this.onIncrementScore,
required this.onDecrementScore, required this.onDecrementScore,
required this.onCompleteStep,
}); });
final WatchSessionUiState state; final WatchSessionUiState state;
final VoidCallback? onTogglePause; final VoidCallback? onTogglePause;
final VoidCallback onIncrementScore; final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore; final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -726,10 +732,15 @@ final class _ActiveContent extends StatelessWidget {
projection, projection,
primaryTimer: timer, primaryTimer: timer,
); );
final repsTarget = _repsStepTarget(projection);
final dominantValue = timer == null final dominantValue = timer == null
? _seriesValue(projection) ? _seriesValue(projection)
: _timerText(timer); : _timerText(timer);
final dominantLabel = timer == null ? 'SÉRIE' : timer.label; final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
final controlsEnabled =
!state.connectionLost &&
projection.phoneReachable &&
!state.commandPending;
return _ScaledContent( return _ScaledContent(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -737,9 +748,15 @@ final class _ActiveContent extends StatelessWidget {
_ExerciseName(projection.exerciseName), _ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName), _StepNameBand(projection.stepName),
const SizedBox(height: 2), const SizedBox(height: 2),
_SmallLabel(dominantLabel), _SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions'),
const SizedBox(height: 2), const SizedBox(height: 2),
if (timer == null) if (repsTarget != null)
_DominantRepsLine(
value: repsTarget.toString(),
pending: state.commandPending,
onComplete: controlsEnabled ? onCompleteStep : null,
)
else if (timer == null)
_DominantValue(dominantValue) _DominantValue(dominantValue)
else else
_DominantTimerLine( _DominantTimerLine(
@ -770,6 +787,62 @@ final class _ActiveContent extends StatelessWidget {
} }
} }
final class _DominantRepsLine extends StatelessWidget {
const _DominantRepsLine({
required this.value,
required this.pending,
required this.onComplete,
});
final String value;
final bool pending;
final VoidCallback? onComplete;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(child: _DominantValue(value)),
const SizedBox(width: 6),
_CompleteStepButton(pending: pending, onPressed: onComplete),
],
),
);
}
}
final class _CompleteStepButton extends StatelessWidget {
const _CompleteStepButton({required this.pending, required this.onPressed});
final bool pending;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 48,
child: IconButton(
onPressed: onPressed,
tooltip: 'Valider létape',
visualDensity: VisualDensity.compact,
iconSize: 24,
color: const Color(0xFFC9A24A),
disabledColor: const Color(0xFF414754),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF141824),
side: const BorderSide(color: Color(0xFFD72638)),
),
icon: pending
? const _PendingDot(key: ValueKey('complete-step-pending-dot'))
: const Icon(Icons.check),
),
);
}
}
final class _DominantTimerLine extends StatelessWidget { final class _DominantTimerLine extends StatelessWidget {
const _DominantTimerLine({ const _DominantTimerLine({
required this.value, required this.value,
@ -901,7 +974,7 @@ final class _ManualScoreContent extends StatelessWidget {
SizedBox( SizedBox(
height: 6, height: 6,
child: state.scoreWaitingForPhone child: state.scoreWaitingForPhone
? const _PendingDot() ? const _PendingDot(key: ValueKey('score-pending-dot'))
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
_MainHeartRateLine(sample: state.sensorSample), _MainHeartRateLine(sample: state.sensorSample),
@ -1465,6 +1538,14 @@ String _seriesValue(WatchSessionProjection projection) {
return '${projection.seriesIndex}/${projection.seriesTotal}'; return '${projection.seriesIndex}/${projection.seriesTotal}';
} }
int? _repsStepTarget(WatchSessionProjection projection) {
final target = projection.stepTargetValue;
if (projection.stepType != WatchStepType.reps || target == null) {
return null;
}
return target > 0 ? target : null;
}
String _scoreText(double value) { String _scoreText(double value) {
if (value == value.roundToDouble()) { if (value == value.roundToDouble()) {
return value.toInt().toString(); return value.toInt().toString();

View File

@ -585,6 +585,104 @@ void main() {
viewModel.dispose(); viewModel.dispose();
}); });
testWidgets('shows reps target and direct completion on a reps-only step', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_repsStepProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('SÉRIE'), findsNothing);
expect(find.text('Prêt pour la série suivante'), findsNothing);
expect(find.byTooltip('Valider létape'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byTooltip('Valider létape'));
await tester.pump();
expect(
client.sentCommands.single.type,
WatchCommandType.completeCurrentStep,
);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'keeps optimistic score visible after timeout until explicit rejection',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
waitingThreshold: const Duration(milliseconds: 50),
commandTimeout: const Duration(milliseconds: 120),
);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await tester.tap(find.byTooltip('Ajouter'));
await tester.pump();
final commandId = client.sentCommands.single.commandId;
expect(find.text('4'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 150));
expect(find.text('4'), findsOneWidget);
expect(find.byKey(const ValueKey('score-pending-dot')), findsOneWidget);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_manualScoreProjection());
await tester.pump();
expect(find.text('4'), findsOneWidget);
client.emitAck(
WatchCommandAckEvent(
commandId: commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
expect(find.text('3'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('vibrates once when a countdown timer reaches zero', ( testWidgets('vibrates once when a countdown timer reaches zero', (
tester, tester,
) async { ) async {
@ -762,6 +860,26 @@ WatchSessionProjection _readyProjection() {
); );
} }
WatchSessionProjection _repsStepProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 7,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Pompes',
stepIndex: 1,
stepTotal: 2,
stepName: 'Pompes strictes',
stepType: WatchStepType.reps,
stepTargetValue: 12,
primaryAction: WatchPrimaryAction.pauseSession,
secondaryActions: const [WatchSecondaryAction.skipCurrentStep],
);
}
WatchSessionProjection _secondaryRestActionProjection() { WatchSessionProjection _secondaryRestActionProjection() {
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: 'session-1', deviceSessionId: 'session-1',