feat(exercice): exécution de séance avec module séquence et bips (ticket #60)

Ajoute le lecteur de bips d'étapes (presentation/exercise_step_audio.dart,
port ExerciseStepAudioCuePlayer injectable en test, jamais le vrai
audioplayers en test) et les fichiers audio associés
(assets/audio/step_countdown_short.wav, step_completion_long.wav).
Câble ActiveExerciseStepUseCases dans app_bootstrap.dart et l'expose à
WorkoutExecutionScreen. Les appels audio réels sont protégés par un
try/catch qui log sans jamais bloquer la progression d'étape. flutter
pub get OK (ajout audioplayers), analyze propre (mêmes infos
préexistantes + 2 nouvelles use_build_context_synchronously, non
bloquantes), 100/100 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 20:07:56 +02:00
parent 83c87d9a42
commit 6947cee30c
13 changed files with 1459 additions and 0 deletions

View File

@ -122,6 +122,12 @@ final class _FakeBootstrap implements AppDependencies {
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeExerciseStepUseCases = ActiveExerciseStepUseCases(
sessionRepository: activeRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
@ -179,6 +185,9 @@ final class _FakeBootstrap implements AppDependencies {
@override
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;

View File

@ -942,6 +942,379 @@ void main() {
expect(find.text('2 / 3'), findsOneWidget);
},
);
testWidgets('le module séquence saffiche 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.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.text('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('deux étapes temps consécutives senchaî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.text('Démarrer la séquence'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1);
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(audio.longBeeps, 1);
});
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.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.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) {
@ -987,6 +1360,18 @@ CloseWorkoutSessionUseCase _closeUseCase(
);
}
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(),
@ -1014,6 +1399,7 @@ String _sessionSnapshot({
String scoreUnit = 'kg',
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<String> imageMediaIds = const [],
List<Map<String, Object?>> exerciseSteps = const [],
String? videoMediaId,
}) {
return jsonEncode({
@ -1043,6 +1429,7 @@ String _sessionSnapshot({
'imageMediaIdsSnapshot': imageMediaIds,
'exerciseVideoMediaIdSnapshot': videoMediaId,
'videoMediaIdSnapshot': videoMediaId,
'exerciseStepsSnapshot': exerciseSteps,
},
],
}),
@ -1052,6 +1439,34 @@ String _sessionSnapshot({
});
}
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,
@ -1080,6 +1495,27 @@ final class _FakeIds implements IdGenerator {
}
}
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>[];