merge(main): modèle domain + Drift pour exercices à étapes (ticket #56)

Fusionne feature/exercise-steps-56-domain-drift — pub get OK,
build_runner OK (migration schemaVersion 7→8), analyze propre (mêmes
8 infos préexistantes), 87/87 tests verts, build APK debug validé.
Premier ticket du chantier "Exercice à plusieurs étapes" (#54).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:32:48 +02:00
35 changed files with 3878 additions and 25 deletions

View File

@ -14,3 +14,5 @@
- [gametime-ux-series-counter](gametime-ux-series-counter.md) — memory note gametime-ux-series-counter - [gametime-ux-series-counter](gametime-ux-series-counter.md) — memory note gametime-ux-series-counter
- [gametime-ux-exercise-media-viewer](gametime-ux-exercise-media-viewer.md) — memory note gametime-ux-exercise-media-viewer - [gametime-ux-exercise-media-viewer](gametime-ux-exercise-media-viewer.md) — memory note gametime-ux-exercise-media-viewer
- [gametime-server-architecture-sync-sharing](gametime-server-architecture-sync-sharing.md) — memory note gametime-server-architecture-sync-sharing - [gametime-server-architecture-sync-sharing](gametime-server-architecture-sync-sharing.md) — memory note gametime-server-architecture-sync-sharing
- [gametime-ux-exercise-steps](gametime-ux-exercise-steps.md) — memory note gametime-ux-exercise-steps
- [gametime-architecture-exercise-steps](gametime-architecture-exercise-steps.md) — memory note gametime-architecture-exercise-steps

View File

@ -0,0 +1,167 @@
---
name: gametime-architecture-exercise-steps
description: memory note gametime-architecture-exercise-steps
metadata:
type: project
---
# GameTime — Architecture exercices à plusieurs étapes
Décision d'architecture pour le ticket #54, basée sur la mémoire UX `gametime-ux-exercise-steps` et les patterns existants : snapshots, `ActiveSetResult` distinct des résultats détaillés, timers persistés via tables dédiées (`ActiveRestState`, `ActiveScoreStopwatchState`).
## Principe métier
Les étapes sont un rythme interne d'un exercice, pas une nouvelle mesure de série. Elles coexistent avec les mesures existantes `Temps`, `Répétitions`, `Score`.
- Si `Répétitions` est active sur la série, un passage complet de la séquence d'étapes = une répétition/passsage réalisé.
- Si `Temps` est actif sur la série, il reste une durée/fenêtre globale de série, indépendante des timers d'étapes.
- Le score de série reste porté par `ActiveSetResult` / `WorkoutHistorySetResult`.
- Les résultats d'étapes restent dans des entités dédiées et ne se mélangent jamais avec les résultats de série.
## Domaine exercice
Ajouter :
```dart
enum ExerciseStepType { time, reps }
final class ExerciseStep {
final String id;
final int position;
final String name;
final ExerciseStepType type;
final int defaultTargetValue;
final bool hasScore;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final String? scoreUnit;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
}
```
Ajouter `List<ExerciseStep> steps` sur `Exercise`.
Invariants :
- `steps.length <= 8`.
- positions uniques, contiguës et `>= 0` dans l'ordre affiché.
- `name` non vide.
- `defaultTargetValue > 0` ; unité métier : secondes si `type=time`, répétitions si `type=reps`.
- si `hasScore=false`, aucune valeur/label de score d'étape ne doit être significative.
- si `hasScore=true` et `scoreInputMode=manual`, `scoreLabel` et `scoreUnit` obligatoires ; `defaultTargetScore` nullable mais si renseigné `>= 0`.
- si `hasScore=true` et `scoreInputMode=stopwatch`, `defaultTargetScoreTimeMs` nullable mais si renseigné `> 0`; pas d'unité libre.
- score manuel et score chrono d'étape sont exclusifs.
- `type=time` + score chrono d'étape est autorisé mais doit rester un avertissement UX non bloquant.
## Snapshot pattern
Les étapes doivent suivre le même pattern que les autres propriétés d'exercice :
`Exercise.steps` -> snapshot dans `ProgramExercise` -> inclus dans `ProgramExercise.toSnapshotJson()` -> inclus dans `WorkoutTemplateProgram.programSnapshotJson` -> résolu dans `ActiveWorkoutSession.resolvedTemplateSnapshotJson` -> copié dans l'historique.
Recommandation Drift :
- source normalisée : table `exercise_steps` liée à `exercises`.
- snapshot programme : colonne `exercise_steps_snapshot_json` sur `program_exercises` plutôt qu'une table normalisée de snapshots pour le MVP.
- historique : les snapshots utiles sont copiés dans `WorkoutHistoryStepResult`, et le snapshot global reste dans `historySnapshotJson`.
Les modifications ultérieures d'un Exercise ne modifient pas les ProgramExercise existants.
## Modèle d'exécution
Ne pas étendre `ActiveSetResult` pour porter les détails d'étapes. `ActiveSetResult` reste le résultat global de série.
Ajouter une table/entité dédiée pour la progression courante : `ActiveExerciseStepProgressState`.
Champs recommandés :
- champs sync communs.
- `activeWorkoutSessionId`.
- `programIndex`, `exerciseIndex`, `setIndex`.
- `currentPassageIndex` : `>= 0`.
- `currentStepIndex` : `>= 0`.
- `currentStepSnapshotId`.
- `status` : `notStarted | waitingManual | runningTimer | pausedTimer | stoppedTimer | sequenceComplete`.
- `startedAt?` : horodatage du run timer courant.
- `accumulatedMs` : `>= 0`, temps déjà accumulé pour l'étape timer courante.
- `lastTransitionAt`.
Contraintes :
- unique `(active_workout_session_id, program_index, exercise_index, set_index)`.
- pas de compteur uniquement mémoire ; tout timer d'étape actif est reconstituable depuis `startedAt + accumulatedMs`.
- pause séance : un `runningTimer` devient `pausedTimer` en figeant `accumulatedMs`.
- kill app : à la reprise, recalculer depuis les horodatages et avancer automatiquement les étapes chronométrées écoulées jusqu'à la première étape manuelle ou fin de séquence.
Ajouter une table/entité de résultats actifs : `ActiveExerciseStepResult`.
Champs recommandés :
- champs sync communs.
- `activeWorkoutSessionId`.
- `programSnapshotId`, `exerciseSnapshotId`.
- `programIndex`, `exerciseIndex`, `setIndex`.
- `passageIndex`, `stepIndex`, `stepSnapshotId`.
- snapshots : `stepNameSnapshot`, `stepTypeSnapshot`, `targetValueSnapshot`, `hasScoreSnapshot`, `scoreInputModeSnapshot`, `scoreLabelSnapshot?`, `scoreUnitSnapshot?`, `targetScoreSnapshot?`, `targetScoreTimeMsSnapshot?`.
- `status` : `completed | skipped`.
- `startedAt?`, `completedAt?`.
- `actualTimeMs?` pour étape `time`.
- `actualReps?` pour étape `reps` si correction future ; MVP peut enregistrer la cible quand validée ou laisser null avec statut completed selon choix UI, mais l'historique doit rester lisible.
- `actualScore?` pour score manuel d'étape.
- `actualScoreTimeMs?` pour score chrono d'étape.
- `note?` optionnel.
Contraintes :
- unique `(active_workout_session_id, program_index, exercise_index, set_index, passage_index, step_index)`.
- `skipped` implique toutes les valeurs `actual*` nulles.
- `actualTimeMs` seulement pour `stepType=time`.
- `actualReps` seulement pour `stepType=reps`.
- `actualScore` seulement si `hasScoreSnapshot=true` et `scoreInputModeSnapshot=manual`.
- `actualScoreTimeMs` seulement si `hasScoreSnapshot=true` et `scoreInputModeSnapshot=stopwatch`.
- score manuel et score chrono jamais remplis simultanément.
## Historique
Ajouter `WorkoutHistoryStepResult`, distinct de `WorkoutHistorySetResult`.
Champs analogues à `ActiveExerciseStepResult`, avec `workoutHistoryId` au lieu de `activeWorkoutSessionId` et snapshots complets pour affichage autonome.
À la clôture d'une séance :
- créer `WorkoutHistory` et `WorkoutHistorySetResult` comme aujourd'hui pour les résultats globaux de série.
- copier tous les `ActiveExerciseStepResult` de la session vers `WorkoutHistoryStepResult`.
- ne jamais recalculer `WorkoutHistorySetResult.actualReps` depuis les step results sans décision explicite du use case ; les passages réalisés peuvent alimenter l'UI, mais le résultat de série reste sa propre source.
## Drift / migration
Le schéma actuel est `schemaVersion = 7`. Le ticket #54 doit passer à `schemaVersion = 8`.
Ajouts recommandés :
- table `exercise_steps`.
- colonne `exercise_steps_snapshot_json` sur `program_exercises`.
- table `active_exercise_step_progress_states`.
- table `active_exercise_step_results`.
- table `workout_history_step_results`.
- index de session sur les tables actives.
- index history sur `workout_history_step_results(workout_history_id)`.
- contraintes CHECK pour types, statuts, valeurs positives/non négatives.
## Audio / bips
Choix recommandé : introduire un port applicatif/presentation `ExerciseStepAudioCuePlayer` ou équivalent, avec méthodes métier `playShortCountdownBeep()` et `playLongCompletionBeep()`.
Adapter Flutter recommandé : `audioplayers` avec deux assets très courts bundlés (`short_beep`, `long_beep`), préchargés et joués en mode faible latence si possible.
Raison : solution mature et multiplateforme iOS/Android, fiable pour distinguer bip court et bip long. `SystemSound` est plus simple mais ne garantit pas un bip long distinct ni un contrôle suffisant. Une génération synthétique pure éviterait les assets mais augmente la complexité native/test.
Invariants de test :
- les widgets/use cases dépendent du port, jamais directement du lecteur audio réel.
- tests unitaires/widget avec fake player uniquement.
- l'absence/échec audio ne doit pas bloquer la progression d'étape.
## Tickets créés
- #56 `[DevBackend] Modèle domain + Drift pour exercices à étapes`.
- #58 `[DevBackend] Exécution persistante des étapes et résultats par passage`, dépend de #56.
- #59 `[DevFrontend] Éditeur d'exercice avec séquence d'étapes`, dépend de #56.
- #60 `[DevFrontend] Exécution de séance avec module séquence et bips`, dépend de #58 et #59.
- #61 `[DevFrontend] Plan de séance et historique avec résultats d'étapes`, dépend de #58.
- #62 `[QA] Validation exercices à étapes, persistance et historique`, dépend de #60 et #61.
Ordre recommandé : #56 -> #58 -> #59 -> #60 et #61 -> #62.
Note orchestration : le sprint dédié `Exercice editor enhancement` existe avec id `1bb8bdf2-9c35-4f53-9a31-1390a47bec63`, mais l'outil de création de ticket exposé à Architect ne permet pas de renseigner `sprintId`. Les tickets ont donc été créés liés à #54 et devront être rattachés au sprint par l'orchestrateur si nécessaire.

View File

@ -0,0 +1,437 @@
---
name: gametime-ux-exercise-steps
description: memory note gametime-ux-exercise-steps
metadata:
type: project
---
# GameTime — Exercice à plusieurs étapes (ticket #54, UX 2026-07-19)
Conception UX pour ajouter des **séquences d'étapes** aux exercices, sans remplacer les mesures existantes `Temps / Répétitions / Score` au niveau de la série.
## Décision structurante
Les étapes sont un **rythme interne de l'exercice**, pas un nouveau mode exclusif d'exécution.
- Un exercice peut conserver toutes ses mesures de série existantes : `Temps`, `Répétitions`, `Score`.
- La séquence d'étapes s'ajoute par-dessus ces mesures.
- Si la mesure `Répétitions` est active au niveau de la série, elle représente le nombre de **passages complets dans la séquence**.
- Si la mesure `Temps` est active au niveau de la série, elle représente une fenêtre globale ou une durée cible de série, indépendante des chronos d'étapes.
- Le score de série reste disponible tel que déjà conçu, y compris score libre ou score chrono.
Exemple validé par le besoin utilisateur : une série peut demander de répéter 10 fois la séquence `dribble gauche -> dribble droite`, ou d'enchaîner cette séquence pendant 10 minutes, ou les deux.
## A. Création / édition d'exercice
Surface concernée : `ExerciseFormScreen`, section existante `Mesures disponibles`.
Ajouter une nouvelle section après les mesures disponibles et leurs valeurs par défaut :
```text
Séquence d'étapes
[ ] Rythmer cet exercice avec des étapes
```
Texte d'aide quand désactivé :
```text
Ajoute des étapes si l'exercice doit suivre un ordre précis pendant chaque série.
```
Quand activé :
```text
Séquence d'étapes
Chaque passage suit ces étapes dans l'ordre. Si la série suit des répétitions, une répétition correspond à un passage complet.
[Ajouter une étape]
```
### Limite d'étapes
Recommandation UX MVP : limite dure à **8 étapes** par exercice.
Raison : 5-6 étapes restent lisibles pendant l'effort ; 8 couvre les exercices complexes sans rendre la progression mobile trop dense. Si l'utilisateur atteint la limite :
```text
Limite atteinte
Un exercice peut contenir jusqu'à 8 étapes.
```
### Liste des étapes dans le formulaire
Chaque étape est une carte compacte réordonnable, style Court Blazer : surface plate, rayon 6 px, bordure, liseré supérieur crimson si ouverte/active.
Carte repliée :
```text
[drag] 1. Dribble main droite [modifier]
Temps · 10 s [menu]
```
ou :
```text
[drag] 3. Pompes [modifier]
Répétitions · 10 [menu]
```
Actions accessibles :
- poignée drag-and-drop pour réordonner ;
- menu `...` avec `Monter`, `Descendre`, `Dupliquer`, `Supprimer` ;
- bouton/icône `Modifier l'étape` ouvrant le détail.
Ne pas dépendre uniquement du drag-and-drop : `Monter` / `Descendre` doivent exister pour l'accessibilité tactile.
### Détail d'étape
Ouvrir un écran ou une bottom sheet haute `Modifier l'étape`. Recommandation : **écran dédié** si le formulaire principal est déjà long ; bottom sheet acceptable seulement si elle reste plein écran.
Champs :
```text
Nom de l'étape
[ Dribble main droite ]
```
Validation :
```text
Le nom de l'étape est obligatoire.
```
Type d'étape, exclusif :
```text
Type d'étape
(•) Temps
( ) Répétitions
```
Si `Temps` :
```text
Durée par défaut (s)
[ 10 ]
```
Validation :
```text
Saisis une durée supérieure à 0.
```
Si `Répétitions` :
```text
Répétitions par défaut
[ 10 ]
```
Validation :
```text
Saisis un nombre de répétitions supérieur à 0.
```
### Score d'étape
Chaque étape peut avoir un score optionnel.
```text
Score d'étape
[ ] Ajouter un score pour cette étape
```
Si activé :
```text
Mode de score
(•) Saisie libre
( ) Chrono intégré
```
Score libre :
```text
Score à saisir
[ Réussites ]
Unité
[ paniers ]
Score par défaut
[ 5 ]
```
Validation :
- `Le libellé du score est obligatoire.`
- `L'unité du score est obligatoire.`
- `Saisis un score supérieur ou égal à 0.`
Score chrono :
```text
Objectif de chrono par défaut (optionnel)
[ 00:12 ]
```
Validation seulement si rempli :
```text
Saisis un objectif supérieur à 0.
```
Recommandation UX : autoriser le score chrono surtout sur les étapes à répétitions. Sur une étape de type `Temps`, si l'utilisateur choisit aussi `Score chrono`, afficher un avertissement non bloquant :
```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.
```
### États et validations du formulaire exercice
- Si `Rythmer cet exercice avec des étapes` est activé, il faut au moins une étape.
- Message : `Ajoute au moins une étape ou désactive la séquence.`
- Chaque étape doit avoir un nom, un type, et une cible par défaut strictement positive pour son type.
- Supprimer une étape demande confirmation seulement si elle contient déjà des champs remplis ou un score configuré.
- Pour un exercice déjà utilisé dans des programmes, conserver l'avertissement existant : les programmes existants restent inchangés. Les étapes doivent être snapshotées comme le reste de l'exercice.
## B. Exécution pendant une séance
Surface concernée : écran d'exécution déjà structuré avec : header discret, bloc `SÉRIE X/Y`, nom d'exercice, bouton médias, inputs de mesures, actions de série.
### Placement du module séquence
Si l'exercice a des étapes, ajouter un module `Séquence` entre le nom de l'exercice et les inputs de mesures de série.
Structure générale :
```text
00:12
Programme 1/2 · Exercice 3/8
SÉRIE
2 / 4
Dribble combo [Voir médias]
SÉQUENCE
Passage 1 / 10
[1] [2] [3] [4]
ÉTAPE 1 / 4
Dribble main droite
10 s
00:10
[Démarrer la séquence]
Résultat de la série
Passages réalisés : 0 / 10
Score : --
[Terminer la série]
[Passer la série]
```
Si la série n'a pas la mesure `Répétitions` active :
```text
Passage en cours
```
Si la série a `Répétitions` active :
```text
Passage 1 / 10
```
Le mot `Passage` est utilisé dans l'UI pour éviter de confondre les répétitions de série avec les répétitions internes d'une étape.
### Progression des étapes
Afficher une progression compacte compatible 5-6 étapes et jusqu'à 8 :
- chips carrées ou petits segments `1 2 3 4` ;
- étape courante : fond primaire or, texte fond ;
- étapes terminées : succès ou contour primaire ;
- étapes passées : contour/texte secondaire ;
- étapes à venir : surface neutre.
Pour plus de 6 étapes, la rangée peut défiler horizontalement, mais le label `ÉTAPE X / Y` reste toujours visible.
### Étape chronométrée
État initial si c'est la première étape active de la série :
```text
ÉTAPE 1 / 4
Dribble main droite
Objectif : 10 s
00:10
[Démarrer la séquence]
[Passer l'étape]
```
Après démarrage :
```text
00:07
[Passer l'étape]
```
Style :
- compte à rebours en Anton, 56-64 px, couleur primaire or ;
- label et nom en Archivo ;
- liseré crimson 2 px sur le panneau ;
- dans les 3 dernières secondes, flash discret du liseré ou du fond du compteur en crimson, sans nuire à la lisibilité.
Son :
- à `3`, `2`, `1` : bip court à chaque seconde ;
- à `0` : bip long ;
- à `0`, passage automatique à l'étape suivante.
Enchaînement :
- Si l'étape suivante est aussi chronométrée, son compte à rebours démarre immédiatement, sans pause ni bouton intermédiaire.
- Si l'étape suivante est à répétitions, l'écran affiche l'étape suivante et attend l'action utilisateur `Étape suivante`.
- Si c'est la dernière étape et qu'un nouveau passage doit commencer, le premier chrono du passage suivant démarre immédiatement si la première étape est chronométrée.
### Étape à répétitions
Affichage :
```text
ÉTAPE 3 / 4
Pompes
10
RÉPÉTITIONS
[Étape suivante]
[Passer l'étape]
```
- Le nombre cible utilise Anton, couleur primaire or.
- `Étape suivante` valide l'étape et avance.
- Il n'y a pas de compteur manuel des répétitions internes au MVP : l'utilisateur confirme quand l'étape est faite.
### Score d'étape pendant l'exécution
Si l'étape a un score libre : afficher un champ compact sous le bloc principal de l'étape :
```text
Score de l'étape (paniers)
[ ]
```
Si l'étape est chronométrée, le score libre ne bloque jamais l'enchaînement automatique. Si l'utilisateur ne l'a pas renseigné, il pourra le corriger via le plan de séance / détail de série.
Si l'étape a un score chrono et que l'étape est à répétitions : afficher un petit module `Chrono score d'étape` avec `Démarrer / Arrêter / Réinitialiser`, même logique que le score chrono de série.
Recommandation UX pour éviter la surcharge : ne pas afficher de second gros compteur si l'étape elle-même est déjà chronométrée. Dans ce cas, si le score chrono est configuré, afficher l'avertissement en configuration et, en exécution, garder le compteur d'étape prioritaire.
### Passage d'une répétition/passage à l'autre
Quand la dernière étape d'un passage est validée ou terminée :
- incrémenter `Passages réalisés` de 1 ;
- si la série a une cible de répétitions et que la cible n'est pas atteinte, commencer le passage suivant ;
- si le prochain passage commence par une étape chronométrée, démarrer immédiatement le chrono ;
- si le prochain passage commence par une étape à répétitions, afficher l'étape et attendre `Étape suivante`.
Quand la cible de passages est atteinte :
```text
Séquence terminée
10 / 10 passages réalisés
```
Le bouton principal devient ou reste :
```text
Terminer la série
```
La fin de séquence ne doit pas forcément clôturer la série automatiquement, car la série peut aussi avoir un score global, un chrono score global ou une correction à faire. L'utilisateur garde le contrôle via `Terminer la série`.
Si la série a `Temps` mais pas `Répétitions`, la séquence boucle tant que l'utilisateur ne termine pas la série. Le temps de série reste indépendant ; à expiration, afficher un feedback `Temps de série terminé`, mais ne pas interrompre brutalement une étape en cours.
### Articulation avec les mesures de série existantes
Quand un exercice a des étapes, renommer visuellement la mesure `Répétitions` de série en contexte :
```text
Passages réalisés
0 / 10
```
Ce compteur est alimenté automatiquement par les passages terminés, avec action secondaire `Corriger` si l'utilisateur doit ajuster.
Les autres mesures de série restent dans un bloc `Résultat de la série`, sous le module séquence :
- `Temps de série` si actif ;
- `Passages réalisés` si répétitions actif ;
- `Score de série` ou `Chrono score` si actif.
Ce bloc peut être compact par défaut pour ne pas écraser la séquence, mais les champs nécessaires doivent rester accessibles sans changer d'écran.
### Skip / passer
Renommer le bouton de série existant en contexte :
```text
Passer la série
```
Dans le module séquence :
```text
Passer l'étape
```
Menu secondaire recommandé :
```text
Passer ce passage
```
Comportements :
- `Passer l'étape` marque l'étape comme passée et avance à la suivante. Si un chrono d'étape tourne, confirmation : `Le chrono de cette étape sera arrêté.`
- `Passer ce passage` marque les étapes restantes du passage comme passées, ne compte pas ce passage dans `Passages réalisés`, puis démarre le passage suivant si la série doit continuer.
- `Passer la série` conserve le comportement existant : la série est passée sans résultat de série. Si une étape est en cours, demander confirmation : `La séquence en cours sera arrêtée.`
### Pause, reprise, fermeture d'app
- Pause de séance met aussi en pause le chrono d'étape et les éventuels chronos score d'étape.
- À la reprise, afficher l'étape courante avec son temps restant exact.
- Si l'app est tuée pendant une étape chronométrée, la reprise doit restaurer le passage, l'étape et le temps restant. UX attend la même robustesse que `ActiveRestState`.
- Si un chrono arrive à zéro pendant que l'app est en arrière-plan, à la réouverture afficher l'état recalculé : étape suivante ou passage suivant selon le temps écoulé. Si plusieurs étapes chronométrées se sont enchaînées, l'app peut avancer jusqu'à la première étape à répétitions ou jusqu'à la fin calculée du passage.
### Plan de séance / édition ponctuelle
Dans le `Plan de séance`, une série avec étapes garde son état global `À faire / En cours / Terminée / Passée`, mais le détail de série doit pouvoir afficher les étapes enregistrées :
```text
Série 2 / 4
Passages réalisés : 7 / 10
Passage 1
Étape 1 · Terminée · 10 s
Étape 2 · Terminée · 10 reps
Étape 3 · Passée
```
Édition ponctuelle d'une série passée/terminée : ne rejoue pas la séquence en mode interactif. Elle permet de corriger les résultats enregistrés : passages réalisés, score de série, scores d'étapes. La position courante de séance ne bouge pas.
## Exigences remontées à Architect
- Les étapes doivent être snapshotées avec l'exercice dans les programmes/séances/historiques.
- Chaque étape : position, nom, type `time` ou `reps`, cible par défaut > 0, score optionnel avec mode et valeurs par défaut selon les règles existantes.
- L'état d'exécution doit suivre : passage courant, étape courante, états des étapes du passage, temps restant/accumulé des chronos d'étapes, scores d'étapes, et robustesse après pause/kill app.
- Les résultats historiques doivent pouvoir stocker les résultats d'étapes par série et par passage, sans remplacer les résultats de série existants.

View File

@ -0,0 +1,11 @@
---
id: "4237adfd-91eb-45ff-9f32-6ce1daa39822"
order: 3
name: "Serveur-client"
status: "planned"
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784482336164
updatedAt: 1784482336164
version: 1
---

View File

@ -18,6 +18,15 @@
"status": "planned", "status": "planned",
"updatedAt": 1784449859008, "updatedAt": 1784449859008,
"version": 1 "version": 1
},
{
"id": "4237adfd-91eb-45ff-9f32-6ce1daa39822",
"path": "4237adfd-91eb-45ff-9f32-6ce1daa39822",
"order": 3,
"name": "Serveur-client",
"status": "planned",
"updatedAt": 1784482336164,
"version": 1
} }
] ]
} }

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#46" issueRef: "#46"
version: 4 version: 6
updatedBy: {"kind":"user"} updatedBy: {"kind":"user"}
updatedAt: 1784380502263 updatedAt: 1784482343900
--- ---

