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:
@ -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;
|
||||
|
||||
|
||||
@ -942,6 +942,379 @@ void main() {
|
||||
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.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 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.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>[];
|
||||
|
||||
Reference in New Issue
Block a user