merge(main): exécution persistante des étapes et éditeur d'étapes (tickets #58, #59)

Fusionne feature/exercise-steps-58-execution-persistence — pub get
OK, build_runner OK, analyze propre (mêmes infos préexistantes),
93/93 tests verts, build APK debug validé. Tickets #58 (DevBackend)
et #59 (DevFrontend) développés dans le même worktree partagé ;
feature/exercise-steps-59-editor-ui restée vide, jamais divergée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:53:17 +02:00
37 changed files with 11467 additions and 98 deletions

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#24" issueRef: "#24"
version: 7 version: 8
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784359379007 updatedAt: 1784482464083
--- ---
Correctif : le SnackBar "Exercice ajouté" se ferme maintenant automatiquement après 3 secondes (Timer explicite, correctement annulé au dispose et avant chaque nouvel ajout pour éviter les fuites/conflits), ou immédiatement si l'utilisateur tape "Personnaliser". Vérifié : analyze propre, 43/43 tests verts, build APK réussi. Correctif : le SnackBar "Exercice ajouté" se ferme maintenant automatiquement après 3 secondes (Timer explicite, correctement annulé au dispose et avant chaque nouvel ajout pour éviter les fuites/conflits), ou immédiatement si l'utilisateur tape "Personnaliser". Vérifié : analyze propre, 43/43 tests verts, build APK réussi.

View File

@ -2,15 +2,15 @@
id: "3dbea39a-1466-47d9-b12a-95a601ec287d" id: "3dbea39a-1466-47d9-b12a-95a601ec287d"
number: 24 number: 24
title: "[Bug] Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice" title: "[Bug] Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice"
status: "qa" status: "closed"
priority: "high" priority: "high"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784328185359 createdAt: 1784328185359
updatedAt: 1784359379007 updatedAt: 1784482464083
version: 7 version: 8
--- ---
Quand j'ajoute un exercice, la popup "Exercie ajouté" s'affiche mais ne s'enleve pas. Il faudrait qu'elle s'enleve au bout que 3 sec par exemple ou si on appuie sur Personnlaiser Quand j'ajoute un exercice, la popup "Exercie ajouté" s'affiche mais ne s'enleve pas. Il faudrait qu'elle s'enleve au bout que 3 sec par exemple ou si on appuie sur Personnlaiser

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#29" issueRef: "#29"
version: 7 version: 8
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784358750450 updatedAt: 1784482456784
--- ---
Correctif appliqué : l'écran d'exécution ne lisait pas correctement `scoreInputModeSnapshot` depuis le snapshot de séance avant de transmettre le résultat à `recordCurrentSetResult`, ce qui empêchait l'enregistrement du score manuel quand Temps+Répétitions+Score étaient actifs simultanément — la série ne pouvait donc jamais être validée. Corrigé : lecture du mode depuis le snapshot, et ajout d'un SnackBar d'erreur explicite si l'enregistrement échoue malgré tout (pour éviter un "rien ne se passe" silencieux à l'avenir). Correctif appliqué : l'écran d'exécution ne lisait pas correctement `scoreInputModeSnapshot` depuis le snapshot de séance avant de transmettre le résultat à `recordCurrentSetResult`, ce qui empêchait l'enregistrement du score manuel quand Temps+Répétitions+Score étaient actifs simultanément — la série ne pouvait donc jamais être validée. Corrigé : lecture du mode depuis le snapshot, et ajout d'un SnackBar d'erreur explicite si l'enregistrement échoue malgré tout (pour éviter un "rien ne se passe" silencieux à l'avenir).

View File

@ -2,15 +2,15 @@
id: "89da284b-871c-4e5b-9bfa-ab16f386ba8f" id: "89da284b-871c-4e5b-9bfa-ab16f386ba8f"
number: 29 number: 29
title: "[Bug] Serie qu'on ne peut pas terminer" title: "[Bug] Serie qu'on ne peut pas terminer"
status: "qa" status: "closed"
priority: "critical" priority: "critical"
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: 1784328426827 createdAt: 1784328426827
updatedAt: 1784358750450 updatedAt: 1784482456784
version: 7 version: 8
--- ---
Dans un programme j'ai un exercice qui se compose de 3 series, une mesure a suivre de temps, de répétition et un score. J'ai mis le temps cible à 30, le nombre de répétition à 5. Une fois dans ma séance, je suis sur la série 2/3, temps indiqué à 30 s, j'ai mis Répétitions sur 3 et score sur 5, mais quand je clique sur temriner la série, rien ne se passe Dans un programme j'ai un exercice qui se compose de 3 series, une mesure a suivre de temps, de répétition et un score. J'ai mis le temps cible à 30, le nombre de répétition à 5. Une fois dans ma séance, je suis sur la série 2/3, temps indiqué à 30 s, j'ai mis Répétitions sur 3 et score sur 5, mais quand je clique sur temriner la série, rien ne se passe

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#32" issueRef: "#32"
version: 6 version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784361147125 updatedAt: 1784482442930
--- ---
Correctif : l'écran d'accueil recharge maintenant l'état de la séance active à chaque retour sur l'accueil (via RouteObserver/RouteAware.didPopNext), pas seulement au lancement de l'app. Fonctionne après "Quitter et sauvegarder" (bandeau apparaît) et après "Abandonner la séance" (bandeau reste absent). Vérifié : analyze propre, 45/45 tests verts, build APK réussi. Correctif : l'écran d'accueil recharge maintenant l'état de la séance active à chaque retour sur l'accueil (via RouteObserver/RouteAware.didPopNext), pas seulement au lancement de l'app. Fonctionne après "Quitter et sauvegarder" (bandeau apparaît) et après "Abandonner la séance" (bandeau reste absent). Vérifié : analyze propre, 45/45 tests verts, build APK réussi.

View File

@ -2,15 +2,15 @@
id: "a3fe5103-8b5c-4cce-80af-f1dd08b76794" id: "a3fe5103-8b5c-4cce-80af-f1dd08b76794"
number: 32 number: 32
title: "[Bug] Reprendre séance en cours pas toujours affiché" title: "[Bug] Reprendre séance en cours pas toujours affiché"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784328903547 createdAt: 1784328903547
updatedAt: 1784361147125 updatedAt: 1784482442930
version: 6 version: 7
--- ---
Si je quitte une seance en cours, pour voir le bandeau de reprise de seance s'afficher je dois quitter totalement l'application et la relancer. J'aimerais qu'il y soit automatiquement quand une séance et en cours et que je ne suis pas dans ma séance Si je quitte une seance en cours, pour voir le bandeau de reprise de seance s'afficher je dois quitter totalement l'application et la relancer. J'aimerais qu'il y soit automatiquement quand une séance et en cours et que je ne suis pas dans ma séance

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#33" issueRef: "#33"
version: 6 version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784361411170 updatedAt: 1784482438271
--- ---
Correctif : tenter de lancer une séance alors qu'une autre est déjà active affiche maintenant un SnackBar explicite ("Une séance est déjà en cours. Termine-la ou reprends-la avant d'en lancer une nouvelle."), au lieu de ne rien faire silencieusement. Vérifié : analyze propre, 46/46 tests verts, build APK réussi. Correctif : tenter de lancer une séance alors qu'une autre est déjà active affiche maintenant un SnackBar explicite ("Une séance est déjà en cours. Termine-la ou reprends-la avant d'en lancer une nouvelle."), au lieu de ne rien faire silencieusement. Vérifié : analyze propre, 46/46 tests verts, build APK réussi.

View File

@ -2,15 +2,15 @@
id: "a588ff04-9e55-4492-ac76-5298ba94e5c7" id: "a588ff04-9e55-4492-ac76-5298ba94e5c7"
number: 33 number: 33
title: "[Bug] Impossible de lancer une seance" title: "[Bug] Impossible de lancer une seance"
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: 1784329082771 createdAt: 1784329082771
updatedAt: 1784361411170 updatedAt: 1784482438271
version: 6 version: 7
--- ---
Si une seance est déjà en cours je ne peux poas en lancer une autre. Je trouve ça bien, j'aimerais juste qu'une petite popup me le dise car on a actuellement aucun retour pour ça Si une seance est déjà en cours je ne peux poas en lancer une autre. Je trouve ça bien, j'aimerais juste qu'une petite popup me le dise car on a actuellement aucun retour pour ça

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#41" issueRef: "#41"
version: 6 version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784406566969 updatedAt: 1784482434770
--- ---

View File

@ -2,15 +2,15 @@
id: "d92c2af3-1981-4782-9aba-460a45b37d56" id: "d92c2af3-1981-4782-9aba-460a45b37d56"
number: 41 number: 41
title: "[bug] images non affichée dans le caroussel" title: "[bug] images non affichée dans le caroussel"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784378925702 createdAt: 1784378925702
updatedAt: 1784406566969 updatedAt: 1784482434770
version: 6 version: 7
--- ---
Dans le carroussel des exercices dans les séances, les images ne s'affichent pas vraiment, je n'ai qu'une icone avec le titre image 1, image 2 etc... Dans le carroussel des exercices dans les séances, les images ne s'affichent pas vraiment, je n'ai qu'une icone avec le titre image 1, image 2 etc...

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#42" issueRef: "#42"
version: 6 version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784406567477 updatedAt: 1784482429394
--- ---

View File

@ -2,15 +2,15 @@
id: "a788671c-fbc4-4c3d-855e-bb44c120ab54" id: "a788671c-fbc4-4c3d-855e-bb44c120ab54"
number: 42 number: 42
title: "[bug] vidéo non affichée dans le caroussel" title: "[bug] vidéo non affichée dans le caroussel"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784378998844 createdAt: 1784378998844
updatedAt: 1784406567477 updatedAt: 1784482429394
version: 6 version: 7
--- ---
Dans les exerccies des séances, je ne peux pas lancer la vidéo. Je n'ai qu'un icone Play avec marqué Vidéo mais rien ne se passe si je selectionne Dans les exerccies des séances, je ne peux pas lancer la vidéo. Je n'ai qu'un icone Play avec marqué Vidéo mais rien ne se passe si je selectionne

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#43" issueRef: "#43"
version: 6 version: 7
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"user"}
updatedAt: 1784406567925 updatedAt: 1784482421672
--- ---

View File

@ -2,15 +2,15 @@
id: "33546028-43ab-467c-90af-f2ff3aeb920f" id: "33546028-43ab-467c-90af-f2ff3aeb920f"
number: 43 number: 43
title: "[Bug] chrono qui n'affiche que les secondes" title: "[Bug] chrono qui n'affiche que les secondes"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784379078126 createdAt: 1784379078126
updatedAt: 1784406567925 updatedAt: 1784482421672
version: 6 version: 7
--- ---
Pour le chrono score, quand je le lance j'ai d'abord 0.9 sec qui s'affiche, puis uniquement les secondes qui s'incrémentent Pour le chrono score, quand je le lance j'ai d'abord 0.9 sec qui s'affiche, puis uniquement les secondes qui s'incrémentent

