feat(exécution): refonte des chronos de séance et carte exercice actif (ticket #90)

Réorganise l'écran d'exécution autour d'une carte "Exercice actif" sous
le compteur SÉRIE X/Y (nom, médias, temps de série, action "Démarrer
l'exercice"). Le timer de série devient un état persistant dédié (plus
un DateTime UI volatile), pause-aware, et "Démarrer l'exercice" lance
en une action tous les chronos qui doivent démarrer en début de série
(série, première étape temps, score chrono si stopwatch).

Implémentation restée non commitée depuis sa réalisation ; QA a validé
les vérifications statiques exécutables en sandbox (dart analyze,
git diff --check). flutter analyze / flutter test restent à relancer
dans un environnement avec cache Flutter SDK accessible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 22:26:28 +02:00
parent 73cfa644c2
commit 75f52200a2
16 changed files with 4615 additions and 364 deletions

View File

@ -40,7 +40,9 @@ void main() {
),
);
expect(find.text('00:45'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:45'), findsOneWidget);
expect(find.text('Démarrer lexercice'), findsOneWidget);
});
testWidgets('les répétitions sont initialisées avec la cible', (
@ -253,7 +255,7 @@ void main() {
expect(find.byTooltip('Voir les médias de lexercice'), findsOneWidget);
await tester.tap(find.text('Voir médias'));
await tester.tap(find.byTooltip('Voir les médias de lexercice'));
await tester.runAsync(() async {
await tester.pumpAndSettle();
});
@ -319,7 +321,7 @@ void main() {
);
});
await tester.tap(find.text('Voir médias'));
await tester.tap(find.byTooltip('Voir les médias de lexercice'));
await tester.runAsync(() async {
await tester.pumpAndSettle();
});
@ -377,7 +379,7 @@ void main() {
expect(find.text('Chrono score'), findsOneWidget);
expect(find.text('00:00.0'), findsOneWidget);
await tester.tap(find.text('Démarrer'));
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100);
await tester.pump(const Duration(milliseconds: 100));
@ -394,7 +396,7 @@ void main() {
expect(find.text('00:01.2'), findsOneWidget);
await tester.tap(find.text('Arrêter'));
await tester.tap(find.widgetWithText(FilledButton, 'Arrêter'));
await tester.pump();
expect(find.text('00:01.2'), findsOneWidget);
@ -443,9 +445,15 @@ void main() {
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(find.text('Aucun temps chronométré'), findsOneWidget);
expect(find.text("Tu n'as pas démarré le chrono score."), findsOneWidget);
expect(find.text('Démarrer le chrono'), findsOneWidget);
expect(find.text('Chrono non lancé'), findsOneWidget);
expect(
find.text("Tu nas pas démarré le chrono de cette série."),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
findsOneWidget,
);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
});
@ -487,7 +495,7 @@ void main() {
),
);
await tester.tap(find.text('Démarrer'));
await tester.tap(find.text('Démarrer lexercice'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2, 500);
await tester.pump(const Duration(seconds: 1));
@ -565,6 +573,59 @@ void main() {
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 {
@ -601,11 +662,14 @@ void main() {
),
),
);
await tester.tap(find.text('Démarrer lexercice'));
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, greaterThan(0));
expect(repository.results.single.actualTimeMs, 2000);
});
testWidgets(
@ -650,9 +714,12 @@ void main() {
);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
expect(find.text('SÉRIE'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('30 s'), findsOneWidget);
expect(find.text('Série 2/3'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:30'), findsOneWidget);
await tester.tap(find.text('Démarrer lexercice'));
await tester.pump();
for (var i = 0; i < 2; i++) {
await tester.tap(find.byIcon(Icons.remove));
@ -672,7 +739,7 @@ void main() {
expect(repository.results.single.actualScore, 5);
expect(repository.session?.currentSetIndex, 2);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
expect(find.text('3 / 3'), findsOneWidget);
expect(find.text('Série 3/3'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
},
);
@ -959,7 +1026,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
expect(find.text('3 / 4'), findsOneWidget);
expect(find.text('Série 3/4'), findsOneWidget);
},
);
@ -1005,7 +1072,7 @@ void main() {
expect(find.text('Plan de séance'), findsNothing);
expect(find.text('Modifier la série'), findsNothing);
expect(find.text('Programme 1/1 · Exercice 1/1'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('Série 2/3'), findsOneWidget);
},
);
@ -1052,6 +1119,7 @@ void main() {
);
await tester.pump();
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
@ -1147,7 +1215,7 @@ void main() {
await tester.pump();
expect(find.text('00:05'), findsOneWidget);
await tester.tap(find.text('Démarrer la séquence'));
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(milliseconds: 100));
@ -1156,6 +1224,68 @@ void main() {
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 nas pas démarré le chrono de cette série."),
findsOneWidget,
);
expect(
find.widgetWithText(TextButton, 'Démarrer lexercice'),
findsOneWidget,
);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
},
);
testWidgets('deux étapes temps consécutives senchaînent automatiquement', (
tester,
) async {
@ -1200,7 +1330,7 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Démarrer la séquence'));
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer la séquence'));
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200));
@ -1262,7 +1392,9 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Démarrer la séquence'));
await tester.tap(
find.widgetWithText(FilledButton, 'Démarrer la séquence'),
);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200));
@ -1280,7 +1412,7 @@ void main() {
);
expect(repository.stepProgressStates.single.startedAt, isNull);
await tester.tap(find.text('Démarrer le chrono'));
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer le chrono'));
await tester.pump();
expect(
@ -1438,6 +1570,8 @@ void main() {
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.skipped);
await tester.tap(find.byTooltip('Plus dactions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer ce passage'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Passer ce passage'));
@ -1445,6 +1579,8 @@ void main() {
expect(find.text('Passage 2 / 2'), findsOneWidget);
await tester.tap(find.byTooltip('Plus dactions'));
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'));
@ -1529,6 +1665,9 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
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,
@ -1552,9 +1691,9 @@ String _sessionSnapshot({
'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': true,
'repsEnabled': true,
'scoreEnabled': true,
'timeEnabled': timeEnabled,
'repsEnabled': repsEnabled,
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': targetReps,
'targetScore': targetScore,
@ -1660,6 +1799,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -1700,6 +1840,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in setTimerStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
@ -1740,6 +1899,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return results;
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
@ -1787,6 +1953,22 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
(saved) =>
saved.activeWorkoutSessionId == state.activeWorkoutSessionId &&
saved.programIndex == state.programIndex &&
saved.exerciseIndex == state.exerciseIndex &&
saved.setIndex == state.setIndex,
);
if (index == -1) {
setTimerStates.add(state);
} else {
setTimerStates[index] = state;
}
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,