Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer l'exercice"). Le timer de série devient un état persistant dédié (plus un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance en une action tous les chronos qui doivent démarrer en début de série (série, première étape temps, score chrono si stopwatch). Implémentation restée non commitée depuis sa réalisation ; QA a validé les vérifications statiques exécutables en sandbox (dart analyze, git diff --check). flutter analyze / flutter test restent à relancer dans un environnement avec cache Flutter SDK accessible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2102 lines
68 KiB
Dart
2102 lines
68 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:gametime/application/application.dart';
|
||
import 'package:gametime/domain/domain.dart';
|
||
import 'package:gametime/presentation/presentation.dart';
|
||
|
||
void main() {
|
||
testWidgets('le temps affiché est recalculé depuis les horodatages', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 30000,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 0,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(
|
||
_FakeActiveSessionRepository(),
|
||
clock,
|
||
),
|
||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Squat'), findsOneWidget);
|
||
expect(find.text('Temps de série : 00:45'), findsOneWidget);
|
||
expect(find.text('Démarrer l’exercice'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('les répétitions sont initialisées avec la cible', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
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(targetReps: 8),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(
|
||
_FakeActiveSessionRepository(),
|
||
clock,
|
||
),
|
||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Répétitions'), findsOneWidget);
|
||
expect(find.text('8'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
|
||
tester,
|
||
) async {
|
||
final scoreController = TextEditingController();
|
||
addTearDown(scoreController.dispose);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: Scaffold(
|
||
body: SetMeasureInput(
|
||
exercise: const ExecutionExercise(
|
||
id: 'exercise-1',
|
||
name: 'Squat',
|
||
setsCount: 3,
|
||
timeEnabled: true,
|
||
repsEnabled: true,
|
||
scoreEnabled: true,
|
||
restSeconds: 0,
|
||
scoreUnit: 'kg',
|
||
),
|
||
reps: 0,
|
||
scoreController: scoreController,
|
||
onRepsChanged: (_) {},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
final timeY = tester.getTopLeft(find.text('Temps')).dy;
|
||
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
|
||
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
|
||
|
||
expect(timeY, lessThan(repsY));
|
||
expect(repsY, lessThan(scoreY));
|
||
});
|
||
|
||
testWidgets('aucun repos ne s’affiche après la toute dernière série', (
|
||
tester,
|
||
) async {
|
||
final plan = WorkoutExecutionPlan.fromSession(
|
||
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: 1,
|
||
restSeconds: 60,
|
||
),
|
||
),
|
||
);
|
||
const position = ExecutionPosition(
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
Directionality(
|
||
textDirection: TextDirection.ltr,
|
||
child: Text(
|
||
plan.shouldShowRestAfter(position) ? 'Repos' : 'Pas de repos',
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Pas de repos'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('le bouton médias est absent sans média', (tester) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
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(),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(
|
||
_FakeActiveSessionRepository(),
|
||
clock,
|
||
),
|
||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Voir médias'), findsNothing);
|
||
expect(find.byTooltip('Voir les médias de l’exercice'), findsNothing);
|
||
});
|
||
|
||
testWidgets('le bouton médias ouvre la sheet si médias présents', (
|
||
tester,
|
||
) async {
|
||
final imageFile = (await tester.runAsync(_createTestImageFile))!;
|
||
addTearDown(() async {
|
||
await tester.runAsync(() async {
|
||
await imageFile.parent.delete(recursive: true);
|
||
});
|
||
});
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
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(
|
||
imageMediaIds: const ['image-1'],
|
||
videoMediaId: 'video-1',
|
||
),
|
||
);
|
||
|
||
await tester.runAsync(() async {
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(
|
||
_FakeActiveSessionRepository(),
|
||
clock,
|
||
),
|
||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
mediaAssetLoader: (id) async {
|
||
if (id == 'image-1') {
|
||
return MediaAsset(
|
||
metadata: _metadata(id),
|
||
kind: MediaKind.image,
|
||
localUri: imageFile.uri.toString(),
|
||
);
|
||
}
|
||
if (id == 'video-1') {
|
||
return MediaAsset(
|
||
metadata: _metadata(id),
|
||
kind: MediaKind.video,
|
||
localUri: imageFile.uri.toString(),
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
videoMediaBuilder: (context, asset) => SizedBox(
|
||
height: 260,
|
||
child: Center(child: Text('Lecteur vidéo ${asset.metadata.id}')),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
});
|
||
|
||
expect(find.byTooltip('Voir les médias de l’exercice'), findsOneWidget);
|
||
|
||
await tester.tap(find.byTooltip('Voir les médias de l’exercice'));
|
||
await tester.runAsync(() async {
|
||
await tester.pumpAndSettle();
|
||
});
|
||
|
||
expect(find.text('Médias de l’exercice'), findsOneWidget);
|
||
expect(find.text('Images'), findsOneWidget);
|
||
expect(find.text('Vidéo'), findsOneWidget);
|
||
expect(find.text('1/1'), findsOneWidget);
|
||
expect(
|
||
find.byKey(const ValueKey('exercise-image-image-1')),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Image 1'), findsNothing);
|
||
|
||
await tester.tap(find.text('Vidéo'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Lecteur vidéo video-1'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('la galerie médias navigue entre les images', (tester) async {
|
||
final imageFile = (await tester.runAsync(_createTestImageFile))!;
|
||
addTearDown(() async {
|
||
await tester.runAsync(() async {
|
||
await imageFile.parent.delete(recursive: true);
|
||
});
|
||
});
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
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(
|
||
imageMediaIds: const ['image-1', 'image-2', 'image-3'],
|
||
),
|
||
);
|
||
|
||
await tester.runAsync(() async {
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(
|
||
_FakeActiveSessionRepository(),
|
||
clock,
|
||
),
|
||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
mediaAssetLoader: (id) async => MediaAsset(
|
||
metadata: _metadata(id),
|
||
kind: MediaKind.image,
|
||
localUri: imageFile.uri.toString(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
});
|
||
|
||
await tester.tap(find.byTooltip('Voir les médias de l’exercice'));
|
||
await tester.runAsync(() async {
|
||
await tester.pumpAndSettle();
|
||
});
|
||
|
||
expect(find.text('1/3'), findsOneWidget);
|
||
|
||
await tester.drag(find.byType(PageView), const Offset(-400, 0));
|
||
await tester.runAsync(() async {
|
||
await tester.pumpAndSettle();
|
||
});
|
||
|
||
expect(find.text('2/3'), findsOneWidget);
|
||
expect(
|
||
find.byKey(const ValueKey('exercise-image-image-2')),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('le chrono score démarre et s’arrête avec la bonne durée', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||
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(
|
||
scoreInputMode: ScoreInputMode.stopwatch,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Chrono score'), findsOneWidget);
|
||
expect(find.text('00:00.0'), findsOneWidget);
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer'));
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100);
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
|
||
expect(find.text('00:00.1'), findsOneWidget);
|
||
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 900);
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
|
||
expect(find.text('00:00.9'), findsOneWidget);
|
||
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
|
||
await tester.pump(const Duration(seconds: 1));
|
||
|
||
expect(find.text('00:01.2'), findsOneWidget);
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Arrêter'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('00:01.2'), findsOneWidget);
|
||
expect(
|
||
repository.scoreStopwatchStates.single.status,
|
||
ActiveScoreStopwatchStatus.stopped,
|
||
);
|
||
});
|
||
|
||
testWidgets('terminer sans chrono démarré affiche une confirmation', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||
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(
|
||
scoreInputMode: ScoreInputMode.stopwatch,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
|
||
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'),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Terminer sans chrono'), findsOneWidget);
|
||
expect(repository.results, isEmpty);
|
||
});
|
||
|
||
testWidgets('terminer avec chrono en cours l’arrête et enregistre', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||
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: 2,
|
||
scoreInputMode: ScoreInputMode.stopwatch,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.text('Démarrer l’exercice'));
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500);
|
||
await tester.pump(const Duration(seconds: 1));
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.results.single.actualScoreTimeMs, 2500);
|
||
expect(
|
||
repository.scoreStopwatchStates.single.status,
|
||
ActiveScoreStopwatchStatus.stopped,
|
||
);
|
||
expect(repository.session?.currentSetIndex, 1);
|
||
});
|
||
|
||
testWidgets('un repos actif est repris avec le temps restant recalculé', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 0,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 2,
|
||
restSeconds: 60,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
repository.restStates.add(
|
||
ActiveRestState(
|
||
metadata: _metadata('rest-1'),
|
||
activeWorkoutSessionId: 'session-1',
|
||
afterProgramIndex: 0,
|
||
afterExerciseIndex: 0,
|
||
afterSetIndex: 0,
|
||
plannedRestSeconds: 60,
|
||
adjustedRestSeconds: 60,
|
||
startedAt: DateTime.now().toUtc().subtract(const Duration(seconds: 15)),
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
|
||
expect(find.text('00:45'), findsOneWidget);
|
||
expect(find.text('Série 2 / 2'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('+15 s'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('01:00'), findsOneWidget);
|
||
expect(repository.restStates.single.adjustedRestSeconds, 75);
|
||
|
||
await tester.tap(find.text('Ignorer le repos'));
|
||
await tester.pump();
|
||
|
||
expect(repository.restStates.single.skippedAt, isNotNull);
|
||
});
|
||
|
||
testWidgets('un repos en pause est repris sans compter la durée de pause', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 0,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 2,
|
||
restSeconds: 60,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
final now = DateTime.now().toUtc();
|
||
repository.restStates.add(
|
||
ActiveRestState(
|
||
metadata: _metadata('rest-1'),
|
||
activeWorkoutSessionId: 'session-1',
|
||
afterProgramIndex: 0,
|
||
afterExerciseIndex: 0,
|
||
afterSetIndex: 0,
|
||
plannedRestSeconds: 60,
|
||
adjustedRestSeconds: 60,
|
||
startedAt: now.subtract(const Duration(minutes: 10, seconds: 15)),
|
||
pausedAt: now.subtract(const Duration(minutes: 10)),
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Repos avant la prochaine série'), findsOneWidget);
|
||
expect(find.text('00:45'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('la mesure temps enregistre une durée réelle de série', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||
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: 2,
|
||
restSeconds: 0,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.tap(find.text('Démarrer l’exercice'));
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
|
||
await tester.pump(const Duration(seconds: 2));
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pump();
|
||
|
||
expect(repository.results.single.actualTimeMs, 2000);
|
||
});
|
||
|
||
testWidgets(
|
||
'terminer la série 2 avec temps répétitions et score manuel avance',
|
||
(tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||
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: 1,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 3,
|
||
restSeconds: 0,
|
||
targetTimeSeconds: 30,
|
||
targetReps: 5,
|
||
targetScore: 10,
|
||
scoreUnit: 'points',
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
|
||
expect(find.text('Série 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.pump();
|
||
|
||
for (var i = 0; i < 2; i++) {
|
||
await tester.tap(find.byIcon(Icons.remove));
|
||
await tester.pump();
|
||
}
|
||
await tester.enterText(
|
||
find.widgetWithText(TextField, 'Score (points)'),
|
||
'5',
|
||
);
|
||
await tester.ensureVisible(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.results.single.setIndex, 1);
|
||
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('5'), findsOneWidget);
|
||
},
|
||
);
|
||
|
||
testWidgets('la flèche de retour met la séance active en pause', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 0,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 2,
|
||
restSeconds: 0,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Mettre en pause'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Reprendre'), findsOneWidget);
|
||
expect(repository.session?.status, ActiveWorkoutStatus.paused);
|
||
});
|
||
|
||
testWidgets('le plan de séance affiche les états des séries', (tester) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 2,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 4,
|
||
restSeconds: 0,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
repository.results.add(
|
||
ActiveSetResult(
|
||
metadata: _metadata('result-1'),
|
||
activeWorkoutSessionId: 'session-1',
|
||
programSnapshotId: 'template-program-1',
|
||
exerciseSnapshotId: 'exercise-1',
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
actualReps: 10,
|
||
actualScore: 7,
|
||
scoreUnitSnapshot: 'paniers',
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Voir le plan'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Plan de séance'), findsOneWidget);
|
||
expect(find.text('Terminée'), findsOneWidget);
|
||
expect(find.text('10 reps · 7 paniers'), findsOneWidget);
|
||
expect(find.text('Passée'), findsOneWidget);
|
||
expect(find.text('Aucun résultat'), findsOneWidget);
|
||
expect(find.text('En cours'), findsOneWidget);
|
||
expect(find.text('À faire'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('En cours'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Plan de séance'), findsNothing);
|
||
});
|
||
|
||
testWidgets('le plan affiche le score chrono formaté avec objectif', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 1,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 2,
|
||
restSeconds: 0,
|
||
scoreInputMode: ScoreInputMode.stopwatch,
|
||
targetScoreTimeMs: 45000,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
repository.results.add(
|
||
ActiveSetResult(
|
||
metadata: _metadata('result-1'),
|
||
activeWorkoutSessionId: 'session-1',
|
||
programSnapshotId: 'template-program-1',
|
||
exerciseSnapshotId: 'exercise-1',
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
actualScoreTimeMs: 38700,
|
||
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Voir le plan'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Score chrono 00:38.7 / objectif 00:45'), findsOneWidget);
|
||
expect(find.textContaining('Temps réalisé 00:38.7'), findsNothing);
|
||
});
|
||
|
||
testWidgets('le plan affiche le résumé de séquence d’une série', (
|
||
tester,
|
||
) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 0,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 2,
|
||
targetReps: 2,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gauche', ExerciseStepType.reps, 4),
|
||
_step('step-2', 1, 'Droite', ExerciseStepType.reps, 4),
|
||
],
|
||
),
|
||
);
|
||
repository.session = session;
|
||
repository.stepResults.add(
|
||
ActiveExerciseStepResult(
|
||
metadata: _metadata('step-result-1'),
|
||
activeWorkoutSessionId: 'session-1',
|
||
programSnapshotId: 'template-program-1',
|
||
exerciseSnapshotId: 'exercise-1',
|
||
programIndex: 0,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
passageIndex: 0,
|
||
stepIndex: 0,
|
||
stepSnapshotId: 'step-1',
|
||
stepNameSnapshot: 'Gauche',
|
||
stepTypeSnapshot: ExerciseStepType.reps,
|
||
targetValueSnapshot: 4,
|
||
hasScoreSnapshot: false,
|
||
status: SetResultStatus.completed,
|
||
actualReps: 4,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
stepUseCases: _stepUseCases(repository, clock),
|
||
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Voir le plan'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Séquence : 1/2 étapes · Passage 1/2'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets(
|
||
'modifier une série passée la termine sans changer la position courante',
|
||
(tester) async {
|
||
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: 2,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 4,
|
||
restSeconds: 0,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Voir le plan'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(ListTile, 'Série 2'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Modifier la série'), findsOneWidget);
|
||
|
||
await tester.enterText(_textFieldWithLabel('Répétitions'), '12');
|
||
await tester.ensureVisible(find.text('Enregistrer le résultat'));
|
||
await tester.tap(find.text('Enregistrer le résultat'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.session?.currentSetIndex, 2);
|
||
expect(repository.results.single.status, SetResultStatus.completed);
|
||
expect(repository.results.single.actualReps, 12);
|
||
expect(find.text('Série mise à jour'), findsWidgets);
|
||
expect(find.text('12 reps'), findsOneWidget);
|
||
|
||
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);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'toucher la série courante dans le plan revient à l’écran principal',
|
||
(tester) async {
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final session = ActiveWorkoutSession(
|
||
metadata: _metadata('session-1'),
|
||
sourceWorkoutTemplateId: 'template-1',
|
||
status: ActiveWorkoutStatus.running,
|
||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||
elapsedActiveMs: 0,
|
||
currentProgramIndex: 0,
|
||
currentExerciseIndex: 0,
|
||
currentSetIndex: 1,
|
||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||
setsCount: 3,
|
||
restSeconds: 0,
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
|
||
await tester.tap(find.byTooltip('Voir le plan'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('En cours'));
|
||
await tester.pumpAndSettle();
|
||
|
||
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);
|
||
},
|
||
);
|
||
|
||
testWidgets('le module séquence s’affiche pour un exercice avec étapes', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
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(
|
||
targetReps: 10,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Dribble main droite', ExerciseStepType.time, 10),
|
||
_step('step-2', 1, 'Pompes', ExerciseStepType.reps, 8),
|
||
],
|
||
),
|
||
);
|
||
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(find.byType(ListView), findsNothing);
|
||
expect(find.text('SÉQUENCE'), findsOneWidget);
|
||
expect(find.text('Passage 1 / 10'), findsOneWidget);
|
||
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
|
||
expect(find.text('Dribble main droite'), findsOneWidget);
|
||
expect(find.text('00:10'), findsOneWidget);
|
||
});
|
||
|
||
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));
|
||
|
||
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(
|
||
targetReps: 1,
|
||
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 8)],
|
||
),
|
||
);
|
||
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(find.text('8'), findsWidgets);
|
||
await tester.tap(find.text('Étape suivante'));
|
||
await tester.pump();
|
||
|
||
expect(repository.stepResults.single.actualReps, 8);
|
||
expect(find.text('Séquence terminée'), findsWidgets);
|
||
});
|
||
|
||
testWidgets('une étape temps affiche un décompte recalculé', (tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final audio = _FakeStepAudioCuePlayer();
|
||
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(
|
||
targetReps: 1,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gainage', ExerciseStepType.time, 5),
|
||
],
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
stepUseCases: _stepUseCases(repository, clock),
|
||
stepAudioCuePlayer: audio,
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('00:05'), findsOneWidget);
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence'));
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
|
||
await tester.pump(const Duration(milliseconds: 100));
|
||
|
||
expect(find.text('00:03'), findsOneWidget);
|
||
expect(audio.shortBeeps, 1);
|
||
});
|
||
|
||
testWidgets(
|
||
'terminer sans démarrer la première étape temps affiche une confirmation',
|
||
(tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final audio = _FakeStepAudioCuePlayer();
|
||
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(
|
||
timeEnabled: false,
|
||
scoreEnabled: false,
|
||
targetReps: 1,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gainage', ExerciseStepType.time, 5),
|
||
],
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
stepUseCases: _stepUseCases(repository, clock),
|
||
stepAudioCuePlayer: audio,
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
|
||
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'),
|
||
findsOneWidget,
|
||
);
|
||
expect(find.text('Terminer sans chrono'), findsOneWidget);
|
||
expect(repository.results, isEmpty);
|
||
},
|
||
);
|
||
|
||
testWidgets('deux étapes temps consécutives s’enchaînent automatiquement', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||
|
||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||
final repository = _FakeActiveSessionRepository();
|
||
final audio = _FakeStepAudioCuePlayer();
|
||
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(
|
||
targetReps: 1,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gauche', ExerciseStepType.time, 1),
|
||
_step('step-2', 1, 'Droite', ExerciseStepType.time, 2),
|
||
],
|
||
),
|
||
);
|
||
repository.session = session;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: _activeUseCases(repository, clock),
|
||
stepUseCases: _stepUseCases(repository, clock),
|
||
stepAudioCuePlayer: audio,
|
||
closeUseCase: _closeUseCase(repository, clock),
|
||
historyUseCases: _historyUseCases(clock),
|
||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence'));
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
await tester.pump();
|
||
|
||
expect(repository.stepResults.single.stepIndex, 0);
|
||
expect(repository.stepResults.single.status, SetResultStatus.completed);
|
||
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
|
||
expect(find.text('Droite'), findsOneWidget);
|
||
expect(find.text('00:02'), findsOneWidget);
|
||
expect(
|
||
repository.stepProgressStates.single.status,
|
||
ActiveExerciseStepProgressStatus.runningTimer,
|
||
);
|
||
expect(audio.longBeeps, 1);
|
||
});
|
||
|
||
testWidgets(
|
||
'chrono suivant prêt attend un démarrage manuel quand l’enchaînement est désactivé',
|
||
(tester) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
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(
|
||
targetReps: 1,
|
||
autoStartNextTimedStep: false,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gauche', ExerciseStepType.time, 1),
|
||
_step('step-2', 1, 'Droite', ExerciseStepType.time, 2),
|
||
],
|
||
),
|
||
);
|
||
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();
|
||
|
||
await tester.tap(
|
||
find.widgetWithText(FilledButton, 'Démarrer la séquence'),
|
||
);
|
||
await tester.pump();
|
||
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
|
||
await tester.pump(const Duration(milliseconds: 200));
|
||
await tester.pump();
|
||
|
||
expect(repository.stepResults.single.stepIndex, 0);
|
||
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(
|
||
repository.stepProgressStates.single.status,
|
||
ActiveExerciseStepProgressStatus.stoppedTimer,
|
||
);
|
||
expect(repository.stepProgressStates.single.startedAt, isNull);
|
||
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer le chrono'));
|
||
await tester.pump();
|
||
|
||
expect(
|
||
repository.stepProgressStates.single.status,
|
||
ActiveExerciseStepProgressStatus.runningTimer,
|
||
);
|
||
expect(repository.stepProgressStates.single.startedAt, clock.value);
|
||
},
|
||
);
|
||
|
||
testWidgets('la séquence passe au deuxième passage si répétitions > 1', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||
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(
|
||
targetReps: 2,
|
||
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
|
||
),
|
||
);
|
||
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();
|
||
await tester.tap(find.text('Étape suivante'));
|
||
await tester.pump();
|
||
|
||
expect(find.text('Passage 2 / 2'), findsOneWidget);
|
||
expect(find.text('Passages réalisés : 1 / 2'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('terminer la série reste utilisable avant la fin de séquence', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1800));
|
||
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: 2,
|
||
restSeconds: 0,
|
||
targetReps: 2,
|
||
exerciseSteps: [_step('step-1', 0, 'Pompes', ExerciseStepType.reps, 4)],
|
||
),
|
||
);
|
||
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();
|
||
|
||
await tester.ensureVisible(find.text('Terminer la série'));
|
||
await tester.tap(find.text('Terminer la série'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.results.single.setIndex, 0);
|
||
expect(repository.session?.currentSetIndex, 1);
|
||
});
|
||
|
||
testWidgets('les trois niveaux de skip de séquence fonctionnent', (
|
||
tester,
|
||
) async {
|
||
await tester.binding.setSurfaceSize(const Size(400, 1800));
|
||
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(
|
||
targetReps: 2,
|
||
exerciseSteps: [
|
||
_step('step-1', 0, 'Gauche', ExerciseStepType.reps, 3),
|
||
_step('step-2', 1, 'Droite', ExerciseStepType.reps, 3),
|
||
],
|
||
),
|
||
);
|
||
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();
|
||
|
||
await tester.tap(find.text('Passer l’étape'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Passer l’étape'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(repository.stepResults.single.stepIndex, 0);
|
||
expect(repository.stepResults.single.status, SetResultStatus.skipped);
|
||
|
||
await tester.tap(find.byTooltip('Plus d’actions'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Passer ce passage'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Passer ce passage'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Passage 2 / 2'), findsOneWidget);
|
||
|
||
await tester.tap(find.byTooltip('Plus d’actions'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Passer la séquence'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Passer la séquence'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Séquence terminée'), findsWidgets);
|
||
});
|
||
}
|
||
|
||
Finder _textFieldWithLabel(String label) {
|
||
return find.byWidgetPredicate(
|
||
(widget) => widget is TextField && widget.decoration?.labelText == label,
|
||
description: 'TextField with label "$label"',
|
||
);
|
||
}
|
||
|
||
Future<File> _createTestImageFile() async {
|
||
final directory = await Directory.systemTemp.createTemp('gametime-media-');
|
||
final file = File('${directory.path}/image.png');
|
||
const transparentPng =
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
|
||
'AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||
await file.writeAsBytes(base64Decode(transparentPng));
|
||
return file;
|
||
}
|
||
|
||
ActiveWorkoutSessionUseCases _activeUseCases(
|
||
_FakeActiveSessionRepository repository,
|
||
_FakeClock clock,
|
||
) {
|
||
return ActiveWorkoutSessionUseCases(
|
||
sessionRepository: repository,
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
CloseWorkoutSessionUseCase _closeUseCase(
|
||
_FakeActiveSessionRepository repository,
|
||
_FakeClock clock,
|
||
) {
|
||
return CloseWorkoutSessionUseCase(
|
||
sessionRepository: repository,
|
||
historyRepository: _FakeWorkoutHistoryRepository(),
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
ActiveExerciseStepUseCases _stepUseCases(
|
||
_FakeActiveSessionRepository repository,
|
||
_FakeClock clock,
|
||
) {
|
||
return ActiveExerciseStepUseCases(
|
||
sessionRepository: repository,
|
||
clock: clock,
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
WorkoutHistoryUseCases _historyUseCases(_FakeClock clock) {
|
||
return WorkoutHistoryUseCases(
|
||
repository: _FakeWorkoutHistoryRepository(),
|
||
clock: clock,
|
||
);
|
||
}
|
||
|
||
WorkoutTemplateUseCases _workoutTemplateUseCases() {
|
||
return WorkoutTemplateUseCases(
|
||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||
programRepository: _FakeProgramRepository(),
|
||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
String _sessionSnapshot({
|
||
int setsCount = 3,
|
||
int restSeconds = 0,
|
||
bool timeEnabled = true,
|
||
bool repsEnabled = true,
|
||
bool scoreEnabled = true,
|
||
int targetTimeSeconds = 45,
|
||
int targetReps = 10,
|
||
double targetScore = 80,
|
||
int? targetScoreTimeMs,
|
||
String scoreUnit = 'kg',
|
||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||
List<String> imageMediaIds = const [],
|
||
List<Map<String, Object?>> exerciseSteps = const [],
|
||
bool autoStartNextTimedStep = true,
|
||
String? videoMediaId,
|
||
}) {
|
||
return jsonEncode({
|
||
'name': 'Séance jambes',
|
||
'programs': [
|
||
{
|
||
'id': 'template-program-1',
|
||
'programNameSnapshot': 'Programme jambes',
|
||
'programSnapshotJson': jsonEncode({
|
||
'exercises': [
|
||
{
|
||
'id': 'exercise-1',
|
||
'exerciseNameSnapshot': 'Squat',
|
||
'setsCount': setsCount,
|
||
'timeEnabled': timeEnabled,
|
||
'repsEnabled': repsEnabled,
|
||
'scoreEnabled': scoreEnabled,
|
||
'targetTimeSeconds': targetTimeSeconds,
|
||
'targetReps': targetReps,
|
||
'targetScore': targetScore,
|
||
'targetScoreTimeMs': targetScoreTimeMs,
|
||
'scoreInputModeSnapshot': scoreInputMode.name,
|
||
'scoreLabelSnapshot': 'Score',
|
||
'scoreUnitSnapshot': scoreUnit,
|
||
'restSecondsOverride': restSeconds,
|
||
'exerciseImageMediaIdsSnapshot': imageMediaIds,
|
||
'imageMediaIdsSnapshot': imageMediaIds,
|
||
'exerciseVideoMediaIdSnapshot': videoMediaId,
|
||
'videoMediaIdSnapshot': videoMediaId,
|
||
'exerciseStepsSnapshot': exerciseSteps,
|
||
'autoStartNextTimedStepSnapshot': autoStartNextTimedStep,
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
],
|
||
'overrides': const [],
|
||
});
|
||
}
|
||
|
||
Map<String, Object?> _step(
|
||
String id,
|
||
int position,
|
||
String name,
|
||
ExerciseStepType type,
|
||
int defaultTargetValue, {
|
||
bool hasScore = false,
|
||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||
String? scoreLabel,
|
||
String? scoreUnit,
|
||
double? defaultTargetScore,
|
||
int? defaultTargetScoreTimeMs,
|
||
}) {
|
||
return {
|
||
'id': id,
|
||
'position': position,
|
||
'name': name,
|
||
'type': type.name,
|
||
'defaultTargetValue': defaultTargetValue,
|
||
'hasScore': hasScore,
|
||
'scoreInputMode': scoreInputMode.name,
|
||
'scoreLabel': scoreLabel,
|
||
'scoreUnit': scoreUnit,
|
||
'defaultTargetScore': defaultTargetScore,
|
||
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
|
||
};
|
||
}
|
||
|
||
EntityMetadata _metadata(String id) {
|
||
return EntityMetadata(
|
||
id: id,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
}
|
||
|
||
final class _FakeClock implements Clock {
|
||
_FakeClock(this.value);
|
||
|
||
DateTime value;
|
||
|
||
@override
|
||
DateTime now() => value;
|
||
}
|
||
|
||
final class _FakeIds implements IdGenerator {
|
||
var _next = 0;
|
||
|
||
@override
|
||
String newId() {
|
||
_next += 1;
|
||
return 'id-$_next';
|
||
}
|
||
}
|
||
|
||
final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer {
|
||
var shortBeeps = 0;
|
||
var longBeeps = 0;
|
||
var disposed = false;
|
||
|
||
@override
|
||
Future<void> playShortCountdownBeep() async {
|
||
shortBeeps += 1;
|
||
}
|
||
|
||
@override
|
||
Future<void> playLongCompletionBeep() async {
|
||
longBeeps += 1;
|
||
}
|
||
|
||
@override
|
||
Future<void> dispose() async {
|
||
disposed = true;
|
||
}
|
||
}
|
||
|
||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||
ActiveWorkoutSession? session;
|
||
final results = <ActiveSetResult>[];
|
||
final restStates = <ActiveRestState>[];
|
||
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
||
final setTimerStates = <ActiveSetTimerState>[];
|
||
final stepProgressStates = <ActiveExerciseStepProgressState>[];
|
||
final stepResults = <ActiveExerciseStepResult>[];
|
||
|
||
@override
|
||
Future<ActiveWorkoutSession?> findById(String id) async {
|
||
return session?.metadata.id == id ? session : null;
|
||
}
|
||
|
||
@override
|
||
Future<ActiveWorkoutSession?> findOpen() async => session;
|
||
|
||
@override
|
||
Future<ActiveRestState?> findRestStateById(String id) async {
|
||
for (final restState in restStates) {
|
||
if (restState.metadata.id == id) {
|
||
return restState;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
}) async {
|
||
for (final state in scoreStopwatchStates) {
|
||
if (state.activeWorkoutSessionId == sessionId &&
|
||
state.programIndex == programIndex &&
|
||
state.exerciseIndex == exerciseIndex &&
|
||
state.setIndex == setIndex &&
|
||
state.metadata.deletedAt == null) {
|
||
return state;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<ActiveSetTimerState?> findSetTimerState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
}) async {
|
||
for (final state in setTimerStates) {
|
||
if (state.activeWorkoutSessionId == sessionId &&
|
||
state.programIndex == programIndex &&
|
||
state.exerciseIndex == exerciseIndex &&
|
||
state.setIndex == setIndex &&
|
||
state.metadata.deletedAt == null) {
|
||
return state;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
}) async {
|
||
for (final state in stepProgressStates) {
|
||
if (state.activeWorkoutSessionId == sessionId &&
|
||
state.programIndex == programIndex &&
|
||
state.exerciseIndex == exerciseIndex &&
|
||
state.setIndex == setIndex &&
|
||
state.metadata.deletedAt == null) {
|
||
return state;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
|
||
return restStates
|
||
.where((restState) => restState.activeWorkoutSessionId == sessionId)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||
String sessionId,
|
||
) async {
|
||
return scoreStopwatchStates
|
||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||
return results;
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
|
||
return setTimerStates
|
||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
|
||
String sessionId,
|
||
) async {
|
||
return stepProgressStates
|
||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
|
||
String sessionId,
|
||
) async {
|
||
return stepResults
|
||
.where((result) => result.activeWorkoutSessionId == sessionId)
|
||
.toList();
|
||
}
|
||
|
||
@override
|
||
Future<void> save(ActiveWorkoutSession session) async {
|
||
this.session = session;
|
||
}
|
||
|
||
@override
|
||
Future<void> saveRestState(ActiveRestState restState) async {
|
||
final index = restStates.indexWhere(
|
||
(saved) => saved.metadata.id == restState.metadata.id,
|
||
);
|
||
if (index == -1) {
|
||
restStates.add(restState);
|
||
} else {
|
||
restStates[index] = restState;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
|
||
final index = scoreStopwatchStates.indexWhere(
|
||
(saved) => saved.metadata.id == state.metadata.id,
|
||
);
|
||
if (index == -1) {
|
||
scoreStopwatchStates.add(state);
|
||
} else {
|
||
scoreStopwatchStates[index] = state;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
|
||
final index = setTimerStates.indexWhere(
|
||
(saved) =>
|
||
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
|
||
saved.programIndex == state.programIndex &&
|
||
saved.exerciseIndex == state.exerciseIndex &&
|
||
saved.setIndex == state.setIndex,
|
||
);
|
||
if (index == -1) {
|
||
setTimerStates.add(state);
|
||
} else {
|
||
setTimerStates[index] = state;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> saveExerciseStepProgressState(
|
||
ActiveExerciseStepProgressState state,
|
||
) async {
|
||
final index = stepProgressStates.indexWhere(
|
||
(saved) =>
|
||
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
|
||
saved.programIndex == state.programIndex &&
|
||
saved.exerciseIndex == state.exerciseIndex &&
|
||
saved.setIndex == state.setIndex,
|
||
);
|
||
if (index == -1) {
|
||
stepProgressStates.add(state);
|
||
} else {
|
||
stepProgressStates[index] = state;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
|
||
final index = stepResults.indexWhere(
|
||
(saved) =>
|
||
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
||
saved.programIndex == result.programIndex &&
|
||
saved.exerciseIndex == result.exerciseIndex &&
|
||
saved.setIndex == result.setIndex &&
|
||
saved.passageIndex == result.passageIndex &&
|
||
saved.stepIndex == result.stepIndex,
|
||
);
|
||
if (index == -1) {
|
||
stepResults.add(result);
|
||
} else {
|
||
stepResults[index] = result;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> deleteScoreStopwatchState({
|
||
required String sessionId,
|
||
required int programIndex,
|
||
required int exerciseIndex,
|
||
required int setIndex,
|
||
required DateTime deletedAt,
|
||
}) async {
|
||
scoreStopwatchStates.removeWhere(
|
||
(state) =>
|
||
state.activeWorkoutSessionId == sessionId &&
|
||
state.programIndex == programIndex &&
|
||
state.exerciseIndex == exerciseIndex &&
|
||
state.setIndex == setIndex,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||
final index = results.indexWhere(
|
||
(saved) =>
|
||
saved.activeWorkoutSessionId == result.activeWorkoutSessionId &&
|
||
saved.programIndex == result.programIndex &&
|
||
saved.exerciseIndex == result.exerciseIndex &&
|
||
saved.setIndex == result.setIndex,
|
||
);
|
||
if (index == -1) {
|
||
results.add(result);
|
||
} else {
|
||
results[index] = result;
|
||
}
|
||
}
|
||
}
|
||
|
||
final class _FakeWorkoutTemplateRepository
|
||
implements WorkoutTemplateRepository {
|
||
@override
|
||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> replaceComposition(
|
||
WorkoutTemplate template,
|
||
DateTime deletedAt,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> save(WorkoutTemplate template) async {}
|
||
|
||
@override
|
||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||
|
||
@override
|
||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||
}
|
||
|
||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||
@override
|
||
Future<void> delete(String id, DateTime deletedAt) async {}
|
||
|
||
@override
|
||
Future<WorkoutHistory?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<WorkoutHistory>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> save(WorkoutHistory history) async {}
|
||
|
||
@override
|
||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||
|
||
@override
|
||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||
}
|
||
|
||
final class _FakeProgramRepository implements ProgramRepository {
|
||
@override
|
||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||
|
||
@override
|
||
Future<Program?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<Program>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> save(Program program) async {}
|
||
|
||
@override
|
||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||
}
|