feat(execution): refonte de l'UI de l'écran de séance (ticket #93)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:32:52 +02:00
parent 9b7ec74831
commit 5c826ce294
2 changed files with 345 additions and 187 deletions

View File

@ -141,7 +141,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
) )
: null, : null,
title: Text(_plan.name), title: _ExecutionAppBarTitle(
workoutName: _plan.name,
programName: _plan.programAt(_position).name,
),
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Voir le plan', tooltip: 'Voir le plan',
@ -183,7 +186,6 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
exercise: _exercise, exercise: _exercise,
currentSet: _position.setIndex + 1, currentSet: _position.setIndex + 1,
totalSets: _exercise.setsCount, totalSets: _exercise.setsCount,
progressLabel: _plan.progressLabel(_position),
setTimerLabel: _setTimerLabel, setTimerLabel: _setTimerLabel,
setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped, setTimerComplete: _setTimer?.status == ActiveSetTimerStatus.stopped,
showStartButton: _shouldShowStartExerciseButton, showStartButton: _shouldShowStartExerciseButton,
@ -191,13 +193,6 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onOpenMedia: _exercise.hasMedia onOpenMedia: _exercise.hasMedia
? () => _openExerciseMedia(_exercise) ? () => _openExerciseMedia(_exercise)
: null, : null,
elapsedLabel: _formatDuration(
Duration(
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
_session,
),
),
),
), ),
), ),
if (_performanceReference != null) ...[ if (_performanceReference != null) ...[
@ -237,16 +232,16 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
onResetStepScore: _resetStepScore, onResetStepScore: _resetStepScore,
), ),
), ),
const SizedBox(height: 8), if (_exercise.manualScoreEnabled) ...[
SizedBox( const SizedBox(height: 8),
height: _exercise.manualScoreEnabled ? 66 : 48, SizedBox(
child: _StepSetResultSummary( height: 66,
view: _stepProgress, child: _StepSetResultSummary(
exercise: _exercise, exercise: _exercise,
reps: _reps, scoreController: _scoreController,
scoreController: _scoreController, ),
), ),
), ],
if (_exercise.stopwatchScoreEnabled) ...[ if (_exercise.stopwatchScoreEnabled) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
@ -1230,7 +1225,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
TextButton( TextButton(
onPressed: () => onPressed: () =>
Navigator.of(context).pop(_MissingSetTimerAction.start), Navigator.of(context).pop(_MissingSetTimerAction.start),
child: const Text('Démarrer lexercice'), child: const Text('Démarrer'),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
@ -1264,7 +1259,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
TextButton( TextButton(
onPressed: () => onPressed: () =>
Navigator.of(context).pop(_MissingStopwatchAction.start), Navigator.of(context).pop(_MissingStopwatchAction.start),
child: const Text('Démarrer lexercice'), child: const Text('Démarrer'),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(
@ -1757,7 +1752,9 @@ final class SetMeasureInput extends StatelessWidget {
Text( Text(
exercise.targetTimeSeconds == null exercise.targetTimeSeconds == null
? 'Chronométrer' ? 'Chronométrer'
: '${exercise.targetTimeSeconds} s', : _formatDuration(
Duration(seconds: exercise.targetTimeSeconds!),
),
style: AppTextStyles.scoreNumber(context), 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 { final class _ExecutionContextHeader extends StatelessWidget {
const _ExecutionContextHeader({ const _ExecutionContextHeader({
required this.exercise, required this.exercise,
required this.currentSet, required this.currentSet,
required this.totalSets, required this.totalSets,
required this.progressLabel,
required this.elapsedLabel,
required this.setTimerLabel, required this.setTimerLabel,
required this.setTimerComplete, required this.setTimerComplete,
required this.showStartButton, required this.showStartButton,
@ -1817,8 +1847,6 @@ final class _ExecutionContextHeader extends StatelessWidget {
final ExecutionExercise exercise; final ExecutionExercise exercise;
final int currentSet; final int currentSet;
final int totalSets; final int totalSets;
final String progressLabel;
final String elapsedLabel;
final String? setTimerLabel; final String? setTimerLabel;
final bool setTimerComplete; final bool setTimerComplete;
final bool showStartButton; final bool showStartButton;
@ -1834,40 +1862,39 @@ final class _ExecutionContextHeader extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( Text(
exercise.name, exercise.name,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge, style: theme.textTheme.titleLarge,
), ),
@ -1896,7 +1923,7 @@ final class _ExecutionContextHeader extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
), ),
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer lexercice'), label: const Text('Démarrer'),
), ),
), ),
if (onOpenMedia != null) ...[ if (onOpenMedia != null) ...[
@ -1951,71 +1978,38 @@ final class _SetActionBar extends StatelessWidget {
final class _StepSetResultSummary extends StatelessWidget { final class _StepSetResultSummary extends StatelessWidget {
const _StepSetResultSummary({ const _StepSetResultSummary({
required this.view,
required this.exercise, required this.exercise,
required this.reps,
required this.scoreController, required this.scoreController,
}); });
final ActiveExerciseStepProgressView? view;
final ExecutionExercise exercise; final ExecutionExercise exercise;
final int reps;
final TextEditingController scoreController; final TextEditingController scoreController;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final completedPassages = _completedPassagesFor(view);
final expectedPassages = view?.expectedPassages ?? reps;
final theme = Theme.of(context); final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context); final tokens = courtBlazerTokensOf(context);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border), border: Border.all(color: tokens.border),
), ),
child: Row( child: TextField(
crossAxisAlignment: CrossAxisAlignment.center, controller: scoreController,
children: [ decoration: InputDecoration(
Expanded( isDense: true,
child: Text( labelText: exercise.scoreUnit == null
expectedPassages > 0 ? 'Score'
? 'Passages réalisés : $completedPassages / $expectedPassages' : 'Score (${exercise.scoreUnit})',
: 'Passages réalisés : $completedPassages', ),
style: theme.textTheme.titleSmall, keyboardType: TextInputType.number,
),
),
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,
),
),
],
],
), ),
); );
} }
} }
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 _MissingSetTimerAction { start, finishWithout }
enum _MissingStopwatchAction { start, finishWithout } enum _MissingStopwatchAction { start, finishWithout }
@ -2221,29 +2215,27 @@ final class _CurrentStepPane extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Flexible( Flexible(
fit: FlexFit.tight, fit: FlexFit.tight,
child: LayoutBuilder( child: step.type == ExerciseStepType.time
builder: (context, constraints) { ? _TimedStepBody(
final body = step.type == ExerciseStepType.time step: step,
? _TimedStepBody( remainingLabel: remainingLabel,
step: step, running:
remainingLabel: remainingLabel, view.state.status ==
running: ActiveExerciseStepProgressStatus.runningTimer,
view.state.status == readyToStart: _isNextTimedStepReady(view),
ActiveExerciseStepProgressStatus.runningTimer, onStartTimer: onStartTimer,
readyToStart: _isNextTimedStepReady(view), )
onStartTimer: onStartTimer, : Align(
)
: _RepsStepBody(step: step, onCompleteStep: onCompleteStep);
return Align(
alignment: Alignment.topCenter,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter, 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) ...[ if (step.hasScore) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@ -2321,34 +2313,37 @@ final class _TimedStepBody extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('Objectif : ${step.defaultTargetValue} s'), Text('Objectif : ${_formatShortSeconds(step.defaultTargetValue)}'),
const SizedBox(height: 8), const SizedBox(height: 4),
Center( Center(
child: Text( child: Text(
remainingLabel, remainingLabel,
style: AppTextStyles.timer( style: Theme.of(context).textTheme.displayMedium?.copyWith(
context, fontFamily: 'Anton',
).copyWith(color: Theme.of(context).colorScheme.primary), fontFeatures: AppTextStyles.timer(context).fontFeatures,
color: Theme.of(context).colorScheme.primary,
),
), ),
), ),
if (!running) ...[ if (!running) ...[
const SizedBox(height: 12), const SizedBox(height: 8),
if (readyToStart) ...[ if (readyToStart) ...[
Text( Text(
'Chrono suivant prêt', 'Chrono prêt',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
], ],
FilledButton.icon( FilledButton.icon(
onPressed: onStartTimer, onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow), style: FilledButton.styleFrom(
label: Text( minimumSize: const Size.fromHeight(48),
readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence',
), ),
icon: const Icon(Icons.play_arrow),
label: const Text('Lancer'),
), ),
], ],
], ],
@ -2444,7 +2439,7 @@ final class _StepScoreInput extends StatelessWidget {
FilledButton.icon( FilledButton.icon(
onPressed: running ? onStop : onStart, onPressed: running ? onStop : onStart,
icon: Icon(running ? Icons.stop : Icons.play_arrow), icon: Icon(running ? Icons.stop : Icons.play_arrow),
label: Text(running ? 'Arrêter' : 'Démarrer'), label: Text(running ? 'Arrêter' : 'Lancer'),
), ),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onReset, onPressed: onReset,
@ -2620,7 +2615,7 @@ final class _ScoreStopwatchInput extends StatelessWidget {
FilledButton.icon( FilledButton.icon(
onPressed: onStart, onPressed: onStart,
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer'), label: const Text('Lancer'),
) )
else if (running) else if (running)
FilledButton.icon( FilledButton.icon(
@ -4089,7 +4084,7 @@ String? _formatSetPerformance(
if (performance == null) return null; if (performance == null) return null;
final values = [ final values = [
if (measures.timeEnabled && performance.actualTimeMs != null) if (measures.timeEnabled && performance.actualTimeMs != null)
_formatReferenceTime(performance.actualTimeMs!, stopwatch: false), _formatReferenceMeasureTime(performance.actualTimeMs!),
if (measures.repsEnabled && performance.actualReps != null) if (measures.repsEnabled && performance.actualReps != null)
_formatReferenceReps( _formatReferenceReps(
performance.actualReps!, performance.actualReps!,
@ -4117,7 +4112,7 @@ String? _formatMetricPerformance(
PerformanceMetric.time => PerformanceMetric.time =>
performance.actualTimeMs == null performance.actualTimeMs == null
? null ? null
: _formatReferenceTime(performance.actualTimeMs!, stopwatch: false), : _formatReferenceMeasureTime(performance.actualTimeMs!),
PerformanceMetric.reps => PerformanceMetric.reps =>
performance.actualReps == null performance.actualReps == null
? null ? null
@ -4129,10 +4124,7 @@ String? _formatMetricPerformance(
performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch performance.scoreInputModeSnapshot == ScoreInputMode.stopwatch
? performance.actualScoreTimeMs == null ? performance.actualScoreTimeMs == null
? null ? null
: _formatReferenceTime( : _formatReferenceStopwatchScore(performance.actualScoreTimeMs!)
performance.actualScoreTimeMs!,
stopwatch: true,
)
: performance.actualScore == null : performance.actualScore == null
? null ? null
: _formatReferenceManualScore(performance.actualScore!, scoreUnit), : _formatReferenceManualScore(performance.actualScore!, scoreUnit),
@ -4162,7 +4154,7 @@ String? _formatReferenceScore({
if (scoreInputMode == ScoreInputMode.stopwatch) { if (scoreInputMode == ScoreInputMode.stopwatch) {
return actualScoreTimeMs == null return actualScoreTimeMs == null
? null ? null
: _formatReferenceTime(actualScoreTimeMs, stopwatch: true); : _formatReferenceStopwatchScore(actualScoreTimeMs);
} }
return actualScore == null return actualScore == null
? null ? null
@ -4180,22 +4172,27 @@ String _formatReferenceManualScore(double score, String? scoreUnit) {
return '$value $unit'; return '$value $unit';
} }
String _formatReferenceTime(int milliseconds, {required bool stopwatch}) { String _formatReferenceMeasureTime(int milliseconds) {
final totalTenths = (milliseconds / 100).round(); final totalSeconds = (milliseconds / 1000).round().clamp(0, 999999).toInt();
final minutes = totalTenths ~/ 600; if (totalSeconds < 60) {
final seconds = (totalTenths ~/ 10) % 60; return '$totalSeconds s';
final tenths = totalTenths % 10;
if (minutes == 0) {
if (!stopwatch && tenths == 0) {
return '$seconds s';
}
return '${(seconds + tenths / 10).toStringAsFixed(1)} s';
} }
final paddedSeconds = seconds.toString().padLeft(2, '0'); final minutes = totalSeconds ~/ 60;
if (stopwatch) { final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$paddedSeconds.$tenths'; 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<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) { List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {

View File

@ -42,7 +42,7 @@ void main() {
expect(find.text('Squat'), findsOneWidget); expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:45'), findsOneWidget); expect(find.text('Temps de série : 00:45'), findsOneWidget);
expect(find.text('Démarrer lexercice'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
}); });
testWidgets('les répétitions sont initialisées avec la cible', ( testWidgets('les répétitions sont initialisées avec la cible', (
@ -81,6 +81,73 @@ void main() {
expect(find.text('8'), findsOneWidget); 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', ( testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
tester, tester,
) async { ) async {
@ -379,7 +446,7 @@ void main() {
expect(find.text('Chrono score'), findsOneWidget); expect(find.text('Chrono score'), findsOneWidget);
expect(find.text('00:00.0'), 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(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@ -447,13 +514,10 @@ void main() {
expect(find.text('Chrono non lancé'), findsOneWidget); expect(find.text('Chrono non lancé'), findsOneWidget);
expect( expect(
find.text("Tu nas pas démarré le chrono de cette série."), find.text('Tu nas pas démarré le chrono de cette série.'),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
findsOneWidget, findsOneWidget,
); );
expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget);
expect(find.text('Terminer sans chrono'), findsOneWidget); expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty); expect(repository.results, isEmpty);
}); });
@ -495,7 +559,7 @@ void main() {
), ),
); );
await tester.tap(find.text('Démarrer lexercice')); await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500);
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
@ -662,7 +726,7 @@ void main() {
), ),
), ),
); );
await tester.tap(find.text('Démarrer lexercice')); await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(seconds: 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('Programme jambes'), findsOneWidget);
expect(find.text('Série 2/3'), findsOneWidget); expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget); expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:30'), findsOneWidget); expect(find.text('Temps de série : 00:30'), findsOneWidget);
await tester.tap(find.text('Démarrer lexercice')); await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump(); await tester.pump();
for (var i = 0; i < 2; i++) { for (var i = 0; i < 2; i++) {
@ -738,8 +802,8 @@ void main() {
expect(repository.results.single.actualReps, 3); expect(repository.results.single.actualReps, 3);
expect(repository.results.single.actualScore, 5); expect(repository.results.single.actualScore, 5);
expect(repository.session?.currentSetIndex, 2); expect(repository.session?.currentSetIndex, 2);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('Série 3/3'), findsOneWidget); expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('5'), findsOneWidget); expect(find.text('5'), findsOneWidget);
}, },
); );
@ -1025,8 +1089,8 @@ void main() {
await tester.tap(find.byTooltip('Fermer')); await tester.tap(find.byTooltip('Fermer'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('Série 3/4'), findsOneWidget); expect(find.text('3 / 4'), findsOneWidget);
}, },
); );
@ -1071,8 +1135,8 @@ void main() {
expect(find.text('Plan de séance'), findsNothing); expect(find.text('Plan de séance'), findsNothing);
expect(find.text('Modifier la série'), findsNothing); expect(find.text('Modifier la série'), findsNothing);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget); expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('Série 2/3'), findsOneWidget); expect(find.text('2 / 3'), findsOneWidget);
}, },
); );
@ -1127,6 +1191,72 @@ void main() {
expect(find.text('00:10'), findsOneWidget); 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 { testWidgets('une étape répétitions peut être validée', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1600)); await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
@ -1215,7 +1345,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('00:05'), findsOneWidget); 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(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
@ -1274,13 +1404,10 @@ void main() {
expect(find.text('Chrono non lancé'), findsOneWidget); expect(find.text('Chrono non lancé'), findsOneWidget);
expect( expect(
find.text("Tu nas pas démarré le chrono de cette série."), find.text('Tu nas pas démarré le chrono de cette série.'),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
findsOneWidget, findsOneWidget,
); );
expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget);
expect(find.text('Terminer sans chrono'), findsOneWidget); expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty); expect(repository.results, isEmpty);
}, },
@ -1330,7 +1457,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence')); await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
await tester.pump(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
@ -1392,9 +1519,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.tap( await tester.tap(find.widgetWithText(FilledButton, 'Lancer'));
find.widgetWithText(FilledButton, 'Démarrer la séquence'),
);
await tester.pump(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
@ -1404,15 +1529,15 @@ void main() {
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget); expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('Droite'), findsOneWidget); expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget); expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono suivant prêt'), findsOneWidget); expect(find.text('Chrono prêt'), findsOneWidget);
expect(find.text('Démarrer le chrono'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Lancer'), findsOneWidget);
expect( expect(
repository.stepProgressStates.single.status, repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.stoppedTimer, ActiveExerciseStepProgressStatus.stoppedTimer,
); );
expect(repository.stepProgressStates.single.startedAt, isNull); 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(); await tester.pump();
expect( expect(
@ -1466,7 +1591,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('Passage 2 / 2'), findsOneWidget); 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', ( testWidgets('terminer la série reste utilisable avant la fin de séquence', (
@ -1678,18 +1803,23 @@ String _sessionSnapshot({
List<Map<String, Object?>> exerciseSteps = const [], List<Map<String, Object?>> exerciseSteps = const [],
bool autoStartNextTimedStep = true, bool autoStartNextTimedStep = true,
String? videoMediaId, String? videoMediaId,
String? sourceExerciseId,
String workoutName = 'Séance jambes',
String programName = 'Programme jambes',
String exerciseName = 'Squat',
}) { }) {
return jsonEncode({ return jsonEncode({
'name': 'Séance jambes', 'name': workoutName,
'programs': [ 'programs': [
{ {
'id': 'template-program-1', 'id': 'template-program-1',
'programNameSnapshot': 'Programme jambes', 'programNameSnapshot': programName,
'programSnapshotJson': jsonEncode({ 'programSnapshotJson': jsonEncode({
'exercises': [ 'exercises': [
{ {
'id': 'exercise-1', 'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat', 'sourceExerciseId': sourceExerciseId,
'exerciseNameSnapshot': exerciseName,
'setsCount': setsCount, 'setsCount': setsCount,
'timeEnabled': timeEnabled, 'timeEnabled': timeEnabled,
'repsEnabled': repsEnabled, '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<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
return true;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
return last;
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required ScoreInputMode scoreInputMode,
}) async {
return record;
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository { final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session; ActiveWorkoutSession? session;
final results = <ActiveSetResult>[]; final results = <ActiveSetResult>[];