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:
@ -141,7 +141,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
onResetStepScore: _resetStepScore,
|
||||
),
|
||||
),
|
||||
if (_exercise.manualScoreEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: _exercise.manualScoreEnabled ? 66 : 48,
|
||||
height: 66,
|
||||
child: _StepSetResultSummary(
|
||||
view: _stepProgress,
|
||||
exercise: _exercise,
|
||||
reps: _reps,
|
||||
scoreController: _scoreController,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_exercise.stopwatchScoreEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
@ -1230,7 +1225,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
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,45 +1978,24 @@ 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(
|
||||
@ -2000,22 +2006,10 @@ final class _StepSetResultSummary extends StatelessWidget {
|
||||
),
|
||||
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,9 +2215,7 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final body = step.type == ExerciseStepType.time
|
||||
child: step.type == ExerciseStepType.time
|
||||
? _TimedStepBody(
|
||||
step: step,
|
||||
remainingLabel: remainingLabel,
|
||||
@ -2233,16 +2225,16 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
readyToStart: _isNextTimedStepReady(view),
|
||||
onStartTimer: onStartTimer,
|
||||
)
|
||||
: _RepsStepBody(step: step, onCompleteStep: onCompleteStep);
|
||||
return Align(
|
||||
: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(width: constraints.maxWidth, child: body),
|
||||
child: _RepsStepBody(
|
||||
step: step,
|
||||
onCompleteStep: onCompleteStep,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (step.hasScore) ...[
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
if (!running) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (readyToStart) ...[
|
||||
Text(
|
||||
'Chrono suivant prêt',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
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: 8),
|
||||
if (readyToStart) ...[
|
||||
Text(
|
||||
'Chrono prêt',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
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) {
|
||||
String _formatReferenceMeasureTime(int milliseconds) {
|
||||
final totalSeconds = (milliseconds / 1000).round().clamp(0, 999999).toInt();
|
||||
if (totalSeconds < 60) {
|
||||
return '$totalSeconds s';
|
||||
}
|
||||
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 '${(seconds + tenths / 10).toStringAsFixed(1)} s';
|
||||
}
|
||||
final paddedSeconds = seconds.toString().padLeft(2, '0');
|
||||
if (stopwatch) {
|
||||
return '$minutes:$paddedSeconds.$tenths';
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -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<Map<String, Object?>> 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<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 {
|
||||
ActiveWorkoutSession? session;
|
||||
final results = <ActiveSetResult>[];
|
||||
|
||||
Reference in New Issue
Block a user