From 5c826ce294559ce5563544e5161d60979ddfea75 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 09:32:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(execution):=20refonte=20de=20l'UI=20de=20l?= =?UTF-8?q?'=C3=A9cran=20de=20s=C3=A9ance=20(ticket=20#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte de workout_execution_screen.dart cadrée UX+Architect ; résout au passage la dette de tests du ticket #90 (3 échecs préexistants désormais verts). Co-Authored-By: Claude Opus 4.8 --- .../workout_execution_screen.dart | 301 +++++++++--------- .../workout_execution_screen_test.dart | 231 ++++++++++++-- 2 files changed, 345 insertions(+), 187 deletions(-) diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 13f75e9..bfecb06 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -141,7 +141,10 @@ final class _WorkoutExecutionScreenState extends State { icon: const Icon(Icons.arrow_back), ) : null, - title: Text(_plan.name), + title: _ExecutionAppBarTitle( + workoutName: _plan.name, + programName: _plan.programAt(_position).name, + ), actions: [ IconButton( tooltip: 'Voir le plan', @@ -183,7 +186,6 @@ final class _WorkoutExecutionScreenState extends State { exercise: _exercise, currentSet: _position.setIndex + 1, totalSets: _exercise.setsCount, - progressLabel: _plan.progressLabel(_position), setTimerLabel: _setTimerLabel, setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped, showStartButton: _shouldShowStartExerciseButton, @@ -191,13 +193,6 @@ final class _WorkoutExecutionScreenState extends State { onOpenMedia: _exercise.hasMedia ? () => _openExerciseMedia(_exercise) : null, - elapsedLabel: _formatDuration( - Duration( - milliseconds: widget.activeUseCases.elapsedActiveMilliseconds( - _session, - ), - ), - ), ), ), if (_performanceReference != null) ...[ @@ -237,16 +232,16 @@ final class _WorkoutExecutionScreenState extends State { onResetStepScore: _resetStepScore, ), ), - const SizedBox(height: 8), - SizedBox( - height: _exercise.manualScoreEnabled ? 66 : 48, - child: _StepSetResultSummary( - view: _stepProgress, - exercise: _exercise, - reps: _reps, - scoreController: _scoreController, + if (_exercise.manualScoreEnabled) ...[ + const SizedBox(height: 8), + SizedBox( + height: 66, + child: _StepSetResultSummary( + exercise: _exercise, + scoreController: _scoreController, + ), ), - ), + ], if (_exercise.stopwatchScoreEnabled) ...[ const SizedBox(height: 8), SizedBox( @@ -1230,7 +1225,7 @@ final class _WorkoutExecutionScreenState extends State { TextButton( onPressed: () => Navigator.of(context).pop(_MissingSetTimerAction.start), - child: const Text('Démarrer l’exercice'), + child: const Text('Démarrer'), ), FilledButton( onPressed: () => Navigator.of( @@ -1264,7 +1259,7 @@ final class _WorkoutExecutionScreenState extends State { TextButton( onPressed: () => Navigator.of(context).pop(_MissingStopwatchAction.start), - child: const Text('Démarrer l’exercice'), + child: const Text('Démarrer'), ), FilledButton( onPressed: () => Navigator.of( @@ -1757,7 +1752,9 @@ final class SetMeasureInput extends StatelessWidget { Text( exercise.targetTimeSeconds == null ? 'Chronométrer' - : '${exercise.targetTimeSeconds} s', + : _formatDuration( + Duration(seconds: exercise.targetTimeSeconds!), + ), style: AppTextStyles.scoreNumber(context), ), ], @@ -1800,13 +1797,46 @@ final class SetMeasureInput extends StatelessWidget { } } +final class _ExecutionAppBarTitle extends StatelessWidget { + const _ExecutionAppBarTitle({ + required this.workoutName, + required this.programName, + }); + + final String workoutName; + final String programName; + + @override + Widget build(BuildContext context) { + final tokens = courtBlazerTokensOf(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workoutName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).appBarTheme.titleTextStyle, + ), + Text( + programName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: tokens.mutedText), + ), + ], + ); + } +} + final class _ExecutionContextHeader extends StatelessWidget { const _ExecutionContextHeader({ required this.exercise, required this.currentSet, required this.totalSets, - required this.progressLabel, - required this.elapsedLabel, required this.setTimerLabel, required this.setTimerComplete, required this.showStartButton, @@ -1817,8 +1847,6 @@ final class _ExecutionContextHeader extends StatelessWidget { final ExecutionExercise exercise; final int currentSet; final int totalSets; - final String progressLabel; - final String elapsedLabel; final String? setTimerLabel; final bool setTimerComplete; final bool showStartButton; @@ -1834,40 +1862,39 @@ final class _ExecutionContextHeader extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ + SizedBox( + width: 76, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'SÉRIE', + style: theme.textTheme.labelMedium?.copyWith( + color: tokens.mutedText, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '$currentSet / $totalSets', + maxLines: 1, + style: AppTextStyles.scoreNumber( + context, + ).copyWith(color: theme.colorScheme.primary), + ), + ], + ), + ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - Text( - 'Série $currentSet/$totalSets', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w700, - ), - ), - Text( - progressLabel, - style: theme.textTheme.labelMedium?.copyWith( - color: tokens.mutedText, - ), - ), - Text( - elapsedLabel, - style: theme.textTheme.labelMedium?.copyWith( - color: tokens.mutedText, - ), - ), - ], - ), - const SizedBox(height: 4), Text( exercise.name, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge, ), @@ -1896,7 +1923,7 @@ final class _ExecutionContextHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), ), icon: const Icon(Icons.play_arrow), - label: const Text('Démarrer l’exercice'), + label: const Text('Démarrer'), ), ), if (onOpenMedia != null) ...[ @@ -1951,71 +1978,38 @@ final class _SetActionBar extends StatelessWidget { final class _StepSetResultSummary extends StatelessWidget { const _StepSetResultSummary({ - required this.view, required this.exercise, - required this.reps, required this.scoreController, }); - final ActiveExerciseStepProgressView? view; final ExecutionExercise exercise; - final int reps; final TextEditingController scoreController; @override Widget build(BuildContext context) { - final completedPassages = _completedPassagesFor(view); - final expectedPassages = view?.expectedPassages ?? reps; final theme = Theme.of(context); final tokens = courtBlazerTokensOf(context); return Container( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(6), border: Border.all(color: tokens.border), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - expectedPassages > 0 - ? 'Passages réalisés : $completedPassages / $expectedPassages' - : 'Passages réalisés : $completedPassages', - style: theme.textTheme.titleSmall, - ), - ), - if (exercise.manualScoreEnabled) ...[ - const SizedBox(width: 12), - SizedBox( - width: 128, - child: TextField( - controller: scoreController, - decoration: InputDecoration( - isDense: true, - labelText: exercise.scoreUnit == null - ? 'Score' - : 'Score (${exercise.scoreUnit})', - ), - keyboardType: TextInputType.number, - ), - ), - ], - ], + child: TextField( + controller: scoreController, + decoration: InputDecoration( + isDense: true, + labelText: exercise.scoreUnit == null + ? 'Score' + : 'Score (${exercise.scoreUnit})', + ), + keyboardType: TextInputType.number, ), ); } } -int _completedPassagesFor(ActiveExerciseStepProgressView? view) { - if (view == null) return 0; - if (view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete) { - return view.expectedPassages; - } - return view.state.currentPassageIndex; -} - enum _MissingSetTimerAction { start, finishWithout } enum _MissingStopwatchAction { start, finishWithout } @@ -2221,29 +2215,27 @@ final class _CurrentStepPane extends StatelessWidget { const SizedBox(height: 4), Flexible( fit: FlexFit.tight, - child: LayoutBuilder( - builder: (context, constraints) { - final body = step.type == ExerciseStepType.time - ? _TimedStepBody( - step: step, - remainingLabel: remainingLabel, - running: - view.state.status == - ActiveExerciseStepProgressStatus.runningTimer, - readyToStart: _isNextTimedStepReady(view), - onStartTimer: onStartTimer, - ) - : _RepsStepBody(step: step, onCompleteStep: onCompleteStep); - return Align( - alignment: Alignment.topCenter, - child: FittedBox( - fit: BoxFit.scaleDown, + child: step.type == ExerciseStepType.time + ? _TimedStepBody( + step: step, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + readyToStart: _isNextTimedStepReady(view), + onStartTimer: onStartTimer, + ) + : Align( alignment: Alignment.topCenter, - child: SizedBox(width: constraints.maxWidth, child: body), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: _RepsStepBody( + step: step, + onCompleteStep: onCompleteStep, + ), + ), ), - ); - }, - ), ), if (step.hasScore) ...[ const SizedBox(height: 8), @@ -2321,34 +2313,37 @@ final class _TimedStepBody extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Objectif : ${step.defaultTargetValue} s'), - const SizedBox(height: 8), + Text('Objectif : ${_formatShortSeconds(step.defaultTargetValue)}'), + const SizedBox(height: 4), Center( child: Text( remainingLabel, - style: AppTextStyles.timer( - context, - ).copyWith(color: Theme.of(context).colorScheme.primary), + style: Theme.of(context).textTheme.displayMedium?.copyWith( + fontFamily: 'Anton', + fontFeatures: AppTextStyles.timer(context).fontFeatures, + color: Theme.of(context).colorScheme.primary, + ), ), ), if (!running) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), if (readyToStart) ...[ Text( - 'Chrono suivant prêt', + 'Chrono prêt', textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( + style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), ], FilledButton.icon( onPressed: onStartTimer, - icon: const Icon(Icons.play_arrow), - label: Text( - readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence', + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), ), + icon: const Icon(Icons.play_arrow), + label: const Text('Lancer'), ), ], ], @@ -2444,7 +2439,7 @@ final class _StepScoreInput extends StatelessWidget { FilledButton.icon( onPressed: running ? onStop : onStart, icon: Icon(running ? Icons.stop : Icons.play_arrow), - label: Text(running ? 'Arrêter' : 'Démarrer'), + label: Text(running ? 'Arrêter' : 'Lancer'), ), OutlinedButton.icon( onPressed: onReset, @@ -2620,7 +2615,7 @@ final class _ScoreStopwatchInput extends StatelessWidget { FilledButton.icon( onPressed: onStart, icon: const Icon(Icons.play_arrow), - label: const Text('Démarrer'), + label: const Text('Lancer'), ) else if (running) FilledButton.icon( @@ -4089,7 +4084,7 @@ String? _formatSetPerformance( if (performance == null) return null; final values = [ if (measures.timeEnabled && performance.actualTimeMs != null) - _formatReferenceTime(performance.actualTimeMs!, stopwatch: false), + _formatReferenceMeasureTime(performance.actualTimeMs!), if (measures.repsEnabled && performance.actualReps != null) _formatReferenceReps( performance.actualReps!, @@ -4117,7 +4112,7 @@ String? _formatMetricPerformance( PerformanceMetric.time => performance.actualTimeMs == null ? null - : _formatReferenceTime(performance.actualTimeMs!, stopwatch: false), + : _formatReferenceMeasureTime(performance.actualTimeMs!), PerformanceMetric.reps => performance.actualReps == null ? null @@ -4129,10 +4124,7 @@ String? _formatMetricPerformance( performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch ? performance.actualScoreTimeMs == null ? null - : _formatReferenceTime( - performance.actualScoreTimeMs!, - stopwatch: true, - ) + : _formatReferenceStopwatchScore(performance.actualScoreTimeMs!) : performance.actualScore == null ? null : _formatReferenceManualScore(performance.actualScore!, scoreUnit), @@ -4162,7 +4154,7 @@ String? _formatReferenceScore({ if (scoreInputMode == ScoreInputMode.stopwatch) { return actualScoreTimeMs == null ? null - : _formatReferenceTime(actualScoreTimeMs, stopwatch: true); + : _formatReferenceStopwatchScore(actualScoreTimeMs); } return actualScore == null ? null @@ -4180,22 +4172,27 @@ String _formatReferenceManualScore(double score, String? scoreUnit) { return '$value $unit'; } -String _formatReferenceTime(int milliseconds, {required bool stopwatch}) { - final totalTenths = (milliseconds / 100).round(); - final minutes = totalTenths ~/ 600; - final seconds = (totalTenths ~/ 10) % 60; - final tenths = totalTenths % 10; - if (minutes == 0) { - if (!stopwatch && tenths == 0) { - return '$seconds s'; - } - return '${(seconds + tenths / 10).toStringAsFixed(1)} s'; +String _formatReferenceMeasureTime(int milliseconds) { + final totalSeconds = (milliseconds / 1000).round().clamp(0, 999999).toInt(); + if (totalSeconds < 60) { + return '$totalSeconds s'; } - final paddedSeconds = seconds.toString().padLeft(2, '0'); - if (stopwatch) { - return '$minutes:$paddedSeconds.$tenths'; + final minutes = totalSeconds ~/ 60; + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} + +String _formatReferenceStopwatchScore(int milliseconds) { + return _formatScoreStopwatch(Duration(milliseconds: milliseconds)); +} + +String _formatShortSeconds(int seconds) { + if (seconds < 60) { + return '$seconds s'; } - return '$minutes:$paddedSeconds'; + final minutes = seconds ~/ 60; + final remainingSeconds = (seconds % 60).toString().padLeft(2, '0'); + return '$minutes:$remainingSeconds'; } List _imageMediaIdsFromSnapshot(Map exercise) { diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 13f5af7..9c333d7 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -42,7 +42,7 @@ void main() { expect(find.text('Squat'), findsOneWidget); expect(find.text('Temps de série : 00:45'), findsOneWidget); - expect(find.text('Démarrer l’exercice'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget); }); testWidgets('les répétitions sont initialisées avec la cible', ( @@ -81,6 +81,73 @@ void main() { expect(find.text('8'), findsOneWidget); }); + testWidgets('les références de temps sont formatées sans valeurs brutes', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final repository = _FakeActiveSessionRepository(); + final performanceRepository = _FakeExercisePerformanceReferenceRepository( + last: WorkoutHistorySetPerformance( + workoutHistoryId: 'history-1', + startedAt: DateTime.utc(2026, 7, 12), + setIndex: 0, + exerciseNameSnapshot: 'Squat', + scoreInputModeSnapshot: ScoreInputMode.stopwatch, + actualTimeMs: 72000, + actualScoreTimeMs: 45100, + ), + record: WorkoutHistoryMetricPerformance( + workoutHistoryId: 'history-2', + startedAt: DateTime.utc(2026, 7, 15), + setIndex: 0, + exerciseNameSnapshot: 'Squat', + metric: PerformanceMetric.score, + scoreInputModeSnapshot: ScoreInputMode.stopwatch, + actualScoreTimeMs: 42800, + ), + ); + 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( + sourceExerciseId: 'exercise-source-1', + scoreInputMode: ScoreInputMode.stopwatch, + targetTimeSeconds: 300, + ), + ); + repository.session = session; + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases(repository, clock), + closeUseCase: _closeUseCase(repository, clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + performanceReferenceUseCase: ExercisePerformanceReferenceUseCase( + repository: performanceRepository, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Temps de série : 05:00'), findsOneWidget); + expect(find.text('1:12 · 00:45.1'), findsOneWidget); + expect(find.text('00:42.8'), findsOneWidget); + expect(find.textContaining('72000'), findsNothing); + expect(find.textContaining('42800'), findsNothing); + }); + testWidgets('les mesures cumulées respectent la hiérarchie visuelle', ( tester, ) async { @@ -379,7 +446,7 @@ void main() { expect(find.text('Chrono score'), findsOneWidget); expect(find.text('00:00.0'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Démarrer')); + await tester.tap(find.widgetWithText(FilledButton, 'Lancer')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100); await tester.pump(const Duration(milliseconds: 100)); @@ -447,13 +514,10 @@ void main() { expect(find.text('Chrono non lancé'), findsOneWidget); expect( - find.text("Tu n’as pas démarré le chrono de cette série."), - findsOneWidget, - ); - expect( - find.widgetWithText(TextButton, 'Démarrer l’exercice'), + find.text('Tu n’as pas démarré le chrono de cette série.'), findsOneWidget, ); + expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget); expect(find.text('Terminer sans chrono'), findsOneWidget); expect(repository.results, isEmpty); }); @@ -495,7 +559,7 @@ void main() { ), ); - await tester.tap(find.text('Démarrer l’exercice')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500); await tester.pump(const Duration(seconds: 1)); @@ -662,7 +726,7 @@ void main() { ), ), ); - await tester.tap(find.text('Démarrer l’exercice')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); await tester.pump(const Duration(seconds: 2)); @@ -713,12 +777,12 @@ void main() { ), ); - expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('Série 2/3'), findsOneWidget); + expect(find.text('Programme jambes'), findsOneWidget); + expect(find.text('2 / 3'), findsOneWidget); expect(find.text('Squat'), findsOneWidget); expect(find.text('Temps de série : 00:30'), findsOneWidget); - await tester.tap(find.text('Démarrer l’exercice')); + await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first); await tester.pump(); for (var i = 0; i < 2; i++) { @@ -738,8 +802,8 @@ void main() { expect(repository.results.single.actualReps, 3); expect(repository.results.single.actualScore, 5); expect(repository.session?.currentSetIndex, 2); - expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('Série 3/3'), findsOneWidget); + expect(find.text('Programme jambes'), findsOneWidget); + expect(find.text('3 / 3'), findsOneWidget); expect(find.text('5'), findsOneWidget); }, ); @@ -1025,8 +1089,8 @@ void main() { await tester.tap(find.byTooltip('Fermer')); await tester.pumpAndSettle(); - expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('Série 3/4'), findsOneWidget); + expect(find.text('Programme jambes'), findsOneWidget); + expect(find.text('3 / 4'), findsOneWidget); }, ); @@ -1071,8 +1135,8 @@ void main() { expect(find.text('Plan de séance'), findsNothing); expect(find.text('Modifier la série'), findsNothing); - expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); - expect(find.text('Série 2/3'), findsOneWidget); + expect(find.text('Programme jambes'), findsOneWidget); + expect(find.text('2 / 3'), findsOneWidget); }, ); @@ -1127,6 +1191,72 @@ void main() { expect(find.text('00:10'), findsOneWidget); }); + testWidgets('l’écran actif avec séquence longue reste sans scroll', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + 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: 4, + workoutName: 'Séance tirs du soir très complète', + programName: + 'Programme finition longue avec nom volontairement interminable', + exerciseName: 'Dribble combo finition contact côté faible très long', + targetReps: 10, + scoreUnit: 'paniers', + exerciseSteps: [ + _step( + 'step-1', + 0, + 'Dribble main droite enchaîné avec finition longue', + ExerciseStepType.time, + 90, + ), + _step('step-2', 1, 'Appuis explosifs', ExerciseStepType.reps, 8), + _step('step-3', 2, 'Tir après contact', ExerciseStepType.reps, 6), + ], + ), + ); + repository.session = session; + + 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.pump(); + + expect(tester.takeException(), isNull); + expect(find.byType(ListView), findsNothing); + expect(find.text('SÉRIE'), findsOneWidget); + expect(find.text('Passage 1 / 10'), findsOneWidget); + expect(find.text('Objectif : 1:30'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Lancer'), findsOneWidget); + expect(find.textContaining('Passages réalisés'), findsNothing); + }); + testWidgets('une étape répétitions peut être validée', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 1600)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -1215,7 +1345,7 @@ void main() { await tester.pump(); expect(find.text('00:05'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence')); + await tester.tap(find.widgetWithText(FilledButton, 'Lancer')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); await tester.pump(const Duration(milliseconds: 100)); @@ -1274,13 +1404,10 @@ void main() { expect(find.text('Chrono non lancé'), findsOneWidget); expect( - find.text("Tu n’as pas démarré le chrono de cette série."), - findsOneWidget, - ); - expect( - find.widgetWithText(TextButton, 'Démarrer l’exercice'), + find.text('Tu n’as pas démarré le chrono de cette série.'), findsOneWidget, ); + expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget); expect(find.text('Terminer sans chrono'), findsOneWidget); expect(repository.results, isEmpty); }, @@ -1330,7 +1457,7 @@ void main() { ); await tester.pump(); - await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence')); + await tester.tap(find.widgetWithText(FilledButton, 'Lancer')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); await tester.pump(const Duration(milliseconds: 200)); @@ -1392,9 +1519,7 @@ void main() { ); await tester.pump(); - await tester.tap( - find.widgetWithText(FilledButton, 'Démarrer la séquence'), - ); + await tester.tap(find.widgetWithText(FilledButton, 'Lancer')); await tester.pump(); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); await tester.pump(const Duration(milliseconds: 200)); @@ -1404,15 +1529,15 @@ void main() { expect(find.text('ÉTAPE 2 / 2'), findsOneWidget); expect(find.text('Droite'), findsOneWidget); expect(find.text('00:02'), findsOneWidget); - expect(find.text('Chrono suivant prêt'), findsOneWidget); - expect(find.text('Démarrer le chrono'), findsOneWidget); + expect(find.text('Chrono prêt'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Lancer'), findsOneWidget); expect( repository.stepProgressStates.single.status, ActiveExerciseStepProgressStatus.stoppedTimer, ); expect(repository.stepProgressStates.single.startedAt, isNull); - await tester.tap(find.widgetWithText(FilledButton, 'Démarrer le chrono')); + await tester.tap(find.widgetWithText(FilledButton, 'Lancer')); await tester.pump(); expect( @@ -1466,7 +1591,7 @@ void main() { await tester.pump(); expect(find.text('Passage 2 / 2'), findsOneWidget); - expect(find.text('Passages réalisés : 1 / 2'), findsOneWidget); + expect(find.textContaining('Passages réalisés'), findsNothing); }); testWidgets('terminer la série reste utilisable avant la fin de séquence', ( @@ -1678,18 +1803,23 @@ String _sessionSnapshot({ List> exerciseSteps = const [], bool autoStartNextTimedStep = true, String? videoMediaId, + String? sourceExerciseId, + String workoutName = 'Séance jambes', + String programName = 'Programme jambes', + String exerciseName = 'Squat', }) { return jsonEncode({ - 'name': 'Séance jambes', + 'name': workoutName, 'programs': [ { 'id': 'template-program-1', - 'programNameSnapshot': 'Programme jambes', + 'programNameSnapshot': programName, 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-1', - 'exerciseNameSnapshot': 'Squat', + 'sourceExerciseId': sourceExerciseId, + 'exerciseNameSnapshot': exerciseName, 'setsCount': setsCount, 'timeEnabled': timeEnabled, 'repsEnabled': repsEnabled, @@ -1794,6 +1924,37 @@ final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { } } +final class _FakeExercisePerformanceReferenceRepository + implements ExercisePerformanceReferenceRepository { + const _FakeExercisePerformanceReferenceRepository({this.last, this.record}); + + final WorkoutHistorySetPerformance? last; + final WorkoutHistoryMetricPerformance? record; + + @override + Future hasAnyCompletedHistoryForExercise(String exerciseId) async { + return true; + } + + @override + Future findLatestSetPerformance({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }) async { + return last; + } + + @override + Future findBestMetricPerformance({ + required String exerciseId, + required PerformanceMetric metric, + required ScoreInputMode scoreInputMode, + }) async { + return record; + } +} + final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = [];