feat(exercice): état d'exécution Chrono suivant prêt (ticket #78)

Ajoute l'état "Chrono suivant prêt" sur workout_execution_screen.dart
pour l'auto-enchaînement des chronos d'étapes. Corrige
_startCurrentStepTimerIfNeeded() qui forçait à tort startTimer() : il
se contente maintenant de rafraîchir le ticker d'affichage, laissant
le backend (#76) seul décisionnaire du démarrage automatique ou de
l'attente du chrono suivant. flutter pub get OK, dart format appliqué
(aucun changement), analyze propre (mêmes infos préexistantes),
153/153 tests verts (dont le test de non-régression pour
l'enchaînement automatique activé par défaut), build APK debug
validé. Dernier ticket de développement du chantier
"auto-enchaînement des chronos d'étapes" (#73) — il ne reste que la
QA finale (#79).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:04:46 +02:00
parent 7d157c49e7
commit 3711e8c5ca
2 changed files with 115 additions and 12 deletions

View File

@ -740,16 +740,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} }
} }
Future<void> _startCurrentStepTimerIfNeeded() async { Future<void> _startCurrentStepTimerIfNeeded() {
final view = _stepProgress; _refreshStepTicker();
final step = view?.currentStep; return Future<void>.value();
if (view == null ||
step == null ||
step.type != ExerciseStepType.time ||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
return;
}
await _startCurrentStepTimer();
} }
Future<void> _completeCurrentStep() async { Future<void> _completeCurrentStep() async {
@ -1537,6 +1530,7 @@ final class _StepSequencePanel extends StatelessWidget {
running: running:
view.state.status == view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer, ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer, onStartTimer: onStartTimer,
) )
else else
@ -1590,12 +1584,14 @@ final class _TimedStepBody extends StatelessWidget {
required this.step, required this.step,
required this.remainingLabel, required this.remainingLabel,
required this.running, required this.running,
required this.readyToStart,
required this.onStartTimer, required this.onStartTimer,
}); });
final ExerciseStep step; final ExerciseStep step;
final String remainingLabel; final String remainingLabel;
final bool running; final bool running;
final bool readyToStart;
final VoidCallback onStartTimer; final VoidCallback onStartTimer;
@override @override
@ -1615,10 +1611,22 @@ final class _TimedStepBody extends StatelessWidget {
), ),
if (!running) ...[ if (!running) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
if (readyToStart) ...[
Text(
'Chrono suivant prêt',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
],
FilledButton.icon( FilledButton.icon(
onPressed: onStartTimer, onPressed: onStartTimer,
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow),
label: const Text('Démarrer la séquence'), label: Text(
readyToStart ? 'Démarrer le chrono' : 'Démarrer la séquence',
),
), ),
], ],
], ],
@ -1626,6 +1634,22 @@ final class _TimedStepBody extends StatelessWidget {
} }
} }
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
final step = view.currentStep;
if (step == null ||
step.type != ExerciseStepType.time ||
view.state.status != ActiveExerciseStepProgressStatus.stoppedTimer) {
return false;
}
return view.results.any((result) {
if (result.passageIndex < view.state.currentPassageIndex) {
return true;
}
return result.passageIndex == view.state.currentPassageIndex &&
result.stepIndex < view.state.currentStepIndex;
});
}
final class _RepsStepBody extends StatelessWidget { final class _RepsStepBody extends StatelessWidget {
const _RepsStepBody({required this.step, required this.onCompleteStep}); const _RepsStepBody({required this.step, required this.onCompleteStep});

View File

@ -1202,7 +1202,7 @@ void main() {
await tester.tap(find.text('Démarrer la séquence')); await tester.tap(find.text('Démarrer la séquence'));
await tester.pump(); await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pump(); await tester.pump();
@ -1211,9 +1211,86 @@ void main() {
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget); expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('Droite'), findsOneWidget); expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget); expect(find.text('00:02'), findsOneWidget);
expect(
repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.runningTimer,
);
expect(audio.longBeeps, 1); expect(audio.longBeeps, 1);
}); });
testWidgets(
'chrono suivant prêt attend un démarrage manuel quand lenchaî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.text('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));
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono suivant prêt'), findsOneWidget);
expect(find.text('Démarrer le chrono'), findsOneWidget);
expect(
repository.stepProgressStates.single.status,
ActiveExerciseStepProgressStatus.stoppedTimer,
);
expect(repository.stepProgressStates.single.startedAt, isNull);
await tester.tap(find.text('Démarrer le chrono'));
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', ( testWidgets('la séquence passe au deuxième passage si répétitions > 1', (
tester, tester,
) async { ) async {
@ -1460,6 +1537,7 @@ String _sessionSnapshot({
ScoreInputMode scoreInputMode = ScoreInputMode.manual, ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<String> imageMediaIds = const [], List<String> imageMediaIds = const [],
List<Map<String, Object?>> exerciseSteps = const [], List<Map<String, Object?>> exerciseSteps = const [],
bool autoStartNextTimedStep = true,
String? videoMediaId, String? videoMediaId,
}) { }) {
return jsonEncode({ return jsonEncode({
@ -1490,6 +1568,7 @@ String _sessionSnapshot({
'exerciseVideoMediaIdSnapshot': videoMediaId, 'exerciseVideoMediaIdSnapshot': videoMediaId,
'videoMediaIdSnapshot': videoMediaId, 'videoMediaIdSnapshot': videoMediaId,
'exerciseStepsSnapshot': exerciseSteps, 'exerciseStepsSnapshot': exerciseSteps,
'autoStartNextTimedStepSnapshot': autoStartNextTimedStep,
}, },
], ],
}), }),