diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 25dee3f..ce793f5 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -5,3 +5,5 @@ - [gametime-architecture-initial-stack-data-model](gametime-architecture-initial-stack-data-model.md) — memory note gametime-architecture-initial-stack-data-model - [gametime-dev-environment](gametime-dev-environment.md) — memory note gametime-dev-environment - [gametime-visual-identity](gametime-visual-identity.md) — memory note gametime-visual-identity +- [gametime-ux-execution-nav-and-program-simplification](gametime-ux-execution-nav-and-program-simplification.md) — memory note gametime-ux-execution-nav-and-program-simplification +- [gametime-architecture-set-editing](gametime-architecture-set-editing.md) — memory note gametime-architecture-set-editing diff --git a/.ideai/memory/gametime-architecture-set-editing.md b/.ideai/memory/gametime-architecture-set-editing.md new file mode 100644 index 0000000..a809f6d --- /dev/null +++ b/.ideai/memory/gametime-architecture-set-editing.md @@ -0,0 +1,25 @@ +--- +name: gametime-architecture-set-editing +description: memory note gametime-architecture-set-editing +metadata: + type: project +--- +# GameTime — Cadrage Architect : édition ponctuelle de séries (2026-07-18) + +Réponse d'Architect au besoin UX de navigation libre dans une séance active (mémoire "gametime-ux-execution-nav-and-program-simplification"). + +## Ce qui existe déjà et est réutilisable +- `ActiveSetResults` (Drift) a déjà `programIndex`, `exerciseIndex`, `setIndex` avec contrainte `UNIQUE (active_workout_session_id, program_index, exercise_index, set_index)` — l'identification positionnelle d'une série est stable, un upsert par position est possible sans ambiguïté. +- `recordCurrentSetResult` n'avance pas le curseur de progression lui-même (c'est `updateProgress`, séparé) mais reste sémantiquement dédié à la série courante (recrée un résultat avec nouvel ID/métadonnées) — pas réutilisable tel quel pour l'édition ponctuelle. + +## Ce qui doit être ajouté +- Champ `status` sur `ActiveSetResult` (enum `completed | skipped`), avec invariant : `skipped` implique aucune valeur `actual*` renseignée. Migration Drift nécessaire (schemaVersion+1). +- Nouveau use case `upsertSetResultAtPosition(...)` : vérifie que la position appartient au snapshot de la séance, conserve l'id/createdAt si une ligne existe déjà à cette position, écrit `completed` ou `skipped`, et **ne touche jamais** `currentProgramIndex/currentExerciseIndex/currentSetIndex`. +- Nouveau use case `listSetResults(sessionId)` pour construire l'état du plan de séance (à faire / en cours / terminée / passée par position). + +## Invariants à respecter côté DevBackend +- L'édition ponctuelle ne crée, ne relance et ne termine jamais de repos (`ActiveRestState` reste un événement indépendant attaché à la série précédente). +- Le temps total de séance reste calculé depuis les horodatages de session, jamais recalculé depuis la liste des résultats. +- Pas d'édition de série future en v1 (seulement passé/courant). +- Si la position éditée correspond à la position courante, rediriger vers le flux d'exécution normal plutôt que permettre une double édition simultanée. +- Propager le statut `skipped` jusqu'à l'historique (WorkoutHistorySetResult) pour que le snapshot final reste lisible et cohérent avec ce qui a été vécu pendant la séance. \ No newline at end of file diff --git a/.ideai/memory/gametime-ux-execution-nav-and-program-simplification.md b/.ideai/memory/gametime-ux-execution-nav-and-program-simplification.md new file mode 100644 index 0000000..f7baa86 --- /dev/null +++ b/.ideai/memory/gametime-ux-execution-nav-and-program-simplification.md @@ -0,0 +1,40 @@ +--- +name: gametime-ux-execution-nav-and-program-simplification +description: memory note gametime-ux-execution-nav-and-program-simplification +metadata: + type: project +--- +# GameTime — Refonte navigation d'exécution + simplification liste programme (UX, 2026-07-18) + +Suite à un retour utilisateur après premier test réel de la v1. + +## Point 1 — Navigation libre dans une séance en cours + +Distinction centrale : **position courante** (où en est réellement la séance) vs **édition ponctuelle** (ouvrir une série passée/passée pour la corriger, sans déplacer le curseur ni relancer un repos/recalcul). + +- Nouvelle action "Voir le plan" sur l'écran d'exécution → bottom sheet plein écran "Plan de séance" listant tous les programmes/exercices/séries de la séance avec leur état : À faire / En cours / Terminée (résumé valeurs) / Passée (Aucun résultat). +- Règle d'accessibilité : Terminée et Passée sont tapables → ouvrent une bottom sheet "Modifier la série" (mêmes champs que l'exécution active, boutons Enregistrer / Marquer comme passée / Annuler). En cours → ferme le plan et revient à l'écran actif. À faire → non tapable en v1 (pas de saut vers le futur, seulement correction du passé). +- Édition d'une série ne déplace jamais `currentProgramIndex/currentExerciseIndex/currentSetIndex`, ne relance pas de repos, ne recalcule pas le temps total (basé sur la séance, pas la somme des séries). +- Repos actif pendant une édition : continue en arrière-plan, bandeau compact "Repos en cours · 00:32" affiché en haut du plan/de l'édition ; s'il arrive à zéro pendant l'édition, bandeau devient "Repos terminé · Reprendre" sans fermer brutalement la vue. +- Cas limites : modifier la série courante via le plan → ferme la sheet et revient à l'écran principal (pas de double édition) ; vider une série terminée → confirmation "Supprimer le résultat de cette série ? / Marquer comme passée" ; logique d'édition n'existe que pour la séance active, pas pour l'historique (autre écran). + +Impacts backend identifiés par UX (à faire trancher par Architect avant implémentation) : +- `listSetResults(sessionId)` pour construire l'état du plan. +- `upsertSetResultAtPosition(...)` distinct de `recordCurrentSetResult(...)` — écrire un résultat à une position arbitraire sans avancer le curseur. +- Distinguer explicitement un résultat "skipped" d'un résultat absent (probablement déjà couvert par le modèle ActiveSetResult existant, à vérifier). +- Ne jamais appeler la logique d'avancement de progression lors d'une édition ponctuelle. + +## Point 2 — Liste d'exercices de programme simplifiée + +- Par défaut, chaque ligne d'exercice dans un programme n'affiche QUE : poignée de déplacement, nom de l'exercice, repos affiché (ex: "Repos 45 s"), badge "Exercice archivé" si pertinent, et une icône "personnaliser" (tune/edit_note) avec tooltip "Personnaliser l'exercice". +- Plus de checkboxes/mesures/cibles/nombre de séries visibles par défaut dans la liste. +- L'icône "personnaliser" ouvre un **écran séparé** "Personnaliser l'exercice" (pas une bottom sheet, pas de déplié inline — clavier numérique + plusieurs sections méritent l'espace d'un écran plein) avec : nombre de séries, mesures à suivre (toggles, au moins une active obligatoire), objectifs par mesure activée, repos après chaque série, actions Enregistrer / Supprimer du programme. +- Ajout d'un exercice au programme : ajouté immédiatement avec des valeurs par défaut (3 séries, toutes les mesures disponibles de l'exercice actives, repos = défaut du programme), retour à la liste, snackbar "Exercice ajouté" avec action rapide "Personnaliser". +- Validation : au moins une mesure active (message inline si tout décoché), repos positif obligatoire, confirmation avant suppression d'un exercice du programme. + +## Découpage proposé par UX (à transformer en tickets) +1. Programme · cartes exercice compactes + écran de personnalisation séparé. +2. Exécution · plan de séance consultable (lecture seule des états, sans édition). +3. Exécution · correction des séries passées/terminées (édition ponctuelle, gestion repos actif pendant édition). + +Point à trancher avec Architect avant d'attaquer les tickets 2 et 3 : le modèle d'écriture d'un résultat de série à une position arbitraire sans avancer le curseur de progression — vérifier que ça ne casse pas les invariants déjà posés (ActiveSetResult, ActiveWorkoutSession, horodatages) définis dans la mémoire "gametime-architecture-initial-stack-data-model" et les tickets #4/#9/#13/#14. \ No newline at end of file diff --git a/.ideai/tickets/20/carnet.md b/.ideai/tickets/20/carnet.md new file mode 100644 index 0000000..2ca3b28 --- /dev/null +++ b/.ideai/tickets/20/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#20" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784325859788 +--- diff --git a/.ideai/tickets/20/issue.md b/.ideai/tickets/20/issue.md new file mode 100644 index 0000000..7adee1a --- /dev/null +++ b/.ideai/tickets/20/issue.md @@ -0,0 +1,16 @@ +--- +id: "8355edb7-bdbd-4f93-820c-d7de0affebef" +number: 20 +title: "[DevFrontend] Programme : cartes exercice compactes + écran de personnalisation séparé" +status: "inProgress" +priority: "high" +sprint: null +links: [{"target":"#7","kind":"relatesTo"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784325837553 +updatedAt: 1784325859788 +version: 2 +--- +Simplifier la liste d'exercices d'un programme (lib/presentation/program_screen.dart) : chaque ligne n'affiche par défaut que la poignée de déplacement, le nom de l'exercice, le repos affiché, et une icône "personnaliser" (tooltip "Personnaliser l'exercice") ouvrant un écran séparé "Personnaliser l'exercice" avec la configuration complète (séries, mesures, cibles, repos). Ajout d'un exercice = valeurs par défaut immédiates (3 séries, toutes les mesures disponibles actives, repos = défaut du programme) + snackbar "Exercice ajouté" avec action rapide "Personnaliser". Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 2 pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/21/carnet.md b/.ideai/tickets/21/carnet.md new file mode 100644 index 0000000..82cb456 --- /dev/null +++ b/.ideai/tickets/21/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#21" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784326729918 +--- diff --git a/.ideai/tickets/21/issue.md b/.ideai/tickets/21/issue.md new file mode 100644 index 0000000..f15c99b --- /dev/null +++ b/.ideai/tickets/21/issue.md @@ -0,0 +1,16 @@ +--- +id: "2444e487-58c1-4180-9fac-c5d5df9bdd68" +number: 21 +title: "[DevBackend] Modèle et use cases pour l'édition ponctuelle de séries en séance active" +status: "closed" +priority: "high" +sprint: null +links: [{"target":"#9","kind":"relatesTo"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784325841003 +updatedAt: 1784326729918 +version: 3 +--- +Ajouter le nécessaire pour permettre de consulter et corriger le résultat d'une série déjà terminée/passée sans déplacer le curseur de progression de la séance. Cf. mémoire "gametime-architecture-set-editing" (cadrage Architect) pour le détail exact : champ status (completed|skipped) sur ActiveSetResult + migration Drift, use case upsertSetResultAtPosition, use case listSetResults(sessionId), propagation du statut skipped vers l'historique. \ No newline at end of file diff --git a/.ideai/tickets/22/carnet.md b/.ideai/tickets/22/carnet.md new file mode 100644 index 0000000..1a7d309 --- /dev/null +++ b/.ideai/tickets/22/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#22" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784325851266 +--- diff --git a/.ideai/tickets/22/issue.md b/.ideai/tickets/22/issue.md new file mode 100644 index 0000000..5810c59 --- /dev/null +++ b/.ideai/tickets/22/issue.md @@ -0,0 +1,16 @@ +--- +id: "a9ed002f-5e6c-4658-9a1b-ee772ce623ea" +number: 22 +title: "[DevFrontend] Exécution : plan de séance consultable" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784325843738 +updatedAt: 1784325851266 +version: 2 +--- +Ajouter une action "Voir le plan" sur l'écran d'exécution (lib/presentation/workout_execution_screen.dart) ouvrant une bottom sheet plein écran "Plan de séance" listant tous les programmes/exercices/séries avec leur état (À faire / En cours / Terminée avec résumé / Passée). Dépend du use case listSetResults du ticket backend dédié. Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1. \ No newline at end of file diff --git a/.ideai/tickets/23/carnet.md b/.ideai/tickets/23/carnet.md new file mode 100644 index 0000000..5b8623a --- /dev/null +++ b/.ideai/tickets/23/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#23" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784325855260 +--- diff --git a/.ideai/tickets/23/issue.md b/.ideai/tickets/23/issue.md new file mode 100644 index 0000000..87b74ab --- /dev/null +++ b/.ideai/tickets/23/issue.md @@ -0,0 +1,16 @@ +--- +id: "5b61b1a0-6bed-4d51-912f-cedf27f2b1a2" +number: 23 +title: "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#9","kind":"relatesTo"},{"target":"#21","kind":"dependsOn"},{"target":"#22","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784325846325 +updatedAt: 1784325855260 +version: 3 +--- +Depuis le plan de séance, permettre de taper une série Terminée ou Passée pour ouvrir une bottom sheet "Modifier la série" (mêmes champs que l'exécution active) et sauvegarder via upsertSetResultAtPosition, sans déplacer le curseur de progression ni affecter les repos/temps total. Gestion du repos actif pendant l'édition (bandeau compact persistant). Cf. mémoire "gametime-ux-execution-nav-and-program-simplification" point 1 pour le détail complet des cas limites. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 2258209..2ada877 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 20 + "nextNumber": 24 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index d42a178..241ae32 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -214,6 +214,54 @@ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], "updatedAt": 1784325166560 + }, + { + "issueRef": "#20", + "path": "20", + "title": "[DevFrontend] Programme : cartes exercice compactes + écran de personnalisation séparé", + "status": "inProgress", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784325859788 + }, + { + "issueRef": "#21", + "path": "21", + "title": "[DevBackend] Modèle et use cases pour l'édition ponctuelle de séries en séance active", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784326729918 + }, + { + "issueRef": "#22", + "path": "22", + "title": "[DevFrontend] Exécution : plan de séance consultable", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784325851266 + }, + { + "issueRef": "#23", + "path": "23", + "title": "[DevFrontend] Exécution : édition ponctuelle d'une série passée ou terminée", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784325855260 } ] } \ No newline at end of file diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 9ff71b2..8393b2e 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -717,11 +717,117 @@ final class ActiveWorkoutSessionUseCases { actualScore: actualScore, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, + status: SetResultStatus.completed, ); await sessionRepository.saveSetResult(result); return result; } + Future upsertSetResultAtPosition({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required SetResultStatus status, + int? actualTimeMs, + int? actualReps, + double? actualScore, + String? scoreLabelSnapshot, + String? scoreUnitSnapshot, + String? note, + }) async { + final session = await _requiredSession(sessionId); + _ensureEditablePastPosition( + session: session, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final snapshot = _findSetSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (snapshot == null) { + throw const DomainException( + 'Set position not found in session snapshot.', + ); + } + final existingResults = await sessionRepository.listSetResults(sessionId); + ActiveSetResult? existing; + for (final result in existingResults) { + if (result.programIndex == programIndex && + result.exerciseIndex == exerciseIndex && + result.setIndex == setIndex) { + existing = result; + break; + } + } + final now = clock.now(); + final isSkipped = status == SetResultStatus.skipped; + final upserted = ActiveSetResult( + metadata: existing == null + ? _newMetadata(ids, originDeviceId, now) + : existing.metadata.touch(now), + activeWorkoutSessionId: sessionId, + programSnapshotId: snapshot.programSnapshotId, + exerciseSnapshotId: snapshot.exerciseSnapshotId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + startedAt: existing?.startedAt, + completedAt: isSkipped ? null : now, + actualTimeMs: isSkipped ? null : actualTimeMs, + actualReps: isSkipped ? null : actualReps, + actualScore: isSkipped ? null : actualScore, + scoreLabelSnapshot: isSkipped + ? null + : scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot, + scoreUnitSnapshot: isSkipped + ? null + : scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot, + note: note, + status: status, + ); + await sessionRepository.saveSetResult(upserted); + return upserted; + } + + Future> listSetResults(String sessionId) async { + final session = await _requiredSession(sessionId); + final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson); + final results = await sessionRepository.listSetResults(sessionId); + final resultsByPosition = { + for (final result in results) + _positionKey( + result.programIndex, + result.exerciseIndex, + result.setIndex, + ): result, + }; + return snapshots.map((snapshot) { + final result = + resultsByPosition[_positionKey( + snapshot.programIndex, + snapshot.exerciseIndex, + snapshot.setIndex, + )]; + return SetResultPositionState( + programIndex: snapshot.programIndex, + exerciseIndex: snapshot.exerciseIndex, + setIndex: snapshot.setIndex, + status: result == null + ? SetPositionStatus.pending + : switch (result.status) { + SetResultStatus.completed => SetPositionStatus.completed, + SetResultStatus.skipped => SetPositionStatus.skipped, + }, + result: result, + ); + }).toList(); + } + Future updateProgress({ required String sessionId, required int programIndex, @@ -921,6 +1027,7 @@ final class CloseWorkoutSessionUseCase { 'actualScore': result.actualScore, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'status': result.status.name, }, ) .toList(), @@ -945,6 +1052,24 @@ final class WorkoutHistoryUseCases { Future delete(String id) => repository.delete(id, clock.now()); } +enum SetPositionStatus { pending, completed, skipped } + +final class SetResultPositionState { + const SetResultPositionState({ + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + required this.status, + this.result, + }); + + final int programIndex; + final int exerciseIndex; + final int setIndex; + final SetPositionStatus status; + final ActiveSetResult? result; +} + void _validateOverrideTargets( String programSnapshotJson, WorkoutTemplateExerciseOverrideConfig input, @@ -973,6 +1098,132 @@ void _validateOverrideTargets( } } +void _ensureEditablePastPosition({ + required ActiveWorkoutSession session, + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + final comparison = _comparePositions( + programIndex, + exerciseIndex, + setIndex, + session.currentProgramIndex, + session.currentExerciseIndex, + session.currentSetIndex, + ); + if (comparison >= 0) { + throw const DomainException( + 'Only past set results can be edited outside the normal execution flow.', + ); + } +} + +int _comparePositions( + int leftProgram, + int leftExercise, + int leftSet, + int rightProgram, + int rightExercise, + int rightSet, +) { + final programComparison = leftProgram.compareTo(rightProgram); + if (programComparison != 0) { + return programComparison; + } + final exerciseComparison = leftExercise.compareTo(rightExercise); + if (exerciseComparison != 0) { + return exerciseComparison; + } + return leftSet.compareTo(rightSet); +} + +String _positionKey(int programIndex, int exerciseIndex, int setIndex) { + return '$programIndex:$exerciseIndex:$setIndex'; +} + +_SetPositionSnapshot? _findSetSnapshot({ + required String resolvedTemplateSnapshotJson, + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) { + if (snapshot.programIndex == programIndex && + snapshot.exerciseIndex == exerciseIndex && + snapshot.setIndex == setIndex) { + return snapshot; + } + } + return null; +} + +List<_SetPositionSnapshot> _listSetSnapshots( + String resolvedTemplateSnapshotJson, +) { + final decoded = + jsonDecode(resolvedTemplateSnapshotJson) as Map; + final programs = (decoded['programs'] as List? ?? const []); + final snapshots = <_SetPositionSnapshot>[]; + for (var programIndex = 0; programIndex < programs.length; programIndex++) { + final program = programs[programIndex] as Map; + final programSnapshotId = + program['id'] as String? ?? 'program-$programIndex'; + final programSnapshotJson = program['programSnapshotJson'] as String?; + if (programSnapshotJson == null) { + continue; + } + final programSnapshot = + jsonDecode(programSnapshotJson) as Map; + final exercises = + (programSnapshot['exercises'] as List? ?? const []); + for ( + var exerciseIndex = 0; + exerciseIndex < exercises.length; + exerciseIndex++ + ) { + final exercise = exercises[exerciseIndex] as Map; + final exerciseSnapshotId = + exercise['id'] as String? ?? 'exercise-$exerciseIndex'; + final setsCount = exercise['setsCount'] as int? ?? 0; + for (var setIndex = 0; setIndex < setsCount; setIndex++) { + snapshots.add( + _SetPositionSnapshot( + programSnapshotId: programSnapshotId, + exerciseSnapshotId: exerciseSnapshotId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, + scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + ), + ); + } + } + } + return snapshots; +} + +final class _SetPositionSnapshot { + const _SetPositionSnapshot({ + required this.programSnapshotId, + required this.exerciseSnapshotId, + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + this.scoreLabelSnapshot, + this.scoreUnitSnapshot, + }); + + final String programSnapshotId; + final String exerciseSnapshotId; + final int programIndex; + final int exerciseIndex; + final int setIndex; + final String? scoreLabelSnapshot; + final String? scoreUnitSnapshot; +} + List _historyResultsFromActiveResults({ required String historyId, required List results, @@ -1012,6 +1263,7 @@ List _historyResultsFromActiveResults({ result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot, startedAt: result.startedAt, completedAt: result.completedAt, + status: result.status, ); }).toList(); } diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 4b23e76..fce8387 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } +enum SetResultStatus { completed, skipped } + final class DomainException implements Exception { const DomainException(this.message); @@ -572,7 +574,7 @@ final class ActiveWorkoutSession { } final class ActiveSetResult { - const ActiveSetResult({ + ActiveSetResult({ required this.metadata, required this.activeWorkoutSessionId, required this.programSnapshotId, @@ -588,7 +590,13 @@ final class ActiveSetResult { this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.note, - }); + this.status = SetResultStatus.completed, + }) { + if (status == SetResultStatus.skipped && + (actualTimeMs != null || actualReps != null || actualScore != null)) { + throw const DomainException('Skipped set results cannot have values.'); + } + } final EntityMetadata metadata; final String activeWorkoutSessionId; @@ -605,6 +613,7 @@ final class ActiveSetResult { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final String? note; + final SetResultStatus status; } final class ActiveRestState { @@ -682,7 +691,7 @@ final class WorkoutHistory { } final class WorkoutHistorySetResult { - const WorkoutHistorySetResult({ + WorkoutHistorySetResult({ required this.metadata, required this.workoutHistoryId, required this.programSnapshotId, @@ -705,7 +714,15 @@ final class WorkoutHistorySetResult { this.scoreUnitSnapshot, this.startedAt, this.completedAt, - }); + this.status = SetResultStatus.completed, + }) { + if (status == SetResultStatus.skipped && + (actualTimeMs != null || actualReps != null || actualScore != null)) { + throw const DomainException( + 'Skipped history results cannot have values.', + ); + } + } final EntityMetadata metadata; final String workoutHistoryId; @@ -729,6 +746,7 @@ final class WorkoutHistorySetResult { final String? scoreUnitSnapshot; final DateTime? startedAt; final DateTime? completedAt; + final SetResultStatus status; } String _nonBlank(String? value, String label) { diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 2750930..8330e5d 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -35,7 +35,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 1; + int get schemaVersion => 2; @override MigrationStrategy get migration { @@ -44,6 +44,19 @@ final class AppDatabase extends _$AppDatabase { await migrator.createAll(); await _createIndexes(); }, + onUpgrade: (migrator, from, to) async { + if (from < 2) { + await customStatement( + 'ALTER TABLE active_set_results ADD COLUMN status TEXT NOT NULL ' + "DEFAULT 'completed' CHECK (status IN ('completed', 'skipped'))", + ); + await customStatement( + 'ALTER TABLE workout_history_set_results ADD COLUMN status TEXT ' + "NOT NULL DEFAULT 'completed' CHECK (status IN ('completed', " + "'skipped'))", + ); + } + }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 21d43f7..3a1c0d5 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -3578,6 +3578,16 @@ class $ActiveSetResultsTable extends ActiveSetResults type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('completed'), + ); @override List get $columns => [ id, @@ -3605,6 +3615,7 @@ class $ActiveSetResultsTable extends ActiveSetResults scoreLabelSnapshot, scoreUnitSnapshot, note, + status, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3837,6 +3848,12 @@ class $ActiveSetResultsTable extends ActiveSetResults note.isAcceptableOrUnknown(data['note']!, _noteMeta), ); } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } return context; } @@ -3946,6 +3963,10 @@ class $ActiveSetResultsTable extends ActiveSetResults DriftSqlType.string, data['${effectivePrefix}note'], ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, ); } @@ -3981,6 +4002,7 @@ class ActiveSetResult extends DataClass implements Insertable { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final String? note; + final String status; const ActiveSetResult({ required this.id, required this.createdAt, @@ -4007,6 +4029,7 @@ class ActiveSetResult extends DataClass implements Insertable { this.scoreLabelSnapshot, this.scoreUnitSnapshot, this.note, + required this.status, }); @override Map toColumns(bool nullToAbsent) { @@ -4060,6 +4083,7 @@ class ActiveSetResult extends DataClass implements Insertable { if (!nullToAbsent || note != null) { map['note'] = Variable(note); } + map['status'] = Variable(status); return map; } @@ -4112,6 +4136,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? const Value.absent() : Value(scoreUnitSnapshot), note: note == null && nullToAbsent ? const Value.absent() : Value(note), + status: Value(status), ); } @@ -4156,6 +4181,7 @@ class ActiveSetResult extends DataClass implements Insertable { json['scoreUnitSnapshot'], ), note: serializer.fromJson(json['note']), + status: serializer.fromJson(json['status']), ); } @override @@ -4189,6 +4215,7 @@ class ActiveSetResult extends DataClass implements Insertable { 'scoreLabelSnapshot': serializer.toJson(scoreLabelSnapshot), 'scoreUnitSnapshot': serializer.toJson(scoreUnitSnapshot), 'note': serializer.toJson(note), + 'status': serializer.toJson(status), }; } @@ -4218,6 +4245,7 @@ class ActiveSetResult extends DataClass implements Insertable { Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + String? status, }) => ActiveSetResult( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -4253,6 +4281,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? scoreUnitSnapshot.value : this.scoreUnitSnapshot, note: note.present ? note.value : this.note, + status: status ?? this.status, ); ActiveSetResult copyWithCompanion(ActiveSetResultsCompanion data) { return ActiveSetResult( @@ -4315,6 +4344,7 @@ class ActiveSetResult extends DataClass implements Insertable { ? data.scoreUnitSnapshot.value : this.scoreUnitSnapshot, note: data.note.present ? data.note.value : this.note, + status: data.status.present ? data.status.value : this.status, ); } @@ -4345,7 +4375,8 @@ class ActiveSetResult extends DataClass implements Insertable { ..write('actualScore: $actualScore, ') ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') - ..write('note: $note') + ..write('note: $note, ') + ..write('status: $status') ..write(')')) .toString(); } @@ -4377,6 +4408,7 @@ class ActiveSetResult extends DataClass implements Insertable { scoreLabelSnapshot, scoreUnitSnapshot, note, + status, ]); @override bool operator ==(Object other) => @@ -4406,7 +4438,8 @@ class ActiveSetResult extends DataClass implements Insertable { other.actualScore == this.actualScore && other.scoreLabelSnapshot == this.scoreLabelSnapshot && other.scoreUnitSnapshot == this.scoreUnitSnapshot && - other.note == this.note); + other.note == this.note && + other.status == this.status); } class ActiveSetResultsCompanion extends UpdateCompanion { @@ -4435,6 +4468,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { final Value scoreLabelSnapshot; final Value scoreUnitSnapshot; final Value note; + final Value status; final Value rowid; const ActiveSetResultsCompanion({ this.id = const Value.absent(), @@ -4462,6 +4496,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), this.note = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }); ActiveSetResultsCompanion.insert({ @@ -4490,6 +4525,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), this.note = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -4529,6 +4565,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { Expression? scoreLabelSnapshot, Expression? scoreUnitSnapshot, Expression? note, + Expression? status, Expression? rowid, }) { return RawValuesInsertable({ @@ -4561,6 +4598,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { 'score_label_snapshot': scoreLabelSnapshot, if (scoreUnitSnapshot != null) 'score_unit_snapshot': scoreUnitSnapshot, if (note != null) 'note': note, + if (status != null) 'status': status, if (rowid != null) 'rowid': rowid, }); } @@ -4591,6 +4629,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { Value? scoreLabelSnapshot, Value? scoreUnitSnapshot, Value? note, + Value? status, Value? rowid, }) { return ActiveSetResultsCompanion( @@ -4620,6 +4659,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { scoreLabelSnapshot: scoreLabelSnapshot ?? this.scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot ?? this.scoreUnitSnapshot, note: note ?? this.note, + status: status ?? this.status, rowid: rowid ?? this.rowid, ); } @@ -4706,6 +4746,9 @@ class ActiveSetResultsCompanion extends UpdateCompanion { if (note.present) { map['note'] = Variable(note.value); } + if (status.present) { + map['status'] = Variable(status.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -4740,6 +4783,7 @@ class ActiveSetResultsCompanion extends UpdateCompanion { ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('note: $note, ') + ..write('status: $status, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -12033,6 +12077,16 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('completed'), + ); @override List get $columns => [ id, @@ -12067,6 +12121,7 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults scoreUnitSnapshot, startedAt, completedAt, + status, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -12375,6 +12430,12 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults ), ); } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } return context; } @@ -12515,6 +12576,10 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults DriftSqlType.dateTime, data['${effectivePrefix}completed_at'], ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, ); } @@ -12558,6 +12623,7 @@ class WorkoutHistorySetResult extends DataClass final String? scoreUnitSnapshot; final DateTime? startedAt; final DateTime? completedAt; + final String status; const WorkoutHistorySetResult({ required this.id, required this.createdAt, @@ -12591,6 +12657,7 @@ class WorkoutHistorySetResult extends DataClass this.scoreUnitSnapshot, this.startedAt, this.completedAt, + required this.status, }); @override Map toColumns(bool nullToAbsent) { @@ -12657,6 +12724,7 @@ class WorkoutHistorySetResult extends DataClass if (!nullToAbsent || completedAt != null) { map['completed_at'] = Variable(completedAt); } + map['status'] = Variable(status); return map; } @@ -12723,6 +12791,7 @@ class WorkoutHistorySetResult extends DataClass completedAt: completedAt == null && nullToAbsent ? const Value.absent() : Value(completedAt), + status: Value(status), ); } @@ -12786,6 +12855,7 @@ class WorkoutHistorySetResult extends DataClass ), startedAt: serializer.fromJson(json['startedAt']), completedAt: serializer.fromJson(json['completedAt']), + status: serializer.fromJson(json['status']), ); } @override @@ -12826,6 +12896,7 @@ class WorkoutHistorySetResult extends DataClass 'scoreUnitSnapshot': serializer.toJson(scoreUnitSnapshot), 'startedAt': serializer.toJson(startedAt), 'completedAt': serializer.toJson(completedAt), + 'status': serializer.toJson(status), }; } @@ -12862,6 +12933,7 @@ class WorkoutHistorySetResult extends DataClass Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + String? status, }) => WorkoutHistorySetResult( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -12909,6 +12981,7 @@ class WorkoutHistorySetResult extends DataClass : this.scoreUnitSnapshot, startedAt: startedAt.present ? startedAt.value : this.startedAt, completedAt: completedAt.present ? completedAt.value : this.completedAt, + status: status ?? this.status, ); WorkoutHistorySetResult copyWithCompanion( WorkoutHistorySetResultsCompanion data, @@ -12996,6 +13069,7 @@ class WorkoutHistorySetResult extends DataClass completedAt: data.completedAt.present ? data.completedAt.value : this.completedAt, + status: data.status.present ? data.status.value : this.status, ); } @@ -13033,7 +13107,8 @@ class WorkoutHistorySetResult extends DataClass ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('startedAt: $startedAt, ') - ..write('completedAt: $completedAt') + ..write('completedAt: $completedAt, ') + ..write('status: $status') ..write(')')) .toString(); } @@ -13072,6 +13147,7 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot, startedAt, completedAt, + status, ]); @override bool operator ==(Object other) => @@ -13108,7 +13184,8 @@ class WorkoutHistorySetResult extends DataClass other.scoreLabelSnapshot == this.scoreLabelSnapshot && other.scoreUnitSnapshot == this.scoreUnitSnapshot && other.startedAt == this.startedAt && - other.completedAt == this.completedAt); + other.completedAt == this.completedAt && + other.status == this.status); } class WorkoutHistorySetResultsCompanion @@ -13145,6 +13222,7 @@ class WorkoutHistorySetResultsCompanion final Value scoreUnitSnapshot; final Value startedAt; final Value completedAt; + final Value status; final Value rowid; const WorkoutHistorySetResultsCompanion({ this.id = const Value.absent(), @@ -13179,6 +13257,7 @@ class WorkoutHistorySetResultsCompanion this.scoreUnitSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutHistorySetResultsCompanion.insert({ @@ -13214,6 +13293,7 @@ class WorkoutHistorySetResultsCompanion this.scoreUnitSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), + this.status = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -13265,6 +13345,7 @@ class WorkoutHistorySetResultsCompanion Expression? scoreUnitSnapshot, Expression? startedAt, Expression? completedAt, + Expression? status, Expression? rowid, }) { return RawValuesInsertable({ @@ -13311,6 +13392,7 @@ class WorkoutHistorySetResultsCompanion if (scoreUnitSnapshot != null) 'score_unit_snapshot': scoreUnitSnapshot, if (startedAt != null) 'started_at': startedAt, if (completedAt != null) 'completed_at': completedAt, + if (status != null) 'status': status, if (rowid != null) 'rowid': rowid, }); } @@ -13348,6 +13430,7 @@ class WorkoutHistorySetResultsCompanion Value? scoreUnitSnapshot, Value? startedAt, Value? completedAt, + Value? status, Value? rowid, }) { return WorkoutHistorySetResultsCompanion( @@ -13384,6 +13467,7 @@ class WorkoutHistorySetResultsCompanion scoreUnitSnapshot: scoreUnitSnapshot ?? this.scoreUnitSnapshot, startedAt: startedAt ?? this.startedAt, completedAt: completedAt ?? this.completedAt, + status: status ?? this.status, rowid: rowid ?? this.rowid, ); } @@ -13499,6 +13583,9 @@ class WorkoutHistorySetResultsCompanion if (completedAt.present) { map['completed_at'] = Variable(completedAt.value); } + if (status.present) { + map['status'] = Variable(status.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -13540,6 +13627,7 @@ class WorkoutHistorySetResultsCompanion ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') ..write('startedAt: $startedAt, ') ..write('completedAt: $completedAt, ') + ..write('status: $status, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -18114,6 +18202,7 @@ typedef $$ActiveSetResultsTableCreateCompanionBuilder = Value scoreLabelSnapshot, Value scoreUnitSnapshot, Value note, + Value status, Value rowid, }); typedef $$ActiveSetResultsTableUpdateCompanionBuilder = @@ -18143,6 +18232,7 @@ typedef $$ActiveSetResultsTableUpdateCompanionBuilder = Value scoreLabelSnapshot, Value scoreUnitSnapshot, Value note, + Value status, Value rowid, }); @@ -18307,6 +18397,11 @@ class $$ActiveSetResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + $$ActiveWorkoutSessionsTableFilterComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableFilterComposer composer = $composerBuilder( @@ -18461,6 +18556,11 @@ class $$ActiveSetResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + $$ActiveWorkoutSessionsTableOrderingComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableOrderingComposer composer = $composerBuilder( @@ -18599,6 +18699,9 @@ class $$ActiveSetResultsTableAnnotationComposer GeneratedColumn get note => $composableBuilder(column: $table.note, builder: (column) => column); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + $$ActiveWorkoutSessionsTableAnnotationComposer get activeWorkoutSessionId { final $$ActiveWorkoutSessionsTableAnnotationComposer composer = $composerBuilder( @@ -18679,6 +18782,7 @@ class $$ActiveSetResultsTableTableManager Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveSetResultsCompanion( id: id, @@ -18706,6 +18810,7 @@ class $$ActiveSetResultsTableTableManager scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, note: note, + status: status, rowid: rowid, ), createCompanionCallback: @@ -18735,6 +18840,7 @@ class $$ActiveSetResultsTableTableManager Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), Value note = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => ActiveSetResultsCompanion.insert( id: id, @@ -18762,6 +18868,7 @@ class $$ActiveSetResultsTableTableManager scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, note: note, + status: status, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -23550,6 +23657,7 @@ typedef $$WorkoutHistorySetResultsTableCreateCompanionBuilder = Value scoreUnitSnapshot, Value startedAt, Value completedAt, + Value status, Value rowid, }); typedef $$WorkoutHistorySetResultsTableUpdateCompanionBuilder = @@ -23586,6 +23694,7 @@ typedef $$WorkoutHistorySetResultsTableUpdateCompanionBuilder = Value scoreUnitSnapshot, Value startedAt, Value completedAt, + Value status, Value rowid, }); @@ -23786,6 +23895,11 @@ class $$WorkoutHistorySetResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + $$WorkoutHistoriesTableFilterComposer get workoutHistoryId { final $$WorkoutHistoriesTableFilterComposer composer = $composerBuilder( composer: this, @@ -23974,6 +24088,11 @@ class $$WorkoutHistorySetResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + $$WorkoutHistoriesTableOrderingComposer get workoutHistoryId { final $$WorkoutHistoriesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -24148,6 +24267,9 @@ class $$WorkoutHistorySetResultsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + $$WorkoutHistoriesTableAnnotationComposer get workoutHistoryId { final $$WorkoutHistoriesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -24243,6 +24365,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistorySetResultsCompanion( id: id, @@ -24277,6 +24400,7 @@ class $$WorkoutHistorySetResultsTableTableManager scoreUnitSnapshot: scoreUnitSnapshot, startedAt: startedAt, completedAt: completedAt, + status: status, rowid: rowid, ), createCompanionCallback: @@ -24313,6 +24437,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value scoreUnitSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), + Value status = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistorySetResultsCompanion.insert( id: id, @@ -24347,6 +24472,7 @@ class $$WorkoutHistorySetResultsTableTableManager scoreUnitSnapshot: scoreUnitSnapshot, startedAt: startedAt, completedAt: completedAt, + status: status, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 9978bda..6931ec2 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -929,6 +929,7 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion( scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), note: Value(result.note), + status: Value(_setResultStatusToDb(result.status)), ); } @@ -949,6 +950,7 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) { scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, note: row.note, + status: _setResultStatusFromDb(row.status), ); } @@ -1059,6 +1061,7 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( scoreUnitSnapshot: Value(result.scoreUnitSnapshot), startedAt: Value(result.startedAt), completedAt: Value(result.completedAt), + status: Value(_setResultStatusToDb(result.status)), ); } @@ -1106,6 +1109,7 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( scoreUnitSnapshot: row.scoreUnitSnapshot, startedAt: row.startedAt, completedAt: row.completedAt, + status: _setResultStatusFromDb(row.status), ); } @@ -1143,6 +1147,17 @@ domain.MediaKind _mediaKindFromDb(String value) => switch (value) { _ => throw domain.DomainException('Unknown media kind: $value'), }; +String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) { + domain.SetResultStatus.completed => 'completed', + domain.SetResultStatus.skipped => 'skipped', +}; + +domain.SetResultStatus _setResultStatusFromDb(String value) => switch (value) { + 'completed' => domain.SetResultStatus.completed, + 'skipped' => domain.SetResultStatus.skipped, + _ => throw domain.DomainException('Unknown set result status: $value'), +}; + 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 7c8b78e..a7ae9d8 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -244,6 +244,7 @@ class ActiveSetResults extends SyncableTable { TextColumn get scoreLabelSnapshot => text().nullable()(); TextColumn get scoreUnitSnapshot => text().nullable()(); TextColumn get note => text().nullable()(); + TextColumn get status => text().withDefault(const Constant('completed'))(); @override List get customConstraints => [ @@ -255,6 +256,9 @@ class ActiveSetResults extends SyncableTable { 'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)', 'CHECK (actual_reps IS NULL OR actual_reps >= 0)', 'CHECK (actual_score IS NULL OR actual_score >= 0)', + "CHECK (status IN ('completed', 'skipped'))", + 'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL ' + 'AND actual_reps IS NULL AND actual_score IS NULL))', 'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL ' 'AND length(trim(score_label_snapshot)) > 0 ' 'AND score_unit_snapshot IS NOT NULL ' @@ -332,6 +336,7 @@ class WorkoutHistorySetResults extends SyncableTable { TextColumn get scoreUnitSnapshot => text().nullable()(); DateTimeColumn get startedAt => dateTime().nullable()(); DateTimeColumn get completedAt => dateTime().nullable()(); + TextColumn get status => text().withDefault(const Constant('completed'))(); @override List get customConstraints => [ @@ -349,6 +354,9 @@ class WorkoutHistorySetResults extends SyncableTable { 'CHECK (actual_time_ms IS NULL OR actual_time_ms >= 0)', 'CHECK (actual_reps IS NULL OR actual_reps >= 0)', 'CHECK (actual_score IS NULL OR actual_score >= 0)', + "CHECK (status IN ('completed', 'skipped'))", + 'CHECK (status != \'skipped\' OR (actual_time_ms IS NULL ' + 'AND actual_reps IS NULL AND actual_score IS NULL))', 'CHECK (actual_score IS NULL OR (score_label_snapshot IS NOT NULL ' 'AND length(trim(score_label_snapshot)) > 0 ' 'AND score_unit_snapshot IS NOT NULL ' diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index a808b73..fee0301 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -209,6 +209,123 @@ void main() { expect(active?.metadata.id, 'latest-active'); }); + + test('upsertSetResultAtPosition does not move session cursor', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 1, + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + await useCase.upsertSetResultAtPosition( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: SetResultStatus.completed, + actualReps: 12, + ); + + expect(repository.session?.currentProgramIndex, 0); + expect(repository.session?.currentExerciseIndex, 0); + expect(repository.session?.currentSetIndex, 1); + expect(repository.results.single.actualReps, 12); + }); + + test( + 'upsertSetResultAtPosition preserves existing id and createdAt', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 1, + ); + final existingCreatedAt = DateTime.utc(2026, 7, 17, 10); + final existing = ActiveSetResult( + metadata: EntityMetadata( + id: 'result-1', + createdAt: existingCreatedAt, + updatedAt: existingCreatedAt, + originDeviceId: 'device-1', + ), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualReps: 8, + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..results.add(existing); + final useCase = _activeUseCase(repository, clock); + + final updated = await useCase.upsertSetResultAtPosition( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: SetResultStatus.completed, + actualReps: 12, + ); + + expect(updated.metadata.id, 'result-1'); + expect(updated.metadata.createdAt, existingCreatedAt); + expect(updated.actualReps, 12); + }, + ); + + test( + 'listSetResults returns pending, skipped and completed statuses', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 2, + setsCount: 3, + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..results.addAll([ + ActiveSetResult( + metadata: _metadata('completed'), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualReps: 10, + status: SetResultStatus.completed, + ), + ActiveSetResult( + metadata: _metadata('skipped'), + activeWorkoutSessionId: session.metadata.id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 1, + status: SetResultStatus.skipped, + ), + ]); + final useCase = _activeUseCase(repository, clock); + + final states = await useCase.listSetResults(session.metadata.id); + + expect(states.map((state) => state.status), [ + SetPositionStatus.completed, + SetPositionStatus.skipped, + SetPositionStatus.pending, + ]); + }, + ); } EntityMetadata _metadata(String id) { @@ -294,13 +411,17 @@ ActiveWorkoutSessionUseCases _activeUseCase( } final class _FakeActiveSessionRepository implements ActiveSessionRepository { + ActiveWorkoutSession? session; + final results = []; final restStates = {}; @override - Future findById(String id) async => null; + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } @override - Future findOpen() async => null; + Future findOpen() async => session; @override Future findRestStateById(String id) async => restStates[id]; @@ -313,11 +434,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future> listSetResults(String sessionId) async => - const []; + Future> listSetResults(String sessionId) async { + return results + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } @override - Future save(ActiveWorkoutSession session) async {} + Future save(ActiveWorkoutSession session) async { + this.session = session; + } @override Future saveRestState(ActiveRestState restState) async { @@ -325,5 +451,53 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future saveSetResult(ActiveSetResult result) async {} + Future saveSetResult(ActiveSetResult result) async { + results.removeWhere( + (existing) => + existing.activeWorkoutSessionId == result.activeWorkoutSessionId && + existing.programIndex == result.programIndex && + existing.exerciseIndex == result.exerciseIndex && + existing.setIndex == result.setIndex, + ); + results.add(result); + } +} + +ActiveWorkoutSession _sessionWithSnapshot({ + required int currentProgramIndex, + required int currentExerciseIndex, + required int currentSetIndex, + int setsCount = 2, +}) { + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: currentProgramIndex, + currentExerciseIndex: currentExerciseIndex, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [ + { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 10, + }, + ], + }), + }, + ], + }), + ); }