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'; import 'package:watch_bridge_contract/watch_bridge_contract.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.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget); }); testWidgets('une séance invalide est abandonnée avec un message clair', ( tester, ) async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15)); final repository = _FakeActiveSessionRepository(); 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: _emptySessionSnapshot(), ); 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.pump(); expect(find.text('Séance indisponible'), findsOneWidget); expect( find.text( 'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.', ), findsOneWidget, ); expect(repository.session?.status, ActiveWorkoutStatus.abandoned); }); 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('affiche les statistiques montre live disponibles', ( tester, ) async { final now = DateTime.now().toUtc(); final clock = _FakeClock(now); final repository = _FakeActiveSessionRepository(); final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock) ..recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: now .subtract(const Duration(hours: 1)) .millisecondsSinceEpoch, heartRateBpm: 124, ), ) ..recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: now.millisecondsSinceEpoch, heartRateBpm: 124, distanceMeters: 840, caloriesKcal: 186, ), ); addTearDown(sensorUseCases.dispose); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); repository.session = session; await tester.pumpWidget( MaterialApp( home: WorkoutExecutionScreen( initialSession: session, activeUseCases: _activeUseCases(repository, clock), closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), sensorUseCases: sensorUseCases, ), ), ); expect(find.text('FC 124 bpm'), findsOneWidget); expect(find.text('840 m'), findsOneWidget); expect(find.text('186 kcal'), findsOneWidget); }); testWidgets('n’affiche aucun indicateur capteur sans donnée FC', ( tester, ) async { final now = DateTime.now().toUtc(); final clock = _FakeClock(now); final repository = _FakeActiveSessionRepository(); final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock); addTearDown(sensorUseCases.dispose); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); repository.session = session; await tester.pumpWidget( MaterialApp( home: WorkoutExecutionScreen( initialSession: session, activeUseCases: _activeUseCases(repository, clock), closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), sensorUseCases: sensorUseCases, ), ), ); expect(find.textContaining('bpm'), findsNothing); expect(find.textContaining('kcal'), findsNothing); expect(find.byIcon(Icons.favorite), findsNothing); expect(find.byIcon(Icons.local_fire_department), findsNothing); }); testWidgets('ne fabrique pas de calories sans mesure montre native', ( tester, ) async { final now = DateTime.now().toUtc(); final clock = _FakeClock(now); final repository = _FakeActiveSessionRepository(); final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock) ..recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: now .subtract(const Duration(hours: 1)) .millisecondsSinceEpoch, heartRateBpm: 124, ), ) ..recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: now.millisecondsSinceEpoch, heartRateBpm: 124, ), ); addTearDown(sensorUseCases.dispose); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); repository.session = session; await tester.pumpWidget( MaterialApp( home: WorkoutExecutionScreen( initialSession: session, activeUseCases: _activeUseCases(repository, clock), closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), sensorUseCases: sensorUseCases, ), ), ); expect(find.text('FC 124 bpm'), findsOneWidget); expect(find.textContaining('kcal'), findsNothing); }); testWidgets('atténue la fréquence cardiaque périmée', (tester) async { final now = DateTime.now().toUtc(); final clock = _FakeClock(now); final repository = _FakeActiveSessionRepository(); final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock) ..recordHeartRateSample( WatchSensorSample( sessionId: 'session-1', recordedAtEpochMs: now .subtract(const Duration(seconds: 30)) .millisecondsSinceEpoch, heartRateBpm: 118, ), ); addTearDown(sensorUseCases.dispose); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, elapsedActiveMs: 0, currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, resolvedTemplateSnapshotJson: _sessionSnapshot(), ); repository.session = session; await tester.pumpWidget( MaterialApp( home: WorkoutExecutionScreen( initialSession: session, activeUseCases: _activeUseCases(repository, clock), closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), sensorUseCases: sensorUseCases, ), ), ); expect(find.text('FC 118 bpm'), findsOneWidget); expect( find.ancestor( of: find.text('FC 118 bpm'), matching: find.byWidgetPredicate( (widget) => widget is Opacity && widget.opacity == 0.45, ), ), 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 { final scoreController = TextEditingController(); final scoreFocusNode = FocusNode(); addTearDown(scoreController.dispose); addTearDown(scoreFocusNode.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, scoreFocusNode: scoreFocusNode, onScoreSubmitted: (_) {}, 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, 'Lancer')); 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'), 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.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)); 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.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)); 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 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.widgetWithText(FilledButton, 'Démarrer').first); 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 jambes'), findsOneWidget); expect(find.text('3 / 3'), findsOneWidget); expect(find.text('5'), findsOneWidget); }, ); testWidgets( 'la saisie du score téléphone alimente le score vivant de la montre', (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 activeUseCases = _activeUseCases(repository, clock); 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( restSeconds: 0, scoreUnit: 'points', ), ); repository.session = session; await tester.pumpWidget( MaterialApp( home: WorkoutExecutionScreen( initialSession: session, activeUseCases: activeUseCases, closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), ), ), ); await tester.enterText( find.widgetWithText(TextField, 'Score (points)'), '7.5', ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); expect(repository.manualScoreStates.single.value, 7.5); final incremented = await activeUseCases.incrementManualScore( sessionId: 'session-1', programIndex: 0, exerciseIndex: 0, setIndex: 0, ); expect(incremented.state.value, 8.5); }, ); 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 jambes'), findsOneWidget); expect(find.text('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 jambes'), findsOneWidget); expect(find.text('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'), findsNothing); expect(find.text('Dribble main droite'), findsOneWidget); expect(find.text('00:10'), findsOneWidget); }); testWidgets('un exercice dense avec étapes et scores reste scrollable', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(400, 700)); 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: 3, targetReps: 20, scoreEnabled: true, scoreUnit: 'paniers', exerciseSteps: [ _step( 'step-1', 0, 'Tirs après dribble', ExerciseStepType.time, 10, hasScore: true, scoreLabel: 'Paniers', scoreUnit: 'paniers', defaultTargetScore: 8, ), _step('step-2', 1, 'Replacement défensif', 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.text('ÉTAPE 1 / 2'), findsNothing); expect(find.text('Tirs après dribble'), findsOneWidget); expect(find.text('00:10'), findsOneWidget); expect(find.text('Passer l’étape'), findsOneWidget); expect(find.byType(SingleChildScrollView), findsWidgets); }); 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)); 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, 'Lancer')); 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'), 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, 'Lancer')); 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'), findsNothing); 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, 'Lancer')); 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'), findsNothing); expect(find.text('Droite'), findsOneWidget); expect(find.text('00:02'), 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, 'Lancer')); 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.textContaining('Passages réalisés'), findsNothing); }); 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 _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 imageMediaIds = const [], List> exerciseSteps = const [], bool autoStartNextTimedStep = true, String? videoMediaId, String? sourceExerciseId, String workoutName = 'Séance jambes', String programName = 'Programme jambes', String exerciseName = 'Squat', }) { return jsonEncode({ 'name': workoutName, 'programs': [ { 'id': 'template-program-1', 'programNameSnapshot': programName, 'programSnapshotJson': jsonEncode({ 'exercises': [ { 'id': 'exercise-1', 'sourceExerciseId': sourceExerciseId, 'exerciseNameSnapshot': exerciseName, '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 [], }); } String _emptySessionSnapshot() { return jsonEncode({ 'name': 'Séance vide', 'programs': [ { 'id': 'template-program-1', 'programNameSnapshot': 'Programme vide', 'programSnapshotJson': jsonEncode({'exercises': const []}), }, ], 'overrides': const [], }); } Map _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 playShortCountdownBeep() async { shortBeeps += 1; } @override Future playLongCompletionBeep() async { longBeeps += 1; } @override Future dispose() async { disposed = true; } } final class _FakeExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { const _FakeExercisePerformanceReferenceRepository({this.last, this.record}); final WorkoutHistorySetPerformance? last; final WorkoutHistoryMetricPerformance? record; @override Future hasAnyCompletedHistoryForExercise(String exerciseId) async { return true; } @override Future findLatestSetPerformance({ required String exerciseId, required ActivePerformanceMeasures activeMeasures, required int currentSetIndex, }) async { return last; } @override Future findBestMetricPerformance({ required String exerciseId, required PerformanceMetric metric, required ScoreInputMode scoreInputMode, }) async { return record; } } final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final results = []; final restStates = []; final scoreStopwatchStates = []; final manualScoreStates = []; final setTimerStates = []; final stepProgressStates = []; final stepResults = []; @override Future findById(String id) async { return session?.metadata.id == id ? session : null; } @override Future findOpen() async => session; @override Future findRestStateById(String id) async { for (final restState in restStates) { if (restState.metadata.id == id) { return restState; } } return null; } @override Future 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 findManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { for (final state in manualScoreStates) { if (state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex && state.metadata.deletedAt == null) { return state; } } return null; } @override Future 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 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> listRestStates(String sessionId) async { return restStates .where((restState) => restState.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listScoreStopwatchStates( String sessionId, ) async { return scoreStopwatchStates .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listManualScoreStates( String sessionId, ) async { return manualScoreStates .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listSetResults(String sessionId) async { return results; } @override Future> listSetTimerStates(String sessionId) async { return setTimerStates .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listExerciseStepProgressStates( String sessionId, ) async { return stepProgressStates .where((state) => state.activeWorkoutSessionId == sessionId) .toList(); } @override Future> listExerciseStepResults( String sessionId, ) async { return stepResults .where((result) => result.activeWorkoutSessionId == sessionId) .toList(); } @override Future save(ActiveWorkoutSession session) async { this.session = session; } @override Future 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 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 saveManualScoreState(ActiveManualScoreState state) async { final index = manualScoreStates.indexWhere( (saved) => saved.metadata.id == state.metadata.id, ); if (index == -1) { manualScoreStates.add(state); } else { manualScoreStates[index] = state; } } @override Future 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 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 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 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 deleteManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { manualScoreStates.removeWhere( (state) => state.activeWorkoutSessionId == sessionId && state.programIndex == programIndex && state.exerciseIndex == exerciseIndex && state.setIndex == setIndex, ); } @override Future 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 findById(String id) async => null; @override Future> listActive() async => const []; @override Future replaceComposition( WorkoutTemplate template, DateTime deletedAt, ) async {} @override Future save(WorkoutTemplate template) async {} @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @override Future saveProgram(WorkoutTemplateProgram program) async {} } final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future delete(String id, DateTime deletedAt) async {} @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(WorkoutHistory history) async {} @override Future patchHeartRateSummary({ required String historyId, required double averageHeartRateBpm, required int maxHeartRateBpm, required DateTime patchedAt, }) async {} @override Future saveSetResult(WorkoutHistorySetResult result) async {} @override Future saveStepResult(WorkoutHistoryStepResult result) async {} } final class _FakeProgramRepository implements ProgramRepository { @override Future replaceExercises(Program program, DateTime deletedAt) async {} @override Future findById(String id) async => null; @override Future> listActive() async => const []; @override Future save(Program program) async {} @override Future saveExercise(ProgramExercise exercise) async {} }