View File

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

View File

@ -2,15 +2,15 @@
id: "c63e88a9-5868-4bd0-a71b-ebc4e7e6552a" id: "c63e88a9-5868-4bd0-a71b-ebc4e7e6552a"
number: 44 number: 44
title: "[Bug] Je veux pouvoir mettre un score par défaut à 0." title: "[Bug] Je veux pouvoir mettre un score par défaut à 0."
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784379259225 createdAt: 1784379259225
updatedAt: 1784406980453 updatedAt: 1784482417515
version: 5 version: 6
--- ---
Je veux pouvoir renseigner un score par défaut à 0 (je ne parle pas du nombre de répétition qui doit lui rester comme actuellement). Je veux pouvoir renseigner un score par défaut à 0 (je ne parle pas du nombre de répétition qui doit lui rester comme actuellement).

View File

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

View File

@ -2,15 +2,15 @@
id: "2f075be4-bcb8-4fdd-89fc-2f897e97bde4" id: "2f075be4-bcb8-4fdd-89fc-2f897e97bde4"
number: 45 number: 45
title: "[Bug] Affichage de répétition en séance à 0" title: "[Bug] Affichage de répétition en séance à 0"
status: "qa" status: "closed"
priority: "medium" priority: "medium"
sprint: "abc4f969-b169-45f7-988c-daeeab762201" sprint: "abc4f969-b169-45f7-988c-daeeab762201"
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] 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":"user"}
createdAt: 1784379414910 createdAt: 1784379414910
updatedAt: 1784406980638 updatedAt: 1784482408550
version: 5 version: 6
--- ---
Quand je suis en séance sur un exercice qui demande un nombre de répétition, l'affichage m'affiche 0 alors que le nombre de répétition est set à une valeur > 0 dans l'exercice. Il devrait être à la même valeur Quand je suis en séance sur un exercice qui demande un nombre de répétition, l'affichage m'affiche 0 alors que le nombre de répétition est set à une valeur > 0 dans l'exercice. Il devrait être à la même valeur

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#56" issueRef: "#56"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784481516635 updatedAt: 1784482387469
--- ---

View File

@ -2,15 +2,15 @@
id: "c902b71c-8033-42dc-a52a-eb6822a1e863" id: "c902b71c-8033-42dc-a52a-eb6822a1e863"
number: 56 number: 56
title: "[DevBackend] Modèle domain + Drift pour exercices à étapes" title: "[DevBackend] Modèle domain + Drift pour exercices à étapes"
status: "open" status: "qa"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#54","kind":"relatesTo"}] links: [{"target":"#54","kind":"relatesTo"}]
agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784481516635 createdAt: 1784481516635
updatedAt: 1784481516635 updatedAt: 1784482387469
version: 1 version: 2
--- ---
Implémenter le socle domain et Drift pour les exercices à étapes. Ajouter `ExerciseStep`, enum `ExerciseStepType time|reps`, liste `steps` sur `Exercise`, validation max 8 étapes, positions uniques, cible par défaut > 0, score d'étape optionnel avec `ScoreInputMode manual|stopwatch`, label/unité/valeurs par défaut. Ajouter snapshots d'étapes dans `ProgramExercise.toSnapshotJson` et dans les snapshots WorkoutTemplate/History. Migration Drift depuis schemaVersion 7 vers 8 : tables/colonnes nécessaires, contraintes et mappers repository. Implémenter le socle domain et Drift pour les exercices à étapes. Ajouter `ExerciseStep`, enum `ExerciseStepType time|reps`, liste `steps` sur `Exercise`, validation max 8 étapes, positions uniques, cible par défaut > 0, score d'étape optionnel avec `ScoreInputMode manual|stopwatch`, label/unité/valeurs par défaut. Ajouter snapshots d'étapes dans `ProgramExercise.toSnapshotJson` et dans les snapshots WorkoutTemplate/History. Migration Drift depuis schemaVersion 7 vers 8 : tables/colonnes nécessaires, contraintes et mappers repository.

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#63" issueRef: "#63"
version: 6 version: 8
updatedBy: {"kind":"user"} updatedBy: {"kind":"user"}
updatedAt: 1784482343900 updatedAt: 1784482673684
--- ---

View File

@ -10,7 +10,7 @@ agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"user"}
createdAt: 1784482089895 createdAt: 1784482089895
updatedAt: 1784482343900 updatedAt: 1784482673684
version: 6 version: 8
--- ---
Une première version du serveur est disponible. J'aimerais que les features serveur soient ajouté à l'application. Le bon fonctionnement de l'applciation reste prioritaire sur le serveur, à savoir que le serveur sert l'applciation et pas l'inverse, c'est a dire que si une structure de donnée est actuellement différente entre le serveur et l'applciation, alors il faut modifier le serveur pour qu'il accepte la donnée du client. Je pense par exemple aux exercices, entre la création serveur et la réalisation du ticket, je pense que la forme des exercice a été modifiée (ajout entre autre des étapes dans une serie d'un exercice), il faudra alors adapter le serveur à ça. Une première version du serveur est disponible. J'aimerais que les features serveur soient ajouté à l'application (j'entends ici l'ajout de la totalité de la stack de la logique méier comme la sync des historiques etc, à la connexion). Pour la connexion, comme indiqué dans le ticket #57, la connexion n'est pas obligatoire pour l'acces à l'applicaiton, elle ne doit être que optionnelle, je pense entre autre à un menu "Profile" à créer. Le bon fonctionnement de l'application reste prioritaire sur le serveur, à savoir que le serveur sert l'applciation et pas l'inverse, c'est a dire que si une structure de donnée est actuellement différente entre le serveur et l'applciation, alors il faut modifier le serveur pour qu'il accepte la donnée du client. Je pense par exemple aux exercices, entre la création serveur et la réalisation du ticket, je pense que la forme des exercice a été modifiée (ajout entre autre des étapes dans une serie d'un exercice), il faudra alors adapter le serveur à ça.

View File

@ -267,13 +267,13 @@
"issueRef": "#24", "issueRef": "#24",
"path": "24", "path": "24",
"title": "[Bug] Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice", "title": "[Bug] Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice",
"status": "qa", "status": "closed",
"priority": "high", "priority": "high",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784359379007 "updatedAt": 1784482464083
}, },
{ {
"issueRef": "#25", "issueRef": "#25",
@ -327,11 +327,11 @@
"issueRef": "#29", "issueRef": "#29",
"path": "29", "path": "29",
"title": "[Bug] Serie qu'on ne peut pas terminer", "title": "[Bug] Serie qu'on ne peut pas terminer",
"status": "qa", "status": "closed",
"priority": "critical", "priority": "critical",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784358750450 "updatedAt": 1784482456784
}, },
{ {
"issueRef": "#30", "issueRef": "#30",
@ -359,23 +359,23 @@
"issueRef": "#32", "issueRef": "#32",
"path": "32", "path": "32",
"title": "[Bug] Reprendre séance en cours pas toujours affiché", "title": "[Bug] Reprendre séance en cours pas toujours affiché",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784361147125 "updatedAt": 1784482442930
}, },
{ {
"issueRef": "#33", "issueRef": "#33",
"path": "33", "path": "33",
"title": "[Bug] Impossible de lancer une seance", "title": "[Bug] Impossible de lancer une seance",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784361411170 "updatedAt": 1784482438271
}, },
{ {
"issueRef": "#34", "issueRef": "#34",
@ -465,61 +465,61 @@
"issueRef": "#41", "issueRef": "#41",
"path": "41", "path": "41",
"title": "[bug] images non affichée dans le caroussel", "title": "[bug] images non affichée dans le caroussel",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784406566969 "updatedAt": 1784482434770
}, },
{ {
"issueRef": "#42", "issueRef": "#42",
"path": "42", "path": "42",
"title": "[bug] vidéo non affichée dans le caroussel", "title": "[bug] vidéo non affichée dans le caroussel",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784406567477 "updatedAt": 1784482429394
}, },
{ {
"issueRef": "#43", "issueRef": "#43",
"path": "43", "path": "43",
"title": "[Bug] chrono qui n'affiche que les secondes", "title": "[Bug] chrono qui n'affiche que les secondes",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784406567925 "updatedAt": 1784482421672
}, },
{ {
"issueRef": "#44", "issueRef": "#44",
"path": "44", "path": "44",
"title": "[Bug] Je veux pouvoir mettre un score par défaut à 0.", "title": "[Bug] Je veux pouvoir mettre un score par défaut à 0.",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784406980453 "updatedAt": 1784482417515
}, },
{ {
"issueRef": "#45", "issueRef": "#45",
"path": "45", "path": "45",
"title": "[Bug] Affichage de répétition en séance à 0", "title": "[Bug] Affichage de répétition en séance à 0",
"status": "qa", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": "abc4f969-b169-45f7-988c-daeeab762201", "sprint": "abc4f969-b169-45f7-988c-daeeab762201",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784406980638 "updatedAt": 1784482408550
}, },
{ {
"issueRef": "#46", "issueRef": "#46",
@ -619,13 +619,13 @@
"issueRef": "#56", "issueRef": "#56",
"path": "56", "path": "56",
"title": "[DevBackend] Modèle domain + Drift pour exercices à étapes", "title": "[DevBackend] Modèle domain + Drift pour exercices à étapes",
"status": "open", "status": "qa",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495" "10ee045b-1c41-479e-ba03-dceed9edd495"
], ],
"updatedAt": 1784481516635 "updatedAt": 1784482387469
}, },
{ {
"issueRef": "#57", "issueRef": "#57",
@ -709,7 +709,7 @@
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784482343900 "updatedAt": 1784482673684
} }
] ]
} }

View File

@ -81,6 +81,10 @@ abstract interface class ActiveSessionRepository {
Future<void> saveSetResult(ActiveSetResult result); Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveRestState(ActiveRestState restState); Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state); Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
);
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result);
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,
required int programIndex, required int programIndex,
@ -95,7 +99,19 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex, required int exerciseIndex,
required int setIndex, required int setIndex,
}); });
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<List<ActiveSetResult>> listSetResults(String sessionId); Future<List<ActiveSetResult>> listSetResults(String sessionId);
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
);
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
);
Future<List<ActiveRestState>> listRestStates(String sessionId); Future<List<ActiveRestState>> listRestStates(String sessionId);
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates( Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId, String sessionId,
@ -107,6 +123,7 @@ abstract interface class WorkoutHistoryRepository {
Future<List<WorkoutHistory>> listActive(); Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history); Future<void> save(WorkoutHistory history);
Future<void> saveSetResult(WorkoutHistorySetResult result); Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> saveStepResult(WorkoutHistoryStepResult result);
Future<void> delete(String id, DateTime deletedAt); Future<void> delete(String id, DateTime deletedAt);
} }