View File

@ -1,16 +1,16 @@
--- ---
id: "dda499ac-7197-4321-bd2b-a303510b1bbe" id: "dda499ac-7197-4321-bd2b-a303510b1bbe"
number: 46 number: 46
title: "Créer un application headless serveur pour l'application" title: "Créer un serveur headless pour l'application"
status: "open" status: "open"
priority: "low" priority: "low"
sprint: null sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822"
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":"user"} updatedBy: {"kind":"user"}
createdAt: 1784379695135 createdAt: 1784379695135
updatedAt: 1784380502263 updatedAt: 1784482343900
version: 4 version: 6
--- ---
J'aimerais que l'application puisse proposer des services online aussi. Un peu a la manière de Heavy avec la possibilité de partager ses séances etc. A la manière de Heavy, j'aimerais que l'on puisse se créer un compte et s'y connecter, synchroniser son historique de seances, ses séances, ses programmes ainsi que ses exercices. Ce ticket est la partie serveur/web API, il faudra que ça soit un serveur headless linux qui tourne dans un container docker. Il faudra que je puisse y acceder via un reverse proxy qui tourne sur une machine différente de celle du serveur headless mais dans le même réseau local. J'y accederais via une adresse https. le container se lancera ensuite à partir d'un docker-compose.yaml dans lequel tu laissera les champs libre de tout ce qui sera necessaire de configurer (adresse ip du reverse proxy, de la machine sur laquelle tourne le docker si necessaire etc.). Pour la création du serveur, créé un subdirectory spécialement pour cette partie serveur. Dans le dossier serveur à lma racine, je veux un script pour pouvoir push le container sur le gitea qui est déjà lié au projet, je veux aussi le docker-compose.yaml demandé. J'aimerais que l'application puisse proposer des services online aussi. Un peu a la manière de Heavy avec la possibilité de partager ses séances etc. A la manière de Heavy, j'aimerais que l'on puisse se créer un compte et s'y connecter, synchroniser son historique de seances, ses séances, ses programmes ainsi que ses exercices. Ce ticket est la partie serveur/web API, il faudra que ça soit un serveur headless linux qui tourne dans un container docker. Il faudra que je puisse y acceder via un reverse proxy qui tourne sur une machine différente de celle du serveur headless mais dans le même réseau local. J'y accederais via une adresse https. le container se lancera ensuite à partir d'un docker-compose.yaml dans lequel tu laissera les champs libre de tout ce qui sera necessaire de configurer (adresse ip du reverse proxy, de la machine sur laquelle tourne le docker si necessaire etc.). Pour la création du serveur, créé un subdirectory spécialement pour cette partie serveur. Dans le dossier serveur à lma racine, je veux un script pour pouvoir push le container sur le gitea qui est déjà lié au projet, je veux aussi le docker-compose.yaml demandé.

