diff --git a/.ideai/tickets/136/carnet.md b/.ideai/tickets/136/carnet.md index 35442f0..f0695b0 100644 --- a/.ideai/tickets/136/carnet.md +++ b/.ideai/tickets/136/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#136" -version: 8 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785244375293 +version: 9 +updatedBy: {"kind":"user"} +updatedAt: 1785252649210 --- ## #136 — Cadrage UX (2026-07-28) diff --git a/.ideai/tickets/136/issue.md b/.ideai/tickets/136/issue.md index 5c953e7..13e4cab 100644 --- a/.ideai/tickets/136/issue.md +++ b/.ideai/tickets/136/issue.md @@ -2,15 +2,15 @@ id: "cd125c5f-5e37-4100-8b61-58cbaab5eac1" number: 136 title: "[Bug] \"passer le repos\" sur la montre ne fonctionne pas" -status: "qa" +status: "closed" priority: "medium" sprint: "abc4f969-b169-45f7-988c-daeeab762201" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"user"} createdAt: 1785141207792 -updatedAt: 1785244375293 -version: 8 +updatedAt: 1785252649210 +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 \ No newline at end of file diff --git a/.ideai/tickets/157/carnet.md b/.ideai/tickets/157/carnet.md index 145c07f..a42b19e 100644 --- a/.ideai/tickets/157/carnet.md +++ b/.ideai/tickets/157/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#157" -version: 4 -updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859176 +version: 5 +updatedBy: {"kind":"user"} +updatedAt: 1785252668623 --- diff --git a/.ideai/tickets/157/issue.md b/.ideai/tickets/157/issue.md index e5e6bec..a9b386f 100644 --- a/.ideai/tickets/157/issue.md +++ b/.ideai/tickets/157/issue.md @@ -2,15 +2,15 @@ id: "4ba5c5bc-c575-4afc-bb55-93feb7e7ad9c" number: 157 title: "[DevFrontend] Distance live montre sur séance en cours" -status: "qa" +status: "open" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#142","kind":"blocks"},{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] agentRefs: [] 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 -updatedAt: 1785248859176 -version: 4 +updatedAt: 1785252668623 +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. \ No newline at end of file diff --git a/.ideai/tickets/160/carnet.md b/.ideai/tickets/160/carnet.md index 373f160..c567e9f 100644 --- a/.ideai/tickets/160/carnet.md +++ b/.ideai/tickets/160/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#160" -version: 4 +version: 5 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859257 +updatedAt: 1785254327417 --- diff --git a/.ideai/tickets/160/issue.md b/.ideai/tickets/160/issue.md index 6badb97..689ea0b 100644 --- a/.ideai/tickets/160/issue.md +++ b/.ideai/tickets/160/issue.md @@ -2,7 +2,7 @@ id: "b5b4fe80-952f-4376-9271-de98817add32" number: 160 title: "[DevFrontend] Graphiques historiques statistiques montre" -status: "inProgress" +status: "qa" priority: "medium" sprint: "d5c18b44-0eec-46db-b8ab-506cfee0bfea" links: [{"target":"#106","kind":"relatesTo"},{"target":"#145","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [] createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785180656778 -updatedAt: 1785248859257 -version: 4 +updatedAt: 1785254327417 +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. \ No newline at end of file diff --git a/.ideai/tickets/163/carnet.md b/.ideai/tickets/163/carnet.md index 1450a14..b5dfaa6 100644 --- a/.ideai/tickets/163/carnet.md +++ b/.ideai/tickets/163/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#163" -version: 5 +version: 6 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1785248859271 +updatedAt: 1785254327435 --- diff --git a/.ideai/tickets/163/issue.md b/.ideai/tickets/163/issue.md index 9ba1ba8..93cb5f7 100644 --- a/.ideai/tickets/163/issue.md +++ b/.ideai/tickets/163/issue.md @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1785242433447 -updatedAt: 1785248859271 -version: 5 +updatedAt: 1785254327435 +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 \ No newline at end of file diff --git a/.ideai/tickets/165/carnet.md b/.ideai/tickets/165/carnet.md new file mode 100644 index 0000000..84ad3ec --- /dev/null +++ b/.ideai/tickets/165/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#165" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1785254327333 +--- diff --git a/.ideai/tickets/165/issue.md b/.ideai/tickets/165/issue.md new file mode 100644 index 0000000..a7ce2c2 --- /dev/null +++ b/.ideai/tickets/165/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/166/carnet.md b/.ideai/tickets/166/carnet.md new file mode 100644 index 0000000..ccf9f0f --- /dev/null +++ b/.ideai/tickets/166/carnet.md @@ -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. diff --git a/.ideai/tickets/166/issue.md b/.ideai/tickets/166/issue.md new file mode 100644 index 0000000..b803284 --- /dev/null +++ b/.ideai/tickets/166/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/167/carnet.md b/.ideai/tickets/167/carnet.md new file mode 100644 index 0000000..5c68560 --- /dev/null +++ b/.ideai/tickets/167/carnet.md @@ -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. diff --git a/.ideai/tickets/167/issue.md b/.ideai/tickets/167/issue.md new file mode 100644 index 0000000..4c5da59 --- /dev/null +++ b/.ideai/tickets/167/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/169/carnet.md b/.ideai/tickets/169/carnet.md new file mode 100644 index 0000000..a1a8786 --- /dev/null +++ b/.ideai/tickets/169/carnet.md @@ -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). diff --git a/.ideai/tickets/169/issue.md b/.ideai/tickets/169/issue.md new file mode 100644 index 0000000..fc3d8d0 --- /dev/null +++ b/.ideai/tickets/169/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 7cf83b1..43d2fbe 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 165 + "nextNumber": 170 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 95c12b7..a08f811 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -1461,11 +1461,11 @@ "issueRef": "#136", "path": "136", "title": "[Bug] \"passer le repos\" sur la montre ne fonctionne pas", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], - "updatedAt": 1785244375293 + "updatedAt": 1785252649210 }, { "issueRef": "#137", @@ -1693,11 +1693,11 @@ "issueRef": "#157", "path": "157", "title": "[DevFrontend] Distance live montre sur séance en cours", - "status": "qa", + "status": "open", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785248859176 + "updatedAt": 1785252668623 }, { "issueRef": "#158", @@ -1723,11 +1723,11 @@ "issueRef": "#160", "path": "160", "title": "[DevFrontend] Graphiques historiques statistiques montre", - "status": "inProgress", + "status": "qa", "priority": "medium", "sprint": "d5c18b44-0eec-46db-b8ab-506cfee0bfea", "assignedAgentIds": [], - "updatedAt": 1785248859257 + "updatedAt": 1785254327417 }, { "issueRef": "#161", @@ -1763,7 +1763,7 @@ "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1785248859271 + "updatedAt": 1785254327435 }, { "issueRef": "#164", @@ -1774,6 +1774,54 @@ "sprint": "abc4f969-b169-45f7-988c-daeeab762201", "assignedAgentIds": [], "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 } ] } \ No newline at end of file diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 140d61f..bac91f3 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3670,6 +3670,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { projection.primaryAction == WatchPrimaryAction.resumeSession, WatchCommandType.startPreparedTimedStep => projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, + WatchCommandType.completeCurrentStep => + projection.stepType == WatchStepType.reps && + projection.stepTargetValue != null, WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( WatchSecondaryAction.skipCurrentStep, ), @@ -3712,6 +3715,7 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep( session, ), + WatchCommandType.completeCurrentStep => _completeCurrentStep(session), WatchCommandType.skipCurrentStep => _skipCurrentStep(session), WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session), WatchCommandType.finishCurrentSet => _finishCurrentSet( @@ -3776,6 +3780,18 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { return WatchCommandAck.accepted; } + Future _completeCurrentStep( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.completeCurrentStep( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + Future _skipCurrentPassage( ActiveWorkoutSession session, ) async { @@ -4151,6 +4167,12 @@ final class WatchSessionProjectionProjector { : (stepState?.currentStepIndex ?? 0) + 1, stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length, stepName: currentStep?.name, + stepType: switch (currentStep?.type) { + ExerciseStepType.time => WatchStepType.time, + ExerciseStepType.reps => WatchStepType.reps, + null => null, + }, + stepTargetValue: currentStep?.defaultTargetValue, dominantTimer: dominantTimer, secondaryTimers: secondaryTimers, primaryAction: _primaryAction(phase), @@ -4389,7 +4411,7 @@ List _secondaryActions({ ]; } -String _statusLabel( +String? _statusLabel( WatchSessionPhase phase, WatchTimerProjection? dominantTimer, ) { @@ -4401,7 +4423,7 @@ String _statusLabel( WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt', WatchSessionPhase.restRunning => 'Repos en cours', 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 || currentStep == null || state.status != ActiveExerciseStepProgressStatus.stoppedTimer || - currentStep.type != ExerciseStepType.time || - snapshot.autoStartNextTimedStepEffective) { + currentStep.type != ExerciseStepType.time) { return false; } final previous = _previousStep(snapshot, state); diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index b085e0e..e6b630d 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -162,13 +162,9 @@ final class HistoryDetailScreen extends StatelessWidget { Text(_formatDateTime(history.startedAt)), const SizedBox(height: 4), Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'), - if (history.averageHeartRateBpm != null && - history.maxHeartRateBpm != null) ...[ + if (_hasWatchStats(history)) ...[ const SizedBox(height: 16), - _HistoryHeartRateSummary( - averageHeartRateBpm: history.averageHeartRateBpm!, - maxHeartRateBpm: history.maxHeartRateBpm!, - ), + _HistoryWatchStatsSummary(history: history), ], const SizedBox(height: 16), for (final program in detail.programs) ...[ @@ -315,52 +311,163 @@ final class HistoryDetailScreen extends StatelessWidget { } } -final class _HistoryHeartRateSummary extends StatelessWidget { - const _HistoryHeartRateSummary({ - required this.averageHeartRateBpm, - required this.maxHeartRateBpm, - }); +final class _HistoryWatchStatsSummary extends StatelessWidget { + const _HistoryWatchStatsSummary({required this.history}); - final double averageHeartRateBpm; - final int maxHeartRateBpm; + final WorkoutHistory history; @override 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( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Fréquence cardiaque', - style: Theme.of(context).textTheme.titleSmall, - ), + Text('Stats montre', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _HistoryHeartRateMetric( - label: 'Moyenne', - value: '${averageHeartRateBpm.round()}', - ), - ), - const SizedBox(width: 12), - Expanded( - child: _HistoryHeartRateMetric( - label: 'Max', - value: '$maxHeartRateBpm', - ), + if (minHeartRate != null || + averageHeartRate != null || + maxHeartRate != null) ...[ + Text('Fréquence cardiaque'), + const SizedBox(height: 8), + Row( + children: [ + if (minHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Min', + value: '$minHeartRate bpm', + ), + ), + if (minHeartRate != null && averageHeartRate != null) + const SizedBox(width: 8), + if (averageHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Moyenne', + value: '${averageHeartRate.round()} bpm', + ), + ), + if ((minHeartRate != null || averageHeartRate != null) && + maxHeartRate != null) + const SizedBox(width: 8), + if (maxHeartRate != null) + Expanded( + child: _HistoryWatchMetric( + label: 'Max', + 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 { - const _HistoryHeartRateMetric({required this.label, required this.value}); +final class _HistoryWatchStatsBars extends StatelessWidget { + 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 value; @@ -373,14 +480,8 @@ final class _HistoryHeartRateMetric extends StatelessWidget { Text(label, style: Theme.of(context).textTheme.labelMedium), RichText( text: TextSpan( - style: AppTextStyles.scoreNumber(context), - children: [ - TextSpan(text: value), - TextSpan( - text: ' bpm', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + style: AppTextStyles.scoreNumber(context).copyWith(fontSize: 24), + children: [TextSpan(text: value)], ), ), ], @@ -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 { const HistoryDetailData({required this.programs}); diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index fa31db9..41507a5 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -5,6 +5,7 @@ enum WatchCommandType { pauseSession, resumeSession, startPreparedTimedStep, + completeCurrentStep, skipCurrentStep, skipCurrentPassage, finishCurrentSet, @@ -60,6 +61,8 @@ enum WatchTimerRunState { stopped, running, paused } enum WatchManualScoreScope { series, step } +enum WatchStepType { time, reps } + final class WatchCommandEnvelope { const WatchCommandEnvelope({ this.schemaVersion = watchBridgeSchemaVersion, @@ -150,6 +153,8 @@ final class WatchSessionProjection { this.stepIndex, this.stepTotal, this.stepName, + this.stepType, + this.stepTargetValue, this.dominantTimer, this.secondaryTimers = const [], required this.primaryAction, @@ -190,6 +195,8 @@ final class WatchSessionProjection { stepIndex: _nullableIntFromJson(json['stepIndex']), stepTotal: _nullableIntFromJson(json['stepTotal']), stepName: _nullableStringFromJson(json['stepName']), + stepType: _nullableEnumFromJson(json['stepType'], WatchStepType.values), + stepTargetValue: _nullableIntFromJson(json['stepTargetValue']), dominantTimer: _timerFromJson(json['dominantTimer']), secondaryTimers: _timerListFromJson(json['secondaryTimers']), primaryAction: _enumFromJson( @@ -238,6 +245,8 @@ final class WatchSessionProjection { final int? stepIndex; final int? stepTotal; final String? stepName; + final WatchStepType? stepType; + final int? stepTargetValue; final WatchTimerProjection? dominantTimer; final List secondaryTimers; final WatchPrimaryAction primaryAction; @@ -270,6 +279,8 @@ final class WatchSessionProjection { 'stepIndex': stepIndex, 'stepTotal': stepTotal, 'stepName': stepName, + 'stepType': stepType?.name, + 'stepTargetValue': stepTargetValue, 'dominantTimer': dominantTimer?.toJson(), 'secondaryTimers': secondaryTimers .map((timer) => timer.toJson()) @@ -310,6 +321,8 @@ final class WatchSessionProjection { stepIndex == other.stepIndex && stepTotal == other.stepTotal && stepName == other.stepName && + stepType == other.stepType && + stepTargetValue == other.stepTargetValue && dominantTimer == other.dominantTimer && _listEquals(secondaryTimers, other.secondaryTimers) && primaryAction == other.primaryAction && @@ -344,6 +357,8 @@ final class WatchSessionProjection { stepIndex, stepTotal, stepName, + stepType, + stepTargetValue, dominantTimer, Object.hashAll(secondaryTimers), primaryAction, diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index 0cf6976..90e6a7e 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -161,6 +161,8 @@ void main() { stepIndex: 2, stepTotal: 4, stepName: 'Descente', + stepType: WatchStepType.reps, + stepTargetValue: 12, dominantTimer: _stepTimer(), secondaryTimers: [_scoreStopwatchTimer(), _setTimer()], primaryAction: primaryAction, @@ -203,6 +205,8 @@ void main() { expect(projection.phase, WatchSessionPhase.restPaused); expect(projection.passageIndex, isNull); expect(projection.stepIndex, isNull); + expect(projection.stepType, isNull); + expect(projection.stepTargetValue, isNull); expect(projection.dominantTimer, isNull); expect(projection.secondaryTimers, isEmpty); expect(projection.primaryAction, WatchPrimaryAction.resumeSession); @@ -233,6 +237,8 @@ void main() { expect(projection.exerciseName, ''); expect(projection.primaryAction, WatchPrimaryAction.none); expect(projection.secondaryActions, isEmpty); + expect(projection.stepType, isNull); + expect(projection.stepTargetValue, isNull); expect(projection.hasManualScore, isFalse); expect(projection.currentManualScoreValue, isNull); expect(projection.canDecrementScore, isFalse); diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 55f1118..8f76417 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -98,6 +98,44 @@ void main() { 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 { final session = _session( targetReps: 2, @@ -542,6 +580,8 @@ WatchSessionProjection _projection({ double? currentManualScoreValue, bool canDecrementScore = false, WatchManualScoreScope? manualScoreScope, + WatchStepType? stepType, + int? stepTargetValue, }) { return WatchSessionProjection( deviceSessionId: deviceSessionId, @@ -557,6 +597,8 @@ WatchSessionProjection _projection({ hasManualScore: hasManualScore, currentManualScoreValue: currentManualScoreValue, canDecrementScore: canDecrementScore, + stepType: stepType, + stepTargetValue: stepTargetValue, manualScoreScope: manualScoreScope ?? (hasManualScore ? WatchManualScoreScope.series : null), @@ -617,6 +659,8 @@ ActiveWorkoutSession _session({ ExerciseStep _step({ String id = 'step-1', int position = 0, + ExerciseStepType type = ExerciseStepType.time, + int defaultTargetValue = 1, bool hasScore = false, String? scoreLabel, String? scoreUnit, @@ -626,8 +670,8 @@ ExerciseStep _step({ id: id, position: position, name: 'Step ${position + 1}', - type: ExerciseStepType.time, - defaultTargetValue: 1, + type: type, + defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreLabel: scoreLabel, scoreUnit: scoreUnit, @@ -701,6 +745,8 @@ final class _FakeProjectionSource implements WatchProjectionSource { seriesIndex: projection.seriesIndex, seriesTotal: projection.seriesTotal, exerciseName: projection.exerciseName, + stepType: projection.stepType, + stepTargetValue: projection.stepTargetValue, primaryAction: projection.primaryAction, secondaryActions: projection.secondaryActions, hasManualScore: projection.hasManualScore, diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index a5830ff..5e8276e 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -40,6 +40,20 @@ void main() { 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 { final now = DateTime.utc(2026, 7, 25, 12); 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 { final now = DateTime.utc(2026, 7, 25, 12); final session = _session(currentSetIndex: 1); @@ -406,7 +456,7 @@ void main() { final projection = await projector.project(revision: 6); expect(projection.phase, WatchSessionPhase.betweenSetsReady); - expect(projection.statusLabel, 'Prêt pour la série suivante'); + expect(projection.statusLabel, isNull); expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); }, ); @@ -594,6 +644,7 @@ ActiveWorkoutSession _session({ ExerciseStep _step({ String id = 'step-1', int position = 0, + ExerciseStepType type = ExerciseStepType.time, int defaultTargetValue = 1, bool hasScore = false, String? scoreLabel, @@ -604,7 +655,7 @@ ExerciseStep _step({ id: id, position: position, name: 'Step ${position + 1}', - type: ExerciseStepType.time, + type: type, defaultTargetValue: defaultTargetValue, hasScore: hasScore, scoreLabel: scoreLabel, diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 1e0d25e..83a18b6 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -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, ) async { await tester.pumpWidget( @@ -79,8 +79,11 @@ void main() { home: HistoryDetailScreen( history: _history( id: 'history-1', + minHeartRateBpm: 88, averageHeartRateBpm: 126.4, maxHeartRateBpm: 171, + totalDistanceMeters: 1234, + totalCaloriesKcal: 83, ), historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), workoutTemplateUseCases: _workoutTemplateUseCases(), @@ -90,11 +93,22 @@ void main() { ), ); + expect(find.text('Stats montre'), findsOneWidget); expect(find.text('Fréquence cardiaque'), findsOneWidget); + expect(find.text('Min'), findsAtLeastNWidgets(1)); 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('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é', ( @@ -256,8 +270,11 @@ WorkoutHistory _history({ bool stopwatchScore = false, bool withStepResults = false, bool emptySnapshot = false, + int? minHeartRateBpm, double? averageHeartRateBpm, int? maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, }) { final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); return WorkoutHistory( @@ -268,8 +285,11 @@ WorkoutHistory _history({ startedAt: start, endedAt: start.add(const Duration(minutes: 30)), totalActiveMs: 1800000, + minHeartRateBpm: minHeartRateBpm, averageHeartRateBpm: averageHeartRateBpm, maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, completed: true, historySnapshotJson: jsonEncode({ 'sessionId': 'session-1', diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 466df41..095d192 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,8 @@ android:name="android.hardware.type.watch" android:required="true" /> - + + + diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 6cb67ec..310b20d 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -55,6 +55,7 @@ object WatchBridgePlugin { private var sensorPermissionRequestInFlight = false private var lastSensorPermissionRequestEpochMs = 0L private var pendingSensorPermissionRequest = false + private var lastSensorProjection: Map? = null fun attachApplicationContext(context: Context) { appContext = context.applicationContext @@ -142,7 +143,13 @@ object WatchBridgePlugin { if (granted) { pendingSensorPermissionRequest = false 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 { val denied = permissions.filterIndexed { index, _ -> grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED @@ -315,11 +322,15 @@ object WatchBridgePlugin { val phase = projection["phase"] as? String ?: "noActiveSession" val sessionId = projection["deviceSessionId"] as? String ?: "" if (phase == "noActiveSession" || sessionId.isBlank()) { + lastSensorProjection = null + WatchHeartRateForegroundService.stop(context) heartRateCollector.finishCurrentSession(context) return } + lastSensorProjection = projection val shouldAggregate = phase == "running" if (!hasRequiredSensorPermissions(context)) { + WatchHeartRateForegroundService.stop(context) heartRateCollector.noteActiveSession( sessionId, shouldAggregate = false, @@ -336,8 +347,10 @@ object WatchBridgePlugin { executionContext = telemetryContext(projection), ) if (shouldAggregate) { + WatchHeartRateForegroundService.start(context, projection) heartRateCollector.start(context) } else { + WatchHeartRateForegroundService.stop(context) heartRateCollector.pause(context) } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index fd0cca3..a6cc1ef 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -1,8 +1,6 @@ package com.gametime.watch.bridge -import android.annotation.SuppressLint import android.content.Context -import android.os.PowerManager import android.util.Log import androidx.health.services.client.HealthServices import androidx.health.services.client.MeasureClient @@ -39,7 +37,6 @@ internal class WatchHeartRateCollector( private val registeredDataTypes = mutableSetOf>() private var shouldAggregate = false private var appContext: Context? = null - private var wakeLock: PowerManager.WakeLock? = null private val callback = object : MeasureCallback { override fun onAvailabilityChanged( @@ -104,7 +101,6 @@ internal class WatchHeartRateCollector( return } appContext = context.applicationContext - acquireWakeLock(context) val measureClient = HealthServices.getClient(context).measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) @@ -221,7 +217,6 @@ internal class WatchHeartRateCollector( } registeredDataTypes.clear() appContext = null - releaseWakeLock() } 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?) { sessionId = nextSessionId sampleCount = 0 diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt new file mode 100644 index 0000000..37ebc9c --- /dev/null +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt @@ -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) { + 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, + ), + ) + } +} diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index b2ccb57..1606514 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -187,6 +187,10 @@ final class WatchSessionViewModel extends ValueNotifier { return _sendScoreCommand(WatchCommandType.decrementScore, -1); } + Future completeCurrentStep() { + return _sendCommand(WatchCommandType.completeCurrentStep); + } + @override void dispose() { _waitingTimer?.cancel(); @@ -288,8 +292,7 @@ final class WatchSessionViewModel extends ValueNotifier { value = value.copyWith(scoreWaitingForPhone: true); }); _scoreCommandTimeoutTimer = Timer(_commandTimeout, () { - _clearScorePending(recalibrate: true); - unawaited(HapticFeedback.heavyImpact()); + value = value.copyWith(scoreWaitingForPhone: true); unawaited(_nativeClient.requestResync()); }); try { diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index f0c3e26..f86a053 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -77,6 +77,7 @@ final class _WatchSessionScreenState extends State { onTogglePause: widget.viewModel.sendPrimaryAction, onIncrementScore: widget.viewModel.incrementScore, onDecrementScore: widget.viewModel.decrementScore, + onCompleteStep: widget.viewModel.completeCurrentStep, ), ), _RoundScaffold( @@ -399,6 +400,7 @@ final class _SessionMainView extends StatelessWidget { required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, + required this.onCompleteStep, }); final WatchSessionUiState state; @@ -407,6 +409,7 @@ final class _SessionMainView extends StatelessWidget { final VoidCallback onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; + final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -456,6 +459,7 @@ final class _SessionMainView extends StatelessWidget { onTogglePause: canToggleTimer ? onTogglePause : null, onIncrementScore: onIncrementScore, onDecrementScore: onDecrementScore, + onCompleteStep: onCompleteStep, ), ), ), @@ -703,12 +707,14 @@ final class _ActiveContent extends StatelessWidget { required this.onTogglePause, required this.onIncrementScore, required this.onDecrementScore, + required this.onCompleteStep, }); final WatchSessionUiState state; final VoidCallback? onTogglePause; final VoidCallback onIncrementScore; final VoidCallback onDecrementScore; + final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -726,10 +732,15 @@ final class _ActiveContent extends StatelessWidget { projection, primaryTimer: timer, ); + final repsTarget = _repsStepTarget(projection); final dominantValue = timer == null ? _seriesValue(projection) : _timerText(timer); final dominantLabel = timer == null ? 'SÉRIE' : timer.label; + final controlsEnabled = + !state.connectionLost && + projection.phoneReachable && + !state.commandPending; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, @@ -737,9 +748,15 @@ final class _ActiveContent extends StatelessWidget { _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), const SizedBox(height: 2), - _SmallLabel(dominantLabel), + _SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions'), 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) else _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 { const _DominantTimerLine({ required this.value, @@ -901,7 +974,7 @@ final class _ManualScoreContent extends StatelessWidget { SizedBox( height: 6, child: state.scoreWaitingForPhone - ? const _PendingDot() + ? const _PendingDot(key: ValueKey('score-pending-dot')) : const SizedBox.shrink(), ), _MainHeartRateLine(sample: state.sensorSample), @@ -1465,6 +1538,14 @@ String _seriesValue(WatchSessionProjection projection) { 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) { if (value == value.roundToDouble()) { return value.toInt().toString(); diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 3530115..81e0a3c 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -585,6 +585,104 @@ void main() { 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', ( tester, ) 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() { return WatchSessionProjection( deviceSessionId: 'session-1',