View File

@ -68,6 +68,7 @@ final class ExerciseUseCases {
defaultTargetReps: defaultTargetReps, defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
); );
await repository.save(exercise); await repository.save(exercise);
return exercise; return exercise;
@ -962,6 +963,23 @@ final class ActiveWorkoutSessionUseCases {
); );
await sessionRepository.saveScoreStopwatchState(stopped); await sessionRepository.saveScoreStopwatchState(stopped);
} }
final runningStepTimers =
(await sessionRepository.listExerciseStepProgressStates(sessionId))
.where(
(state) =>
state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
);
for (final state in runningStepTimers) {
final pausedStep = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.pausedTimer,
accumulatedMs: state.elapsedMillisecondsAt(now),
startedAt: null,
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(pausedStep);
}
final paused = session.pause(now); final paused = session.pause(now);
await sessionRepository.save(paused); await sessionRepository.save(paused);
return paused; return paused;
@ -1441,6 +1459,555 @@ final class ActiveWorkoutSessionUseCases {
} }
} }
final class ActiveExerciseStepProgressView {
const ActiveExerciseStepProgressView({
required this.state,
required this.steps,
required this.expectedPassages,
required this.results,
});
final ActiveExerciseStepProgressState state;
final List<ExerciseStep> steps;
final int expectedPassages;
final List<ActiveExerciseStepResult> results;
ExerciseStep? get currentStep =>
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
? null
: steps[state.currentStepIndex];
}
final class ActiveExerciseStepUseCases {
const ActiveExerciseStepUseCases({
required this.sessionRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<ActiveExerciseStepProgressView> startOrResumeProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final existing = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final state = existing ??
_initialStepProgressState(
sessionId: sessionId,
context: context,
now: now,
);
if (existing == null) {
await sessionRepository.saveExerciseStepProgressState(state);
}
final advanced = await _autoAdvanceElapsedTimers(context, state, now);
return ActiveExerciseStepProgressView(
state: advanced,
steps: context.steps,
expectedPassages: context.expectedPassages,
results: await _stepResultsForPosition(context),
);
}
Future<ActiveExerciseStepProgressView> readProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) => startOrResumeProgress(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
Future<ActiveExerciseStepProgressState> startTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final view = await startOrResumeProgress(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final step = view.currentStep;
if (step == null || step.type != ExerciseStepType.time) {
throw const DomainException('Current exercise step is not timed.');
}
if (view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
return view.state;
}
final now = clock.now();
final running = view.state.copyWith(
metadata: view.state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(running);
return running;
}
Future<ActiveExerciseStepProgressState> pauseTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state.status != ActiveExerciseStepProgressStatus.runningTimer) {
throw const DomainException('Exercise step timer is not running.');
}
final now = clock.now();
final paused = state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.pausedTimer,
startedAt: null,
accumulatedMs: state.elapsedMillisecondsAt(now),
lastTransitionAt: now,
);
await sessionRepository.saveExerciseStepProgressState(paused);
return paused;
}
Future<ActiveExerciseStepProgressState> resumeTimer({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) {
return startTimer(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
}
int elapsedMilliseconds(ActiveExerciseStepProgressState state) {
return state.elapsedMillisecondsAt(clock.now());
}
int remainingMilliseconds({
required ActiveExerciseStepProgressState state,
required ExerciseStep step,
}) {
final targetMs = step.defaultTargetValue * 1000;
final remaining = targetMs - elapsedMilliseconds(state);
return remaining < 0 ? 0 : remaining;
}
Future<ActiveExerciseStepResult> completeCurrentStep({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
String? note,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final state = await _autoAdvanceElapsedTimers(
context,
await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
),
now,
);
final step = _currentStep(context, state);
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.');
}
final completed = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.completed,
now: now,
actualTimeMs: step.type == ExerciseStepType.time
? state.elapsedMillisecondsAt(now)
: null,
actualReps: step.type == ExerciseStepType.reps
? actualReps ?? step.defaultTargetValue
: null,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
note: note,
);
await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
);
return completed;
}
Future<ActiveExerciseStepResult> skipCurrentStep({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final step = _currentStep(context, state);
final skipped = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
await sessionRepository.saveExerciseStepResult(skipped);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
);
return skipped;
}
Future<List<ActiveExerciseStepResult>> skipCurrentPassage({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
var state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final skipped = <ActiveExerciseStepResult>[];
final passage = state.currentPassageIndex;
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
state.currentPassageIndex == passage) {
final step = _currentStep(context, state);
final result = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
}
Future<List<ActiveExerciseStepResult>> skipSequence({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final context = await _stepContext(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
var state = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final now = clock.now();
final skipped = <ActiveExerciseStepResult>[];
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete) {
final step = _currentStep(context, state);
final result = _stepResult(
context: context,
state: state,
step: step,
status: SetResultStatus.skipped,
now: now,
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
}
Future<ActiveExerciseStepProgressState> _requiredStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final state = await sessionRepository.findExerciseStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
throw const DomainException('Exercise step progress state not found.');
}
return state;
}
Future<_StepSequenceContext> _stepContext({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final session = await sessionRepository.findById(sessionId);
if (session == null) {
throw const DomainException('Active workout session not found.');
}
final snapshot = _findExerciseSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
);
if (snapshot == null || snapshot.steps.isEmpty) {
throw const DomainException('Exercise step sequence not found.');
}
return _StepSequenceContext(
sessionId: sessionId,
programSnapshotId: snapshot.programSnapshotId,
exerciseSnapshotId: snapshot.exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
steps: snapshot.steps,
expectedPassages: snapshot.repsEnabled
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
: 1,
);
}
ActiveExerciseStepProgressState _initialStepProgressState({
required String sessionId,
required _StepSequenceContext context,
required DateTime now,
}) {
final firstStep = context.steps.first;
return ActiveExerciseStepProgressState(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programIndex: context.programIndex,
exerciseIndex: context.exerciseIndex,
setIndex: context.setIndex,
currentPassageIndex: 0,
currentStepIndex: 0,
currentStepSnapshotId: firstStep.id,
status: firstStep.type == ExerciseStepType.time
? ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
Future<ActiveExerciseStepProgressState> _autoAdvanceElapsedTimers(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
DateTime now,
) async {
var current = state;
while (current.status == ActiveExerciseStepProgressStatus.runningTimer) {
final step = _currentStep(context, current);
if (step.type != ExerciseStepType.time) {
break;
}
final targetMs = step.defaultTargetValue * 1000;
final elapsed = current.elapsedMillisecondsAt(now);
if (elapsed < targetMs) {
break;
}
final result = _stepResult(
context: context,
state: current,
step: step,
status: SetResultStatus.completed,
now: now,
actualTimeMs: targetMs,
);
await sessionRepository.saveExerciseStepResult(result);
final overflowMs = elapsed - targetMs;
current = _advanceState(
context,
current,
now,
runningTimerOverflowMs: overflowMs,
);
await sessionRepository.saveExerciseStepProgressState(current);
}
return current;
}
ActiveExerciseStepProgressState _advanceState(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
DateTime now, {
int runningTimerOverflowMs = 0,
}) {
var nextPassage = state.currentPassageIndex;
var nextStep = state.currentStepIndex + 1;
if (nextStep >= context.steps.length) {
nextStep = 0;
nextPassage++;
}
if (nextPassage >= context.expectedPassages) {
return state.copyWith(
metadata: state.metadata.touch(now),
status: ActiveExerciseStepProgressStatus.sequenceComplete,
startedAt: null,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
final step = context.steps[nextStep];
final status = step.type == ExerciseStepType.time
? runningTimerOverflowMs > 0
? ActiveExerciseStepProgressStatus.runningTimer
: ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual;
return state.copyWith(
metadata: state.metadata.touch(now),
currentPassageIndex: nextPassage,
currentStepIndex: nextStep,
currentStepSnapshotId: step.id,
status: status,
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
? now.subtract(Duration(milliseconds: runningTimerOverflowMs))
: null,
accumulatedMs: 0,
lastTransitionAt: now,
);
}
ExerciseStep _currentStep(
_StepSequenceContext context,
ActiveExerciseStepProgressState state,
) {
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.');
}
return context.steps[state.currentStepIndex];
}
Future<List<ActiveExerciseStepResult>> _stepResultsForPosition(
_StepSequenceContext context,
) async {
return (await sessionRepository.listExerciseStepResults(context.sessionId))
.where(
(result) =>
result.programIndex == context.programIndex &&
result.exerciseIndex == context.exerciseIndex &&
result.setIndex == context.setIndex,
)
.toList();
}
ActiveExerciseStepResult _stepResult({
required _StepSequenceContext context,
required ActiveExerciseStepProgressState state,
required ExerciseStep step,
required SetResultStatus status,
required DateTime now,
int? actualTimeMs,
int? actualReps,
double? actualScore,
int? actualScoreTimeMs,
String? note,
}) {
return ActiveExerciseStepResult(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: context.sessionId,
programSnapshotId: context.programSnapshotId,
exerciseSnapshotId: context.exerciseSnapshotId,
programIndex: context.programIndex,
exerciseIndex: context.exerciseIndex,
setIndex: context.setIndex,
passageIndex: state.currentPassageIndex,
stepIndex: state.currentStepIndex,
stepSnapshotId: step.id,
stepNameSnapshot: step.name,
stepTypeSnapshot: step.type,
targetValueSnapshot: step.defaultTargetValue,
hasScoreSnapshot: step.hasScore,
scoreInputModeSnapshot: step.hasScore ? step.scoreInputMode : null,
scoreLabelSnapshot: step.scoreLabel,
scoreUnitSnapshot: step.scoreUnit,
targetScoreSnapshot: step.defaultTargetScore,
targetScoreTimeMsSnapshot: step.defaultTargetScoreTimeMs,
status: status,
startedAt: state.startedAt,
completedAt: status == SetResultStatus.completed ? now : null,
actualTimeMs: status == SetResultStatus.completed ? actualTimeMs : null,
actualReps: status == SetResultStatus.completed ? actualReps : null,
actualScore: status == SetResultStatus.completed ? actualScore : null,
actualScoreTimeMs: status == SetResultStatus.completed
? actualScoreTimeMs
: null,
note: note,
);
}
}
final class CloseWorkoutSessionUseCase { final class CloseWorkoutSessionUseCase {
const CloseWorkoutSessionUseCase({ const CloseWorkoutSessionUseCase({
required this.sessionRepository, required this.sessionRepository,
@ -1467,6 +2034,9 @@ final class CloseWorkoutSessionUseCase {
} }
final now = clock.now(); final now = clock.now();
final results = await sessionRepository.listSetResults(sessionId); final results = await sessionRepository.listSetResults(sessionId);
final stepResults = await sessionRepository.listExerciseStepResults(
sessionId,
);
final historyId = ids.newId(); final historyId = ids.newId();
final historyResults = _historyResultsFromActiveResults( final historyResults = _historyResultsFromActiveResults(
historyId: historyId, historyId: historyId,
@ -1476,6 +2046,13 @@ final class CloseWorkoutSessionUseCase {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
); );
final historyStepResults = _historyStepResultsFromActiveResults(
historyId: historyId,
results: stepResults,
now: now,
ids: ids,
originDeviceId: originDeviceId,
);
final history = WorkoutHistory( final history = WorkoutHistory(
metadata: EntityMetadata( metadata: EntityMetadata(
id: historyId, id: historyId,
@ -1512,8 +2089,38 @@ final class CloseWorkoutSessionUseCase {
}, },
) )
.toList(), .toList(),
'stepResults': stepResults
.map(
(result) => {
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepSnapshotId': result.stepSnapshotId,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'status': result.status.name,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'note': result.note,
},
)
.toList(),
}), }),
results: historyResults, results: historyResults,
stepResults: historyStepResults,
); );
await historyRepository.save(history); await historyRepository.save(history);
return history; return history;
@ -1867,6 +2474,53 @@ _SetPositionSnapshot? _findSetSnapshot({
return null; return null;
} }
_ResolvedExerciseSnapshot? _findExerciseSnapshot({
required String resolvedTemplateSnapshotJson,
required int programIndex,
required int exerciseIndex,
}) {
final decoded =
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
if (programIndex < 0 || programIndex >= programs.length) {
return null;
}
final program = programs[programIndex] as Map<String, dynamic>;
final programSnapshotId = program['id'] as String? ?? 'program-$programIndex';
final programName = program['programNameSnapshot'] as String? ?? '';
final programSnapshotJson = program['programSnapshotJson'] as String?;
if (programSnapshotJson == null) {
return null;
}
final programSnapshot =
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
final exercises = (programSnapshot['exercises'] as List<dynamic>? ?? const []);
if (exerciseIndex < 0 || exerciseIndex >= exercises.length) {
return null;
}
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
return _ResolvedExerciseSnapshot(
programSnapshotId: programSnapshotId,
exerciseSnapshotId: id,
programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true,
repsEnabled: exercise['repsEnabled'] == true,
scoreEnabled: exercise['scoreEnabled'] == true,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
);
}
List<_SetPositionSnapshot> _listSetSnapshots( List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson, String resolvedTemplateSnapshotJson,
) { ) {
@ -1938,6 +2592,28 @@ final class _SetPositionSnapshot {
final ScoreInputMode scoreInputModeSnapshot; final ScoreInputMode scoreInputModeSnapshot;
} }
final class _StepSequenceContext {
const _StepSequenceContext({
required this.sessionId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.steps,
required this.expectedPassages,
});
final String sessionId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final List<ExerciseStep> steps;
final int expectedPassages;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({ List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
required String historyId, required String historyId,
required List<ActiveSetResult> results, required List<ActiveSetResult> results,
@ -2010,6 +2686,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
continue; continue;
} }
snapshots[id] = _ResolvedExerciseSnapshot( snapshots[id] = _ResolvedExerciseSnapshot(
programSnapshotId: program['id'] as String? ?? id,
exerciseSnapshotId: id,
programNameSnapshot: programName, programNameSnapshot: programName,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id, exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
timeEnabled: exercise['timeEnabled'] == true, timeEnabled: exercise['timeEnabled'] == true,
@ -2024,6 +2702,7 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
), ),
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
); );
} }
} }
@ -2032,6 +2711,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
final class _ResolvedExerciseSnapshot { final class _ResolvedExerciseSnapshot {
const _ResolvedExerciseSnapshot({ const _ResolvedExerciseSnapshot({
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programNameSnapshot, required this.programNameSnapshot,
required this.exerciseNameSnapshot, required this.exerciseNameSnapshot,
required this.timeEnabled, required this.timeEnabled,
@ -2044,8 +2725,11 @@ final class _ResolvedExerciseSnapshot {
required this.scoreInputModeSnapshot, required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot, this.scoreLabelSnapshot,
this.scoreUnitSnapshot, this.scoreUnitSnapshot,
this.steps = const [],
}); });
final String programSnapshotId;
final String exerciseSnapshotId;
final String programNameSnapshot; final String programNameSnapshot;
final String exerciseNameSnapshot; final String exerciseNameSnapshot;
final bool timeEnabled; final bool timeEnabled;
@ -2058,6 +2742,73 @@ final class _ResolvedExerciseSnapshot {
final ScoreInputMode scoreInputModeSnapshot; final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot; final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot; final String? scoreUnitSnapshot;
final List<ExerciseStep> steps;
}
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {
if (value is! List) {
return const [];
}
return value.map((item) {
final json = Map<String, dynamic>.from(item as Map);
return ExerciseStep(
id: json['id'] as String,
position: json['position'] as int,
name: json['name'] as String,
type: switch (json['type']) {
'time' => ExerciseStepType.time,
'reps' => ExerciseStepType.reps,
_ => throw const DomainException('Unknown exercise step type.'),
},
defaultTargetValue: json['defaultTargetValue'] as int,
hasScore: json['hasScore'] == true,
scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']),
scoreLabel: json['scoreLabel'] as String?,
scoreUnit: json['scoreUnit'] as String?,
defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?,
);
}).toList(growable: false);
}
List<WorkoutHistoryStepResult> _historyStepResultsFromActiveResults({
required String historyId,
required List<ActiveExerciseStepResult> results,
required DateTime now,
required IdGenerator ids,
required String originDeviceId,
}) {
return results.map((result) {
return WorkoutHistoryStepResult(
metadata: _newMetadata(ids, originDeviceId, now),
workoutHistoryId: historyId,
programSnapshotId: result.programSnapshotId,
exerciseSnapshotId: result.exerciseSnapshotId,
programIndex: result.programIndex,
exerciseIndex: result.exerciseIndex,
setIndex: result.setIndex,
passageIndex: result.passageIndex,
stepIndex: result.stepIndex,
stepSnapshotId: result.stepSnapshotId,
stepNameSnapshot: result.stepNameSnapshot,
stepTypeSnapshot: result.stepTypeSnapshot,
targetValueSnapshot: result.targetValueSnapshot,
hasScoreSnapshot: result.hasScoreSnapshot,
scoreInputModeSnapshot: result.scoreInputModeSnapshot,
scoreLabelSnapshot: result.scoreLabelSnapshot,
scoreUnitSnapshot: result.scoreUnitSnapshot,
targetScoreSnapshot: result.targetScoreSnapshot,
targetScoreTimeMsSnapshot: result.targetScoreTimeMsSnapshot,
status: result.status,
startedAt: result.startedAt,
completedAt: result.completedAt,
actualTimeMs: result.actualTimeMs,
actualReps: result.actualReps,
actualScore: result.actualScore,
actualScoreTimeMs: result.actualScoreTimeMs,
note: result.note,
);
}).toList();
} }
EntityMetadata _newMetadata( EntityMetadata _newMetadata(

View File

@ -16,6 +16,15 @@ enum SetResultStatus { completed, skipped }
enum ActiveScoreStopwatchStatus { running, stopped } enum ActiveScoreStopwatchStatus { running, stopped }
enum ActiveExerciseStepProgressStatus {
notStarted,
waitingManual,
runningTimer,
pausedTimer,
stoppedTimer,
sequenceComplete,
}
final class DomainException implements Exception { final class DomainException implements Exception {
const DomainException(this.message); const DomainException(this.message);
@ -916,6 +925,166 @@ final class ActiveRestState {
} }
} }
final class ActiveExerciseStepProgressState {
ActiveExerciseStepProgressState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.currentPassageIndex,
required this.currentStepIndex,
required this.currentStepSnapshotId,
required this.status,
this.startedAt,
required this.accumulatedMs,
required this.lastTransitionAt,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNonNegative(currentPassageIndex, 'Current passage index');
_requireNonNegative(currentStepIndex, 'Current step index');
_nonBlank(currentStepSnapshotId, 'Current step snapshot id');
_requireNonNegative(accumulatedMs, 'Accumulated milliseconds');
if (status == ActiveExerciseStepProgressStatus.runningTimer &&
startedAt == null) {
throw const DomainException(
'Running exercise step timer requires a start timestamp.',
);
}
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int currentPassageIndex;
final int currentStepIndex;
final String currentStepSnapshotId;
final ActiveExerciseStepProgressStatus status;
final DateTime? startedAt;
final int accumulatedMs;
final DateTime lastTransitionAt;
int elapsedMillisecondsAt(DateTime now) {
if (status != ActiveExerciseStepProgressStatus.runningTimer ||
startedAt == null) {
return accumulatedMs;
}
return accumulatedMs + now.difference(startedAt!).inMilliseconds;
}
ActiveExerciseStepProgressState copyWith({
EntityMetadata? metadata,
int? currentPassageIndex,
int? currentStepIndex,
String? currentStepSnapshotId,
ActiveExerciseStepProgressStatus? status,
Object? startedAt = _unchanged,
int? accumulatedMs,
DateTime? lastTransitionAt,
}) {
return ActiveExerciseStepProgressState(
metadata: metadata ?? this.metadata,
activeWorkoutSessionId: activeWorkoutSessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
currentPassageIndex: currentPassageIndex ?? this.currentPassageIndex,
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
currentStepSnapshotId:
currentStepSnapshotId ?? this.currentStepSnapshotId,
status: status ?? this.status,
startedAt: startedAt == _unchanged ? this.startedAt : startedAt as DateTime?,
accumulatedMs: accumulatedMs ?? this.accumulatedMs,
lastTransitionAt: lastTransitionAt ?? this.lastTransitionAt,
);
}
}
final class ActiveExerciseStepResult {
ActiveExerciseStepResult({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.passageIndex,
required this.stepIndex,
required this.stepSnapshotId,
required this.stepNameSnapshot,
required this.stepTypeSnapshot,
required this.targetValueSnapshot,
required this.hasScoreSnapshot,
this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.targetScoreSnapshot,
this.targetScoreTimeMsSnapshot,
required this.status,
this.startedAt,
this.completedAt,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.note,
}) {
_validateExerciseStepResult(
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
stepTypeSnapshot: stepTypeSnapshot,
targetValueSnapshot: targetValueSnapshot,
hasScoreSnapshot: hasScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
targetScoreSnapshot: targetScoreSnapshot,
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
status: status,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int passageIndex;
final int stepIndex;
final String stepSnapshotId;
final String stepNameSnapshot;
final ExerciseStepType stepTypeSnapshot;
final int targetValueSnapshot;
final bool hasScoreSnapshot;
final ScoreInputMode? scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final double? targetScoreSnapshot;
final int? targetScoreTimeMsSnapshot;
final SetResultStatus status;
final DateTime? startedAt;
final DateTime? completedAt;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final String? note;
}
final class WorkoutHistory { final class WorkoutHistory {
const WorkoutHistory({ const WorkoutHistory({
required this.metadata, required this.metadata,
@ -928,6 +1097,7 @@ final class WorkoutHistory {
required this.completed, required this.completed,
required this.historySnapshotJson, required this.historySnapshotJson,
this.results = const [], this.results = const [],
this.stepResults = const [],
}); });
final EntityMetadata metadata; final EntityMetadata metadata;
@ -940,6 +1110,7 @@ final class WorkoutHistory {
final bool completed; final bool completed;
final String historySnapshotJson; final String historySnapshotJson;
final List<WorkoutHistorySetResult> results; final List<WorkoutHistorySetResult> results;
final List<WorkoutHistoryStepResult> stepResults;
} }
final class WorkoutHistorySetResult { final class WorkoutHistorySetResult {
@ -1021,6 +1192,87 @@ final class WorkoutHistorySetResult {
final SetResultStatus status; final SetResultStatus status;
} }
final class WorkoutHistoryStepResult {
WorkoutHistoryStepResult({
required this.metadata,
required this.workoutHistoryId,
required this.programSnapshotId,
required this.exerciseSnapshotId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.passageIndex,
required this.stepIndex,
required this.stepSnapshotId,
required this.stepNameSnapshot,
required this.stepTypeSnapshot,
required this.targetValueSnapshot,
required this.hasScoreSnapshot,
this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.targetScoreSnapshot,
this.targetScoreTimeMsSnapshot,
required this.status,
this.startedAt,
this.completedAt,
this.actualTimeMs,
this.actualReps,
this.actualScore,
this.actualScoreTimeMs,
this.note,
}) {
_validateExerciseStepResult(
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
stepTypeSnapshot: stepTypeSnapshot,
targetValueSnapshot: targetValueSnapshot,
hasScoreSnapshot: hasScoreSnapshot,
scoreInputModeSnapshot: scoreInputModeSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
targetScoreSnapshot: targetScoreSnapshot,
targetScoreTimeMsSnapshot: targetScoreTimeMsSnapshot,
status: status,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
}
final EntityMetadata metadata;
final String workoutHistoryId;
final String programSnapshotId;
final String exerciseSnapshotId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final int passageIndex;
final int stepIndex;
final String stepSnapshotId;
final String stepNameSnapshot;
final ExerciseStepType stepTypeSnapshot;
final int targetValueSnapshot;
final bool hasScoreSnapshot;
final ScoreInputMode? scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final double? targetScoreSnapshot;
final int? targetScoreTimeMsSnapshot;
final SetResultStatus status;
final DateTime? startedAt;
final DateTime? completedAt;
final int? actualTimeMs;
final int? actualReps;
final double? actualScore;
final int? actualScoreTimeMs;
final String? note;
}
String _nonBlank(String? value, String label) { String _nonBlank(String? value, String label) {
final trimmed = value?.trim(); final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) { if (trimmed == null || trimmed.isEmpty) {
@ -1222,3 +1474,67 @@ void _requireScoreResultShape({
); );
} }
} }
void _validateExerciseStepResult({
required int programIndex,
required int exerciseIndex,
required int setIndex,
required int passageIndex,
required int stepIndex,
required ExerciseStepType stepTypeSnapshot,
required int targetValueSnapshot,
required bool hasScoreSnapshot,
required ScoreInputMode? scoreInputModeSnapshot,
required String? scoreLabelSnapshot,
required String? scoreUnitSnapshot,
required double? targetScoreSnapshot,
required int? targetScoreTimeMsSnapshot,
required SetResultStatus status,
required int? actualTimeMs,
required int? actualReps,
required double? actualScore,
required int? actualScoreTimeMs,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNonNegative(passageIndex, 'Passage index');
_requireNonNegative(stepIndex, 'Step index');
_requirePositive(targetValueSnapshot, 'Step target value snapshot');
_requireNullableNonNegative(actualTimeMs, 'Actual time milliseconds');
_requireNullableNonNegative(actualReps, 'Actual reps');
_requireNullableNonNegativeDouble(actualScore, 'Actual score');
_requireNullableNonNegative(actualScoreTimeMs, 'Actual score time ms');
if (status == SetResultStatus.skipped &&
(actualTimeMs != null ||
actualReps != null ||
actualScore != null ||
actualScoreTimeMs != null)) {
throw const DomainException('Skipped step results cannot have values.');
}
if (actualTimeMs != null && stepTypeSnapshot != ExerciseStepType.time) {
throw const DomainException('Actual time requires a time step.');
}
if (actualReps != null && stepTypeSnapshot != ExerciseStepType.reps) {
throw const DomainException('Actual reps requires a reps step.');
}
final mode = scoreInputModeSnapshot ?? ScoreInputMode.manual;
_requireScoreTargetShape(
scoreEnabled: hasScoreSnapshot,
scoreInputMode: mode,
targetScore: targetScoreSnapshot,
targetScoreTimeMs: targetScoreTimeMsSnapshot,
);
_requireScoreResultShape(
scoreInputMode: mode,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
);
if (!hasScoreSnapshot && (actualScore != null || actualScoreTimeMs != null)) {
throw const DomainException('Step score result requires active score.');
}
if (actualScore != null) {
_nonBlank(scoreLabelSnapshot, 'Step score label snapshot');
_nonBlank(scoreUnitSnapshot, 'Step score unit snapshot');
}
}

View File

@ -7,6 +7,8 @@ part 'app_database.g.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates, ActiveRestStates,
ActiveScoreStopwatchStates, ActiveScoreStopwatchStates,
ActiveSetResults, ActiveSetResults,
@ -20,6 +22,7 @@ part 'app_database.g.dart';
Programs, Programs,
WorkoutHistories, WorkoutHistories,
WorkoutHistorySetResults, WorkoutHistorySetResults,
WorkoutHistoryStepResults,
WorkoutTemplateExerciseOverrides, WorkoutTemplateExerciseOverrides,
WorkoutTemplatePrograms, WorkoutTemplatePrograms,
WorkoutTemplates, WorkoutTemplates,
@ -38,7 +41,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 8; int get schemaVersion => 9;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -77,6 +80,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 8) { if (from < 8) {
await _migrateToSchema8(migrator); await _migrateToSchema8(migrator);
} }
if (from < 9) {
await _migrateToSchema9(migrator);
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -133,6 +139,15 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id ' 'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
'ON active_rest_states (active_workout_session_id)', 'ON active_rest_states (active_workout_session_id)',
); );
await customStatement(
'CREATE INDEX IF NOT EXISTS '
'idx_active_exercise_step_progress_states_session_id '
'ON active_exercise_step_progress_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id '
'ON active_exercise_step_results (active_workout_session_id)',
);
await customStatement( await customStatement(
'CREATE UNIQUE INDEX IF NOT EXISTS ' 'CREATE UNIQUE INDEX IF NOT EXISTS '
'idx_active_workout_sessions_single_open ' 'idx_active_workout_sessions_single_open '
@ -148,6 +163,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id ' 'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
'ON workout_history_set_results (workout_history_id)', 'ON workout_history_set_results (workout_history_id)',
); );
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
'ON workout_history_step_results (workout_history_id)',
);
await customStatement( await customStatement(
'CREATE INDEX IF NOT EXISTS idx_change_log_entity ' 'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
'ON change_log (entity_type, entity_id)', 'ON change_log (entity_type, entity_id)',
@ -164,6 +183,8 @@ final class AppDatabase extends _$AppDatabase {
} }
const _syncableTableNames = [ const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_rest_states', 'active_rest_states',
'active_score_stopwatch_states', 'active_score_stopwatch_states',
'active_set_results', 'active_set_results',
@ -176,6 +197,7 @@ const _syncableTableNames = [
'programs', 'programs',
'workout_history', 'workout_history',
'workout_history_set_results', 'workout_history_set_results',
'workout_history_step_results',
'workout_template_exercise_overrides', 'workout_template_exercise_overrides',
'workout_template_programs', 'workout_template_programs',
'workout_templates', 'workout_templates',
@ -287,4 +309,10 @@ extension on AppDatabase {
'exercise_steps_snapshot_json TEXT', 'exercise_steps_snapshot_json TEXT',
); );
} }
Future<void> _migrateToSchema9(Migrator migrator) async {
await migrator.createTable(activeExerciseStepProgressStates);
await migrator.createTable(activeExerciseStepResults);
await migrator.createTable(workoutHistoryStepResults);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -532,6 +532,38 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
); );
} }
@override
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_exercise_step_progress_states',
entityType: 'ActiveExerciseStepProgressState',
metadata: state.metadata,
write: () => database
.into(database.activeExerciseStepProgressStates)
.insertOnConflictUpdate(
_activeExerciseStepProgressStateCompanion(state),
),
);
}
@override
Future<void> saveExerciseStepResult(
domain.ActiveExerciseStepResult result,
) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_exercise_step_results',
entityType: 'ActiveExerciseStepResult',
metadata: result.metadata,
write: () => database
.into(database.activeExerciseStepResults)
.insertOnConflictUpdate(_activeExerciseStepResultCompanion(result)),
);
}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,
@ -588,6 +620,28 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeScoreStopwatchStateFromRow(row); return row == null ? null : _activeScoreStopwatchStateFromRow(row);
} }
@override
Future<domain.ActiveExerciseStepProgressState?>
findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeExerciseStepProgressStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeExerciseStepProgressStateFromRow(row);
}
@override @override
Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async { Future<List<domain.ActiveSetResult>> listSetResults(String sessionId) async {
final rows = final rows =
@ -602,6 +656,47 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return rows.map(_activeSetResultFromRow).toList(); return rows.map(_activeSetResultFromRow).toList();
} }
@override
Future<List<domain.ActiveExerciseStepProgressState>>
listExerciseStepProgressStates(String sessionId) async {
final rows =
await (database.select(database.activeExerciseStepProgressStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeExerciseStepProgressStateFromRow).toList();
}
@override
Future<List<domain.ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
final rows =
await (database.select(database.activeExerciseStepResults)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
(table) => OrderingTerm.asc(table.passageIndex),
(table) => OrderingTerm.asc(table.stepIndex),
]))
.get();
return rows.map(_activeExerciseStepResultFromRow).toList();
}
@override @override
Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async { Future<List<domain.ActiveRestState>> listRestStates(String sessionId) async {
final rows = final rows =
@ -661,9 +756,25 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
(table) => OrderingTerm.asc(table.setIndex), (table) => OrderingTerm.asc(table.setIndex),
])) ]))
.get(); .get();
final stepResults =
await (database.select(database.workoutHistoryStepResults)
..where(
(table) =>
table.workoutHistoryId.equals(id) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
(table) => OrderingTerm.asc(table.passageIndex),
(table) => OrderingTerm.asc(table.stepIndex),
]))
.get();
return _workoutHistoryFromRow( return _workoutHistoryFromRow(
row, row,
results.map(_workoutHistorySetResultFromRow).toList(), results.map(_workoutHistorySetResultFromRow).toList(),
stepResults.map(_workoutHistoryStepResultFromRow).toList(),
); );
} }
@ -699,6 +810,9 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
for (final result in history.results) { for (final result in history.results) {
await saveSetResult(result); await saveSetResult(result);
} }
for (final result in history.stepResults) {
await saveStepResult(result);
}
}); });
} }
@ -715,6 +829,19 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
); );
} }
@override
Future<void> saveStepResult(domain.WorkoutHistoryStepResult result) async {
await _upsertWithChangeLog(
database: database,
tableName: 'workout_history_step_results',
entityType: 'WorkoutHistoryStepResult',
metadata: result.metadata,
write: () => database
.into(database.workoutHistoryStepResults)
.insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)),
);
}
@override @override
Future<void> delete(String id, DateTime deletedAt) async { Future<void> delete(String id, DateTime deletedAt) async {
await database.transaction(() async { await database.transaction(() async {
@ -753,6 +880,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
resultRows, resultRows,
deletedAt, deletedAt,
); );
final stepResultRows =
await (database.select(database.workoutHistoryStepResults)..where(
(table) =>
table.workoutHistoryId.equals(id) &
table.deletedAt.isNull(),
))
.get();
await _softDeleteWorkoutHistoryStepResultRows(
database,
stepResultRows,
deletedAt,
);
}); });
} }
} }
@ -1149,6 +1288,35 @@ Future<void> _softDeleteWorkoutHistorySetResultRows(
} }
} }
Future<void> _softDeleteWorkoutHistoryStepResultRows(
db.AppDatabase database,
List<db.WorkoutHistoryStepResult> rows,
DateTime deletedAt,
) async {
for (final row in rows) {
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistoryStepResults,
)..where((table) => table.id.equals(row.id))).write(
db.WorkoutHistoryStepResultsCompanion(
deletedAt: Value(deletedAt.toUtc()),
updatedAt: Value(deletedAt.toUtc()),
localRevision: Value(revision),
syncState: const Value('deleted'),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistoryStepResult',
entityId: row.id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
}
DateTime _utc(DateTime value) => value.toUtc(); DateTime _utc(DateTime value) => value.toUtc();
DateTime? _utcOrNull(DateTime? value) => value?.toUtc(); DateTime? _utcOrNull(DateTime? value) => value?.toUtc();
@ -1732,6 +1900,142 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
); );
} }
db.ActiveExerciseStepProgressStatesCompanion
_activeExerciseStepProgressStateCompanion(
domain.ActiveExerciseStepProgressState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveExerciseStepProgressStatesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
currentPassageIndex: Value(state.currentPassageIndex),
currentStepIndex: Value(state.currentStepIndex),
currentStepSnapshotId: Value(state.currentStepSnapshotId),
status: Value(_exerciseStepProgressStatusToDb(state.status)),
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
accumulatedMs: Value(state.accumulatedMs),
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
);
}
domain.ActiveExerciseStepProgressState
_activeExerciseStepProgressStateFromRow(
db.ActiveExerciseStepProgressState row,
) {
return domain.ActiveExerciseStepProgressState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
currentPassageIndex: row.currentPassageIndex,
currentStepIndex: row.currentStepIndex,
currentStepSnapshotId: row.currentStepSnapshotId,
status: _exerciseStepProgressStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
accumulatedMs: row.accumulatedMs,
lastTransitionAt: _utc(row.lastTransitionAt),
);
}
db.ActiveExerciseStepResultsCompanion _activeExerciseStepResultCompanion(
domain.ActiveExerciseStepResult result,
) {
final values = _metadataValues(result.metadata);
return db.ActiveExerciseStepResultsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(result.activeWorkoutSessionId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
passageIndex: Value(result.passageIndex),
stepIndex: Value(result.stepIndex),
stepSnapshotId: Value(result.stepSnapshotId),
stepNameSnapshot: Value(result.stepNameSnapshot),
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
targetValueSnapshot: Value(result.targetValueSnapshot),
hasScoreSnapshot: Value(result.hasScoreSnapshot),
scoreInputModeSnapshot: Value<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
);
}
domain.ActiveExerciseStepResult _activeExerciseStepResultFromRow(
db.ActiveExerciseStepResult row,
) {
return domain.ActiveExerciseStepResult(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
passageIndex: row.passageIndex,
stepIndex: row.stepIndex,
stepSnapshotId: row.stepSnapshotId,
stepNameSnapshot: row.stepNameSnapshot,
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
targetValueSnapshot: row.targetValueSnapshot,
hasScoreSnapshot: row.hasScoreSnapshot,
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
? null
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
targetScoreSnapshot: row.targetScoreSnapshot,
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
status: _setResultStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
completedAt: _utcOrNull(row.completedAt),
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
note: row.note,
);
}
db.WorkoutHistoriesCompanion _workoutHistoryCompanion( db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
domain.WorkoutHistory history, domain.WorkoutHistory history,
) { ) {
@ -1805,9 +2109,59 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
); );
} }
db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion(
domain.WorkoutHistoryStepResult result,
) {
final values = _metadataValues(result.metadata);
return db.WorkoutHistoryStepResultsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
workoutHistoryId: Value(result.workoutHistoryId),
programSnapshotId: Value(result.programSnapshotId),
exerciseSnapshotId: Value(result.exerciseSnapshotId),
programIndex: Value(result.programIndex),
exerciseIndex: Value(result.exerciseIndex),
setIndex: Value(result.setIndex),
passageIndex: Value(result.passageIndex),
stepIndex: Value(result.stepIndex),
stepSnapshotId: Value(result.stepSnapshotId),
stepNameSnapshot: Value(result.stepNameSnapshot),
stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)),
targetValueSnapshot: Value(result.targetValueSnapshot),
hasScoreSnapshot: Value(result.hasScoreSnapshot),
scoreInputModeSnapshot: Value<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
);
}
domain.WorkoutHistory _workoutHistoryFromRow( domain.WorkoutHistory _workoutHistoryFromRow(
db.WorkoutHistory row, db.WorkoutHistory row,
List<domain.WorkoutHistorySetResult> results, List<domain.WorkoutHistorySetResult> results,
List<domain.WorkoutHistoryStepResult> stepResults,
) { ) {
return domain.WorkoutHistory( return domain.WorkoutHistory(
metadata: _metadataFromRow(row), metadata: _metadataFromRow(row),
@ -1820,6 +2174,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
completed: row.completed, completed: row.completed,
historySnapshotJson: row.historySnapshotJson, historySnapshotJson: row.historySnapshotJson,
results: results, results: results,
stepResults: stepResults,
); );
} }
@ -1856,6 +2211,42 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
); );
} }
domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow(
db.WorkoutHistoryStepResult row,
) {
return domain.WorkoutHistoryStepResult(
metadata: _metadataFromRow(row),
workoutHistoryId: row.workoutHistoryId,
programSnapshotId: row.programSnapshotId,
exerciseSnapshotId: row.exerciseSnapshotId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
passageIndex: row.passageIndex,
stepIndex: row.stepIndex,
stepSnapshotId: row.stepSnapshotId,
stepNameSnapshot: row.stepNameSnapshot,
stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot),
targetValueSnapshot: row.targetValueSnapshot,
hasScoreSnapshot: row.hasScoreSnapshot,
scoreInputModeSnapshot: row.scoreInputModeSnapshot == null
? null
: _scoreInputModeFromDb(row.scoreInputModeSnapshot!),
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
targetScoreSnapshot: row.targetScoreSnapshot,
targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot,
status: _setResultStatusFromDb(row.status),
startedAt: _utcOrNull(row.startedAt),
completedAt: _utcOrNull(row.completedAt),
actualTimeMs: row.actualTimeMs,
actualReps: row.actualReps,
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
note: row.note,
);
}
String _syncStateToDb(domain.SyncState state) => switch (state) { String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly', domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty', domain.SyncState.dirty => 'dirty',
@ -1939,6 +2330,33 @@ domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) =>
), ),
}; };
String _exerciseStepProgressStatusToDb(
domain.ActiveExerciseStepProgressStatus status,
) => switch (status) {
domain.ActiveExerciseStepProgressStatus.notStarted => 'notStarted',
domain.ActiveExerciseStepProgressStatus.waitingManual => 'waitingManual',
domain.ActiveExerciseStepProgressStatus.runningTimer => 'runningTimer',
domain.ActiveExerciseStepProgressStatus.pausedTimer => 'pausedTimer',
domain.ActiveExerciseStepProgressStatus.stoppedTimer => 'stoppedTimer',
domain.ActiveExerciseStepProgressStatus.sequenceComplete =>
'sequenceComplete',
};
domain.ActiveExerciseStepProgressStatus _exerciseStepProgressStatusFromDb(
String value,
) => switch (value) {
'notStarted' => domain.ActiveExerciseStepProgressStatus.notStarted,
'waitingManual' => domain.ActiveExerciseStepProgressStatus.waitingManual,
'runningTimer' => domain.ActiveExerciseStepProgressStatus.runningTimer,
'pausedTimer' => domain.ActiveExerciseStepProgressStatus.pausedTimer,
'stoppedTimer' => domain.ActiveExerciseStepProgressStatus.stoppedTimer,
'sequenceComplete' =>
domain.ActiveExerciseStepProgressStatus.sequenceComplete,
_ => throw domain.DomainException(
'Unknown active exercise step progress status: $value',
),
};
String? _encodeImageMediaIdsSnapshot(List<String> imageMediaIds) { String? _encodeImageMediaIdsSnapshot(List<String> imageMediaIds) {
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds); return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
} }