View File

@ -1,6 +1,9 @@
--- ---
issueRef: "#53" issueRef: "#53"
version: 1 version: 3
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784412015509 updatedAt: 1784469423570
--- ---
Contrat `server/openapi.yaml` (OpenAPI 3.0.3) documentant les 11 endpoints réels (health, auth register/login/logout, sync push/pull/exchange, shares create/inbox/accept/decline/revoke) avec sécurité bearerAuth et schémas de requêtes/réponses fidèles au code. Trou de couverture comblé : tests API HTTP directs pour les endpoints auth (register/login/logout), qui n'étaient testés qu'au niveau use case/middleware. `server/docs/integration-checklist.md` ajouté pour lister tout ce qui reste à vérifier manuellement une fois un vrai Docker/PostgreSQL disponible. Suite complète serveur verte (29/29), `dart analyze` clean.
Non vérifié, pour toute la suite du chantier serveur #47-#53 (limite d'environnement du sandbox Main, pas d'accès Docker daemon) : aucun `docker build`/`docker compose up` réel, aucun test contre un vrai PostgreSQL, aucun appel HTTP de bout en bout sur le serveur réellement démarré avec DB. Tout le reste (logique métier, SQL relu manuellement, syntaxe Docker Compose validée via `docker compose config`, 29 tests avec fakes en mémoire) est vérifié. Voir `server/docs/integration-checklist.md` pour la checklist complète à dérouler quand Docker sera disponible.

