merge(main): exécution de séance avec module séquence et bips (ticket #60)
Fusionne feature/exercise-steps-60-execution-ui — pub get OK, 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:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#58"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedAt: 1784481524706
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784483616127
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "d18752bf-3e5b-4fb9-935c-064d0d2e837a"
|
||||
number: 58
|
||||
title: "[DevBackend] Exécution persistante des étapes et résultats par passage"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}]
|
||||
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784481524706
|
||||
updatedAt: 1784481524706
|
||||
version: 1
|
||||
updatedAt: 1784483616127
|
||||
version: 2
|
||||
---
|
||||
Ajouter le modèle d'exécution des étapes distinct de `ActiveSetResult`. Créer une table dédiée type `ActiveExerciseStepProgressState` pour session+position de série, passage courant, étape courante, statut `idle|running|stopped`, `startedAt`, `accumulatedMs`, et actions pause/reprise/skip. Créer `ActiveExerciseStepResult` pour résultats par `activeWorkoutSessionId + programIndex + exerciseIndex + setIndex + passageIndex + stepSnapshotId/stepIndex`, statut `completed|skipped`, durée/rep cible snapshot, score manuel ou score chrono d'étape. À la clôture de séance, projeter vers `WorkoutHistoryStepResult` sans mélanger avec `WorkoutHistorySetResult`.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#59"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedAt: 1784481530478
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784483616855
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "be486426-a17d-4521-b475-9230a2e30169"
|
||||
number: 59
|
||||
title: "[DevFrontend] Éditeur d'exercice avec séquence d'étapes"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#54","kind":"relatesTo"},{"target":"#56","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
|
||||
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
createdAt: 1784481530478
|
||||
updatedAt: 1784481530478
|
||||
version: 1
|
||||
updatedAt: 1784483616855
|
||||
version: 2
|
||||
---
|
||||
Implémenter la section UX `Séquence d'étapes` dans `ExerciseFormScreen` : activation optionnelle, ajout/réordonnancement max 8, écran de détail d'étape, type exclusif temps/répétitions, cible obligatoire > 0, score d'étape optionnel avec mode manual/stopwatch. Préserver l'avertissement existant sur les exercices déjà utilisés : les programmes existants gardent leurs snapshots.
|
||||
@ -643,25 +643,25 @@
|
||||
"issueRef": "#58",
|
||||
"path": "58",
|
||||
"title": "[DevBackend] Exécution persistante des étapes et résultats par passage",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"10ee045b-1c41-479e-ba03-dceed9edd495"
|
||||
],
|
||||
"updatedAt": 1784481524706
|
||||
"updatedAt": 1784483616127
|
||||
},
|
||||
{
|
||||
"issueRef": "#59",
|
||||
"path": "59",
|
||||
"title": "[DevFrontend] Éditeur d'exercice avec séquence d'étapes",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||
],
|
||||
"updatedAt": 1784481530478
|
||||
"updatedAt": 1784483616855
|
||||
},
|
||||
{
|
||||
"issueRef": "#60",
|
||||
|
||||
BIN
assets/audio/step_completion_long.wav
Normal file
BIN
assets/audio/step_completion_long.wav
Normal file
Binary file not shown.
BIN
assets/audio/step_countdown_short.wav
Normal file
BIN
assets/audio/step_countdown_short.wav
Normal file
Binary file not shown.
@ -7,6 +7,7 @@ abstract interface class AppDependencies {
|
||||
ProgramUseCases get programUseCases;
|
||||
WorkoutTemplateUseCases get workoutTemplateUseCases;
|
||||
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
|
||||
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
|
||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
}
|
||||
@ -19,6 +20,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.programUseCases,
|
||||
required this.workoutTemplateUseCases,
|
||||
required this.activeWorkoutSessionUseCases,
|
||||
required this.activeExerciseStepUseCases,
|
||||
required this.closeWorkoutSessionUseCase,
|
||||
required this.workoutHistoryUseCases,
|
||||
required this.syncGateway,
|
||||
@ -36,6 +38,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
@override
|
||||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||
@override
|
||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||
@override
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
@override
|
||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||
@ -93,6 +97,12 @@ final class AppBootstrap implements AppDependencies {
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
activeExerciseStepUseCases: ActiveExerciseStepUseCases(
|
||||
sessionRepository: activeSessionRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeSessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
|
||||
49
lib/presentation/exercise_step_audio.dart
Normal file
49
lib/presentation/exercise_step_audio.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
|
||||
abstract interface class ExerciseStepAudioCuePlayer {
|
||||
Future<void> playShortCountdownBeep();
|
||||
|
||||
Future<void> playLongCompletionBeep();
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
final class AudioplayersExerciseStepAudioCuePlayer
|
||||
implements ExerciseStepAudioCuePlayer {
|
||||
AudioplayersExerciseStepAudioCuePlayer();
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
@override
|
||||
Future<void> playShortCountdownBeep() {
|
||||
return _play('audio/step_countdown_short.wav');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playLongCompletionBeep() {
|
||||
return _play('audio/step_completion_long.wav');
|
||||
}
|
||||
|
||||
Future<void> _play(String assetPath) async {
|
||||
await _player.stop();
|
||||
await _player.play(AssetSource(assetPath));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
return _player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final class NoOpExerciseStepAudioCuePlayer implements ExerciseStepAudioCuePlayer {
|
||||
const NoOpExerciseStepAudioCuePlayer();
|
||||
|
||||
@override
|
||||
Future<void> playShortCountdownBeep() async {}
|
||||
|
||||
@override
|
||||
Future<void> playLongCompletionBeep() async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
@ -14,12 +14,14 @@ final class HistoryListScreen extends StatefulWidget {
|
||||
required this.activeUseCases,
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
|
||||
@ -94,6 +96,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
|
||||
historyUseCases: widget.historyUseCases,
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
stepUseCases: widget.stepUseCases,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
),
|
||||
@ -115,6 +118,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
required this.activeUseCases,
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@ -122,6 +126,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
|
||||
@ -193,6 +198,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: activeUseCases,
|
||||
stepUseCases: stepUseCases,
|
||||
closeUseCase: closeUseCase,
|
||||
historyUseCases: historyUseCases,
|
||||
workoutTemplateUseCases: workoutTemplateUseCases,
|
||||
|
||||
@ -152,6 +152,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
widget.bootstrap.workoutTemplateUseCases,
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||
@ -170,6 +171,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
workoutTemplateUseCases:
|
||||
widget.bootstrap.workoutTemplateUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
mediaUseCases: widget.bootstrap.mediaUseCases,
|
||||
),
|
||||
@ -187,6 +189,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export 'game_time_app.dart';
|
||||
export 'exercise_step_audio.dart';
|
||||
export 'exercise_library_screen.dart';
|
||||
export 'history_screen.dart';
|
||||
export 'home_screen.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,12 +14,14 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
required this.closeUseCase,
|
||||
required this.historyUseCases,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ProgramUseCases programUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
@ -140,6 +142,7 @@ final class _WorkoutTemplateListScreenState
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
stepUseCases: widget.stepUseCases,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
historyUseCases: widget.historyUseCases,
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
|
||||
72
pubspec.lock
72
pubspec.lock
@ -41,6 +41,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audioplayers
|
||||
sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.8.1"
|
||||
audioplayers_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_android
|
||||
sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.3.0"
|
||||
audioplayers_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_darwin
|
||||
sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
audioplayers_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_linux
|
||||
sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
audioplayers_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_platform_interface
|
||||
sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
audioplayers_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_web
|
||||
sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.3.0"
|
||||
audioplayers_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_windows
|
||||
sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -813,6 +869,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -837,6 +901,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -14,6 +14,7 @@ dependencies:
|
||||
drift: ^2.34.2
|
||||
drift_flutter: ^0.3.1
|
||||
image_picker: ^1.2.3
|
||||
audioplayers: ^6.8.1
|
||||
path: ^1.9.1
|
||||
path_provider: ^2.1.5
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
@ -29,6 +30,8 @@ dev_dependencies:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/audio/
|
||||
fonts:
|
||||
- family: Anton
|
||||
fonts:
|
||||
|
||||
@ -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