View File

@ -419,6 +419,108 @@ class ActiveRestStates extends SyncableTable {
]; ];
} }
class ActiveExerciseStepProgressStates extends SyncableTable {
@override
String get tableName => 'active_exercise_step_progress_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get currentPassageIndex => integer()();
IntColumn get currentStepIndex => integer()();
TextColumn get currentStepSnapshotId => text().withLength(min: 1)();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
IntColumn get accumulatedMs => integer()();
DateTimeColumn get lastTransitionAt => dateTime()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (current_passage_index >= 0)',
'CHECK (current_step_index >= 0)',
"CHECK (status IN ('notStarted', 'waitingManual', 'runningTimer', "
"'pausedTimer', 'stoppedTimer', 'sequenceComplete'))",
'CHECK (status != \'runningTimer\' OR started_at IS NOT NULL)',
'CHECK (accumulated_ms >= 0)',
];
}
class ActiveExerciseStepResults extends SyncableTable {
@override
String get tableName => 'active_exercise_step_results';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
TextColumn get programSnapshotId => text().withLength(min: 1)();
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get passageIndex => integer()();
IntColumn get stepIndex => integer()();
TextColumn get stepSnapshotId => text().withLength(min: 1)();
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
TextColumn get stepTypeSnapshot => text()();
IntColumn get targetValueSnapshot => integer()();
BoolColumn get hasScoreSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot => text().nullable()();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
RealColumn get targetScoreSnapshot => real().nullable()();
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get note => text().nullable()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index, passage_index, step_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (passage_index >= 0)',
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
'CHECK (actual_score IS NULL OR actual_score >= 0)',
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
'CHECK (target_score_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (actual_score_time_ms IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (target_score_snapshot IS NULL OR '
'target_score_time_ms_snapshot IS NULL)',
];
}
class WorkoutHistories extends SyncableTable { class WorkoutHistories extends SyncableTable {
@override @override
String get tableName => 'workout_history'; String get tableName => 'workout_history';
@ -511,6 +613,74 @@ class WorkoutHistorySetResults extends SyncableTable {
]; ];
} }
class WorkoutHistoryStepResults extends SyncableTable {
@override
String get tableName => 'workout_history_step_results';
TextColumn get workoutHistoryId => text().references(WorkoutHistories, #id)();
TextColumn get programSnapshotId => text().withLength(min: 1)();
TextColumn get exerciseSnapshotId => text().withLength(min: 1)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
IntColumn get passageIndex => integer()();
IntColumn get stepIndex => integer()();
TextColumn get stepSnapshotId => text().withLength(min: 1)();
TextColumn get stepNameSnapshot => text().withLength(min: 1)();
TextColumn get stepTypeSnapshot => text()();
IntColumn get targetValueSnapshot => integer()();
BoolColumn get hasScoreSnapshot => boolean()();
TextColumn get scoreInputModeSnapshot => text().nullable()();
TextColumn get scoreLabelSnapshot => text().nullable()();
TextColumn get scoreUnitSnapshot => text().nullable()();
RealColumn get targetScoreSnapshot => real().nullable()();
IntColumn get targetScoreTimeMsSnapshot => integer().nullable()();
TextColumn get status => text()();
DateTimeColumn get startedAt => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get actualTimeMs => integer().nullable()();
IntColumn get actualReps => integer().nullable()();
RealColumn get actualScore => real().nullable()();
IntColumn get actualScoreTimeMs => integer().nullable()();
TextColumn get note => text().nullable()();
@override
List<String> get customConstraints => [
'UNIQUE (workout_history_id, program_index, exercise_index, set_index, '
'passage_index, step_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (passage_index >= 0)',
'CHECK (step_index >= 0)',
"CHECK (step_type_snapshot IN ('time', 'reps'))",
'CHECK (target_value_snapshot > 0)',
"CHECK (score_input_mode_snapshot IS NULL OR "
"score_input_mode_snapshot IN ('manual', 'stopwatch'))",
"CHECK (status IN ('completed', 'skipped'))",
'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)',
'CHECK (actual_reps IS NULL OR actual_reps >= 0)',
'CHECK (actual_score IS NULL OR actual_score >= 0)',
'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
'CHECK (actual_time_ms IS NULL OR step_type_snapshot = \'time\')',
'CHECK (actual_reps IS NULL OR step_type_snapshot = \'reps\')',
'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL '
'AND actual_reps IS NULL AND actual_score IS NULL '
'AND actual_score_time_ms IS NULL))',
'CHECK (target_score_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (target_score_time_ms_snapshot IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'manual'))",
'CHECK (actual_score_time_ms IS NULL OR '
"(has_score_snapshot AND score_input_mode_snapshot = 'stopwatch'))",
'CHECK (actual_score IS NULL OR actual_score_time_ms IS NULL)',
'CHECK (target_score_snapshot IS NULL OR '
'target_score_time_ms_snapshot IS NULL)',
];
}
class ChangeLogEntries extends Table { class ChangeLogEntries extends Table {
@override @override
String get tableName => 'change_log'; String get tableName => 'change_log';

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -400,7 +402,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _saving = false; var _saving = false;
var _importingImage = false; var _importingImage = false;
var _importingVideo = false; var _importingVideo = false;
var _stepsEnabled = false;
var _nextStepDraftId = 0;
final _stepDrafts = <_ExerciseStepDraft>[];
String? _measureError; String? _measureError;
String? _stepError;
String? _warning; String? _warning;
bool get _isEditing => widget.exercise != null; bool get _isEditing => widget.exercise != null;
@ -434,6 +440,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasReps = exercise?.hasRepsMeasure ?? false; _hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
} }
@override @override
@ -446,6 +456,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_defaultRepsController.dispose(); _defaultRepsController.dispose();
_defaultScoreController.dispose(); _defaultScoreController.dispose();
_defaultScoreTimeController.dispose(); _defaultScoreTimeController.dispose();
for (final draft in _stepDrafts) {
draft.dispose();
}
super.dispose(); super.dispose();
} }
@ -666,6 +679,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
), ),
], ],
], ],
const SizedBox(height: 24),
_buildExerciseStepsSection(context),
if (_warning != null) ...[ if (_warning != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
MaterialBanner( MaterialBanner(
@ -696,6 +711,305 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
); );
} }
Widget _buildExerciseStepsSection(BuildContext context) {
final reachedLimit = _stepDrafts.length >= 8;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence détapes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Rythmer cet exercice avec des étapes'),
subtitle: Text(
_stepsEnabled
? 'Chaque passage suit ces étapes dans lordre. Si la série '
'suit des répétitions, une répétition correspond à un '
'passage complet.'
: 'Ajoute des étapes si lexercice doit suivre un ordre précis '
'pendant chaque série.',
),
value: _stepsEnabled,
onChanged: (value) {
setState(() {
_stepsEnabled = value;
_stepError = null;
});
},
),
if (_stepsEnabled) ...[
const SizedBox(height: 8),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
),
if (_stepError != null) ...[
const SizedBox(height: 8),
Text(
_stepError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
if (reachedLimit)
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.info_outline),
title: Text('Limite atteinte'),
subtitle: Text('Un exercice peut contenir jusquà 8 étapes.'),
)
else
OutlinedButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add),
label: const Text('Ajouter une étape'),
),
],
],
);
}
Widget _buildStepCard(
BuildContext context,
int index,
_ExerciseStepDraft draft,
) {
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
final targetMessage = draft.type == ExerciseStepType.time
? 'Saisis une durée supérieure à 0.'
: 'Saisis un nombre de répétitions supérieur à 0.';
final summaryUnit = draft.type == ExerciseStepType.time ? 's' : '';
return CourtBlazerAccentPanel(
key: ValueKey(draft.id),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
child: Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
'${index + 1}. ${draft.displayName}',
style: Theme.of(context).textTheme.titleSmall,
),
),
PopupMenuButton<_StepAction>(
tooltip: 'Actions de létape ${index + 1}',
onSelected: (action) => _handleStepAction(index, action),
itemBuilder: (context) => [
PopupMenuItem(
value: _StepAction.moveUp,
enabled: index > 0,
child: const Text('Monter'),
),
PopupMenuItem(
value: _StepAction.moveDown,
enabled: index < _stepDrafts.length - 1,
child: const Text('Descendre'),
),
const PopupMenuItem(
value: _StepAction.duplicate,
child: Text('Dupliquer'),
),
const PopupMenuItem(
value: _StepAction.delete,
child: Text('Supprimer'),
),
],
),
],
),
const SizedBox(height: 8),
Text(
draft.type == ExerciseStepType.time
? 'Temps · ${draft.targetController.text.trim()} $summaryUnit'
: 'Répétitions · ${draft.targetController.text.trim()}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextFormField(
controller: draft.nameController,
decoration: const InputDecoration(labelText: 'Nom de létape'),
validator: (value) {
if (!_stepsEnabled) return null;
return value == null || value.trim().isEmpty
? 'Le nom de létape est obligatoire.'
: null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
TextFormField(
controller: draft.targetController,
decoration: InputDecoration(labelText: targetLabel),
keyboardType: TextInputType.number,
validator: (value) =>
_stepsEnabled ? _positiveIntValidator(value, targetMessage) : null,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Score détape'),
subtitle: const Text('Ajouter un score pour cette étape'),
value: draft.hasScore,
onChanged: (value) => setState(() => draft.hasScore = value),
),
if (draft.hasScore) ...[
Text('Mode de score', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(labelText: 'Score à saisir'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
const Text(
'Cette étape utilise déjà un compte à rebours. Le score '
'chrono ajoute un second temps mesuré ; garde-le seulement '
'si tu veux enregistrer une performance distincte.',
),
],
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
],
),
),
);
}
void _setMeasure(VoidCallback update) { void _setMeasure(VoidCallback update) {
setState(() { setState(() {
update(); update();
@ -704,6 +1018,141 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}); });
} }
void _addStep() {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.add(
_ExerciseStepDraft(
id: 'step-draft-${_nextStepDraftId++}',
name: 'Nouvelle étape',
defaultTargetValue: '10',
),
);
});
}
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
}
void _handleStepAction(int index, _StepAction action) {
switch (action) {
case _StepAction.moveUp:
if (index <= 0) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index - 1, draft);
});
break;
case _StepAction.moveDown:
if (index >= _stepDrafts.length - 1) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index + 1, draft);
});
break;
case _StepAction.duplicate:
_duplicateStep(index);
break;
case _StepAction.delete:
unawaited(_confirmRemoveStep(index));
break;
}
}
void _duplicateStep(int index) {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.insert(index + 1, _stepDrafts[index].duplicate(
'step-draft-${_nextStepDraftId++}',
));
});
}
Future<void> _confirmRemoveStep(int index) async {
final draft = _stepDrafts[index];
var confirmed = true;
if (draft.hasFilledContent) {
confirmed =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cette étape ?'),
content: const Text('Cette étape sera retirée de la séquence.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
) ??
false;
}
if (!confirmed || !mounted) return;
setState(() {
final removed = _stepDrafts.removeAt(index);
removed.dispose();
_stepError = null;
});
}
_ExerciseStepDraft _draftFromStep(ExerciseStep step) {
return _ExerciseStepDraft(
id: step.id,
name: step.name,
type: step.type,
defaultTargetValue: step.defaultTargetValue.toString(),
hasScore: step.hasScore,
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
)
: _optionalDoubleText(step.defaultTargetScore),
);
}
List<ExerciseStep> _buildSteps() {
if (!_stepsEnabled) return const [];
return [
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode ==
ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
_stepDrafts[index].scoreTargetController.text,
)
: null,
),
];
}
Future<void> _importImage() async { Future<void> _importImage() async {
if (_imageMediaIds.length >= 5) { if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.'); _showSnackBar('Maximum 5 images par exercice.');
@ -784,12 +1233,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
Future<void> _save() async { Future<void> _save() async {
final hasMeasure = _hasTime || _hasReps || _hasScore; final hasMeasure = _hasTime || _hasReps || _hasScore;
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
setState(() { setState(() {
_measureError = hasMeasure _measureError = hasMeasure
? null ? null
: 'Active au moins une mesure pour enregistrer.'; : 'Active au moins une mesure pour enregistrer.';
_stepError = hasValidSteps
? null
: 'Ajoute au moins une étape ou désactive la séquence.';
}); });
if (!_formKey.currentState!.validate() || !hasMeasure) { if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) {
return; return;
} }
@ -832,6 +1285,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasScore && scoreInputMode == ScoreInputMode.stopwatch _hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text) ? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null; : null;
final steps = _buildSteps();
try { try {
if (exercise == null) { if (exercise == null) {
await widget.exerciseUseCases.create( await widget.exerciseUseCases.create(
@ -850,6 +1304,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps, defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
); );
} else { } else {
await widget.exerciseUseCases.update( await widget.exerciseUseCases.update(
@ -869,6 +1324,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps, defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
); );
} }
if (mounted) { if (mounted) {
@ -961,6 +1417,104 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
} }
} }
enum _StepAction { moveUp, moveDown, duplicate, delete }
final class _ExerciseStepDraft {
_ExerciseStepDraft({
required this.id,
required String name,
this.type = ExerciseStepType.time,
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
}) : nameController = TextEditingController(text: name),
targetController = TextEditingController(text: defaultTargetValue),
scoreLabelController = TextEditingController(text: scoreLabel),
scoreUnitController = TextEditingController(text: scoreUnit),
scoreTargetController = TextEditingController(text: scoreTarget);
final String id;
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
final TextEditingController scoreUnitController;
final TextEditingController scoreTargetController;
String get displayName {
final text = nameController.text.trim();
return text.isEmpty ? 'Étape sans nom' : text;
}
bool get hasFilledContent {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
}
_ExerciseStepDraft duplicate(String id) {
return _ExerciseStepDraft(
id: id,
name: nameController.text,
type: type,
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
);
}
ExerciseStep toExerciseStep({
required int position,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
return ExerciseStep(
id: id,
position: position,
name: nameController.text.trim(),
type: type,
defaultTargetValue: int.parse(targetController.text.trim()),
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
defaultTargetScoreTimeMs:
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
);
}
void dispose() {
nameController.dispose();
targetController.dispose();
scoreLabelController.dispose();
scoreUnitController.dispose();
scoreTargetController.dispose();
}
}
final class MeasureBadge extends StatelessWidget { final class MeasureBadge extends StatelessWidget {
const MeasureBadge({ const MeasureBadge({
required this.measure, required this.measure,

View File

@ -1311,6 +1311,8 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[]; final results = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{}; final restStates = <String, ActiveRestState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{}; final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override @override
Future<ActiveWorkoutSession?> findById(String id) async { Future<ActiveWorkoutSession?> findById(String id) async {
@ -1342,6 +1344,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.firstOrNull; .firstOrNull;
} }
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return stepProgressStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override @override
Future<List<ActiveRestState>> listRestStates(String sessionId) async { Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values return restStates.values
@ -1369,6 +1390,24 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList(); .toList();
} }
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override @override
Future<void> save(ActiveWorkoutSession session) async { Future<void> save(ActiveWorkoutSession session) async {
this.session = session; this.session = session;
@ -1384,6 +1423,29 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state; scoreStopwatchStates[state.metadata.id] = state;
} }
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepResult(
ActiveExerciseStepResult result,
) async {
stepResults.removeWhere(
(existing) =>
existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
existing.programIndex == result.programIndex &&
existing.exerciseIndex == result.exerciseIndex &&
existing.setIndex == result.setIndex &&
existing.passageIndex == result.passageIndex &&
existing.stepIndex == result.stepIndex,
);
stepResults.add(result);
}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,

View File

@ -216,6 +216,204 @@ void main() {
expect(find.text('Aucune image sélectionnée'), findsOneWidget); expect(find.text('Aucune image sélectionnée'), findsOneWidget);
}); });
testWidgets('activer les étapes permet dajouter une étape temps', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'12',
);
await tester.pump();
expect(find.text('1. Dribble main droite'), findsOneWidget);
expect(find.text('Temps · 12 s'), findsOneWidget);
});
testWidgets('ajouter une étape répétitions avec score manuel', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.tap(_stepTypeRadio('Répétitions').last);
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Pompes',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
'10',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score détape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Score à saisir').last,
'Réussites',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Unité').last,
'paniers',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Score par défaut').last,
'0',
);
await tester.pump();
expect(find.text('1. Pompes'), findsOneWidget);
expect(find.text('Répétitions · 10'), findsOneWidget);
expect(find.text('Mode de score'), findsOneWidget);
});
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 4200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').at(0),
'Première',
);
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').at(1),
'Deuxième',
);
await tester.pump();
await tester.tap(find.byTooltip('Actions de létape 2'));
await tester.pumpAndSettle();
await tester.tap(find.text('Monter'));
await tester.pumpAndSettle();
expect(find.text('1. Deuxième'), findsOneWidget);
expect(find.text('2. Première'), findsOneWidget);
});
testWidgets('supprimer une étape demande confirmation', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.tap(find.byTooltip('Actions de létape 1'));
await tester.pumpAndSettle();
await tester.tap(find.text('Supprimer'));
await tester.pumpAndSettle();
expect(find.text('Supprimer cette étape ?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Supprimer'));
await tester.pumpAndSettle();
expect(find.text('Nouvelle étape'), findsNothing);
});
testWidgets('la limite de 8 étapes bloque lajout dune neuvième', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
for (var index = 0; index < 8; index++) {
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
}
expect(find.text('Limite atteinte'), findsOneWidget);
expect(find.text('Un exercice peut contenir jusquà 8 étapes.'), findsOneWidget);
expect(find.text('Ajouter une étape'), findsNothing);
});
testWidgets('sauvegarder transmet les étapes au use case', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'12',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final saved = exerciseRepository.saved.single;
expect(saved.steps, hasLength(1));
expect(saved.steps.single.name, 'Dribble main droite');
expect(saved.steps.single.type, ExerciseStepType.time);
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets('désigner une image comme icône lindique visuellement', ( testWidgets('désigner une image comme icône lindique visuellement', (
tester, tester,
) async { ) async {
@ -335,6 +533,30 @@ void main() {
); );
} }
Future<void> _pumpExerciseForm(
WidgetTester tester,
_FakeExerciseRepository exerciseRepository,
) {
return tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
}
Finder _stepTypeRadio(String title) {
return find.byWidgetPredicate(
(widget) =>
widget is RadioListTile<ExerciseStepType> &&
widget.title is Text &&
(widget.title as Text).data == title,
description: 'RadioListTile<ExerciseStepType> "$title"',
);
}
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
return ExerciseUseCases( return ExerciseUseCases(
repository: repository, repository: repository,

View File

@ -274,6 +274,9 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override @override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {} Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
} }
final class _FakeWorkoutTemplateRepository final class _FakeWorkoutTemplateRepository
@ -335,6 +338,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex, required int setIndex,
}) async => null; }) async => null;
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override @override
Future<List<ActiveRestState>> listRestStates(String sessionId) async { Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const []; return const [];
@ -352,6 +363,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const []; return const [];
} }
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return const [];
}
@override @override
Future<void> save(ActiveWorkoutSession session) async {} Future<void> save(ActiveWorkoutSession session) async {}
@ -361,6 +386,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override @override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,