View File

@ -2,15 +2,15 @@
id: "d5fce252-f3c4-499f-ad0a-d3c81ea13305" id: "d5fce252-f3c4-499f-ad0a-d3c81ea13305"
number: 53 number: 53
title: "[Server] Tests API, contrats OpenAPI et vérification d'intégration" title: "[Server] Tests API, contrats OpenAPI et vérification d'intégration"
status: "open" status: "qa"
priority: "low" priority: "low"
sprint: null sprint: null
links: [{"target":"#46","kind":"relatesTo"},{"target":"#50","kind":"dependsOn"},{"target":"#51","kind":"dependsOn"},{"target":"#52","kind":"dependsOn"}] links: [{"target":"#46","kind":"relatesTo"},{"target":"#50","kind":"dependsOn"},{"target":"#51","kind":"dependsOn"},{"target":"#52","kind":"dependsOn"}]
agentRefs: [] agentRefs: []
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: 1784412015509 createdAt: 1784412015509
updatedAt: 1784412015509 updatedAt: 1784469423570
version: 1 version: 3
--- ---
Ajouter la couverture de validation serveur : tests unitaires domain/application, tests repository PostgreSQL, tests API auth/sync/share, fixtures de payload GameTime, vérification Docker Compose locale. Produire un contrat OpenAPI ou équivalent documentant auth, sync et partage pour les futurs tickets d'intégration Flutter. Ajouter la couverture de validation serveur : tests unitaires domain/application, tests repository PostgreSQL, tests API auth/sync/share, fixtures de payload GameTime, vérification Docker Compose locale. Produire un contrat OpenAPI ou équivalent documentant auth, sync et partage pour les futurs tickets d'intégration Flutter.

View File

@ -0,0 +1,6 @@
---
issueRef: "#56"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481516635
---

View File

@ -0,0 +1,16 @@
---
id: "c902b71c-8033-42dc-a52a-eb6822a1e863"
number: 56
title: "[DevBackend] Modèle domain + Drift pour exercices à étapes"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"}]
agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481516635
updatedAt: 1784481516635
version: 1
---
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

@ -0,0 +1,6 @@
---
issueRef: "#57"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784482343900
---

View File

@ -0,0 +1,16 @@
---
id: "d5b0bd7e-a021-4824-a278-b2e9c14a1cf6"
number: 57
title: "Allimenter la mémoire projet pour l'UI online de l'app"
status: "open"
priority: "medium"
sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822"
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784481519943
updatedAt: 1784482343900
version: 4
---
L'application aura donc une couche online. L'application se veut cependant rester un maximum offline sur les ofnctionnalités de base. Je veux que cette couche online soit le plus transparente possible pour l'utilisateur. En clair, si le serveur n'est pas disponible, je ne veux pas de popup qui lui dise qu'il n'est pas connecté. Le but, c'est que les informations soient autant que possibles stockées en local pour tout ce qui est necessaire au bon affichage de l'application sans pour autant donner de mauvaises ou de fausses informations (par exemple, garder les exercices créés, les programmes, les séances etc en local, si plus tard on calcule des statistiques pareilles, les garder aussi en local, si il y a un systeme de compte, garder la photo de profile ainsi que le pseudo etc en local. En bref la référence reste encore une fois Heavy qui fait très bien ça). Ajoute ceci à la mémoire du projet pour les futurs features qui traiteront de ça.

View File

@ -0,0 +1,6 @@
---
issueRef: "#58"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481524706
---

View File

@ -0,0 +1,16 @@
---
id: "d18752bf-3e5b-4fb9-935c-064d0d2e837a"
number: 58
title: "[DevBackend] Exécution persistante des étapes et résultats par passage"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}]
agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481524706
updatedAt: 1784481524706
version: 1
---
Ajouter le modèle d'exécution des étapes distinct de `ActiveSetResult`. Créer une table dédiée type `ActiveExerciseStepProgressState` pour session+position de série, passage courant, étape courante, statut `idle|running|stopped`, `startedAt`, `accumulatedMs`, et actions pause/reprise/skip. Créer `ActiveExerciseStepResult` pour résultats par `activeWorkoutSessionId + programIndex + exerciseIndex + setIndex + passageIndex + stepSnapshotId/stepIndex`, statut `completed|skipped`, durée/rep cible snapshot, score manuel ou score chrono d'étape. À la clôture de séance, projeter vers `WorkoutHistoryStepResult` sans mélanger avec `WorkoutHistorySetResult`.

View File

@ -0,0 +1,6 @@
---
issueRef: "#59"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481530478
---

View File

@ -0,0 +1,16 @@
---
id: "be486426-a17d-4521-b475-9230a2e30169"
number: 59
title: "[DevFrontend] Éditeur d'exercice avec séquence d'étapes"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481530478
updatedAt: 1784481530478
version: 1
---
Implémenter la section UX `Séquence d'étapes` dans `ExerciseFormScreen` : activation optionnelle, ajout/réordonnancement max 8, écran de détail d'étape, type exclusif temps/répétitions, cible obligatoire > 0, score d'étape optionnel avec mode manual/stopwatch. Préserver l'avertissement existant sur les exercices déjà utilisés : les programmes existants gardent leurs snapshots.

View File

@ -0,0 +1,6 @@
---
issueRef: "#60"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481536357
---

View File

@ -0,0 +1,16 @@
---
id: "4807a946-834e-44a4-8a39-25ad951e6668"
number: 60
title: "[DevFrontend] Exécution de séance avec module séquence et bips"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"},{"target":"#59","kind":"dependsOn"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481536357
updatedAt: 1784481536357
version: 1
---
Implémenter le module `Séquence` sur l'écran d'exécution : affichage passage/étape, étapes temps avec compte à rebours, étapes répétitions avec validation manuelle, boucle multi-passages si reps de série actives, skips distincts étape/passage/série, persistance après pause/kill via use cases backend. Ajouter un port audio testable et un adapter Flutter utilisant des bips courts/longs, sans dépendance des tests widget à un lecteur audio réel.

View File

@ -0,0 +1,6 @@
---
issueRef: "#61"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481541650
---

View File

@ -0,0 +1,16 @@
---
id: "1eed02ec-04af-4966-a0ff-449c4a957ea0"
number: 61
title: "[DevFrontend] Plan de séance et historique avec résultats d'étapes"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#58","kind":"dependsOn"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481541650
updatedAt: 1784481541650
version: 1
---
Afficher les résultats d'étapes dans le plan de séance et l'historique sans remplacer les résultats de série. Une série avec étapes garde son état global `à faire/en cours/terminée/passée`; le détail affiche les passages, étapes terminées/passées, durées/répétitions et scores d'étape. L'édition ponctuelle d'une série passée/terminée permet de corriger les valeurs enregistrées sans rejouer la séquence interactive et sans déplacer la progression courante.

View File

@ -0,0 +1,6 @@
---
issueRef: "#62"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784481549150
---

View File

@ -0,0 +1,16 @@
---
id: "a53d84b5-06d9-4683-ac64-9100cb60669f"
number: 62
title: "[QA] Validation exercices à étapes, persistance et historique"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#54","kind":"relatesTo"},{"target":"#60","kind":"dependsOn"},{"target":"#61","kind":"dependsOn"}]
agentRefs: [{"agentId":"7efa512f-3b3a-47b5-ade0-a2dd13073055","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
createdAt: 1784481549150
updatedAt: 1784481549150
version: 1
---
Préparer et exécuter le plan QA pour les exercices à étapes : création/édition et limites max 8, snapshots programme/séance, exécution étapes temps/répétitions, bips 3/2/1/0, enchaînement de chronos, skips étape/passage/série, pause/reprise/kill app, projection historique, édition ponctuelle sans déplacer la progression ni mélanger résultats d'étape et résultats de série.

View File

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

View File

@ -0,0 +1,16 @@
---
id: "17f54d9f-0317-429d-a275-480966976ced"
number: 63
title: "Ajouter les features déjà présentes sur le serveur au client"
status: "open"
priority: "medium"
sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822"
links: [{"target":"#57","kind":"dependsOn"},{"target":"#46","kind":"dependsOn"}]
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784482089895
updatedAt: 1784482343900
version: 6
---
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.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 55 "nextNumber": 64
} }

