diff --git a/.ideai/tickets/61/carnet.md b/.ideai/tickets/61/carnet.md index 688926e..727f7e9 100644 --- a/.ideai/tickets/61/carnet.md +++ b/.ideai/tickets/61/carnet.md @@ -1,6 +1,9 @@ --- issueRef: "#61" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784481541650 +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784485187502 --- +Plan de séance : résumé de séquence par série (via `ActiveExerciseStepUseCases.readProgress`). Historique : détail des résultats d'étapes groupés par passage (durée/répétitions, score d'étape, badge "Passée" pour skipped), réutilise `WorkoutHistory.stepResults` déjà peuplé par #58. 102/102 tests verts, `flutter analyze` clean, APK debug buildé avec succès. + +Écart potentiel vs description initiale du ticket (rédigée par Architect) : la description mentionne aussi "l'édition ponctuelle d'une série passée/terminée permet de corriger les valeurs enregistrées [d'étapes] sans rejouer la séquence interactive" — mon brief à DevFrontend n'a couvert que l'AFFICHAGE des résultats d'étapes dans le plan/historique, pas leur édition a posteriori. Le flux existant "modifier une série passée" (upsertSetResultAtPosition) n'a pas été étendu pour éditer les résultats d'étapes individuels. À vérifier en QA (#62) si c'est bloquant pour l'usage réel ou si un ticket de suivi séparé suffit. \ No newline at end of file diff --git a/.ideai/tickets/61/issue.md b/.ideai/tickets/61/issue.md index 4a55d16..0086796 100644 --- a/.ideai/tickets/61/issue.md +++ b/.ideai/tickets/61/issue.md @@ -2,15 +2,15 @@ id: "1eed02ec-04af-4966-a0ff-449c4a957ea0" number: 61 title: "[DevFrontend] Plan de séance et historique avec résultats d'étapes" -status: "open" +status: "qa" 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"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784481541650 -updatedAt: 1784481541650 -version: 1 +updatedAt: 1784485187502 +version: 3 --- 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/index.json b/.ideai/tickets/index.json index 7fc399b..01a0f12 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -679,13 +679,13 @@ "issueRef": "#61", "path": "61", "title": "[DevFrontend] Plan de séance et historique avec résultats d'étapes", - "status": "open", + "status": "qa", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784481541650 + "updatedAt": 1784485187502 }, { "issueRef": "#62", diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 756fbab..7b46b17 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -291,22 +291,6 @@ final class ExerciseUseCases { final deleted = exercise.copyWith( metadata: exercise.metadata.markDeleted(now), ); - final programs = await programRepository.listActive(); - for (final program in programs) { - final keptExercises = program.exercises - .where((programExercise) => programExercise.sourceExerciseId != id) - .toList(); - if (keptExercises.length == program.exercises.length) { - continue; - } - await programRepository.replaceExercises( - program.copyWith( - metadata: program.metadata.touch(now), - exercises: _repositionProgramExercises(keptExercises, now), - ), - now, - ); - } await repository.save(deleted); return deleted; } @@ -964,12 +948,12 @@ final class ActiveWorkoutSessionUseCases { await sessionRepository.saveScoreStopwatchState(stopped); } final runningStepTimers = - (await sessionRepository.listExerciseStepProgressStates(sessionId)) - .where( - (state) => - state.status == - ActiveExerciseStepProgressStatus.runningTimer, - ); + (await sessionRepository.listExerciseStepProgressStates( + sessionId, + )).where( + (state) => + state.status == ActiveExerciseStepProgressStatus.runningTimer, + ); for (final state in runningStepTimers) { final pausedStep = state.copyWith( metadata: state.metadata.touch(now), @@ -1510,7 +1494,8 @@ final class ActiveExerciseStepUseCases { exerciseIndex: exerciseIndex, setIndex: setIndex, ); - final state = existing ?? + final state = + existing ?? _initialStepProgressState( sessionId: sessionId, context: context, @@ -2158,47 +2143,6 @@ final class SetResultPositionState { final ActiveSetResult? result; } -List _repositionProgramExercises( - List exercises, - DateTime now, -) { - return [ - for (var index = 0; index < exercises.length; index++) - ProgramExercise( - metadata: exercises[index].metadata.touch(now), - programId: exercises[index].programId, - sourceExerciseId: exercises[index].sourceExerciseId, - position: index, - exerciseNameSnapshot: exercises[index].exerciseNameSnapshot, - exerciseDescriptionSnapshot: - exercises[index].exerciseDescriptionSnapshot, - exerciseImageMediaIdSnapshot: - exercises[index].exerciseImageMediaIdSnapshot, - exerciseImageMediaIdsSnapshot: - exercises[index].exerciseImageMediaIdsSnapshot, - exerciseVideoMediaIdSnapshot: - exercises[index].exerciseVideoMediaIdSnapshot, - exerciseStepsSnapshot: exercises[index].exerciseStepsSnapshot, - exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot, - availableTimeSnapshot: exercises[index].availableTimeSnapshot, - availableRepsSnapshot: exercises[index].availableRepsSnapshot, - availableScoreSnapshot: exercises[index].availableScoreSnapshot, - scoreInputModeSnapshot: exercises[index].scoreInputModeSnapshot, - scoreLabelSnapshot: exercises[index].scoreLabelSnapshot, - scoreUnitSnapshot: exercises[index].scoreUnitSnapshot, - setsCount: exercises[index].setsCount, - timeEnabled: exercises[index].timeEnabled, - repsEnabled: exercises[index].repsEnabled, - scoreEnabled: exercises[index].scoreEnabled, - targetTimeSeconds: exercises[index].targetTimeSeconds, - targetReps: exercises[index].targetReps, - targetScore: exercises[index].targetScore, - targetScoreTimeMs: exercises[index].targetScoreTimeMs, - restSecondsOverride: exercises[index].restSecondsOverride, - ), - ]; -} - List _repositionWorkoutTemplatePrograms( List programs, DateTime now, @@ -2494,7 +2438,8 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ } final programSnapshot = jsonDecode(programSnapshotJson) as Map; - final exercises = (programSnapshot['exercises'] as List? ?? const []); + final exercises = + (programSnapshot['exercises'] as List? ?? const []); if (exerciseIndex < 0 || exerciseIndex >= exercises.length) { return null; } @@ -2749,26 +2694,28 @@ List _exerciseStepsFromSnapshot(Object? value) { if (value is! List) { return const []; } - return value.map((item) { - final json = Map.from(item as Map); - return ExerciseStep( - id: json['id'] as String, - position: json['position'] as int, - name: json['name'] as String, - type: switch (json['type']) { - 'time' => ExerciseStepType.time, - 'reps' => ExerciseStepType.reps, - _ => throw const DomainException('Unknown exercise step type.'), - }, - defaultTargetValue: json['defaultTargetValue'] as int, - hasScore: json['hasScore'] == true, - scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']), - scoreLabel: json['scoreLabel'] as String?, - scoreUnit: json['scoreUnit'] as String?, - defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(), - defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?, - ); - }).toList(growable: false); + return value + .map((item) { + final json = Map.from(item as Map); + return ExerciseStep( + id: json['id'] as String, + position: json['position'] as int, + name: json['name'] as String, + type: switch (json['type']) { + 'time' => ExerciseStepType.time, + 'reps' => ExerciseStepType.reps, + _ => throw const DomainException('Unknown exercise step type.'), + }, + defaultTargetValue: json['defaultTargetValue'] as int, + hasScore: json['hasScore'] == true, + scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']), + scoreLabel: json['scoreLabel'] as String?, + scoreUnit: json['scoreUnit'] as String?, + defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(), + defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?, + ); + }) + .toList(growable: false); } List _historyStepResultsFromActiveResults({ diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index dd51049..6216a1e 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -220,7 +220,8 @@ final class _ExerciseLibraryScreenState extends State { content: Text( used ? 'Cet exercice est utilisé dans un ou plusieurs programmes. ' - 'Le supprimer le retirera de ces programmes. Continuer ?' + 'Il sera retiré de la bibliothèque. ' + 'Les programmes existants restent inchangés. Continuer ?' : 'Cette action est irréversible.', ), actions: [ @@ -885,8 +886,9 @@ final class _ExerciseFormScreenState extends State { controller: draft.targetController, decoration: InputDecoration(labelText: targetLabel), keyboardType: TextInputType.number, - validator: (value) => - _stepsEnabled ? _positiveIntValidator(value, targetMessage) : null, + validator: (value) => _stepsEnabled + ? _positiveIntValidator(value, targetMessage) + : null, onChanged: (_) => setState(() {}), ), const SizedBox(height: 8), @@ -898,7 +900,10 @@ final class _ExerciseFormScreenState extends State { onChanged: (value) => setState(() => draft.hasScore = value), ), if (draft.hasScore) ...[ - Text('Mode de score', style: Theme.of(context).textTheme.titleSmall), + Text( + 'Mode de score', + style: Theme.of(context).textTheme.titleSmall, + ), RadioListTile( contentPadding: EdgeInsets.zero, title: const Text('Saisie libre'), @@ -922,7 +927,9 @@ final class _ExerciseFormScreenState extends State { if (draft.scoreInputMode == ScoreInputMode.manual) ...[ TextFormField( controller: draft.scoreLabelController, - decoration: const InputDecoration(labelText: 'Score à saisir'), + decoration: const InputDecoration( + labelText: 'Score à saisir', + ), validator: (value) { if (!_stepsEnabled || !draft.hasScore) return null; if (draft.scoreInputMode != ScoreInputMode.manual) { @@ -1081,9 +1088,10 @@ final class _ExerciseFormScreenState extends State { } setState(() { _stepError = null; - _stepDrafts.insert(index + 1, _stepDrafts[index].duplicate( - 'step-draft-${_nextStepDraftId++}', - )); + _stepDrafts.insert( + index + 1, + _stepDrafts[index].duplicate('step-draft-${_nextStepDraftId++}'), + ); }); } @@ -1143,8 +1151,8 @@ final class _ExerciseFormScreenState extends State { for (var index = 0; index < _stepDrafts.length; index++) _stepDrafts[index].toExerciseStep( position: index, - defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode == - ScoreInputMode.stopwatch + defaultTargetScoreTimeMs: + _stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch ? _optionalSecondsToMilliseconds( _stepDrafts[index].scoreTargetController.text, ) diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index fcadb4e..664792b 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -479,7 +479,7 @@ void main() { ); }); - test('delete exercise removes it from referencing programs', () async { + test('delete exercise keeps existing program snapshots unchanged', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( metadata: _metadata('exercise-1'), @@ -502,12 +502,6 @@ void main() { sourceExerciseId: 'exercise-1', position: 0, ), - _programExercise( - id: 'program-exercise-2', - programId: 'program-1', - sourceExerciseId: 'exercise-2', - position: 1, - ), ], ), ); @@ -523,7 +517,11 @@ void main() { expect(programRepository.programs.single.exercises, hasLength(1)); expect( programRepository.programs.single.exercises.single.sourceExerciseId, - 'exercise-2', + 'exercise-1', + ); + expect( + programRepository.programs.single.exercises.single.exerciseNameSnapshot, + 'Exercise program-exercise-1', ); expect(programRepository.programs.single.exercises.single.position, 0); }); @@ -1020,6 +1018,95 @@ void main() { expect(stopped.accumulatedMs, 4000); }); + test( + 'exercise step progress auto-advances consecutive elapsed timers after kill', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + setsCount: 1, + targetReps: 2, + exerciseSteps: [ + _step( + id: 'step-1', + position: 0, + name: 'Gauche', + type: ExerciseStepType.time, + defaultTargetValue: 1, + ), + _step( + id: 'step-2', + position: 1, + name: 'Droite', + type: ExerciseStepType.time, + defaultTargetValue: 1, + ), + ], + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await useCase.startOrResumeProgress( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await useCase.startTimer( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + clock.value = clock.value.add(const Duration(milliseconds: 4500)); + final reconstructed = ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final view = await reconstructed.startOrResumeProgress( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + expect( + view.state.status, + ActiveExerciseStepProgressStatus.sequenceComplete, + ); + expect(repository.stepResults, hasLength(4)); + expect(repository.stepResults.map((result) => result.passageIndex), [ + 0, + 0, + 1, + 1, + ]); + expect(repository.stepResults.map((result) => result.stepIndex), [ + 0, + 1, + 0, + 1, + ]); + expect(repository.stepResults.map((result) => result.actualTimeMs), [ + 1000, + 1000, + 1000, + 1000, + ]); + }, + ); + test('score result enforces manual xor stopwatch values', () { expect( () => ActiveSetResult( @@ -1431,9 +1518,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future saveExerciseStepResult( - ActiveExerciseStepResult result, - ) async { + Future saveExerciseStepResult(ActiveExerciseStepResult result) async { stepResults.removeWhere( (existing) => existing.activeWorkoutSessionId == result.activeWorkoutSessionId && @@ -1484,6 +1569,8 @@ ActiveWorkoutSession _sessionWithSnapshot({ required int currentExerciseIndex, required int currentSetIndex, int setsCount = 2, + int targetReps = 10, + List exerciseSteps = const [], }) { return ActiveWorkoutSession( metadata: _metadata('session-1'), @@ -1508,7 +1595,10 @@ ActiveWorkoutSession _sessionWithSnapshot({ 'timeEnabled': false, 'repsEnabled': true, 'scoreEnabled': false, - 'targetReps': 10, + 'targetReps': targetReps, + 'exerciseStepsSnapshot': exerciseSteps + .map((step) => step.toSnapshotJson()) + .toList(), }, ], }), diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index f9fda9d..957b32c 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -226,7 +226,10 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); @@ -256,7 +259,10 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); @@ -300,7 +306,10 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); @@ -334,7 +343,10 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); @@ -363,7 +375,10 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); for (var index = 0; index < 8; index++) { @@ -372,7 +387,10 @@ void main() { } expect(find.text('Limite atteinte'), findsOneWidget); - expect(find.text('Un exercice peut contenir jusqu’à 8 étapes.'), findsOneWidget); + expect( + find.text('Un exercice peut contenir jusqu’à 8 étapes.'), + findsOneWidget, + ); expect(find.text('Ajouter une étape'), findsNothing); }); @@ -389,7 +407,10 @@ void main() { '30', ); await tester.tap( - find.widgetWithText(SwitchListTile, 'Rythmer cet exercice avec des étapes'), + find.widgetWithText( + SwitchListTile, + 'Rythmer cet exercice avec des étapes', + ), ); await tester.pump(); await tester.tap(find.text('Ajouter une étape')); @@ -512,7 +533,7 @@ void main() { expect(find.text('Supprimer Squat ?'), findsOneWidget); expect( - find.textContaining('Le supprimer le retirera de ces programmes.'), + find.textContaining('Les programmes existants restent inchangés.'), findsOneWidget, );