View File

@ -267,6 +267,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null; return null;
} }
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override @override
Future<List<ActiveRestState>> listRestStates(String sessionId) async { Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates; return restStates;
@ -284,6 +294,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return results; return results;
} }
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return const [];
}
@override @override
Future<void> save(ActiveWorkoutSession session) async { Future<void> save(ActiveWorkoutSession session) async {
this.session = session; this.session = session;
@ -299,6 +323,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.add(state); scoreStopwatchStates.add(state);
} }
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,
@ -416,4 +448,7 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override @override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {} Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
} }

View File

@ -1085,6 +1085,8 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[]; final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[]; final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[]; final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@override @override
Future<ActiveWorkoutSession?> findById(String id) async { Future<ActiveWorkoutSession?> findById(String id) async {
@ -1123,6 +1125,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null; return null;
} }
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in stepProgressStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override @override
Future<List<ActiveRestState>> listRestStates(String sessionId) async { Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates return restStates
@ -1144,6 +1165,24 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return results; return results;
} }
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override @override
Future<void> save(ActiveWorkoutSession session) async { Future<void> save(ActiveWorkoutSession session) async {
this.session = session; this.session = session;
@ -1173,6 +1212,44 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
} }
} }
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
final index = stepProgressStates.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
saved.programIndex == state.programIndex &&
saved.exerciseIndex == state.exerciseIndex &&
saved.setIndex == state.setIndex,
);
if (index == -1) {
stepProgressStates.add(state);
} else {
stepProgressStates[index] = state;
}
}
@override
Future<void> saveExerciseStepResult(
ActiveExerciseStepResult result,
) async {
final index = stepResults.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
saved.programIndex == result.programIndex &&
saved.exerciseIndex == result.exerciseIndex &&
saved.setIndex == result.setIndex &&
saved.passageIndex == result.passageIndex &&
saved.stepIndex == result.stepIndex,
);
if (index == -1) {
stepResults.add(result);
} else {
stepResults[index] = result;
}
}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,
@ -1246,6 +1323,9 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override @override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {} Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
} }
final class _FakeProgramRepository implements ProgramRepository { final class _FakeProgramRepository implements ProgramRepository {

View File

@ -342,6 +342,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null; return null;
} }
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override @override
Future<List<ActiveRestState>> listRestStates(String sessionId) async { Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const []; return const [];
@ -359,6 +369,20 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const []; return const [];
} }
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return const [];
}
@override @override
Future<void> save(ActiveWorkoutSession session) async { Future<void> save(ActiveWorkoutSession session) async {
saveCount += 1; saveCount += 1;
@ -371,6 +395,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override @override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override @override
Future<void> deleteScoreStopwatchState({ Future<void> deleteScoreStopwatchState({
required String sessionId, required String sessionId,
@ -430,4 +462,7 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override @override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {} Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
} }