View File

@ -524,14 +524,14 @@
{ {
"issueRef": "#46", "issueRef": "#46",
"path": "46", "path": "46",
"title": "Créer un application headless serveur pour l'application", "title": "Créer un serveur headless pour l'application",
"status": "open", "status": "open",
"priority": "low", "priority": "low",
"sprint": null, "sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822",
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784380502263 "updatedAt": 1784482343900
}, },
{ {
"issueRef": "#47", "issueRef": "#47",
@ -597,11 +597,11 @@
"issueRef": "#53", "issueRef": "#53",
"path": "53", "path": "53",
"title": "[Server] Tests API, contrats OpenAPI et vérification d'intégration", "title": "[Server] Tests API, contrats OpenAPI et vérification d'intégration",
"status": "open", "status": "qa",
"priority": "low", "priority": "low",
"sprint": null, "sprint": null,
"assignedAgentIds": [], "assignedAgentIds": [],
"updatedAt": 1784412015509 "updatedAt": 1784469423570
}, },
{ {
"issueRef": "#54", "issueRef": "#54",
@ -614,6 +614,102 @@
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784451193831 "updatedAt": 1784451193831
},
{
"issueRef": "#56",
"path": "56",
"title": "[DevBackend] Modèle domain + Drift pour exercices à étapes",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495"
],
"updatedAt": 1784481516635
},
{
"issueRef": "#57",
"path": "57",
"title": "Allimenter la mémoire projet pour l'UI online de l'app",
"status": "open",
"priority": "medium",
"sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784482343900
},
{
"issueRef": "#58",
"path": "58",
"title": "[DevBackend] Exécution persistante des étapes et résultats par passage",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495"
],
"updatedAt": 1784481524706
},
{
"issueRef": "#59",
"path": "59",
"title": "[DevFrontend] Éditeur d'exercice avec séquence d'étapes",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784481530478
},
{
"issueRef": "#60",
"path": "60",
"title": "[DevFrontend] Exécution de séance avec module séquence et bips",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784481536357
},
{
"issueRef": "#61",
"path": "61",
"title": "[DevFrontend] Plan de séance et historique avec résultats d'étapes",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784481541650
},
{
"issueRef": "#62",
"path": "62",
"title": "[QA] Validation exercices à étapes, persistance et historique",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"7efa512f-3b3a-47b5-ade0-a2dd13073055"
],
"updatedAt": 1784481549150
},
{
"issueRef": "#63",
"path": "63",
"title": "Ajouter les features déjà présentes sur le serveur au client",
"status": "open",
"priority": "medium",
"sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822",
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784482343900
} }
] ]
} }

View File

