diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 43334f1..214c6ec 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -14,3 +14,5 @@ - [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-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 diff --git a/.ideai/memory/gametime-architecture-exercise-steps.md b/.ideai/memory/gametime-architecture-exercise-steps.md new file mode 100644 index 0000000..82f91b8 --- /dev/null +++ b/.ideai/memory/gametime-architecture-exercise-steps.md @@ -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 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. \ No newline at end of file diff --git a/.ideai/memory/gametime-ux-exercise-steps.md b/.ideai/memory/gametime-ux-exercise-steps.md new file mode 100644 index 0000000..3387614 --- /dev/null +++ b/.ideai/memory/gametime-ux-exercise-steps.md @@ -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. \ No newline at end of file diff --git a/.ideai/sprints/4237adfd-91eb-45ff-9f32-6ce1daa39822/sprint.md b/.ideai/sprints/4237adfd-91eb-45ff-9f32-6ce1daa39822/sprint.md new file mode 100644 index 0000000..22b8648 --- /dev/null +++ b/.ideai/sprints/4237adfd-91eb-45ff-9f32-6ce1daa39822/sprint.md @@ -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 +--- diff --git a/.ideai/sprints/index.json b/.ideai/sprints/index.json index f6734fb..0d01b57 100644 --- a/.ideai/sprints/index.json +++ b/.ideai/sprints/index.json @@ -18,6 +18,15 @@ "status": "planned", "updatedAt": 1784449859008, "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 } ] } \ No newline at end of file diff --git a/.ideai/tickets/46/carnet.md b/.ideai/tickets/46/carnet.md index b069455..b59cdf9 100644 --- a/.ideai/tickets/46/carnet.md +++ b/.ideai/tickets/46/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#46" -version: 4 +version: 6 updatedBy: {"kind":"user"} -updatedAt: 1784380502263 +updatedAt: 1784482343900 --- diff --git a/.ideai/tickets/46/issue.md b/.ideai/tickets/46/issue.md index a5505fd..fc297b5 100644 --- a/.ideai/tickets/46/issue.md +++ b/.ideai/tickets/46/issue.md @@ -1,16 +1,16 @@ --- id: "dda499ac-7197-4321-bd2b-a303510b1bbe" number: 46 -title: "Créer un application headless serveur pour l'application" +title: "Créer un serveur headless pour l'application" status: "open" priority: "low" -sprint: null +sprint: "4237adfd-91eb-45ff-9f32-6ce1daa39822" links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} updatedBy: {"kind":"user"} createdAt: 1784379695135 -updatedAt: 1784380502263 -version: 4 +updatedAt: 1784482343900 +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é. \ No newline at end of file diff --git a/.ideai/tickets/53/carnet.md b/.ideai/tickets/53/carnet.md index f6c492d..d41e7a2 100644 --- a/.ideai/tickets/53/carnet.md +++ b/.ideai/tickets/53/carnet.md @@ -1,6 +1,9 @@ --- issueRef: "#53" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784412015509 +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +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. \ No newline at end of file diff --git a/.ideai/tickets/53/issue.md b/.ideai/tickets/53/issue.md index 53bb79d..82c964e 100644 --- a/.ideai/tickets/53/issue.md +++ b/.ideai/tickets/53/issue.md @@ -2,15 +2,15 @@ id: "d5fce252-f3c4-499f-ad0a-d3c81ea13305" number: 53 title: "[Server] Tests API, contrats OpenAPI et vérification d'intégration" -status: "open" +status: "qa" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#50","kind":"dependsOn"},{"target":"#51","kind":"dependsOn"},{"target":"#52","kind":"dependsOn"}] agentRefs: [] 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 -updatedAt: 1784412015509 -version: 1 +updatedAt: 1784469423570 +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. \ No newline at end of file diff --git a/.ideai/tickets/56/carnet.md b/.ideai/tickets/56/carnet.md new file mode 100644 index 0000000..b97fdad --- /dev/null +++ b/.ideai/tickets/56/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#56" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481516635 +--- diff --git a/.ideai/tickets/56/issue.md b/.ideai/tickets/56/issue.md new file mode 100644 index 0000000..a043bb9 --- /dev/null +++ b/.ideai/tickets/56/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/57/carnet.md b/.ideai/tickets/57/carnet.md new file mode 100644 index 0000000..24b7cb1 --- /dev/null +++ b/.ideai/tickets/57/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#57" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784482343900 +--- diff --git a/.ideai/tickets/57/issue.md b/.ideai/tickets/57/issue.md new file mode 100644 index 0000000..ee3c413 --- /dev/null +++ b/.ideai/tickets/57/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/58/carnet.md b/.ideai/tickets/58/carnet.md new file mode 100644 index 0000000..bb18644 --- /dev/null +++ b/.ideai/tickets/58/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#58" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481524706 +--- diff --git a/.ideai/tickets/58/issue.md b/.ideai/tickets/58/issue.md new file mode 100644 index 0000000..5e93c6d --- /dev/null +++ b/.ideai/tickets/58/issue.md @@ -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`. \ No newline at end of file diff --git a/.ideai/tickets/59/carnet.md b/.ideai/tickets/59/carnet.md new file mode 100644 index 0000000..fe8c16b --- /dev/null +++ b/.ideai/tickets/59/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#59" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481530478 +--- diff --git a/.ideai/tickets/59/issue.md b/.ideai/tickets/59/issue.md new file mode 100644 index 0000000..6e1b2ff --- /dev/null +++ b/.ideai/tickets/59/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/60/carnet.md b/.ideai/tickets/60/carnet.md new file mode 100644 index 0000000..60722bd --- /dev/null +++ b/.ideai/tickets/60/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#60" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481536357 +--- diff --git a/.ideai/tickets/60/issue.md b/.ideai/tickets/60/issue.md new file mode 100644 index 0000000..01d6e55 --- /dev/null +++ b/.ideai/tickets/60/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/61/carnet.md b/.ideai/tickets/61/carnet.md new file mode 100644 index 0000000..688926e --- /dev/null +++ b/.ideai/tickets/61/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#61" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481541650 +--- diff --git a/.ideai/tickets/61/issue.md b/.ideai/tickets/61/issue.md new file mode 100644 index 0000000..4a55d16 --- /dev/null +++ b/.ideai/tickets/61/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/62/carnet.md b/.ideai/tickets/62/carnet.md new file mode 100644 index 0000000..b9ea2f7 --- /dev/null +++ b/.ideai/tickets/62/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#62" +version: 1 +updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedAt: 1784481549150 +--- diff --git a/.ideai/tickets/62/issue.md b/.ideai/tickets/62/issue.md new file mode 100644 index 0000000..59a506e --- /dev/null +++ b/.ideai/tickets/62/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/63/carnet.md b/.ideai/tickets/63/carnet.md new file mode 100644 index 0000000..2033c21 --- /dev/null +++ b/.ideai/tickets/63/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#63" +version: 6 +updatedBy: {"kind":"user"} +updatedAt: 1784482343900 +--- diff --git a/.ideai/tickets/63/issue.md b/.ideai/tickets/63/issue.md new file mode 100644 index 0000000..49ba802 --- /dev/null +++ b/.ideai/tickets/63/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 5db1edc..79e9b2a 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 55 + "nextNumber": 64 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 5b18fec..7082eef 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -524,14 +524,14 @@ { "issueRef": "#46", "path": "46", - "title": "Créer un application headless serveur pour l'application", + "title": "Créer un serveur headless pour l'application", "status": "open", "priority": "low", - "sprint": null, + "sprint": "4237adfd-91eb-45ff-9f32-6ce1daa39822", "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784380502263 + "updatedAt": 1784482343900 }, { "issueRef": "#47", @@ -597,11 +597,11 @@ "issueRef": "#53", "path": "53", "title": "[Server] Tests API, contrats OpenAPI et vérification d'intégration", - "status": "open", + "status": "qa", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784412015509 + "updatedAt": 1784469423570 }, { "issueRef": "#54", @@ -614,6 +614,102 @@ "57695b92-24d0-4876-837c-76116e70a6ae" ], "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 } ] } \ No newline at end of file diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index e3d9fa3..f612def 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -37,7 +37,9 @@ final class ExerciseUseCases { int? defaultTargetReps, double? defaultTargetScore, int? defaultTargetScoreTimeMs, + List steps = const [], }) async { + _validateExerciseSteps(steps); _validateExerciseDefaultTargets( hasTimeMeasure: hasTimeMeasure, hasRepsMeasure: hasRepsMeasure, @@ -97,6 +99,7 @@ final class ExerciseUseCases { Object? defaultTargetReps = _useCaseUnchanged, Object? defaultTargetScore = _useCaseUnchanged, Object? defaultTargetScoreTimeMs = _useCaseUnchanged, + Object? steps = _useCaseUnchanged, }) async { final exercise = await repository.findById(id); if (exercise == null) { @@ -126,6 +129,10 @@ final class ExerciseUseCases { defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, ); + final resolvedSteps = steps == _useCaseUnchanged + ? exercise.steps + : steps as List; + _validateExerciseSteps(resolvedSteps); final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged ? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds) : imageMediaIds as List; @@ -152,6 +159,7 @@ final class ExerciseUseCases { defaultTargetReps: resolvedDefaultTargetReps, defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, + steps: resolvedSteps, ); await repository.save(updated); return updated; @@ -475,6 +483,7 @@ final class ProgramUseCases { exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot, + exerciseStepsSnapshot: input.exerciseStepsSnapshot, exerciseArchivedSnapshot: input.exerciseArchivedSnapshot, availableTimeSnapshot: input.availableTimeSnapshot, availableRepsSnapshot: input.availableRepsSnapshot, @@ -592,6 +601,7 @@ final class ProgramExerciseConfig { this.exerciseImageMediaIdSnapshot, this.exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, + this.exerciseStepsSnapshot = const [], this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, required this.availableRepsSnapshot, @@ -615,6 +625,7 @@ final class ProgramExerciseConfig { final String? exerciseImageMediaIdSnapshot; final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; + final List exerciseStepsSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; final bool availableRepsSnapshot; @@ -1560,6 +1571,7 @@ List _repositionProgramExercises( exercises[index].exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exercises[index].exerciseVideoMediaIdSnapshot, + exerciseStepsSnapshot: exercises[index].exerciseStepsSnapshot, exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot, availableTimeSnapshot: exercises[index].availableTimeSnapshot, availableRepsSnapshot: exercises[index].availableRepsSnapshot, @@ -1625,6 +1637,82 @@ bool _sameImageSet(List left, List right) { return left.toSet().containsAll(right) && right.toSet().containsAll(left); } +void _validateExerciseSteps(List 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({ required bool hasTimeMeasure, required bool hasRepsMeasure, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index f23e779..a8e8501 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score } enum ScoreInputMode { manual, stopwatch } +enum ExerciseStepType { time, reps } + enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } @@ -138,6 +140,7 @@ final class Exercise { this.defaultTargetReps, this.defaultTargetScore, this.defaultTargetScoreTimeMs, + List steps = const [], this.archivedAt, }) : name = _nonBlank(name, 'Exercise name'), imageMediaIds = _resolvedExerciseImageMediaIds( @@ -147,7 +150,8 @@ final class Exercise { iconMediaId = _validatedIconMediaId( iconMediaId, _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), - ) { + ), + steps = _validatedExerciseSteps(steps) { _requireAtLeastOneMeasure( hasTime: hasTimeMeasure, hasReps: hasRepsMeasure, @@ -193,6 +197,7 @@ final class Exercise { final int? defaultTargetReps; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; + final List steps; final DateTime? archivedAt; Set get availableMeasures => { @@ -226,6 +231,7 @@ final class Exercise { Object? defaultTargetReps = _unchanged, Object? defaultTargetScore = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged, + Object? steps = _unchanged, Object? archivedAt = _unchanged, }) { final resolvedImageMediaIds = imageMediaIds == _unchanged @@ -272,6 +278,7 @@ final class Exercise { defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged ? this.defaultTargetScoreTimeMs : defaultTargetScoreTimeMs as int?, + steps: steps == _unchanged ? this.steps : steps as List, archivedAt: archivedAt == _unchanged ? this.archivedAt : 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 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(); final class Program { @@ -322,6 +383,7 @@ final class ProgramExercise { this.exerciseImageMediaIdSnapshot, List exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, + List exerciseStepsSnapshot = const [], this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, required this.availableRepsSnapshot, @@ -347,7 +409,8 @@ final class ProgramExercise { exerciseImageMediaIdSnapshot != null ? [exerciseImageMediaIdSnapshot] : exerciseImageMediaIdsSnapshot, - ) { + ), + exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) { _requireNonNegative(position, 'Position'); _requirePositive(setsCount, 'Sets count'); _requireAtLeastOneMeasure( @@ -386,6 +449,7 @@ final class ProgramExercise { final String? exerciseImageMediaIdSnapshot; final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; + final List exerciseStepsSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; final bool availableRepsSnapshot; @@ -432,6 +496,7 @@ final class ProgramExercise { exerciseImageMediaIdSnapshot: exercise.imageMediaId, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, + exerciseStepsSnapshot: exercise.steps, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, @@ -461,6 +526,9 @@ final class ProgramExercise { 'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot, + 'exerciseStepsSnapshot': exerciseStepsSnapshot + .map((step) => step.toSnapshotJson()) + .toList(), 'exerciseArchivedSnapshot': exerciseArchivedSnapshot, 'availableTimeSnapshot': availableTimeSnapshot, 'availableRepsSnapshot': availableRepsSnapshot, @@ -998,6 +1066,24 @@ String? _validatedIconMediaId(String? iconMediaId, List imageMediaIds) { return normalized; } +List _validatedExerciseSteps(List 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({ required bool hasTime, 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({ required ScoreInputMode scoreInputMode, required double? actualScore, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index fb1f104..65328d8 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -14,6 +14,7 @@ part 'app_database.g.dart'; ChangeLogEntries, Exercises, ExerciseImages, + ExerciseSteps, MediaAssets, ProgramExercises, Programs, @@ -37,7 +38,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration { @@ -73,6 +74,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 7) { await _migrateToSchema7(); } + if (from < 8) { + await _migrateToSchema8(migrator); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -109,6 +113,10 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_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( 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'ON workout_template_programs (workout_template_id)', @@ -162,6 +170,7 @@ const _syncableTableNames = [ 'active_workout_sessions', 'exercises', 'exercise_images', + 'exercise_steps', 'media_assets', 'program_exercises', 'programs', @@ -270,4 +279,12 @@ extension on AppDatabase { 'REFERENCES media_assets(id)', ); } + + Future _migrateToSchema8(Migrator migrator) async { + await migrator.createTable(exerciseSteps); + await customStatement( + 'ALTER TABLE program_exercises ADD COLUMN ' + 'exercise_steps_snapshot_json TEXT', + ); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 6415a96..5bf1c04 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -10199,6 +10199,1286 @@ class ExerciseImagesCompanion extends UpdateCompanion { } } +class $ExerciseStepsTable extends ExerciseSteps + with TableInfo<$ExerciseStepsTable, ExerciseStep> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ExerciseStepsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _deletedAtMeta = const VerificationMeta( + 'deletedAt', + ); + @override + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _schemaVersionMeta = const VerificationMeta( + 'schemaVersion', + ); + @override + late final GeneratedColumn schemaVersion = GeneratedColumn( + 'schema_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _syncStateMeta = const VerificationMeta( + 'syncState', + ); + @override + late final GeneratedColumn syncState = GeneratedColumn( + 'sync_state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL CHECK (sync_state IN (\'localOnly\', \'dirty\', \'synced\', \'deleted\'))', + ); + static const VerificationMeta _localRevisionMeta = const VerificationMeta( + 'localRevision', + ); + @override + late final GeneratedColumn localRevision = GeneratedColumn( + 'local_revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (local_revision >= 0)', + ); + static const VerificationMeta _originDeviceIdMeta = const VerificationMeta( + 'originDeviceId', + ); + @override + late final GeneratedColumn originDeviceId = GeneratedColumn( + 'origin_device_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _futureOwnerProfileIdMeta = + const VerificationMeta('futureOwnerProfileId'); + @override + late final GeneratedColumn futureOwnerProfileId = + GeneratedColumn( + 'future_owner_profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSyncedAtMeta = const VerificationMeta( + 'lastSyncedAt', + ); + @override + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteRevisionMeta = const VerificationMeta( + 'remoteRevision', + ); + @override + late final GeneratedColumn remoteRevision = GeneratedColumn( + 'remote_revision', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _exerciseIdMeta = const VerificationMeta( + 'exerciseId', + ); + @override + late final GeneratedColumn exerciseId = GeneratedColumn( + 'exercise_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES exercises (id)', + ), + ); + static const VerificationMeta _positionMeta = const VerificationMeta( + 'position', + ); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _defaultTargetValueMeta = + const VerificationMeta('defaultTargetValue'); + @override + late final GeneratedColumn defaultTargetValue = GeneratedColumn( + 'default_target_value', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _hasScoreMeta = const VerificationMeta( + 'hasScore', + ); + @override + late final GeneratedColumn hasScore = GeneratedColumn( + 'has_score', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_score" IN (0, 1))', + ), + ); + static const VerificationMeta _scoreInputModeMeta = const VerificationMeta( + 'scoreInputMode', + ); + @override + late final GeneratedColumn scoreInputMode = GeneratedColumn( + 'score_input_mode', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _scoreLabelMeta = const VerificationMeta( + 'scoreLabel', + ); + @override + late final GeneratedColumn scoreLabel = GeneratedColumn( + 'score_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _scoreUnitMeta = const VerificationMeta( + 'scoreUnit', + ); + @override + late final GeneratedColumn scoreUnit = GeneratedColumn( + 'score_unit', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreMeta = + const VerificationMeta('defaultTargetScore'); + @override + late final GeneratedColumn defaultTargetScore = + GeneratedColumn( + 'default_target_score', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _defaultTargetScoreTimeMsMeta = + const VerificationMeta('defaultTargetScoreTimeMs'); + @override + late final GeneratedColumn defaultTargetScoreTimeMs = + GeneratedColumn( + 'default_target_score_time_ms', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + exerciseId, + position, + name, + type, + defaultTargetValue, + hasScore, + scoreInputMode, + scoreLabel, + scoreUnit, + defaultTargetScore, + defaultTargetScoreTimeMs, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'exercise_steps'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } + if (data.containsKey('schema_version')) { + context.handle( + _schemaVersionMeta, + schemaVersion.isAcceptableOrUnknown( + data['schema_version']!, + _schemaVersionMeta, + ), + ); + } + if (data.containsKey('sync_state')) { + context.handle( + _syncStateMeta, + syncState.isAcceptableOrUnknown(data['sync_state']!, _syncStateMeta), + ); + } else if (isInserting) { + context.missing(_syncStateMeta); + } + if (data.containsKey('local_revision')) { + context.handle( + _localRevisionMeta, + localRevision.isAcceptableOrUnknown( + data['local_revision']!, + _localRevisionMeta, + ), + ); + } else if (isInserting) { + context.missing(_localRevisionMeta); + } + if (data.containsKey('origin_device_id')) { + context.handle( + _originDeviceIdMeta, + originDeviceId.isAcceptableOrUnknown( + data['origin_device_id']!, + _originDeviceIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_originDeviceIdMeta); + } + if (data.containsKey('future_owner_profile_id')) { + context.handle( + _futureOwnerProfileIdMeta, + futureOwnerProfileId.isAcceptableOrUnknown( + data['future_owner_profile_id']!, + _futureOwnerProfileIdMeta, + ), + ); + } + if (data.containsKey('last_synced_at')) { + context.handle( + _lastSyncedAtMeta, + lastSyncedAt.isAcceptableOrUnknown( + data['last_synced_at']!, + _lastSyncedAtMeta, + ), + ); + } + if (data.containsKey('remote_revision')) { + context.handle( + _remoteRevisionMeta, + remoteRevision.isAcceptableOrUnknown( + data['remote_revision']!, + _remoteRevisionMeta, + ), + ); + } + if (data.containsKey('exercise_id')) { + context.handle( + _exerciseIdMeta, + exerciseId.isAcceptableOrUnknown(data['exercise_id']!, _exerciseIdMeta), + ); + } else if (isInserting) { + context.missing(_exerciseIdMeta); + } + if (data.containsKey('position')) { + context.handle( + _positionMeta, + position.isAcceptableOrUnknown(data['position']!, _positionMeta), + ); + } else if (isInserting) { + context.missing(_positionMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, + type.isAcceptableOrUnknown(data['type']!, _typeMeta), + ); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('default_target_value')) { + context.handle( + _defaultTargetValueMeta, + defaultTargetValue.isAcceptableOrUnknown( + data['default_target_value']!, + _defaultTargetValueMeta, + ), + ); + } else if (isInserting) { + context.missing(_defaultTargetValueMeta); + } + if (data.containsKey('has_score')) { + context.handle( + _hasScoreMeta, + hasScore.isAcceptableOrUnknown(data['has_score']!, _hasScoreMeta), + ); + } else if (isInserting) { + context.missing(_hasScoreMeta); + } + if (data.containsKey('score_input_mode')) { + context.handle( + _scoreInputModeMeta, + scoreInputMode.isAcceptableOrUnknown( + data['score_input_mode']!, + _scoreInputModeMeta, + ), + ); + } + if (data.containsKey('score_label')) { + context.handle( + _scoreLabelMeta, + scoreLabel.isAcceptableOrUnknown(data['score_label']!, _scoreLabelMeta), + ); + } + if (data.containsKey('score_unit')) { + context.handle( + _scoreUnitMeta, + scoreUnit.isAcceptableOrUnknown(data['score_unit']!, _scoreUnitMeta), + ); + } + if (data.containsKey('default_target_score')) { + context.handle( + _defaultTargetScoreMeta, + defaultTargetScore.isAcceptableOrUnknown( + data['default_target_score']!, + _defaultTargetScoreMeta, + ), + ); + } + if (data.containsKey('default_target_score_time_ms')) { + context.handle( + _defaultTargetScoreTimeMsMeta, + defaultTargetScoreTimeMs.isAcceptableOrUnknown( + data['default_target_score_time_ms']!, + _defaultTargetScoreTimeMsMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ExerciseStep map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ExerciseStep( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + schemaVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}schema_version'], + )!, + syncState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sync_state'], + )!, + localRevision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}local_revision'], + )!, + originDeviceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin_device_id'], + )!, + futureOwnerProfileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}future_owner_profile_id'], + ), + lastSyncedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_synced_at'], + ), + remoteRevision: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}remote_revision'], + ), + exerciseId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exercise_id'], + )!, + position: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}position'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}type'], + )!, + defaultTargetValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_value'], + )!, + hasScore: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_score'], + )!, + scoreInputMode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}score_input_mode'], + ), + scoreLabel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}score_label'], + ), + scoreUnit: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}score_unit'], + ), + defaultTargetScore: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}default_target_score'], + ), + defaultTargetScoreTimeMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}default_target_score_time_ms'], + ), + ); + } + + @override + $ExerciseStepsTable createAlias(String alias) { + return $ExerciseStepsTable(attachedDatabase, alias); + } +} + +class ExerciseStep extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final int schemaVersion; + final String syncState; + final int localRevision; + final String originDeviceId; + final String? futureOwnerProfileId; + final DateTime? lastSyncedAt; + final String? remoteRevision; + final String exerciseId; + final int position; + final String name; + final String type; + final int defaultTargetValue; + final bool hasScore; + final String? scoreInputMode; + final String? scoreLabel; + final String? scoreUnit; + final double? defaultTargetScore; + final int? defaultTargetScoreTimeMs; + const ExerciseStep({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.schemaVersion, + required this.syncState, + required this.localRevision, + required this.originDeviceId, + this.futureOwnerProfileId, + this.lastSyncedAt, + this.remoteRevision, + required this.exerciseId, + required this.position, + required this.name, + required this.type, + required this.defaultTargetValue, + required this.hasScore, + this.scoreInputMode, + this.scoreLabel, + this.scoreUnit, + this.defaultTargetScore, + this.defaultTargetScoreTimeMs, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['schema_version'] = Variable(schemaVersion); + map['sync_state'] = Variable(syncState); + map['local_revision'] = Variable(localRevision); + map['origin_device_id'] = Variable(originDeviceId); + if (!nullToAbsent || futureOwnerProfileId != null) { + map['future_owner_profile_id'] = Variable(futureOwnerProfileId); + } + if (!nullToAbsent || lastSyncedAt != null) { + map['last_synced_at'] = Variable(lastSyncedAt); + } + if (!nullToAbsent || remoteRevision != null) { + map['remote_revision'] = Variable(remoteRevision); + } + map['exercise_id'] = Variable(exerciseId); + map['position'] = Variable(position); + map['name'] = Variable(name); + map['type'] = Variable(type); + map['default_target_value'] = Variable(defaultTargetValue); + map['has_score'] = Variable(hasScore); + if (!nullToAbsent || scoreInputMode != null) { + map['score_input_mode'] = Variable(scoreInputMode); + } + if (!nullToAbsent || scoreLabel != null) { + map['score_label'] = Variable(scoreLabel); + } + if (!nullToAbsent || scoreUnit != null) { + map['score_unit'] = Variable(scoreUnit); + } + if (!nullToAbsent || defaultTargetScore != null) { + map['default_target_score'] = Variable(defaultTargetScore); + } + if (!nullToAbsent || defaultTargetScoreTimeMs != null) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs, + ); + } + return map; + } + + ExerciseStepsCompanion toCompanion(bool nullToAbsent) { + return ExerciseStepsCompanion( + id: Value(id), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + deletedAt: deletedAt == null && nullToAbsent + ? const Value.absent() + : Value(deletedAt), + schemaVersion: Value(schemaVersion), + syncState: Value(syncState), + localRevision: Value(localRevision), + originDeviceId: Value(originDeviceId), + futureOwnerProfileId: futureOwnerProfileId == null && nullToAbsent + ? const Value.absent() + : Value(futureOwnerProfileId), + lastSyncedAt: lastSyncedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastSyncedAt), + remoteRevision: remoteRevision == null && nullToAbsent + ? const Value.absent() + : Value(remoteRevision), + exerciseId: Value(exerciseId), + position: Value(position), + name: Value(name), + type: Value(type), + defaultTargetValue: Value(defaultTargetValue), + hasScore: Value(hasScore), + scoreInputMode: scoreInputMode == null && nullToAbsent + ? const Value.absent() + : Value(scoreInputMode), + scoreLabel: scoreLabel == null && nullToAbsent + ? const Value.absent() + : Value(scoreLabel), + scoreUnit: scoreUnit == null && nullToAbsent + ? const Value.absent() + : Value(scoreUnit), + defaultTargetScore: defaultTargetScore == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScore), + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == null && nullToAbsent + ? const Value.absent() + : Value(defaultTargetScoreTimeMs), + ); + } + + factory ExerciseStep.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ExerciseStep( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + schemaVersion: serializer.fromJson(json['schemaVersion']), + syncState: serializer.fromJson(json['syncState']), + localRevision: serializer.fromJson(json['localRevision']), + originDeviceId: serializer.fromJson(json['originDeviceId']), + futureOwnerProfileId: serializer.fromJson( + json['futureOwnerProfileId'], + ), + lastSyncedAt: serializer.fromJson(json['lastSyncedAt']), + remoteRevision: serializer.fromJson(json['remoteRevision']), + exerciseId: serializer.fromJson(json['exerciseId']), + position: serializer.fromJson(json['position']), + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + defaultTargetValue: serializer.fromJson(json['defaultTargetValue']), + hasScore: serializer.fromJson(json['hasScore']), + scoreInputMode: serializer.fromJson(json['scoreInputMode']), + scoreLabel: serializer.fromJson(json['scoreLabel']), + scoreUnit: serializer.fromJson(json['scoreUnit']), + defaultTargetScore: serializer.fromJson( + json['defaultTargetScore'], + ), + defaultTargetScoreTimeMs: serializer.fromJson( + json['defaultTargetScoreTimeMs'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'schemaVersion': serializer.toJson(schemaVersion), + 'syncState': serializer.toJson(syncState), + 'localRevision': serializer.toJson(localRevision), + 'originDeviceId': serializer.toJson(originDeviceId), + 'futureOwnerProfileId': serializer.toJson(futureOwnerProfileId), + 'lastSyncedAt': serializer.toJson(lastSyncedAt), + 'remoteRevision': serializer.toJson(remoteRevision), + 'exerciseId': serializer.toJson(exerciseId), + 'position': serializer.toJson(position), + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'defaultTargetValue': serializer.toJson(defaultTargetValue), + 'hasScore': serializer.toJson(hasScore), + 'scoreInputMode': serializer.toJson(scoreInputMode), + 'scoreLabel': serializer.toJson(scoreLabel), + 'scoreUnit': serializer.toJson(scoreUnit), + 'defaultTargetScore': serializer.toJson(defaultTargetScore), + 'defaultTargetScoreTimeMs': serializer.toJson( + defaultTargetScoreTimeMs, + ), + }; + } + + ExerciseStep copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + int? schemaVersion, + String? syncState, + int? localRevision, + String? originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + String? exerciseId, + int? position, + String? name, + String? type, + int? defaultTargetValue, + bool? hasScore, + Value scoreInputMode = const Value.absent(), + Value scoreLabel = const Value.absent(), + Value scoreUnit = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), + }) => ExerciseStep( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId.present + ? futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt.present ? lastSyncedAt.value : this.lastSyncedAt, + remoteRevision: remoteRevision.present + ? remoteRevision.value + : this.remoteRevision, + exerciseId: exerciseId ?? this.exerciseId, + position: position ?? this.position, + name: name ?? this.name, + type: type ?? this.type, + defaultTargetValue: defaultTargetValue ?? this.defaultTargetValue, + hasScore: hasScore ?? this.hasScore, + scoreInputMode: scoreInputMode.present + ? scoreInputMode.value + : this.scoreInputMode, + scoreLabel: scoreLabel.present ? scoreLabel.value : this.scoreLabel, + scoreUnit: scoreUnit.present ? scoreUnit.value : this.scoreUnit, + defaultTargetScore: defaultTargetScore.present + ? defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs.present + ? defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, + ); + ExerciseStep copyWithCompanion(ExerciseStepsCompanion data) { + return ExerciseStep( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + schemaVersion: data.schemaVersion.present + ? data.schemaVersion.value + : this.schemaVersion, + syncState: data.syncState.present ? data.syncState.value : this.syncState, + localRevision: data.localRevision.present + ? data.localRevision.value + : this.localRevision, + originDeviceId: data.originDeviceId.present + ? data.originDeviceId.value + : this.originDeviceId, + futureOwnerProfileId: data.futureOwnerProfileId.present + ? data.futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: data.lastSyncedAt.present + ? data.lastSyncedAt.value + : this.lastSyncedAt, + remoteRevision: data.remoteRevision.present + ? data.remoteRevision.value + : this.remoteRevision, + exerciseId: data.exerciseId.present + ? data.exerciseId.value + : this.exerciseId, + position: data.position.present ? data.position.value : this.position, + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + defaultTargetValue: data.defaultTargetValue.present + ? data.defaultTargetValue.value + : this.defaultTargetValue, + hasScore: data.hasScore.present ? data.hasScore.value : this.hasScore, + scoreInputMode: data.scoreInputMode.present + ? data.scoreInputMode.value + : this.scoreInputMode, + scoreLabel: data.scoreLabel.present + ? data.scoreLabel.value + : this.scoreLabel, + scoreUnit: data.scoreUnit.present ? data.scoreUnit.value : this.scoreUnit, + defaultTargetScore: data.defaultTargetScore.present + ? data.defaultTargetScore.value + : this.defaultTargetScore, + defaultTargetScoreTimeMs: data.defaultTargetScoreTimeMs.present + ? data.defaultTargetScoreTimeMs.value + : this.defaultTargetScoreTimeMs, + ); + } + + @override + String toString() { + return (StringBuffer('ExerciseStep(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('exerciseId: $exerciseId, ') + ..write('position: $position, ') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('defaultTargetValue: $defaultTargetValue, ') + ..write('hasScore: $hasScore, ') + ..write('scoreInputMode: $scoreInputMode, ') + ..write('scoreLabel: $scoreLabel, ') + ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + exerciseId, + position, + name, + type, + defaultTargetValue, + hasScore, + scoreInputMode, + scoreLabel, + scoreUnit, + defaultTargetScore, + defaultTargetScoreTimeMs, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ExerciseStep && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.schemaVersion == this.schemaVersion && + other.syncState == this.syncState && + other.localRevision == this.localRevision && + other.originDeviceId == this.originDeviceId && + other.futureOwnerProfileId == this.futureOwnerProfileId && + other.lastSyncedAt == this.lastSyncedAt && + other.remoteRevision == this.remoteRevision && + other.exerciseId == this.exerciseId && + other.position == this.position && + other.name == this.name && + other.type == this.type && + other.defaultTargetValue == this.defaultTargetValue && + other.hasScore == this.hasScore && + other.scoreInputMode == this.scoreInputMode && + other.scoreLabel == this.scoreLabel && + other.scoreUnit == this.scoreUnit && + other.defaultTargetScore == this.defaultTargetScore && + other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs); +} + +class ExerciseStepsCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value schemaVersion; + final Value syncState; + final Value localRevision; + final Value originDeviceId; + final Value futureOwnerProfileId; + final Value lastSyncedAt; + final Value remoteRevision; + final Value exerciseId; + final Value position; + final Value name; + final Value type; + final Value defaultTargetValue; + final Value hasScore; + final Value scoreInputMode; + final Value scoreLabel; + final Value scoreUnit; + final Value defaultTargetScore; + final Value defaultTargetScoreTimeMs; + final Value rowid; + const ExerciseStepsCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + this.syncState = const Value.absent(), + this.localRevision = const Value.absent(), + this.originDeviceId = const Value.absent(), + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + this.exerciseId = const Value.absent(), + this.position = const Value.absent(), + this.name = const Value.absent(), + this.type = const Value.absent(), + this.defaultTargetValue = const Value.absent(), + this.hasScore = const Value.absent(), + this.scoreInputMode = const Value.absent(), + this.scoreLabel = const Value.absent(), + this.scoreUnit = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), + this.rowid = const Value.absent(), + }); + ExerciseStepsCompanion.insert({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + required String exerciseId, + required int position, + required String name, + required String type, + required int defaultTargetValue, + required bool hasScore, + this.scoreInputMode = const Value.absent(), + this.scoreLabel = const Value.absent(), + this.scoreUnit = const Value.absent(), + this.defaultTargetScore = const Value.absent(), + this.defaultTargetScoreTimeMs = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + syncState = Value(syncState), + localRevision = Value(localRevision), + originDeviceId = Value(originDeviceId), + exerciseId = Value(exerciseId), + position = Value(position), + name = Value(name), + type = Value(type), + defaultTargetValue = Value(defaultTargetValue), + hasScore = Value(hasScore); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? schemaVersion, + Expression? syncState, + Expression? localRevision, + Expression? originDeviceId, + Expression? futureOwnerProfileId, + Expression? lastSyncedAt, + Expression? remoteRevision, + Expression? exerciseId, + Expression? position, + Expression? name, + Expression? type, + Expression? defaultTargetValue, + Expression? hasScore, + Expression? scoreInputMode, + Expression? scoreLabel, + Expression? scoreUnit, + Expression? defaultTargetScore, + Expression? defaultTargetScoreTimeMs, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (schemaVersion != null) 'schema_version': schemaVersion, + if (syncState != null) 'sync_state': syncState, + if (localRevision != null) 'local_revision': localRevision, + if (originDeviceId != null) 'origin_device_id': originDeviceId, + if (futureOwnerProfileId != null) + 'future_owner_profile_id': futureOwnerProfileId, + if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt, + if (remoteRevision != null) 'remote_revision': remoteRevision, + if (exerciseId != null) 'exercise_id': exerciseId, + if (position != null) 'position': position, + if (name != null) 'name': name, + if (type != null) 'type': type, + if (defaultTargetValue != null) + 'default_target_value': defaultTargetValue, + if (hasScore != null) 'has_score': hasScore, + if (scoreInputMode != null) 'score_input_mode': scoreInputMode, + if (scoreLabel != null) 'score_label': scoreLabel, + if (scoreUnit != null) 'score_unit': scoreUnit, + if (defaultTargetScore != null) + 'default_target_score': defaultTargetScore, + if (defaultTargetScoreTimeMs != null) + 'default_target_score_time_ms': defaultTargetScoreTimeMs, + if (rowid != null) 'rowid': rowid, + }); + } + + ExerciseStepsCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? schemaVersion, + Value? syncState, + Value? localRevision, + Value? originDeviceId, + Value? futureOwnerProfileId, + Value? lastSyncedAt, + Value? remoteRevision, + Value? exerciseId, + Value? position, + Value? name, + Value? type, + Value? defaultTargetValue, + Value? hasScore, + Value? scoreInputMode, + Value? scoreLabel, + Value? scoreUnit, + Value? defaultTargetScore, + Value? defaultTargetScoreTimeMs, + Value? rowid, + }) { + return ExerciseStepsCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId ?? this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + remoteRevision: remoteRevision ?? this.remoteRevision, + exerciseId: exerciseId ?? this.exerciseId, + position: position ?? this.position, + name: name ?? this.name, + type: type ?? this.type, + defaultTargetValue: defaultTargetValue ?? this.defaultTargetValue, + hasScore: hasScore ?? this.hasScore, + scoreInputMode: scoreInputMode ?? this.scoreInputMode, + scoreLabel: scoreLabel ?? this.scoreLabel, + scoreUnit: scoreUnit ?? this.scoreUnit, + defaultTargetScore: defaultTargetScore ?? this.defaultTargetScore, + defaultTargetScoreTimeMs: + defaultTargetScoreTimeMs ?? this.defaultTargetScoreTimeMs, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (schemaVersion.present) { + map['schema_version'] = Variable(schemaVersion.value); + } + if (syncState.present) { + map['sync_state'] = Variable(syncState.value); + } + if (localRevision.present) { + map['local_revision'] = Variable(localRevision.value); + } + if (originDeviceId.present) { + map['origin_device_id'] = Variable(originDeviceId.value); + } + if (futureOwnerProfileId.present) { + map['future_owner_profile_id'] = Variable( + futureOwnerProfileId.value, + ); + } + if (lastSyncedAt.present) { + map['last_synced_at'] = Variable(lastSyncedAt.value); + } + if (remoteRevision.present) { + map['remote_revision'] = Variable(remoteRevision.value); + } + if (exerciseId.present) { + map['exercise_id'] = Variable(exerciseId.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (defaultTargetValue.present) { + map['default_target_value'] = Variable(defaultTargetValue.value); + } + if (hasScore.present) { + map['has_score'] = Variable(hasScore.value); + } + if (scoreInputMode.present) { + map['score_input_mode'] = Variable(scoreInputMode.value); + } + if (scoreLabel.present) { + map['score_label'] = Variable(scoreLabel.value); + } + if (scoreUnit.present) { + map['score_unit'] = Variable(scoreUnit.value); + } + if (defaultTargetScore.present) { + map['default_target_score'] = Variable(defaultTargetScore.value); + } + if (defaultTargetScoreTimeMs.present) { + map['default_target_score_time_ms'] = Variable( + defaultTargetScoreTimeMs.value, + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ExerciseStepsCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('exerciseId: $exerciseId, ') + ..write('position: $position, ') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('defaultTargetValue: $defaultTargetValue, ') + ..write('hasScore: $hasScore, ') + ..write('scoreInputMode: $scoreInputMode, ') + ..write('scoreLabel: $scoreLabel, ') + ..write('scoreUnit: $scoreUnit, ') + ..write('defaultTargetScore: $defaultTargetScore, ') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { @override final GeneratedDatabase attachedDatabase; @@ -11216,6 +12496,17 @@ class $ProgramExercisesTable extends ProgramExercises type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _exerciseStepsSnapshotJsonMeta = + const VerificationMeta('exerciseStepsSnapshotJson'); + @override + late final GeneratedColumn exerciseStepsSnapshotJson = + GeneratedColumn( + 'exercise_steps_snapshot_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _exerciseVideoMediaIdSnapshotMeta = const VerificationMeta('exerciseVideoMediaIdSnapshot'); @override @@ -11449,6 +12740,7 @@ class $ProgramExercisesTable extends ProgramExercises exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot, availableTimeSnapshot, @@ -11635,6 +12927,15 @@ class $ProgramExercisesTable extends ProgramExercises ), ); } + if (data.containsKey('exercise_steps_snapshot_json')) { + context.handle( + _exerciseStepsSnapshotJsonMeta, + exerciseStepsSnapshotJson.isAcceptableOrUnknown( + data['exercise_steps_snapshot_json']!, + _exerciseStepsSnapshotJsonMeta, + ), + ); + } if (data.containsKey('exercise_video_media_id_snapshot')) { context.handle( _exerciseVideoMediaIdSnapshotMeta, @@ -11877,6 +13178,10 @@ class $ProgramExercisesTable extends ProgramExercises DriftSqlType.string, data['${effectivePrefix}exercise_image_media_ids_snapshot_json'], ), + exerciseStepsSnapshotJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exercise_steps_snapshot_json'], + ), exerciseVideoMediaIdSnapshot: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}exercise_video_media_id_snapshot'], @@ -11973,6 +13278,7 @@ class ProgramExercise extends DataClass implements Insertable { final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; final String? exerciseImageMediaIdsSnapshotJson; + final String? exerciseStepsSnapshotJson; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; @@ -12009,6 +13315,7 @@ class ProgramExercise extends DataClass implements Insertable { this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, this.exerciseImageMediaIdsSnapshotJson, + this.exerciseStepsSnapshotJson, this.exerciseVideoMediaIdSnapshot, required this.exerciseArchivedSnapshot, required this.availableTimeSnapshot, @@ -12070,6 +13377,11 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdsSnapshotJson, ); } + if (!nullToAbsent || exerciseStepsSnapshotJson != null) { + map['exercise_steps_snapshot_json'] = Variable( + exerciseStepsSnapshotJson, + ); + } if (!nullToAbsent || exerciseVideoMediaIdSnapshot != null) { map['exercise_video_media_id_snapshot'] = Variable( exerciseVideoMediaIdSnapshot, @@ -12149,6 +13461,10 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdsSnapshotJson == null && nullToAbsent ? const Value.absent() : Value(exerciseImageMediaIdsSnapshotJson), + exerciseStepsSnapshotJson: + exerciseStepsSnapshotJson == null && nullToAbsent + ? const Value.absent() + : Value(exerciseStepsSnapshotJson), exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot == null && nullToAbsent ? const Value.absent() @@ -12220,6 +13536,9 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdsSnapshotJson: serializer.fromJson( json['exerciseImageMediaIdsSnapshotJson'], ), + exerciseStepsSnapshotJson: serializer.fromJson( + json['exerciseStepsSnapshotJson'], + ), exerciseVideoMediaIdSnapshot: serializer.fromJson( json['exerciseVideoMediaIdSnapshot'], ), @@ -12285,6 +13604,9 @@ class ProgramExercise extends DataClass implements Insertable { 'exerciseImageMediaIdsSnapshotJson': serializer.toJson( exerciseImageMediaIdsSnapshotJson, ), + 'exerciseStepsSnapshotJson': serializer.toJson( + exerciseStepsSnapshotJson, + ), 'exerciseVideoMediaIdSnapshot': serializer.toJson( exerciseVideoMediaIdSnapshot, ), @@ -12330,6 +13652,7 @@ class ProgramExercise extends DataClass implements Insertable { Value exerciseDescriptionSnapshot = const Value.absent(), Value exerciseImageMediaIdSnapshot = const Value.absent(), Value exerciseImageMediaIdsSnapshotJson = const Value.absent(), + Value exerciseStepsSnapshotJson = const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), bool? exerciseArchivedSnapshot, bool? availableTimeSnapshot, @@ -12378,6 +13701,9 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdsSnapshotJson: exerciseImageMediaIdsSnapshotJson.present ? exerciseImageMediaIdsSnapshotJson.value : this.exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson: exerciseStepsSnapshotJson.present + ? exerciseStepsSnapshotJson.value + : this.exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot.present ? exerciseVideoMediaIdSnapshot.value : this.exerciseVideoMediaIdSnapshot, @@ -12454,6 +13780,9 @@ class ProgramExercise extends DataClass implements Insertable { data.exerciseImageMediaIdsSnapshotJson.present ? data.exerciseImageMediaIdsSnapshotJson.value : this.exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson: data.exerciseStepsSnapshotJson.present + ? data.exerciseStepsSnapshotJson.value + : this.exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot: data.exerciseVideoMediaIdSnapshot.present ? data.exerciseVideoMediaIdSnapshot.value : this.exerciseVideoMediaIdSnapshot, @@ -12531,6 +13860,7 @@ class ProgramExercise extends DataClass implements Insertable { ..write( 'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ', ) + ..write('exerciseStepsSnapshotJson: $exerciseStepsSnapshotJson, ') ..write( 'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ', ) @@ -12574,6 +13904,7 @@ class ProgramExercise extends DataClass implements Insertable { exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot, availableTimeSnapshot, @@ -12617,6 +13948,7 @@ class ProgramExercise extends DataClass implements Insertable { this.exerciseImageMediaIdSnapshot && other.exerciseImageMediaIdsSnapshotJson == this.exerciseImageMediaIdsSnapshotJson && + other.exerciseStepsSnapshotJson == this.exerciseStepsSnapshotJson && other.exerciseVideoMediaIdSnapshot == this.exerciseVideoMediaIdSnapshot && other.exerciseArchivedSnapshot == this.exerciseArchivedSnapshot && @@ -12656,6 +13988,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { final Value exerciseDescriptionSnapshot; final Value exerciseImageMediaIdSnapshot; final Value exerciseImageMediaIdsSnapshotJson; + final Value exerciseStepsSnapshotJson; final Value exerciseVideoMediaIdSnapshot; final Value exerciseArchivedSnapshot; final Value availableTimeSnapshot; @@ -12693,6 +14026,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { this.exerciseDescriptionSnapshot = const Value.absent(), this.exerciseImageMediaIdSnapshot = const Value.absent(), this.exerciseImageMediaIdsSnapshotJson = const Value.absent(), + this.exerciseStepsSnapshotJson = const Value.absent(), this.exerciseVideoMediaIdSnapshot = const Value.absent(), this.exerciseArchivedSnapshot = const Value.absent(), this.availableTimeSnapshot = const Value.absent(), @@ -12731,6 +14065,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { this.exerciseDescriptionSnapshot = const Value.absent(), this.exerciseImageMediaIdSnapshot = const Value.absent(), this.exerciseImageMediaIdsSnapshotJson = const Value.absent(), + this.exerciseStepsSnapshotJson = const Value.absent(), this.exerciseVideoMediaIdSnapshot = const Value.absent(), this.exerciseArchivedSnapshot = const Value.absent(), required bool availableTimeSnapshot, @@ -12784,6 +14119,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { Expression? exerciseDescriptionSnapshot, Expression? exerciseImageMediaIdSnapshot, Expression? exerciseImageMediaIdsSnapshotJson, + Expression? exerciseStepsSnapshotJson, Expression? exerciseVideoMediaIdSnapshot, Expression? exerciseArchivedSnapshot, Expression? availableTimeSnapshot, @@ -12828,6 +14164,8 @@ class ProgramExercisesCompanion extends UpdateCompanion { if (exerciseImageMediaIdsSnapshotJson != null) 'exercise_image_media_ids_snapshot_json': exerciseImageMediaIdsSnapshotJson, + if (exerciseStepsSnapshotJson != null) + 'exercise_steps_snapshot_json': exerciseStepsSnapshotJson, if (exerciseVideoMediaIdSnapshot != null) 'exercise_video_media_id_snapshot': exerciseVideoMediaIdSnapshot, if (exerciseArchivedSnapshot != null) @@ -12876,6 +14214,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { Value? exerciseDescriptionSnapshot, Value? exerciseImageMediaIdSnapshot, Value? exerciseImageMediaIdsSnapshotJson, + Value? exerciseStepsSnapshotJson, Value? exerciseVideoMediaIdSnapshot, Value? exerciseArchivedSnapshot, Value? availableTimeSnapshot, @@ -12918,6 +14257,8 @@ class ProgramExercisesCompanion extends UpdateCompanion { exerciseImageMediaIdsSnapshotJson: exerciseImageMediaIdsSnapshotJson ?? this.exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson: + exerciseStepsSnapshotJson ?? this.exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot ?? this.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: @@ -13012,6 +14353,11 @@ class ProgramExercisesCompanion extends UpdateCompanion { exerciseImageMediaIdsSnapshotJson.value, ); } + if (exerciseStepsSnapshotJson.present) { + map['exercise_steps_snapshot_json'] = Variable( + exerciseStepsSnapshotJson.value, + ); + } if (exerciseVideoMediaIdSnapshot.present) { map['exercise_video_media_id_snapshot'] = Variable( exerciseVideoMediaIdSnapshot.value, @@ -13106,6 +14452,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { ..write( 'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ', ) + ..write('exerciseStepsSnapshotJson: $exerciseStepsSnapshotJson, ') ..write( 'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ', ) @@ -18629,6 +19976,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $MediaAssetsTable mediaAssets = $MediaAssetsTable(this); late final $ExercisesTable exercises = $ExercisesTable(this); late final $ExerciseImagesTable exerciseImages = $ExerciseImagesTable(this); + late final $ExerciseStepsTable exerciseSteps = $ExerciseStepsTable(this); late final $ProgramsTable programs = $ProgramsTable(this); late final $ProgramExercisesTable programExercises = $ProgramExercisesTable( this, @@ -18658,6 +20006,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { mediaAssets, exercises, exerciseImages, + exerciseSteps, programs, programExercises, workoutHistories, @@ -24010,6 +25359,24 @@ final class $$ExercisesTableReferences ); } + static MultiTypedResultKey<$ExerciseStepsTable, List> + _exerciseStepsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.exerciseSteps, + aliasName: 'exercises__id__exercise_steps__exercise_id', + ); + + $$ExerciseStepsTableProcessedTableManager get exerciseStepsRefs { + final manager = $$ExerciseStepsTableTableManager( + $_db, + $_db.exerciseSteps, + ).filter((f) => f.exerciseId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_exerciseStepsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey<$ProgramExercisesTable, List> _programExercisesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.programExercises, @@ -24231,6 +25598,31 @@ class $$ExercisesTableFilterComposer return f(composer); } + Expression exerciseStepsRefs( + Expression Function($$ExerciseStepsTableFilterComposer f) f, + ) { + final $$ExerciseStepsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.exerciseSteps, + getReferencedColumn: (t) => t.exerciseId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExerciseStepsTableFilterComposer( + $db: $db, + $table: $db.exerciseSteps, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableFilterComposer f) f, ) { @@ -24619,6 +26011,31 @@ class $$ExercisesTableAnnotationComposer return f(composer); } + Expression exerciseStepsRefs( + Expression Function($$ExerciseStepsTableAnnotationComposer a) f, + ) { + final $$ExerciseStepsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.exerciseSteps, + getReferencedColumn: (t) => t.exerciseId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExerciseStepsTableAnnotationComposer( + $db: $db, + $table: $db.exerciseSteps, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableAnnotationComposer a) f, ) { @@ -24662,6 +26079,7 @@ class $$ExercisesTableTableManager bool iconMediaId, bool videoMediaId, bool exerciseImagesRefs, + bool exerciseStepsRefs, bool programExercisesRefs, }) > { @@ -24805,12 +26223,14 @@ class $$ExercisesTableTableManager iconMediaId = false, videoMediaId = false, exerciseImagesRefs = false, + exerciseStepsRefs = false, programExercisesRefs = false, }) { return PrefetchHooks( db: db, explicitlyWatchedTables: [ if (exerciseImagesRefs) db.exerciseImages, + if (exerciseStepsRefs) db.exerciseSteps, if (programExercisesRefs) db.programExercises, ], addJoins: @@ -24881,6 +26301,27 @@ class $$ExercisesTableTableManager ), typedResults: items, ), + if (exerciseStepsRefs) + await $_getPrefetchedData< + Exercise, + $ExercisesTable, + ExerciseStep + >( + currentTable: table, + referencedTable: $$ExercisesTableReferences + ._exerciseStepsRefsTable(db), + managerFromTypedResult: (p0) => + $$ExercisesTableReferences( + db, + table, + p0, + ).exerciseStepsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.exerciseId == item.id, + ), + typedResults: items, + ), if (programExercisesRefs) await $_getPrefetchedData< Exercise, @@ -24926,6 +26367,7 @@ typedef $$ExercisesTableProcessedTableManager = bool iconMediaId, bool videoMediaId, bool exerciseImagesRefs, + bool exerciseStepsRefs, bool programExercisesRefs, }) >; @@ -25524,6 +26966,672 @@ typedef $$ExerciseImagesTableProcessedTableManager = ExerciseImage, PrefetchHooks Function({bool exerciseId, bool mediaAssetId}) >; +typedef $$ExerciseStepsTableCreateCompanionBuilder = + ExerciseStepsCompanion Function({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt, + Value schemaVersion, + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + required String exerciseId, + required int position, + required String name, + required String type, + required int defaultTargetValue, + required bool hasScore, + Value scoreInputMode, + Value scoreLabel, + Value scoreUnit, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, + Value rowid, + }); +typedef $$ExerciseStepsTableUpdateCompanionBuilder = + ExerciseStepsCompanion Function({ + Value id, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value schemaVersion, + Value syncState, + Value localRevision, + Value originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + Value exerciseId, + Value position, + Value name, + Value type, + Value defaultTargetValue, + Value hasScore, + Value scoreInputMode, + Value scoreLabel, + Value scoreUnit, + Value defaultTargetScore, + Value defaultTargetScoreTimeMs, + Value rowid, + }); + +final class $$ExerciseStepsTableReferences + extends BaseReferences<_$AppDatabase, $ExerciseStepsTable, ExerciseStep> { + $$ExerciseStepsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $ExercisesTable _exerciseIdTable(_$AppDatabase db) => + db.exercises.createAlias('exercise_steps__exercise_id__exercises__id'); + + $$ExercisesTableProcessedTableManager get exerciseId { + final $_column = $_itemColumn('exercise_id')!; + + final manager = $$ExercisesTableTableManager( + $_db, + $_db.exercises, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_exerciseIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ExerciseStepsTableFilterComposer + extends Composer<_$AppDatabase, $ExerciseStepsTable> { + $$ExerciseStepsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get position => $composableBuilder( + column: $table.position, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetValue => $composableBuilder( + column: $table.defaultTargetValue, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hasScore => $composableBuilder( + column: $table.hasScore, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scoreInputMode => $composableBuilder( + column: $table.scoreInputMode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scoreLabel => $composableBuilder( + column: $table.scoreLabel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scoreUnit => $composableBuilder( + column: $table.scoreUnit, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnFilters(column), + ); + + $$ExercisesTableFilterComposer get exerciseId { + final $$ExercisesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.exerciseId, + referencedTable: $db.exercises, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExercisesTableFilterComposer( + $db: $db, + $table: $db.exercises, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ExerciseStepsTableOrderingComposer + extends Composer<_$AppDatabase, $ExerciseStepsTable> { + $$ExerciseStepsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get position => $composableBuilder( + column: $table.position, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetValue => $composableBuilder( + column: $table.defaultTargetValue, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hasScore => $composableBuilder( + column: $table.hasScore, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scoreInputMode => $composableBuilder( + column: $table.scoreInputMode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scoreLabel => $composableBuilder( + column: $table.scoreLabel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scoreUnit => $composableBuilder( + column: $table.scoreUnit, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => ColumnOrderings(column), + ); + + $$ExercisesTableOrderingComposer get exerciseId { + final $$ExercisesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.exerciseId, + referencedTable: $db.exercises, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExercisesTableOrderingComposer( + $db: $db, + $table: $db.exercises, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ExerciseStepsTableAnnotationComposer + extends Composer<_$AppDatabase, $ExerciseStepsTable> { + $$ExerciseStepsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + GeneratedColumn get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => column, + ); + + GeneratedColumn get syncState => + $composableBuilder(column: $table.syncState, builder: (column) => column); + + GeneratedColumn get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => column, + ); + + GeneratedColumn get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => column, + ); + + GeneratedColumn get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => column, + ); + + GeneratedColumn get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => column, + ); + + GeneratedColumn get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => column, + ); + + GeneratedColumn get position => + $composableBuilder(column: $table.position, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get type => + $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get defaultTargetValue => $composableBuilder( + column: $table.defaultTargetValue, + builder: (column) => column, + ); + + GeneratedColumn get hasScore => + $composableBuilder(column: $table.hasScore, builder: (column) => column); + + GeneratedColumn get scoreInputMode => $composableBuilder( + column: $table.scoreInputMode, + builder: (column) => column, + ); + + GeneratedColumn get scoreLabel => $composableBuilder( + column: $table.scoreLabel, + builder: (column) => column, + ); + + GeneratedColumn get scoreUnit => + $composableBuilder(column: $table.scoreUnit, builder: (column) => column); + + GeneratedColumn get defaultTargetScore => $composableBuilder( + column: $table.defaultTargetScore, + builder: (column) => column, + ); + + GeneratedColumn get defaultTargetScoreTimeMs => $composableBuilder( + column: $table.defaultTargetScoreTimeMs, + builder: (column) => column, + ); + + $$ExercisesTableAnnotationComposer get exerciseId { + final $$ExercisesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.exerciseId, + referencedTable: $db.exercises, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExercisesTableAnnotationComposer( + $db: $db, + $table: $db.exercises, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ExerciseStepsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ExerciseStepsTable, + ExerciseStep, + $$ExerciseStepsTableFilterComposer, + $$ExerciseStepsTableOrderingComposer, + $$ExerciseStepsTableAnnotationComposer, + $$ExerciseStepsTableCreateCompanionBuilder, + $$ExerciseStepsTableUpdateCompanionBuilder, + (ExerciseStep, $$ExerciseStepsTableReferences), + ExerciseStep, + PrefetchHooks Function({bool exerciseId}) + > { + $$ExerciseStepsTableTableManager(_$AppDatabase db, $ExerciseStepsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ExerciseStepsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ExerciseStepsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ExerciseStepsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + Value syncState = const Value.absent(), + Value localRevision = const Value.absent(), + Value originDeviceId = const Value.absent(), + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + Value exerciseId = const Value.absent(), + Value position = const Value.absent(), + Value name = const Value.absent(), + Value type = const Value.absent(), + Value defaultTargetValue = const Value.absent(), + Value hasScore = const Value.absent(), + Value scoreInputMode = const Value.absent(), + Value scoreLabel = const Value.absent(), + Value scoreUnit = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), + Value rowid = const Value.absent(), + }) => ExerciseStepsCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + exerciseId: exerciseId, + position: position, + name: name, + type: type, + defaultTargetValue: defaultTargetValue, + hasScore: hasScore, + scoreInputMode: scoreInputMode, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + required String exerciseId, + required int position, + required String name, + required String type, + required int defaultTargetValue, + required bool hasScore, + Value scoreInputMode = const Value.absent(), + Value scoreLabel = const Value.absent(), + Value scoreUnit = const Value.absent(), + Value defaultTargetScore = const Value.absent(), + Value defaultTargetScoreTimeMs = const Value.absent(), + Value rowid = const Value.absent(), + }) => ExerciseStepsCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + exerciseId: exerciseId, + position: position, + name: name, + type: type, + defaultTargetValue: defaultTargetValue, + hasScore: hasScore, + scoreInputMode: scoreInputMode, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, + defaultTargetScore: defaultTargetScore, + defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ExerciseStepsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({exerciseId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (exerciseId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.exerciseId, + referencedTable: $$ExerciseStepsTableReferences + ._exerciseIdTable(db), + referencedColumn: $$ExerciseStepsTableReferences + ._exerciseIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ExerciseStepsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ExerciseStepsTable, + ExerciseStep, + $$ExerciseStepsTableFilterComposer, + $$ExerciseStepsTableOrderingComposer, + $$ExerciseStepsTableAnnotationComposer, + $$ExerciseStepsTableCreateCompanionBuilder, + $$ExerciseStepsTableUpdateCompanionBuilder, + (ExerciseStep, $$ExerciseStepsTableReferences), + ExerciseStep, + PrefetchHooks Function({bool exerciseId}) + >; typedef $$ProgramsTableCreateCompanionBuilder = ProgramsCompanion Function({ required String id, @@ -26130,6 +28238,7 @@ typedef $$ProgramExercisesTableCreateCompanionBuilder = Value exerciseDescriptionSnapshot, Value exerciseImageMediaIdSnapshot, Value exerciseImageMediaIdsSnapshotJson, + Value exerciseStepsSnapshotJson, Value exerciseVideoMediaIdSnapshot, Value exerciseArchivedSnapshot, required bool availableTimeSnapshot, @@ -26169,6 +28278,7 @@ typedef $$ProgramExercisesTableUpdateCompanionBuilder = Value exerciseDescriptionSnapshot, Value exerciseImageMediaIdSnapshot, Value exerciseImageMediaIdsSnapshotJson, + Value exerciseStepsSnapshotJson, Value exerciseVideoMediaIdSnapshot, Value exerciseArchivedSnapshot, Value availableTimeSnapshot, @@ -26363,6 +28473,11 @@ class $$ProgramExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get exerciseStepsSnapshotJson => $composableBuilder( + column: $table.exerciseStepsSnapshotJson, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => ColumnFilters(column), @@ -26621,6 +28736,11 @@ class $$ProgramExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get exerciseStepsSnapshotJson => $composableBuilder( + column: $table.exerciseStepsSnapshotJson, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => ColumnOrderings(column), @@ -26867,6 +28987,11 @@ class $$ProgramExercisesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get exerciseStepsSnapshotJson => $composableBuilder( + column: $table.exerciseStepsSnapshotJson, + builder: (column) => column, + ); + GeneratedColumn get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => column, @@ -27094,6 +29219,7 @@ class $$ProgramExercisesTableTableManager const Value.absent(), Value exerciseImageMediaIdsSnapshotJson = const Value.absent(), + Value exerciseStepsSnapshotJson = const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), Value exerciseArchivedSnapshot = const Value.absent(), @@ -27133,6 +29259,7 @@ class $$ProgramExercisesTableTableManager exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshotJson: exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson: exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, @@ -27175,6 +29302,7 @@ class $$ProgramExercisesTableTableManager const Value.absent(), Value exerciseImageMediaIdsSnapshotJson = const Value.absent(), + Value exerciseStepsSnapshotJson = const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), Value exerciseArchivedSnapshot = const Value.absent(), @@ -27214,6 +29342,7 @@ class $$ProgramExercisesTableTableManager exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshotJson: exerciseImageMediaIdsSnapshotJson, + exerciseStepsSnapshotJson: exerciseStepsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, @@ -30660,6 +32789,8 @@ class $AppDatabaseManager { $$ExercisesTableTableManager(_db, _db.exercises); $$ExerciseImagesTableTableManager get exerciseImages => $$ExerciseImagesTableTableManager(_db, _db.exerciseImages); + $$ExerciseStepsTableTableManager get exerciseSteps => + $$ExerciseStepsTableTableManager(_db, _db.exerciseSteps); $$ProgramsTableTableManager get programs => $$ProgramsTableTableManager(_db, _db.programs); $$ProgramExercisesTableTableManager get programExercises => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index b220b6f..0089e8f 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -18,7 +18,11 @@ final class DriftExerciseRepository implements ExerciseRepository { )..where((table) => table.id.equals(id))).getSingleOrNull(); return row == null ? null - : _exerciseFromRow(row, await _imageMediaIdsForExercise(id)); + : _exerciseFromRow( + row, + await _imageMediaIdsForExercise(id), + await _stepsForExercise(id), + ); } @override @@ -33,7 +37,11 @@ final class DriftExerciseRepository implements ExerciseRepository { final exercises = []; for (final row in rows) { exercises.add( - _exerciseFromRow(row, await _imageMediaIdsForExercise(row.id)), + _exerciseFromRow( + row, + await _imageMediaIdsForExercise(row.id), + await _stepsForExercise(row.id), + ), ); } return exercises; @@ -66,6 +74,7 @@ final class DriftExerciseRepository implements ExerciseRepository { .insertOnConflictUpdate(_exerciseCompanion(exercise)), ); await _replaceExerciseImages(database, exercise); + await _replaceExerciseSteps(database, exercise); }); } @@ -81,6 +90,19 @@ final class DriftExerciseRepository implements ExerciseRepository { .get(); return rows.map((row) => row.mediaAssetId).toList(); } + + Future> _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 { @@ -823,6 +845,76 @@ Future _replaceExerciseImages( } } +Future _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 _softDeleteExerciseImageRows( db.AppDatabase database, List rows, @@ -852,6 +944,35 @@ Future _softDeleteExerciseImageRows( } } +Future _softDeleteExerciseStepRows( + db.AppDatabase database, + List 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) { return 'exercise-image:$exerciseId:$mediaId'; } @@ -1094,7 +1215,11 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { ); } -domain.Exercise _exerciseFromRow(db.Exercise row, List imageMediaIds) { +domain.Exercise _exerciseFromRow( + db.Exercise row, + List imageMediaIds, + List steps, +) { return domain.Exercise( metadata: _metadataFromRow(row), name: row.name, @@ -1112,10 +1237,29 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List imageMediaIds) { defaultTargetReps: row.defaultTargetReps, defaultTargetScore: row.defaultTargetScore, defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, + steps: steps, 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) { final values = _metadataValues(asset.metadata); return db.MediaAssetsCompanion( @@ -1215,6 +1359,9 @@ db.ProgramExercisesCompanion _programExerciseCompanion( exerciseImageMediaIdsSnapshotJson: Value( _encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot), ), + exerciseStepsSnapshotJson: Value( + _encodeExerciseStepsSnapshot(exercise.exerciseStepsSnapshot), + ), exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot), exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot), availableTimeSnapshot: Value(exercise.availableTimeSnapshot), @@ -1250,6 +1397,9 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) { row.exerciseImageMediaIdsSnapshotJson, row.exerciseImageMediaIdSnapshot, ), + exerciseStepsSnapshot: _decodeExerciseStepsSnapshot( + row.exerciseStepsSnapshotJson, + ), exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, availableTimeSnapshot: row.availableTimeSnapshot, @@ -1751,6 +1901,18 @@ domain.ScoreInputMode _scoreInputModeFromDb(String value) => switch (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) { domain.SetResultStatus.completed => 'completed', domain.SetResultStatus.skipped => 'skipped', @@ -1800,6 +1962,90 @@ List _decodeImageMediaIdsSnapshot( .toList(growable: false); } +String? _encodeExerciseStepsSnapshot(List steps) { + return steps.isEmpty + ? null + : jsonEncode(steps.map((step) => step.toSnapshotJson()).toList()); +} + +List _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.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 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 json, String key) { + final value = json[key]; + if (value == null) { + return null; + } + return value is String ? value : null; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is int) { + return value; + } + throw domain.DomainException('$key must be an integer.'); +} + +int? _optionalInt(Map json, String key) { + final value = json[key]; + return value is int ? value : null; +} + +double? _optionalDouble(Map json, String key) { + final value = json[key]; + return (value as num?)?.toDouble(); +} + +bool _requiredBool(Map 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) { 'running' => domain.ActiveWorkoutStatus.running, 'paused' => domain.ActiveWorkoutStatus.paused, diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index af9cf36..eeee536 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -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 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 { @override String get tableName => 'programs'; @@ -132,6 +176,7 @@ class ProgramExercises extends SyncableTable { TextColumn get exerciseImageMediaIdSnapshot => text().nullable().references(MediaAssets, #id)(); TextColumn get exerciseImageMediaIdsSnapshotJson => text().nullable()(); + TextColumn get exerciseStepsSnapshotJson => text().nullable()(); @ReferenceName('programExerciseVideoSnapshotReferences') TextColumn get exerciseVideoMediaIdSnapshot => diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 33ebcbc..bb02d5f 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -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()), + ); + }); + + 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()), + ); + }); + + test('Exercise step validation rejects invalid basic fields', () { + expect(() => _step(name: ''), throwsA(isA())); + expect(() => _step(defaultTargetValue: 0), throwsA(isA())); + }); + + test('Exercise step validation rejects disabled score values', () { + expect( + () => _step(hasScore: false, scoreLabel: 'Charge'), + throwsA(isA()), + ); + expect( + () => _step(hasScore: false, defaultTargetScore: 0), + throwsA(isA()), + ); + }); + + 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()), + ); + expect( + () => _step(hasScore: true, scoreLabel: 'Charge'), + throwsA(isA()), + ); + }, + ); + + 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()), + ); + expect( + () => _step( + hasScore: true, + scoreInputMode: ScoreInputMode.stopwatch, + scoreLabel: 'Charge', + ), + throwsA(isA()), + ); + }); + test('Exercise image gallery rejects a sixth image', () async { final repository = _FakeExerciseRepository() ..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) { return EntityMetadata( id: id, diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 0d4d836..c1b3f2b 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -79,6 +79,158 @@ void main() { 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( 'removing a program exercise writes a soft delete change log entry', () async {