From 8bf4f892769a05694b5777f69000ac3eba7031a2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 20:19:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(exercice):=20plan=20de=20s=C3=A9ance?= =?UTF-8?q?=20et=20historique=20avec=20r=C3=A9sultats=20d'=C3=A9tapes=20(t?= =?UTF-8?q?icket=20#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiche le plan de séance et l'historique avec les résultats par étape sur workout_execution_screen.dart et history_screen.dart. flutter pub get OK, dart format appliqué (3 fichiers reformatés), analyze propre (mêmes infos préexistantes), 102/102 tests verts, build APK debug validé. Dernier ticket UI du chantier "Exercice à plusieurs étapes" (#54) — il ne reste que la QA finale (#62). Co-Authored-By: Claude Opus 4.8 --- lib/presentation/history_screen.dart | 228 +++++++++++++++++- .../workout_execution_screen.dart | 208 ++++++++++++---- test/presentation/history_screen_test.dart | 101 +++++++- .../workout_execution_screen_test.dart | 82 ++++++- 4 files changed, 557 insertions(+), 62 deletions(-) diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index e55fda1..79832fc 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -151,10 +151,21 @@ final class HistoryDetailScreen extends StatelessWidget { style: Theme.of(context).textTheme.titleSmall, ), for (final set in exercise.sets) - ListTile( - dense: true, - title: Text('Série ${set.setIndex + 1}'), - subtitle: Text(set.summary), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text('Série ${set.setIndex + 1}'), + subtitle: Text(set.summary), + ), + if (set.stepPassages.isNotEmpty) + _HistoryStepResultsSection(passages: set.stepPassages), + ], + ), ), ], const SizedBox(height: 12), @@ -246,6 +257,10 @@ final class HistoryDetailData { ? history.results.map(_resultFromDomain).toList() : (snapshot['results'] as List? ?? const []) .cast>(); + final rawStepResults = history.stepResults.isNotEmpty + ? history.stepResults.map(_stepResultFromDomain).toList() + : (snapshot['stepResults'] as List? ?? const []) + .cast>(); final programs = []; final rawPrograms = resolved['programs'] as List? ?? const []; for ( @@ -270,12 +285,25 @@ final class HistoryDetailData { return result['programIndex'] == programIndex && result['exerciseIndex'] == exerciseIndex; }).toList(); + final exerciseStepResults = rawStepResults.where((result) { + return result['programIndex'] == programIndex && + result['exerciseIndex'] == exerciseIndex; + }).toList(); exercises.add( HistoryExerciseDetail( name: rawExercise['exerciseNameSnapshot'] as String, sets: [ for (final result in exerciseResults) - HistorySetDetail.fromSnapshot(rawExercise, result), + HistorySetDetail.fromSnapshot( + rawExercise, + result, + exerciseStepResults + .where( + (stepResult) => + stepResult['setIndex'] == result['setIndex'], + ) + .toList(), + ), ], ), ); @@ -308,11 +336,16 @@ final class HistoryExerciseDetail { } final class HistorySetDetail { - const HistorySetDetail({required this.setIndex, required this.summary}); + const HistorySetDetail({ + required this.setIndex, + required this.summary, + required this.stepPassages, + }); factory HistorySetDetail.fromSnapshot( Map exercise, Map result, + List> stepResults, ) { final parts = []; if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) { @@ -345,11 +378,101 @@ final class HistorySetDetail { return HistorySetDetail( setIndex: result['setIndex'] as int, summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '), + stepPassages: _stepPassagesFromResults(stepResults), ); } final int setIndex; final String summary; + final List stepPassages; +} + +final class HistoryStepPassageDetail { + const HistoryStepPassageDetail({required this.index, required this.steps}); + + final int index; + final List steps; +} + +final class HistoryStepResultDetail { + const HistoryStepResultDetail({ + required this.index, + required this.name, + required this.status, + required this.valueLabel, + this.scoreLabel, + }); + + factory HistoryStepResultDetail.fromResult(Map result) { + final status = _setResultStatusFromSnapshot(result['status']); + return HistoryStepResultDetail( + index: result['stepIndex'] as int, + name: result['stepNameSnapshot'] as String, + status: status, + valueLabel: status == SetResultStatus.skipped + ? null + : _historyStepValueLabel(result), + scoreLabel: status == SetResultStatus.skipped + ? null + : _historyStepScoreLabel(result), + ); + } + + final int index; + final String name; + final SetResultStatus status; + final String? valueLabel; + final String? scoreLabel; +} + +final class _HistoryStepResultsSection extends StatelessWidget { + const _HistoryStepResultsSection({required this.passages}); + + final List passages; + + @override + Widget build(BuildContext context) { + final tokens = courtBlazerTokensOf(context); + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Étapes réalisées', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + for (final passage in passages) ...[ + Text( + 'Passage ${passage.index + 1}', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + for (final step in passage.steps) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(_historyStepLine(step))), + if (step.status == SetResultStatus.skipped) ...[ + const SizedBox(width: 8), + Chip( + visualDensity: VisualDensity.compact, + label: const Text('Passée'), + side: BorderSide(color: tokens.border), + ), + ], + ], + ), + ), + const SizedBox(height: 8), + ], + ], + ), + ); + } } Map> groupHistoriesByPeriod( @@ -395,6 +518,99 @@ Map _resultFromDomain(WorkoutHistorySetResult result) { }; } +Map _stepResultFromDomain(WorkoutHistoryStepResult result) { + return { + 'programIndex': result.programIndex, + 'exerciseIndex': result.exerciseIndex, + 'setIndex': result.setIndex, + 'passageIndex': result.passageIndex, + 'stepIndex': result.stepIndex, + 'stepNameSnapshot': result.stepNameSnapshot, + 'stepTypeSnapshot': result.stepTypeSnapshot.name, + 'targetValueSnapshot': result.targetValueSnapshot, + 'hasScoreSnapshot': result.hasScoreSnapshot, + 'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name, + 'scoreLabelSnapshot': result.scoreLabelSnapshot, + 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'actualTimeMs': result.actualTimeMs, + 'actualReps': result.actualReps, + 'actualScore': result.actualScore, + 'actualScoreTimeMs': result.actualScoreTimeMs, + 'status': result.status.name, + }; +} + +List _stepPassagesFromResults( + List> results, +) { + if (results.isEmpty) return const []; + final byPassage = >>{}; + for (final result in results) { + final passageIndex = result['passageIndex'] as int; + byPassage.putIfAbsent(passageIndex, () => []).add(result); + } + final passages = byPassage.entries.toList() + ..sort((left, right) => left.key.compareTo(right.key)); + return passages + .map((entry) { + final steps = entry.value + ..sort( + (left, right) => + (left['stepIndex'] as int).compareTo(right['stepIndex'] as int), + ); + return HistoryStepPassageDetail( + index: entry.key, + steps: steps.map(HistoryStepResultDetail.fromResult).toList(), + ); + }) + .toList(growable: false); +} + +String _historyStepLine(HistoryStepResultDetail step) { + final parts = ['Étape ${step.index + 1}', step.name]; + if (step.valueLabel != null) { + parts.add(step.valueLabel!); + } + if (step.scoreLabel != null) { + parts.add(step.scoreLabel!); + } + return parts.join(' · '); +} + +String? _historyStepValueLabel(Map result) { + final type = result['stepTypeSnapshot']; + if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) { + return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}'; + } + if (type == ExerciseStepType.reps.name && result['actualReps'] != null) { + return 'Répétitions ${result['actualReps']}'; + } + return null; +} + +String? _historyStepScoreLabel(Map result) { + if (result['hasScoreSnapshot'] != true) return null; + final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']); + if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) { + return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}'; + } + if (mode == ScoreInputMode.manual && result['actualScore'] != null) { + final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score'; + final score = _formatScoreValue(result['actualScore'] as num); + final unit = result['scoreUnitSnapshot'] as String?; + final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit'; + return '$scoreLabel : $score$suffix'; + } + return null; +} + +SetResultStatus _setResultStatusFromSnapshot(Object? value) { + return switch (value) { + 'skipped' => SetResultStatus.skipped, + _ => SetResultStatus.completed, + }; +} + String _historySummary(WorkoutHistory history) { final snapshot = jsonDecode(history.historySnapshotJson) as Map; diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index da3b29c..20e7e77 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -13,7 +13,8 @@ import 'theme.dart'; enum WorkoutExecutionMode { active, rest, paused, finished } -typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset); +typedef VideoMediaBuilder = + Widget Function(BuildContext context, MediaAsset asset); final class WorkoutExecutionScreen extends StatefulWidget { const WorkoutExecutionScreen({ @@ -244,7 +245,7 @@ final class _WorkoutExecutionScreenState extends State { style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(52), ), - child: const Text('Passer'), + child: Text(_hasActiveStepSequence ? 'Passer la série' : 'Passer'), ), ], ); @@ -632,7 +633,9 @@ final class _WorkoutExecutionScreenState extends State { } on Exception catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible de rafraîchir la séquence : $error')), + SnackBar( + content: Text('Impossible de rafraîchir la séquence : $error'), + ), ); } } @@ -642,7 +645,8 @@ final class _WorkoutExecutionScreenState extends State { final previousKey = previous == null ? null : '${previous.state.currentPassageIndex}:${previous.state.currentStepIndex}'; - final nextKey = '${view.state.currentPassageIndex}:${view.state.currentStepIndex}'; + final nextKey = + '${view.state.currentPassageIndex}:${view.state.currentStepIndex}'; if (previousKey != null && previousKey != nextKey) { _resetStepScoreInput(); } @@ -776,7 +780,8 @@ final class _WorkoutExecutionScreenState extends State { Future _skipCurrentStep() async { final confirmed = await _confirmStepSkip( 'Passer cette étape ?', - _stepProgress?.state.status == ActiveExerciseStepProgressStatus.runningTimer + _stepProgress?.state.status == + ActiveExerciseStepProgressStatus.runningTimer ? 'Le chrono de cette étape sera arrêté.' : 'Cette étape sera marquée comme passée.', 'Passer l’étape', @@ -1017,6 +1022,7 @@ final class _WorkoutExecutionScreenState extends State { setStates: setStates, activeRest: activeRest, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, ), ), ); @@ -1602,9 +1608,9 @@ final class _TimedStepBody extends StatelessWidget { Center( child: Text( remainingLabel, - style: AppTextStyles.timer(context).copyWith( - color: Theme.of(context).colorScheme.primary, - ), + style: AppTextStyles.timer( + context, + ).copyWith(color: Theme.of(context).colorScheme.primary), ), ), if (!running) ...[ @@ -1632,9 +1638,9 @@ final class _RepsStepBody extends StatelessWidget { children: [ Text( '${step.defaultTargetValue}', - style: AppTextStyles.timer(context).copyWith( - color: Theme.of(context).colorScheme.primary, - ), + style: AppTextStyles.timer( + context, + ).copyWith(color: Theme.of(context).colorScheme.primary), ), Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 12), @@ -1679,7 +1685,10 @@ final class _StepScoreInput extends StatelessWidget { ), const SizedBox(height: 8), Center( - child: Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), + child: Text( + elapsedLabel, + style: AppTextStyles.scoreNumber(context), + ), ), const SizedBox(height: 8), Wrap( @@ -1980,6 +1989,7 @@ final class _WorkoutPlanSheet extends StatefulWidget { required this.currentPosition, required this.setStates, required this.activeUseCases, + this.stepUseCases, this.activeRest, }); @@ -1988,6 +1998,7 @@ final class _WorkoutPlanSheet extends StatefulWidget { final ExecutionPosition currentPosition; final List setStates; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final ActiveRestState? activeRest; @override @@ -2099,6 +2110,7 @@ final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> { )], onUpdated: _refreshAfterEdit, activeUseCases: widget.activeUseCases, + stepUseCases: widget.stepUseCases, sessionId: widget.sessionId, activeRest: widget.activeRest, ), @@ -2524,7 +2536,7 @@ File _fileFromLocalUri(String localUri) { return File(localUri); } -final class _WorkoutPlanSetTile extends StatelessWidget { +final class _WorkoutPlanSetTile extends StatefulWidget { const _WorkoutPlanSetTile({ required this.position, required this.currentPosition, @@ -2532,6 +2544,7 @@ final class _WorkoutPlanSetTile extends StatelessWidget { required this.onUpdated, required this.activeUseCases, required this.sessionId, + this.stepUseCases, this.activeRest, this.state, }); @@ -2542,12 +2555,51 @@ final class _WorkoutPlanSetTile extends StatelessWidget { final SetResultPositionState? state; final Future Function() onUpdated; final ActiveWorkoutSessionUseCases activeUseCases; + final ActiveExerciseStepUseCases? stepUseCases; final String sessionId; final ActiveRestState? activeRest; + @override + State<_WorkoutPlanSetTile> createState() => _WorkoutPlanSetTileState(); +} + +final class _WorkoutPlanSetTileState extends State<_WorkoutPlanSetTile> { + late final Future _stepProgress; + + @override + void initState() { + super.initState(); + _stepProgress = _loadStepProgress(); + } + + Future _loadStepProgress() async { + final stepUseCases = widget.stepUseCases; + if (stepUseCases == null || widget.exercise.steps.isEmpty) { + return null; + } + final planState = _planSetState( + widget.position, + widget.currentPosition, + widget.state, + ); + if (planState == _PlanSetStatus.todo) { + return null; + } + return stepUseCases.readProgress( + sessionId: widget.sessionId, + programIndex: widget.position.programIndex, + exerciseIndex: widget.position.exerciseIndex, + setIndex: widget.position.setIndex, + ); + } + @override Widget build(BuildContext context) { - final planState = _planSetState(position, currentPosition, state); + final planState = _planSetState( + widget.position, + widget.currentPosition, + widget.state, + ); final tappable = planState != _PlanSetStatus.todo; final VoidCallback? onTap = tappable ? () => unawaited(_handleTap(context, planState)) @@ -2565,8 +2617,13 @@ final class _WorkoutPlanSetTile extends StatelessWidget { ), child: ListTile( enabled: tappable, - title: Text('Série ${position.setIndex + 1}'), - subtitle: Text(_planSetSubtitle(planState, state?.result, exercise)), + title: Text('Série ${widget.position.setIndex + 1}'), + subtitle: _WorkoutPlanSetSubtitle( + status: planState, + result: widget.state?.result, + exercise: widget.exercise, + stepProgress: _stepProgress, + ), trailing: _PlanStatusBadge(status: planState, onTap: onTap), onTap: onTap, ), @@ -2583,19 +2640,59 @@ final class _WorkoutPlanSetTile extends StatelessWidget { isScrollControlled: true, useSafeArea: true, builder: (context) => _EditSetResultSheet( - sessionId: sessionId, - position: position, - exercise: exercise, - state: state, + sessionId: widget.sessionId, + position: widget.position, + exercise: widget.exercise, + state: widget.state, status: status, - activeUseCases: activeUseCases, - activeRest: activeRest, - onSaved: onUpdated, + activeUseCases: widget.activeUseCases, + activeRest: widget.activeRest, + onSaved: widget.onUpdated, ), ); } } +final class _WorkoutPlanSetSubtitle extends StatelessWidget { + const _WorkoutPlanSetSubtitle({ + required this.status, + required this.result, + required this.exercise, + required this.stepProgress, + }); + + final _PlanSetStatus status; + final ActiveSetResult? result; + final ExecutionExercise exercise; + final Future stepProgress; + + @override + Widget build(BuildContext context) { + final resultLabel = _planSetSubtitle(status, result, exercise); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (resultLabel.isNotEmpty) Text(resultLabel), + if (exercise.steps.isNotEmpty) + FutureBuilder( + future: stepProgress, + builder: (context, snapshot) { + final progress = snapshot.data; + final label = progress == null + ? 'Séquence : 0/${exercise.steps.length} étapes' + : _planStepSequenceSummary(progress); + return Padding( + padding: EdgeInsets.only(top: resultLabel.isEmpty ? 0 : 4), + child: Text(label), + ); + }, + ), + ], + ); + } +} + final class _EditSetResultSheet extends StatefulWidget { const _EditSetResultSheet({ required this.sessionId, @@ -2916,6 +3013,29 @@ String _planSetSubtitle( return ''; } +String _planStepSequenceSummary(ActiveExerciseStepProgressView view) { + final state = view.state; + final stepsCount = view.steps.length; + final passageIndex = + state.status == ActiveExerciseStepProgressStatus.sequenceComplete + ? (view.expectedPassages - 1).clamp(0, 9999).toInt() + : state.currentPassageIndex; + final completedInPassage = + state.status == ActiveExerciseStepProgressStatus.sequenceComplete + ? stepsCount + : view.results + .where((result) => result.passageIndex == passageIndex) + .map((result) => result.stepIndex) + .toSet() + .length + .clamp(0, stepsCount) + .toInt(); + final passageLabel = view.expectedPassages > 1 + ? ' · Passage ${passageIndex + 1}/${view.expectedPassages}' + : ''; + return 'Séquence : $completedInPassage/$stepsCount étapes$passageLabel'; +} + String _setResultSummary( ActiveSetResult result, { ExecutionExercise? exercise, @@ -3210,26 +3330,28 @@ List _imageMediaIdsFromSnapshot(Map exercise) { List _exerciseStepsFromSnapshot(Map exercise) { final rawSteps = exercise['exerciseStepsSnapshot']; if (rawSteps is! List) return const []; - return rawSteps.map((rawStep) { - final step = rawStep as Map; - final typeName = step['type'] as String? ?? ExerciseStepType.time.name; - return ExerciseStep( - id: step['id'] as String, - position: step['position'] as int, - name: step['name'] as String, - type: ExerciseStepType.values.firstWhere( - (type) => type.name == typeName, - orElse: () => ExerciseStepType.time, - ), - defaultTargetValue: step['defaultTargetValue'] as int, - hasScore: step['hasScore'] as bool? ?? false, - scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']), - scoreLabel: step['scoreLabel'] as String?, - scoreUnit: step['scoreUnit'] as String?, - defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), - defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, - ); - }).toList(growable: false); + return rawSteps + .map((rawStep) { + final step = rawStep as Map; + final typeName = step['type'] as String? ?? ExerciseStepType.time.name; + return ExerciseStep( + id: step['id'] as String, + position: step['position'] as int, + name: step['name'] as String, + type: ExerciseStepType.values.firstWhere( + (type) => type.name == typeName, + orElse: () => ExerciseStepType.time, + ), + defaultTargetValue: step['defaultTargetValue'] as int, + hasScore: step['hasScore'] as bool? ?? false, + scoreInputMode: _scoreInputModeFromSnapshot(step['scoreInputMode']), + scoreLabel: step['scoreLabel'] as String?, + scoreUnit: step['scoreUnit'] as String?, + defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), + defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, + ); + }) + .toList(growable: false); } ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 5b30d29..5169de6 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -75,6 +75,33 @@ void main() { expect(find.textContaining('Score 38.7'), findsNothing); }); + testWidgets('le détail affiche les résultats d’étapes par passage', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: HistoryDetailScreen( + history: _history(id: 'history-1', withStepResults: true), + historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), + workoutTemplateUseCases: _workoutTemplateUseCases(), + activeUseCases: _activeUseCases(), + closeUseCase: _closeUseCase(), + ), + ), + ); + + expect(find.text('Étapes réalisées'), findsOneWidget); + expect(find.text('Passage 1'), findsOneWidget); + expect( + find.text( + 'Étape 1 · Dribble gauche · Temps 00:10 · Réussites : 4 paniers', + ), + findsOneWidget, + ); + expect(find.text('Étape 2 · Dribble droite'), findsOneWidget); + expect(find.text('Passée'), findsOneWidget); + }); + testWidgets('la suppression demande une confirmation', (tester) async { final repository = _FakeWorkoutHistoryRepository(); @@ -114,6 +141,7 @@ WorkoutHistory _history({ required String id, DateTime? startedAt, bool stopwatchScore = false, + bool withStepResults = false, }) { final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); return WorkoutHistory( @@ -129,6 +157,7 @@ WorkoutHistory _history({ 'sessionId': 'session-1', 'resolvedTemplateSnapshotJson': _resolvedSnapshot( stopwatchScore: stopwatchScore, + withSteps: withStepResults, ), 'results': [ { @@ -150,10 +179,57 @@ WorkoutHistory _history({ }, ], }), + stepResults: withStepResults + ? [ + WorkoutHistoryStepResult( + metadata: _metadata('step-result-1'), + workoutHistoryId: id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + passageIndex: 0, + stepIndex: 0, + stepSnapshotId: 'step-1', + stepNameSnapshot: 'Dribble gauche', + stepTypeSnapshot: ExerciseStepType.time, + targetValueSnapshot: 10, + hasScoreSnapshot: true, + scoreInputModeSnapshot: ScoreInputMode.manual, + scoreLabelSnapshot: 'Réussites', + scoreUnitSnapshot: 'paniers', + targetScoreSnapshot: 3, + status: SetResultStatus.completed, + actualTimeMs: 10000, + actualScore: 4, + ), + WorkoutHistoryStepResult( + metadata: _metadata('step-result-2'), + workoutHistoryId: id, + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + passageIndex: 0, + stepIndex: 1, + stepSnapshotId: 'step-2', + stepNameSnapshot: 'Dribble droite', + stepTypeSnapshot: ExerciseStepType.reps, + targetValueSnapshot: 8, + hasScoreSnapshot: false, + status: SetResultStatus.skipped, + ), + ] + : const [], ); } -String _resolvedSnapshot({bool stopwatchScore = false}) { +String _resolvedSnapshot({ + bool stopwatchScore = false, + bool withSteps = false, +}) { return jsonEncode({ 'name': 'Séance jambes', 'programs': [ @@ -179,6 +255,29 @@ String _resolvedSnapshot({bool stopwatchScore = false}) { 'scoreLabelSnapshot': 'Charge', if (!stopwatchScore) 'scoreUnitSnapshot': 'kg', 'restSecondsOverride': 0, + if (withSteps) + 'exerciseStepsSnapshot': [ + { + 'id': 'step-1', + 'position': 0, + 'name': 'Dribble gauche', + 'type': ExerciseStepType.time.name, + 'defaultTargetValue': 10, + 'hasScore': true, + 'scoreInputMode': ScoreInputMode.manual.name, + 'scoreLabel': 'Réussites', + 'scoreUnit': 'paniers', + 'defaultTargetScore': 3, + }, + { + 'id': 'step-2', + 'position': 1, + 'name': 'Dribble droite', + 'type': ExerciseStepType.reps.name, + 'defaultTargetValue': 8, + 'hasScore': false, + }, + ], }, ], }), diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 2de0dfd..e480968 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -837,6 +837,72 @@ void main() { expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing); }); + testWidgets('le plan affiche le résumé de séquence d’une série', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + setsCount: 2, + targetReps: 2, + exerciseSteps: [ + _step('step-1', 0, 'Gauche', ExerciseStepType.reps, 4), + _step('step-2', 1, 'Droite', ExerciseStepType.reps, 4), + ], + ), + ); + repository.session = session; + repository.stepResults.add( + ActiveExerciseStepResult( + metadata: _metadata('step-result-1'), + activeWorkoutSessionId: 'session-1', + programSnapshotId: 'template-program-1', + exerciseSnapshotId: 'exercise-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + passageIndex: 0, + stepIndex: 0, + stepSnapshotId: 'step-1', + stepNameSnapshot: 'Gauche', + stepTypeSnapshot: ExerciseStepType.reps, + targetValueSnapshot: 4, + hasScoreSnapshot: false, + status: SetResultStatus.completed, + actualReps: 4, + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + stepUseCases: _stepUseCases(repository, clock), + stepAudioCuePlayer: _FakeStepAudioCuePlayer(), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + await tester.tap(find.byTooltip('Voir le plan')); + await tester.pumpAndSettle(); + + expect(find.text('Séquence : 1/2 étapes · Passage 1/2'), findsOneWidget); + }); + testWidgets( 'modifier une série passée la termine sans changer la position courante', (tester) async { @@ -1011,9 +1077,7 @@ void main() { currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot( targetReps: 1, - exerciseSteps: [ - _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8), - ], + exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8)], ), ); repository.session = session; @@ -1170,9 +1234,7 @@ void main() { currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot( targetReps: 2, - exerciseSteps: [ - _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4), - ], + exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)], ), ); repository.session = session; @@ -1220,9 +1282,7 @@ void main() { setsCount: 2, restSeconds: 0, targetReps: 2, - exerciseSteps: [ - _step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4), - ], + exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)], ), ); repository.session = session; @@ -1667,9 +1727,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future saveExerciseStepResult( - ActiveExerciseStepResult result, - ) async { + Future saveExerciseStepResult(ActiveExerciseStepResult result) async { final index = stepResults.indexWhere( (saved) => saved.activeWorkoutSessionId == result.activeWorkoutSessionId && From 21e0e5b079c43d3a764f60e5c47e7b841b9139b0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 20:19:06 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20le=20t?= =?UTF-8?q?icket=20#60=20apr=C3=A8s=20plan=20de=20s=C3=A9ance=20et=20histo?= =?UTF-8?q?rique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/60/carnet.md | 6 +++--- .ideai/tickets/60/issue.md | 8 ++++---- .ideai/tickets/index.json | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.ideai/tickets/60/carnet.md b/.ideai/tickets/60/carnet.md index 60722bd..2492fc5 100644 --- a/.ideai/tickets/60/carnet.md +++ b/.ideai/tickets/60/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#60" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784481536357 +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784484539686 --- diff --git a/.ideai/tickets/60/issue.md b/.ideai/tickets/60/issue.md index 01d6e55..92478b8 100644 --- a/.ideai/tickets/60/issue.md +++ b/.ideai/tickets/60/issue.md @@ -2,15 +2,15 @@ id: "4807a946-834e-44a4-8a39-25ad951e6668" number: 60 title: "[DevFrontend] Exécution de séance avec module séquence et bips" -status: "open" +status: "qa" 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"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784481536357 -updatedAt: 1784481536357 -version: 1 +updatedAt: 1784484539686 +version: 2 --- 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/index.json b/.ideai/tickets/index.json index 722e55b..7fc399b 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -667,13 +667,13 @@ "issueRef": "#60", "path": "60", "title": "[DevFrontend] Exécution de séance avec module séquence et bips", - "status": "open", + "status": "qa", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784481536357 + "updatedAt": 1784484539686 }, { "issueRef": "#61",