@ -37,7 +37,9 @@ final class ExerciseUseCases {
int? defaultTargetReps, int? defaultTargetReps,
double? defaultTargetScore, double? defaultTargetScore,
int? defaultTargetScoreTimeMs, int? defaultTargetScoreTimeMs,
List<ExerciseStep> steps = const [],
}) async { }) async {
_validateExerciseSteps(steps);
_validateExerciseDefaultTargets( _validateExerciseDefaultTargets(
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -97,6 +99,7 @@ final class ExerciseUseCases {
Object? defaultTargetReps = _useCaseUnchanged, Object? defaultTargetReps = _useCaseUnchanged,
Object? defaultTargetScore = _useCaseUnchanged, Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged, Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
Object? steps = _useCaseUnchanged,
}) async { }) async {
final exercise = await repository.findById(id); final exercise = await repository.findById(id);
if (exercise == null) { if (exercise == null) {
@ -126,6 +129,10 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
); );
final resolvedSteps = steps == _useCaseUnchanged
? exercise.steps
: steps as List<ExerciseStep>;
_validateExerciseSteps(resolvedSteps);
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds) ? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
: imageMediaIds as List<String>; : imageMediaIds as List<String>;
@ -152,6 +159,7 @@ final class ExerciseUseCases {
defaultTargetReps: resolvedDefaultTargetReps, defaultTargetReps: resolvedDefaultTargetReps,
defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
steps: resolvedSteps,
); );
await repository.save(updated); await repository.save(updated);
return updated; return updated;
@ -475,6 +483,7 @@ final class ProgramUseCases {
exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot, exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot, exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot, exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: input.exerciseStepsSnapshot,
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot, exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
availableTimeSnapshot: input.availableTimeSnapshot, availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot, availableRepsSnapshot: input.availableRepsSnapshot,
@ -592,6 +601,7 @@ final class ProgramExerciseConfig {
this.exerciseImageMediaIdSnapshot, this.exerciseImageMediaIdSnapshot,
this.exerciseImageMediaIdsSnapshot = const [], this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot, this.exerciseVideoMediaIdSnapshot,
this.exerciseStepsSnapshot = const [],
this.exerciseArchivedSnapshot = false, this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot, required this.availableTimeSnapshot,
required this.availableRepsSnapshot, required this.availableRepsSnapshot,
@ -615,6 +625,7 @@ final class ProgramExerciseConfig {
final String? exerciseImageMediaIdSnapshot; final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot; final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot; final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool exerciseArchivedSnapshot; final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot; final bool availableTimeSnapshot;
final bool availableRepsSnapshot; final bool availableRepsSnapshot;
@ -1560,6 +1571,7 @@ List<ProgramExercise> _repositionProgramExercises(
exercises[index].exerciseImageMediaIdsSnapshot, exercises[index].exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot:
exercises[index].exerciseVideoMediaIdSnapshot, exercises[index].exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exercises[index].exerciseStepsSnapshot,
exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot, exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot,
availableTimeSnapshot: exercises[index].availableTimeSnapshot, availableTimeSnapshot: exercises[index].availableTimeSnapshot,
availableRepsSnapshot: exercises[index].availableRepsSnapshot, availableRepsSnapshot: exercises[index].availableRepsSnapshot,
@ -1625,6 +1637,82 @@ bool _sameImageSet(List<String> left, List<String> right) {
return left.toSet().containsAll(right) && right.toSet().containsAll(left); return left.toSet().containsAll(right) && right.toSet().containsAll(left);
} }
void _validateExerciseSteps(List<ExerciseStep> steps) {
if (steps.length > 8) {
throw const DomainException('An exercise cannot have more than 8 steps.');
}
for (var index = 0; index < steps.length; index++) {
final step = steps[index];
if (step.position != index) {
throw const DomainException(
'Exercise step positions must be contiguous and ordered.',
);
}
if (step.name.trim().isEmpty) {
throw const DomainException('Exercise step name must not be blank.');
}
if (step.defaultTargetValue <= 0) {
throw const DomainException(
'Exercise step default target value must be positive.',
);
}
if (!step.hasScore) {
if (step.scoreLabel != null ||
step.scoreUnit != null ||
step.defaultTargetScore != null ||
step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
}
continue;
}
if (step.defaultTargetScore != null &&
step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch step score targets cannot both be set.',
);
}
switch (step.scoreInputMode) {
case ScoreInputMode.manual:
if (step.scoreLabel == null || step.scoreLabel!.trim().isEmpty) {
throw const DomainException(
'Exercise step score label must not be blank.',
);
}
if (step.scoreUnit == null || step.scoreUnit!.trim().isEmpty) {
throw const DomainException(
'Exercise step score unit must not be blank.',
);
}
if (step.defaultTargetScore != null && step.defaultTargetScore! < 0) {
throw const DomainException(
'Exercise step default target score must not be negative.',
);
}
if (step.defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual exercise step score cannot define stopwatch target.',
);
}
case ScoreInputMode.stopwatch:
if (step.scoreLabel != null ||
step.scoreUnit != null ||
step.defaultTargetScore != null) {
throw const DomainException(
'Stopwatch exercise step score cannot define manual score values.',
);
}
if (step.defaultTargetScoreTimeMs != null &&
step.defaultTargetScoreTimeMs! <= 0) {
throw const DomainException(
'Exercise step default target score time ms must be positive.',
);
}
}
}
}
void _validateExerciseDefaultTargets({ void _validateExerciseDefaultTargets({
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,

View File

@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score }
enum ScoreInputMode { manual, stopwatch } enum ScoreInputMode { manual, stopwatch }
enum ExerciseStepType { time, reps }
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped } enum SetResultStatus { completed, skipped }
@ -138,6 +140,7 @@ final class Exercise {
this.defaultTargetReps, this.defaultTargetReps,
this.defaultTargetScore, this.defaultTargetScore,
this.defaultTargetScoreTimeMs, this.defaultTargetScoreTimeMs,
List<ExerciseStep> steps = const [],
this.archivedAt, this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name'), }) : name = _nonBlank(name, 'Exercise name'),
imageMediaIds = _resolvedExerciseImageMediaIds( imageMediaIds = _resolvedExerciseImageMediaIds(
@ -147,7 +150,8 @@ final class Exercise {
iconMediaId = _validatedIconMediaId( iconMediaId = _validatedIconMediaId(
iconMediaId, iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
) { ),
steps = _validatedExerciseSteps(steps) {
_requireAtLeastOneMeasure( _requireAtLeastOneMeasure(
hasTime: hasTimeMeasure, hasTime: hasTimeMeasure,
hasReps: hasRepsMeasure, hasReps: hasRepsMeasure,
@ -193,6 +197,7 @@ final class Exercise {
final int? defaultTargetReps; final int? defaultTargetReps;
final double? defaultTargetScore; final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs; final int? defaultTargetScoreTimeMs;
final List<ExerciseStep> steps;
final DateTime? archivedAt; final DateTime? archivedAt;
Set<WorkoutMeasure> get availableMeasures => { Set<WorkoutMeasure> get availableMeasures => {
@ -226,6 +231,7 @@ final class Exercise {
Object? defaultTargetReps = _unchanged, Object? defaultTargetReps = _unchanged,
Object? defaultTargetScore = _unchanged, Object? defaultTargetScore = _unchanged,
Object? defaultTargetScoreTimeMs = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged,
Object? steps = _unchanged,
Object? archivedAt = _unchanged, Object? archivedAt = _unchanged,
}) { }) {
final resolvedImageMediaIds = imageMediaIds == _unchanged final resolvedImageMediaIds = imageMediaIds == _unchanged
@ -272,6 +278,7 @@ final class Exercise {
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged
? this.defaultTargetScoreTimeMs ? this.defaultTargetScoreTimeMs
: defaultTargetScoreTimeMs as int?, : defaultTargetScoreTimeMs as int?,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
archivedAt: archivedAt == _unchanged archivedAt: archivedAt == _unchanged
? this.archivedAt ? this.archivedAt
: archivedAt as DateTime?, : archivedAt as DateTime?,
@ -279,6 +286,60 @@ final class Exercise {
} }
} }
final class ExerciseStep {
ExerciseStep({
required String id,
required this.position,
required String name,
required this.type,
required this.defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.scoreUnit,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
}) : id = _nonBlank(id, 'Exercise step id'),
name = _nonBlank(name, 'Exercise step name') {
_requireNonNegative(position, 'Exercise step position');
_requirePositive(defaultTargetValue, 'Exercise step default target value');
_validateExerciseStepScoreShape(
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
final String id;
final int position;
final String name;
final ExerciseStepType type;
final int defaultTargetValue;
final bool hasScore;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final String? scoreUnit;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
Map<String, Object?> toSnapshotJson() => {
'id': id,
'position': position,
'name': name,
'type': type.name,
'defaultTargetValue': defaultTargetValue,
'hasScore': hasScore,
'scoreInputMode': scoreInputMode.name,
'scoreLabel': scoreLabel,
'scoreUnit': scoreUnit,
'defaultTargetScore': defaultTargetScore,
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
};
}
const Object _unchanged = Object(); const Object _unchanged = Object();
final class Program { final class Program {
@ -322,6 +383,7 @@ final class ProgramExercise {
this.exerciseImageMediaIdSnapshot, this.exerciseImageMediaIdSnapshot,
List<String> exerciseImageMediaIdsSnapshot = const [], List<String> exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot, this.exerciseVideoMediaIdSnapshot,
List<ExerciseStep> exerciseStepsSnapshot = const [],
this.exerciseArchivedSnapshot = false, this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot, required this.availableTimeSnapshot,
required this.availableRepsSnapshot, required this.availableRepsSnapshot,
@ -347,7 +409,8 @@ final class ProgramExercise {
exerciseImageMediaIdSnapshot != null exerciseImageMediaIdSnapshot != null
? [exerciseImageMediaIdSnapshot] ? [exerciseImageMediaIdSnapshot]
: exerciseImageMediaIdsSnapshot, : exerciseImageMediaIdsSnapshot,
) { ),
exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) {
_requireNonNegative(position, 'Position'); _requireNonNegative(position, 'Position');
_requirePositive(setsCount, 'Sets count'); _requirePositive(setsCount, 'Sets count');
_requireAtLeastOneMeasure( _requireAtLeastOneMeasure(
@ -386,6 +449,7 @@ final class ProgramExercise {
final String? exerciseImageMediaIdSnapshot; final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot; final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot; final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool exerciseArchivedSnapshot; final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot; final bool availableTimeSnapshot;
final bool availableRepsSnapshot; final bool availableRepsSnapshot;
@ -432,6 +496,7 @@ final class ProgramExercise {
exerciseImageMediaIdSnapshot: exercise.imageMediaId, exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseStepsSnapshot: exercise.steps,
exerciseArchivedSnapshot: exercise.archivedAt != null, exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure, availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure, availableRepsSnapshot: exercise.hasRepsMeasure,
@ -461,6 +526,9 @@ final class ProgramExercise {
'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot, 'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
'exerciseStepsSnapshot': exerciseStepsSnapshot
.map((step) => step.toSnapshotJson())
.toList(),
'exerciseArchivedSnapshot': exerciseArchivedSnapshot, 'exerciseArchivedSnapshot': exerciseArchivedSnapshot,
'availableTimeSnapshot': availableTimeSnapshot, 'availableTimeSnapshot': availableTimeSnapshot,
'availableRepsSnapshot': availableRepsSnapshot, 'availableRepsSnapshot': availableRepsSnapshot,
@ -998,6 +1066,24 @@ String? _validatedIconMediaId(String? iconMediaId, List<String> imageMediaIds) {
return normalized; return normalized;
} }
List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
if (steps.length > 8) {
throw const DomainException('An exercise cannot have more than 8 steps.');
}
for (var index = 0; index < steps.length; index++) {
if (steps[index].position != index) {
throw const DomainException(
'Exercise step positions must be contiguous and ordered.',
);
}
}
final ids = steps.map((step) => _nonBlank(step.id, 'Exercise step id'));
if (ids.toSet().length != steps.length) {
throw const DomainException('Exercise step ids must be unique.');
}
return List.unmodifiable(steps);
}
void _requireAtLeastOneMeasure({ void _requireAtLeastOneMeasure({
required bool hasTime, required bool hasTime,
required bool hasReps, required bool hasReps,
@ -1063,6 +1149,58 @@ void _requireScoreTargetShape({
} }
} }
void _validateExerciseStepScoreShape({
required bool hasScore,
required ScoreInputMode scoreInputMode,
required String? scoreLabel,
required String? scoreUnit,
required double? defaultTargetScore,
required int? defaultTargetScoreTimeMs,
}) {
if (!hasScore) {
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null ||
defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
}
return;
}
if (defaultTargetScore != null && defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual and stopwatch step score targets cannot both be set.',
);
}
switch (scoreInputMode) {
case ScoreInputMode.manual:
_nonBlank(scoreLabel, 'Exercise step score label');
_nonBlank(scoreUnit, 'Exercise step score unit');
_requireNullableNonNegativeDouble(
defaultTargetScore,
'Exercise step default target score',
);
if (defaultTargetScoreTimeMs != null) {
throw const DomainException(
'Manual exercise step score cannot define stopwatch target.',
);
}
case ScoreInputMode.stopwatch:
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null) {
throw const DomainException(
'Stopwatch exercise step score cannot define manual score values.',
);
}
_requireNullablePositive(
defaultTargetScoreTimeMs,
'Exercise step default target score time ms',
);
}
}
void _requireScoreResultShape({ void _requireScoreResultShape({
required ScoreInputMode scoreInputMode, required ScoreInputMode scoreInputMode,
required double? actualScore, required double? actualScore,

View File

@ -14,6 +14,7 @@ part 'app_database.g.dart';
ChangeLogEntries, ChangeLogEntries,
Exercises, Exercises,
ExerciseImages, ExerciseImages,
ExerciseSteps,
MediaAssets, MediaAssets,
ProgramExercises, ProgramExercises,
Programs, Programs,
@ -37,7 +38,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 7; int get schemaVersion => 8;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -73,6 +74,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 7) { if (from < 7) {
await _migrateToSchema7(); await _migrateToSchema7();
} }
if (from < 8) {
await _migrateToSchema8(migrator);
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -109,6 +113,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id ' 'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id '
'ON exercise_images (exercise_id)', 'ON exercise_images (exercise_id)',
); );
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position '
'ON exercise_steps (exercise_id, position)',
);
await customStatement( await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)', 'ON workout_template_programs (workout_template_id)',
@ -162,6 +170,7 @@ const _syncableTableNames = [
'active_workout_sessions', 'active_workout_sessions',
'exercises', 'exercises',
'exercise_images', 'exercise_images',
'exercise_steps',
'media_assets', 'media_assets',
'program_exercises', 'program_exercises',
'programs', 'programs',
@ -270,4 +279,12 @@ extension on AppDatabase {
'REFERENCES media_assets(id)', 'REFERENCES media_assets(id)',
); );
} }
Future<void> _migrateToSchema8(Migrator migrator) async {
await migrator.createTable(exerciseSteps);
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'exercise_steps_snapshot_json TEXT',
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
)..where((table) => table.id.equals(id))).getSingleOrNull(); )..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null return row == null
? null ? null
: _exerciseFromRow(row, await _imageMediaIdsForExercise(id)); : _exerciseFromRow(
row,
await _imageMediaIdsForExercise(id),
await _stepsForExercise(id),
);
} }
@override @override
@ -33,7 +37,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
final exercises = <domain.Exercise>[]; final exercises = <domain.Exercise>[];
for (final row in rows) { for (final row in rows) {
exercises.add( exercises.add(
_exerciseFromRow(row, await _imageMediaIdsForExercise(row.id)), _exerciseFromRow(
row,
await _imageMediaIdsForExercise(row.id),
await _stepsForExercise(row.id),
),
); );
} }
return exercises; return exercises;
@ -66,6 +74,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
.insertOnConflictUpdate(_exerciseCompanion(exercise)), .insertOnConflictUpdate(_exerciseCompanion(exercise)),
); );
await _replaceExerciseImages(database, exercise); await _replaceExerciseImages(database, exercise);
await _replaceExerciseSteps(database, exercise);
}); });
} }
@ -81,6 +90,19 @@ final class DriftExerciseRepository implements ExerciseRepository {
.get(); .get();
return rows.map((row) => row.mediaAssetId).toList(); return rows.map((row) => row.mediaAssetId).toList();
} }
Future<List<domain.ExerciseStep>> _stepsForExercise(String exerciseId) async {
final rows =
await (database.select(database.exerciseSteps)
..where(
(table) =>
table.exerciseId.equals(exerciseId) &
table.deletedAt.isNull(),
)
..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get();
return rows.map(_exerciseStepFromRow).toList();
}
} }
final class DriftMediaAssetRepository implements MediaAssetRepository { final class DriftMediaAssetRepository implements MediaAssetRepository {
@ -823,6 +845,76 @@ Future<void> _replaceExerciseImages(
} }
} }
Future<void> _replaceExerciseSteps(
db.AppDatabase database,
domain.Exercise exercise,
) async {
final rows = await (database.select(
database.exerciseSteps,
)..where((table) => table.exerciseId.equals(exercise.metadata.id))).get();
final activeRowsByStepId = {
for (final row in rows)
if (row.deletedAt == null) row.id: row,
};
final desiredIds = exercise.steps.map((step) => step.id).toSet();
final removedRows = activeRowsByStepId.values
.where((row) => !desiredIds.contains(row.id))
.toList();
await _softDeleteExerciseStepRows(
database,
removedRows,
exercise.metadata.updatedAt,
);
for (final step in exercise.steps) {
final existing = activeRowsByStepId[step.id];
final metadata = existing == null
? domain.EntityMetadata(
id: step.id,
createdAt: exercise.metadata.updatedAt,
updatedAt: exercise.metadata.updatedAt,
originDeviceId: exercise.metadata.originDeviceId,
)
: _metadataFromRow(existing).touch(exercise.metadata.updatedAt);
await _upsertWithChangeLog(
database: database,
tableName: 'exercise_steps',
entityType: 'ExerciseStep',
metadata: metadata,
write: () => database
.into(database.exerciseSteps)
.insertOnConflictUpdate(
db.ExerciseStepsCompanion(
id: Value(metadata.id),
createdAt: Value(metadata.createdAt.toUtc()),
updatedAt: Value(metadata.updatedAt.toUtc()),
deletedAt: Value(_utcOrNull(metadata.deletedAt)),
schemaVersion: Value(metadata.schemaVersion),
syncState: Value(_syncStateToDb(metadata.syncState)),
localRevision: Value(metadata.localRevision),
originDeviceId: Value(metadata.originDeviceId),
futureOwnerProfileId: Value(metadata.futureOwnerProfileId),
lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)),
remoteRevision: Value(metadata.remoteRevision),
exerciseId: Value(exercise.metadata.id),
position: Value(step.position),
name: Value(step.name),
type: Value(_exerciseStepTypeToDb(step.type)),
defaultTargetValue: Value(step.defaultTargetValue),
hasScore: Value(step.hasScore),
scoreInputMode: Value(
step.hasScore ? _scoreInputModeToDb(step.scoreInputMode) : null,
),
scoreLabel: Value(step.scoreLabel),
scoreUnit: Value(step.scoreUnit),
defaultTargetScore: Value(step.defaultTargetScore),
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
),
),
);
}
}
Future<void> _softDeleteExerciseImageRows( Future<void> _softDeleteExerciseImageRows(
db.AppDatabase database, db.AppDatabase database,
List<db.ExerciseImage> rows, List<db.ExerciseImage> rows,
@ -852,6 +944,35 @@ Future<void> _softDeleteExerciseImageRows(
} }
} }
Future<void> _softDeleteExerciseStepRows(
db.AppDatabase database,
List<db.ExerciseStep> rows,
DateTime deletedAt,
) async {
for (final row in rows) {
final revision = row.localRevision + 1;
await (database.update(
database.exerciseSteps,
)..where((table) => table.id.equals(row.id))).write(
db.ExerciseStepsCompanion(
deletedAt: Value(deletedAt.toUtc()),
updatedAt: Value(deletedAt.toUtc()),
localRevision: Value(revision),
syncState: const Value('deleted'),
),
);
await _writeChangeLog(
database: database,
entityType: 'ExerciseStep',
entityId: row.id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
}
String _exerciseImageId(String exerciseId, String mediaId) { String _exerciseImageId(String exerciseId, String mediaId) {
return 'exercise-image:$exerciseId:$mediaId'; return 'exercise-image:$exerciseId:$mediaId';
} }
@ -1094,7 +1215,11 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
); );
} }
domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) { domain.Exercise _exerciseFromRow(
db.Exercise row,
List<String> imageMediaIds,
List<domain.ExerciseStep> steps,
) {
return domain.Exercise( return domain.Exercise(
metadata: _metadataFromRow(row), metadata: _metadataFromRow(row),
name: row.name, name: row.name,
@ -1112,10 +1237,29 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
defaultTargetReps: row.defaultTargetReps, defaultTargetReps: row.defaultTargetReps,
defaultTargetScore: row.defaultTargetScore, defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
steps: steps,
archivedAt: _utcOrNull(row.archivedAt), archivedAt: _utcOrNull(row.archivedAt),
); );
} }
domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
return domain.ExerciseStep(
id: row.id,
position: row.position,
name: row.name,
type: _exerciseStepTypeFromDb(row.type),
defaultTargetValue: row.defaultTargetValue,
hasScore: row.hasScore,
scoreInputMode: row.scoreInputMode == null
? domain.ScoreInputMode.manual
: _scoreInputModeFromDb(row.scoreInputMode!),
scoreLabel: row.scoreLabel,
scoreUnit: row.scoreUnit,
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
);
}
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) { db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
final values = _metadataValues(asset.metadata); final values = _metadataValues(asset.metadata);
return db.MediaAssetsCompanion( return db.MediaAssetsCompanion(
@ -1215,6 +1359,9 @@ db.ProgramExercisesCompanion _programExerciseCompanion(
exerciseImageMediaIdsSnapshotJson: Value( exerciseImageMediaIdsSnapshotJson: Value(
_encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot), _encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot),
), ),
exerciseStepsSnapshotJson: Value(
_encodeExerciseStepsSnapshot(exercise.exerciseStepsSnapshot),
),
exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot), exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot),
exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot), exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot),
availableTimeSnapshot: Value(exercise.availableTimeSnapshot), availableTimeSnapshot: Value(exercise.availableTimeSnapshot),
@ -1250,6 +1397,9 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
row.exerciseImageMediaIdsSnapshotJson, row.exerciseImageMediaIdsSnapshotJson,
row.exerciseImageMediaIdSnapshot, row.exerciseImageMediaIdSnapshot,
), ),
exerciseStepsSnapshot: _decodeExerciseStepsSnapshot(
row.exerciseStepsSnapshotJson,
),
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
availableTimeSnapshot: row.availableTimeSnapshot, availableTimeSnapshot: row.availableTimeSnapshot,
@ -1751,6 +1901,18 @@ domain.ScoreInputMode _scoreInputModeFromDb(String value) => switch (value) {
_ => throw domain.DomainException('Unknown score input mode: $value'), _ => throw domain.DomainException('Unknown score input mode: $value'),
}; };
String _exerciseStepTypeToDb(domain.ExerciseStepType type) => switch (type) {
domain.ExerciseStepType.time => 'time',
domain.ExerciseStepType.reps => 'reps',
};
domain.ExerciseStepType _exerciseStepTypeFromDb(String value) =>
switch (value) {
'time' => domain.ExerciseStepType.time,
'reps' => domain.ExerciseStepType.reps,
_ => throw domain.DomainException('Unknown exercise step type: $value'),
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) { String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed', domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped', domain.SetResultStatus.skipped => 'skipped',
@ -1800,6 +1962,90 @@ List<String> _decodeImageMediaIdsSnapshot(
.toList(growable: false); .toList(growable: false);
} }
String? _encodeExerciseStepsSnapshot(List<domain.ExerciseStep> steps) {
return steps.isEmpty
? null
: jsonEncode(steps.map((step) => step.toSnapshotJson()).toList());
}
List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
if (encoded == null || encoded.trim().isEmpty) {
return const [];
}
final decoded = jsonDecode(encoded);
if (decoded is! List) {
throw const domain.DomainException('Invalid exercise steps snapshot.');
}
return decoded
.map((item) {
if (item is! Map) {
throw const domain.DomainException('Invalid exercise step snapshot.');
}
final json = Map<String, Object?>.from(item);
return domain.ExerciseStep(
id: _requiredString(json, 'id'),
position: _requiredInt(json, 'position'),
name: _requiredString(json, 'name'),
type: _exerciseStepTypeFromDb(_requiredString(json, 'type')),
defaultTargetValue: _requiredInt(json, 'defaultTargetValue'),
hasScore: _requiredBool(json, 'hasScore'),
scoreInputMode: _scoreInputModeFromDb(
_optionalString(json, 'scoreInputMode') ?? 'manual',
),
scoreLabel: _optionalString(json, 'scoreLabel'),
scoreUnit: _optionalString(json, 'scoreUnit'),
defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'),
defaultTargetScoreTimeMs: _optionalInt(
json,
'defaultTargetScoreTimeMs',
),
);
})
.toList(growable: false);
}
String _requiredString(Map<String, Object?> json, String key) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) {
return value;
}
throw domain.DomainException('$key must be a non-empty string.');
}
String? _optionalString(Map<String, Object?> json, String key) {
final value = json[key];
if (value == null) {
return null;
}
return value is String ? value : null;
}
int _requiredInt(Map<String, Object?> json, String key) {
final value = json[key];
if (value is int) {
return value;
}
throw domain.DomainException('$key must be an integer.');
}
int? _optionalInt(Map<String, Object?> json, String key) {
final value = json[key];
return value is int ? value : null;
}
double? _optionalDouble(Map<String, Object?> json, String key) {
final value = json[key];
return (value as num?)?.toDouble();
}
bool _requiredBool(Map<String, Object?> json, String key) {
final value = json[key];
if (value is bool) {
return value;
}
throw domain.DomainException('$key must be a boolean.');
}
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) { domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
'running' => domain.ActiveWorkoutStatus.running, 'running' => domain.ActiveWorkoutStatus.running,
'paused' => domain.ActiveWorkoutStatus.paused, 'paused' => domain.ActiveWorkoutStatus.paused,

View File

@ -107,6 +107,50 @@ class ExerciseImages extends SyncableTable {
]; ];
} }
class ExerciseSteps extends SyncableTable {
@override
String get tableName => 'exercise_steps';
TextColumn get exerciseId => text().references(Exercises, #id)();
IntColumn get position => integer()();
TextColumn get name => text().withLength(min: 1)();
TextColumn get type => text()();
IntColumn get defaultTargetValue => integer()();
BoolColumn get hasScore => boolean()();
TextColumn get scoreInputMode => text().nullable()();
TextColumn get scoreLabel => text().nullable()();
TextColumn get scoreUnit => text().nullable()();
RealColumn get defaultTargetScore => real().nullable()();
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
@override
List<String> get customConstraints => [
"CHECK (type IN ('time', 'reps'))",
'CHECK (position >= 0 AND position < 8)',
'CHECK (default_target_value > 0)',
"CHECK (score_input_mode IS NULL OR score_input_mode IN ('manual', "
"'stopwatch'))",
'CHECK (has_score OR (score_input_mode IS NULL '
'AND score_label IS NULL AND score_unit IS NULL '
'AND default_target_score IS NULL '
'AND default_target_score_time_ms IS NULL))',
'CHECK (NOT has_score OR score_input_mode IS NOT NULL)',
"CHECK (NOT has_score OR score_input_mode != 'manual' OR "
'(score_label IS NOT NULL AND length(trim(score_label)) > 0 '
'AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0))',
'CHECK (default_target_score IS NULL OR default_target_score >= 0)',
'CHECK (default_target_score_time_ms IS NULL OR '
'default_target_score_time_ms > 0)',
"CHECK (score_input_mode != 'manual' OR "
'default_target_score_time_ms IS NULL)',
"CHECK (score_input_mode != 'stopwatch' OR "
'(score_label IS NULL AND score_unit IS NULL '
'AND default_target_score IS NULL))',
'CHECK (default_target_score IS NULL OR '
'default_target_score_time_ms IS NULL)',
];
}
class Programs extends SyncableTable { class Programs extends SyncableTable {
@override @override
String get tableName => 'programs'; String get tableName => 'programs';
@ -132,6 +176,7 @@ class ProgramExercises extends SyncableTable {
TextColumn get exerciseImageMediaIdSnapshot => TextColumn get exerciseImageMediaIdSnapshot =>
text().nullable().references(MediaAssets, #id)(); text().nullable().references(MediaAssets, #id)();
TextColumn get exerciseImageMediaIdsSnapshotJson => text().nullable()(); TextColumn get exerciseImageMediaIdsSnapshotJson => text().nullable()();
TextColumn get exerciseStepsSnapshotJson => text().nullable()();
@ReferenceName('programExerciseVideoSnapshotReferences') @ReferenceName('programExerciseVideoSnapshotReferences')
TextColumn get exerciseVideoMediaIdSnapshot => TextColumn get exerciseVideoMediaIdSnapshot =>

View File

@ -177,6 +177,113 @@ void main() {
); );
}); });
test('Exercise use case rejects more than eight steps', () async {
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
for (var index = 0; index < 9; index++)
_step(id: 'step-$index', position: index),
],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise use case rejects non-contiguous step positions', () async {
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
_step(id: 'step-1', position: 1),
_step(id: 'step-0', position: 0),
],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise step validation rejects invalid basic fields', () {
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
});
test('Exercise step validation rejects disabled score values', () {
expect(
() => _step(hasScore: false, scoreLabel: 'Charge'),
throwsA(isA<DomainException>()),
);
expect(
() => _step(hasScore: false, defaultTargetScore: 0),
throwsA(isA<DomainException>()),
);
});
test(
'Exercise step manual score validation accepts zero and rejects negative',
() {
final step = _step(
hasScore: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: 0,
);
expect(step.defaultTargetScore, 0);
expect(
() => _step(
hasScore: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
defaultTargetScore: -1,
),
throwsA(isA<DomainException>()),
);
expect(
() => _step(hasScore: true, scoreLabel: 'Charge'),
throwsA(isA<DomainException>()),
);
},
);
test('Exercise step stopwatch score validation rejects manual fields', () {
final step = _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 12000,
);
expect(step.defaultTargetScoreTimeMs, 12000);
expect(
() => _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 0,
),
throwsA(isA<DomainException>()),
);
expect(
() => _step(
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
scoreLabel: 'Charge',
),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery rejects a sixth image', () async { test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository() final repository = _FakeExerciseRepository()
..exercise = Exercise( ..exercise = Exercise(
@ -959,6 +1066,34 @@ void main() {
}); });
} }
ExerciseStep _step({
String id = 'step-1',
int position = 0,
String name = 'Phase',
ExerciseStepType type = ExerciseStepType.time,
int defaultTargetValue = 30,
bool hasScore = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
}) {
return ExerciseStep(
id: id,
position: position,
name: name,
type: type,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
EntityMetadata _metadata(String id) { EntityMetadata _metadata(String id) {
return EntityMetadata( return EntityMetadata(
id: id, id: id,

View File

@ -79,6 +79,158 @@ void main() {
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue); expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
}); });
test('exercise repository round-trips configured steps', () async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-steps-1', now),
name: 'Burpee complexe',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
ExerciseStep(
id: 'step-1',
position: 0,
name: 'Planche',
type: ExerciseStepType.time,
defaultTargetValue: 20,
),
ExerciseStep(
id: 'step-2',
position: 1,
name: 'Sauts',
type: ExerciseStepType.reps,
defaultTargetValue: 8,
hasScore: true,
scoreLabel: 'Amplitude',
scoreUnit: 'pts',
defaultTargetScore: 0,
),
ExerciseStep(
id: 'step-3',
position: 2,
name: 'Sprint final',
type: ExerciseStepType.time,
defaultTargetValue: 10,
hasScore: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetScoreTimeMs: 12000,
),
],
);
await exerciseRepository.save(exercise);
final restored = await exerciseRepository.findById(exercise.metadata.id);
expect(restored, isNotNull);
expect(restored!.steps, hasLength(3));
expect(restored.steps.map((step) => step.name), [
'Planche',
'Sauts',
'Sprint final',
]);
expect(restored.steps[1].defaultTargetScore, 0);
expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
});
test(
'program exercise step snapshot is preserved when source exercise changes',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final source = Exercise(
metadata: _metadata('exercise-source-1', now),
name: 'Complexe original',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [
ExerciseStep(
id: 'step-original-1',
position: 0,
name: 'Phase originale A',
type: ExerciseStepType.time,
defaultTargetValue: 30,
),
ExerciseStep(
id: 'step-original-2',
position: 1,
name: 'Phase originale B',
type: ExerciseStepType.reps,
defaultTargetValue: 12,
),
],
);
await exerciseRepository.save(source);
await programRepository.save(
Program(
metadata: _metadata('program-steps-1', now),
name: 'Programme étapes',
defaultRestSeconds: 60,
),
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: templateRepository,
clock: _FakeClock(now),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final snapshot = await useCase.addExercise(
programId: 'program-steps-1',
exerciseId: source.metadata.id,
position: 0,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.reps},
);
await exerciseRepository.save(
source.copyWith(
metadata: _metadata(
source.metadata.id,
now.add(const Duration(minutes: 1)),
1,
),
steps: [
ExerciseStep(
id: 'step-updated-1',
position: 0,
name: 'Phase modifiée',
type: ExerciseStepType.time,
defaultTargetValue: 45,
),
],
),
);
final restoredProgram = await programRepository.findById(
'program-steps-1',
);
final restoredSnapshot = restoredProgram!.exercises.single;
final snapshotJson = snapshot.toSnapshotJson();
expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
expect(
restoredSnapshot.exerciseStepsSnapshot.first.name,
'Phase originale A',
);
expect(
restoredSnapshot.exerciseStepsSnapshot.last.type,
ExerciseStepType.reps,
);
expect(
(snapshotJson['exerciseStepsSnapshot'] as List).map(
(step) => (step as Map)['name'],
),
['Phase originale A', 'Phase originale B'],
);
},
);
test( test(
'removing a program exercise writes a soft delete change log entry', 'removing a program exercise writes a soft delete change log entry',
() async { () async {