merge(main): état d'exécution Chrono suivant prêt (ticket #78)
Fusionne feature/step-chaining-78-execution-ui — pub get OK, dart format appliqué (aucun changement), analyze propre (mêmes infos préexistantes), 153/153 tests verts, build APK debug validé. Fix du bug d'intégration critique confirmé : _startCurrentStepTimerIfNeeded() ne force plus startTimer(), le backend (#76) reste seul décisionnaire. 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:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#76"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedAt: 1784547227107
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784548663380
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "8896c34d-bc57-424b-967f-d3ef767185e1"
|
||||
number: 76
|
||||
title: "[DevBackend] Résolution effective et auto-advance des chronos d'étapes"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#73","kind":"relatesTo"},{"target":"#75","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: 1784547227107
|
||||
updatedAt: 1784547227107
|
||||
version: 1
|
||||
updatedAt: 1784548663380
|
||||
version: 2
|
||||
---
|
||||
Adapter la résolution de séance et `ActiveExerciseStepUseCases` pour appliquer `séance > programme > exercice` à `autoStartNextTimedStep`. Inclure la valeur effective dans le snapshot résolu ou dans `_StepSequenceContext`. Modifier `_advanceState` et `_autoAdvanceElapsedTimers` : si une étape Temps expirée est suivie d'une étape Temps et que la valeur effective est false, enregistrer le résultat de l'étape expirée puis s'arrêter sur l'étape suivante en `stoppedTimer`, sans consommer l'overflow ni démarrer le chrono suivant. Conserver l'auto-enchaînement existant quand true.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#77"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedAt: 1784547232756
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784548663818
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "835be070-248f-42fb-b996-4df86445f154"
|
||||
number: 77
|
||||
title: "[DevFrontend] Réglages auto-enchaînement exercice, programme et séance"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#73","kind":"relatesTo"},{"target":"#75","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: 1784547232756
|
||||
updatedAt: 1784547232756
|
||||
version: 1
|
||||
updatedAt: 1784548663818
|
||||
version: 2
|
||||
---
|
||||
Ajouter l'UI du réglage `Enchaîner automatiquement les chronos consécutifs` aux trois niveaux : `ExerciseFormScreen` valeur de base visible si séquence activée; `ProgramExerciseCustomizationScreen` override nullable avec indication héritage et action `Revenir au réglage de l'exercice`; `WorkoutTemplateProgramDetailScreen` override nullable avec action `Revenir au réglage du programme`. Attention à propager le champ dans `ProgramExerciseConfig` et `_ProgramExerciseDraft` comme pour `exerciseStepsSnapshot` corrigé au #72.
|
||||
@ -859,25 +859,25 @@
|
||||
"issueRef": "#76",
|
||||
"path": "76",
|
||||
"title": "[DevBackend] Résolution effective et auto-advance des chronos d'étapes",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"10ee045b-1c41-479e-ba03-dceed9edd495"
|
||||
],
|
||||
"updatedAt": 1784547227107
|
||||
"updatedAt": 1784548663380
|
||||
},
|
||||
{
|
||||
"issueRef": "#77",
|
||||
"path": "77",
|
||||
"title": "[DevFrontend] Réglages auto-enchaînement exercice, programme et séance",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||
],
|
||||
"updatedAt": 1784547232756
|
||||
"updatedAt": 1784548663818
|
||||
},
|
||||
{
|
||||
"issueRef": "#78",
|
||||
|
||||
@ -740,16 +740,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startCurrentStepTimerIfNeeded() async {
|
||||
final view = _stepProgress;
|
||||
final step = view?.currentStep;
|
||||
if (view == null ||
|
||||
step == null ||
|
||||
step.type != ExerciseStepType.time ||
|
||||
view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
|
||||
return;
|
||||
}
|
||||
await _startCurrentStepTimer();
|
||||
Future<void> _startCurrentStepTimerIfNeeded() {
|
||||
_refreshStepTicker();
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
Future<void> _completeCurrentStep() async {
|
||||
@ -1537,6 +1530,7 @@ final class _StepSequencePanel extends StatelessWidget {
|
||||
running:
|
||||
view.state.status ==
|
||||
ActiveExerciseStepProgressStatus.runningTimer,
|
||||
readyToStart: _isNextTimedStepReady(view),
|
||||
onStartTimer: onStartTimer,
|
||||
)
|
||||
else
|
||||
@ -1590,12 +1584,14 @@ final class _TimedStepBody extends StatelessWidget {
|
||||
required this.step,
|
||||
required this.remainingLabel,
|
||||
required this.running,
|
||||
required this.readyToStart,
|
||||
required this.onStartTimer,
|
||||
});
|
||||
|
||||
final ExerciseStep step;
|
||||
final String remainingLabel;
|
||||
final bool running;
|
||||
final bool readyToStart;
|
||||
final VoidCallback onStartTimer;
|
||||
|
||||
@override
|
||||
@ -1615,10 +1611,22 @@ final class _TimedStepBody extends StatelessWidget {
|
||||
),
|
||||
if (!running) ...[
|
||||
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(
|
||||
onPressed: onStartTimer,
|
||||
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 {
|
||||
const _RepsStepBody({required this.step, required this.onCompleteStep});
|
||||
|
||||
|
||||
@ -1202,7 +1202,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Démarrer la séquence'));
|
||||
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();
|
||||
|
||||
@ -1211,9 +1211,86 @@ void main() {
|
||||
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
|
||||
expect(find.text('Droite'), findsOneWidget);
|
||||
expect(find.text('00:02'), findsOneWidget);
|
||||
expect(
|
||||
repository.stepProgressStates.single.status,
|
||||
ActiveExerciseStepProgressStatus.runningTimer,
|
||||
);
|
||||
expect(audio.longBeeps, 1);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'chrono suivant prêt attend un démarrage manuel quand l’enchaî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', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1460,6 +1537,7 @@ String _sessionSnapshot({
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
List<String> imageMediaIds = const [],
|
||||
List<Map<String, Object?>> exerciseSteps = const [],
|
||||
bool autoStartNextTimedStep = true,
|
||||
String? videoMediaId,
|
||||
}) {
|
||||
return jsonEncode({
|
||||
@ -1490,6 +1568,7 @@ String _sessionSnapshot({
|
||||
'exerciseVideoMediaIdSnapshot': videoMediaId,
|
||||
'videoMediaIdSnapshot': videoMediaId,
|
||||
'exerciseStepsSnapshot': exerciseSteps,
|
||||
'autoStartNextTimedStepSnapshot': autoStartNextTimedStep,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user