merge(main): résolution effective et réglages auto-enchaînement des chronos d'étapes (tickets #76, #77)

Fusionne feature/step-chaining-76-resolution — pub get OK, dart
format appliqué (7 fichiers reformatés), analyze propre (mêmes infos
préexistantes), 152/152 tests verts, build APK debug validé.
Propagation du champ de _ProgramExerciseDraft vérifiée exhaustivement
sur ses 5 points d'impact (pas de répétition du bug #72). Tickets #76
(DevBackend) et #77 (DevFrontend) développés dans le même worktree
partagé, commités séparément (fichiers disjoints) ;
feature/step-chaining-77-ui-settings restée vide, jamais divergée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 13:57:19 +02:00
13 changed files with 1110 additions and 179 deletions

View File

@ -1,6 +1,6 @@
---
issueRef: "#75"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784547220555
version: 2
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784547809520
---

View File

@ -2,15 +2,15 @@
id: "0197daa2-674e-4b03-9142-d0180874ffc4"
number: 75
title: "[DevBackend] Modèle et migration pour auto-enchaînement des chronos d'étapes"
status: "open"
status: "qa"
priority: "medium"
sprint: null
links: [{"target":"#73","kind":"relatesTo"}]
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: 1784547220555
updatedAt: 1784547220555
version: 1
updatedAt: 1784547809520
version: 2
---
Ajouter le réglage `autoStartNextTimedStep` côté modèle. Sur `Exercise` : bool non-null par défaut true. Sur `ProgramExercise` : `autoStartNextTimedStepSnapshot` non-null + `autoStartNextTimedStepOverride` nullable pour revenir au réglage exercice snapshoté. Sur `WorkoutTemplateExerciseOverride` : `autoStartNextTimedStepOverride` nullable, décision consciente d'élargir l'override séance à un comportement booléen non structurel. Propager dans `ProgramExerciseConfig`, `_ProgramExerciseDraft`, snapshots JSON et mappers Drift. Migration Drift schemaVersion 13 -> 14 avec defaults true et colonnes nullable d'override.

View File

@ -847,13 +847,13 @@
"issueRef": "#75",
"path": "75",
"title": "[DevBackend] Modèle et migration pour auto-enchaînement des chronos d'étapes",
"status": "open",
"status": "qa",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495"
],
"updatedAt": 1784547220555
"updatedAt": 1784547809520
},
{
"issueRef": "#76",

View File

@ -1214,10 +1214,8 @@ final class ProgramUseCases {
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: input.exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot:
input.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride:
input.autoStartNextTimedStepOverride,
autoStartNextTimedStepSnapshot: input.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
availableTimeSnapshot: input.availableTimeSnapshot,
availableRepsSnapshot: input.availableRepsSnapshot,
@ -1478,8 +1476,7 @@ final class WorkoutTemplateUseCases {
targetRepsOverride: input.targetRepsOverride,
targetScoreOverride: input.targetScoreOverride,
targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
autoStartNextTimedStepOverride:
input.autoStartNextTimedStepOverride,
autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
),
);
}
@ -2387,16 +2384,43 @@ final class ActiveExerciseStepUseCases {
setIndex: setIndex,
);
final now = clock.now();
final state = await _autoAdvanceElapsedTimers(
context,
await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
),
now,
final currentState = await _requiredStepProgressState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
final currentStep = _currentStep(context, currentState);
if (currentState.status == ActiveExerciseStepProgressStatus.runningTimer &&
currentStep.type == ExerciseStepType.time) {
final targetMs = currentStep.defaultTargetValue * 1000;
final elapsedMs = currentState.elapsedMillisecondsAt(now);
if (elapsedMs >= targetMs) {
final completed = _stepResult(
context: context,
state: currentState,
step: currentStep,
status: SetResultStatus.completed,
now: now,
actualTimeMs: targetMs,
actualScore: actualScore,
actualScoreTimeMs: actualScoreTimeMs,
note: note,
);
await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(
context,
currentState,
now,
runningTimerOverflowMs: elapsedMs - targetMs,
completedStep: currentStep,
),
);
return completed;
}
}
final state = await _autoAdvanceElapsedTimers(context, currentState, now);
final step = _currentStep(context, state);
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
throw const DomainException('Exercise step sequence is complete.');
@ -2419,7 +2443,7 @@ final class ActiveExerciseStepUseCases {
);
await sessionRepository.saveExerciseStepResult(completed);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
_advanceState(context, state, now, completedStep: step),
);
return completed;
}
@ -2453,7 +2477,7 @@ final class ActiveExerciseStepUseCases {
);
await sessionRepository.saveExerciseStepResult(skipped);
await sessionRepository.saveExerciseStepProgressState(
_advanceState(context, state, now),
_advanceState(context, state, now, completedStep: step),
);
return skipped;
}
@ -2491,7 +2515,7 @@ final class ActiveExerciseStepUseCases {
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
state = _advanceState(context, state, now, completedStep: step);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
@ -2528,7 +2552,7 @@ final class ActiveExerciseStepUseCases {
);
skipped.add(result);
await sessionRepository.saveExerciseStepResult(result);
state = _advanceState(context, state, now);
state = _advanceState(context, state, now, completedStep: step);
}
await sessionRepository.saveExerciseStepProgressState(state);
return skipped;
@ -2581,6 +2605,7 @@ final class ActiveExerciseStepUseCases {
expectedPassages: snapshot.repsEnabled
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
: 1,
autoStartNextTimedStep: snapshot.autoStartNextTimedStepEffective,
);
}
@ -2638,6 +2663,7 @@ final class ActiveExerciseStepUseCases {
current,
now,
runningTimerOverflowMs: overflowMs,
completedStep: step,
);
await sessionRepository.saveExerciseStepProgressState(current);
}
@ -2649,6 +2675,7 @@ final class ActiveExerciseStepUseCases {
ActiveExerciseStepProgressState state,
DateTime now, {
int runningTimerOverflowMs = 0,
ExerciseStep? completedStep,
}) {
var nextPassage = state.currentPassageIndex;
var nextStep = state.currentStepIndex + 1;
@ -2666,8 +2693,15 @@ final class ActiveExerciseStepUseCases {
);
}
final step = context.steps[nextStep];
final shouldStopBeforeNextTimedStep =
!context.autoStartNextTimedStep &&
completedStep?.type == ExerciseStepType.time &&
step.type == ExerciseStepType.time;
final resolvedOverflowMs = shouldStopBeforeNextTimedStep
? 0
: runningTimerOverflowMs;
final status = step.type == ExerciseStepType.time
? runningTimerOverflowMs > 0
? resolvedOverflowMs > 0
? ActiveExerciseStepProgressStatus.runningTimer
: ActiveExerciseStepProgressStatus.stoppedTimer
: ActiveExerciseStepProgressStatus.waitingManual;
@ -2678,7 +2712,7 @@ final class ActiveExerciseStepUseCases {
currentStepSnapshotId: step.id,
status: status,
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
? now.subtract(Duration(milliseconds: runningTimerOverflowMs))
? now.subtract(Duration(milliseconds: resolvedOverflowMs))
: null,
accumulatedMs: 0,
lastTransitionAt: now,
@ -3320,6 +3354,15 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
}
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
final autoStartNextTimedStepEffective =
_templateStepChainingOverride(
decoded,
workoutTemplateProgramId: programSnapshotId,
snapshotProgramExerciseId: id,
) ??
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true;
return _ResolvedExerciseSnapshot(
programSnapshotId: programSnapshotId,
exerciseSnapshotId: id,
@ -3338,9 +3381,31 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
);
}
bool? _templateStepChainingOverride(
Map<String, dynamic> resolvedTemplateSnapshot, {
required String workoutTemplateProgramId,
required String snapshotProgramExerciseId,
}) {
final overrides =
(resolvedTemplateSnapshot['overrides'] as List<dynamic>? ?? const []);
for (final raw in overrides) {
if (raw is! Map) {
continue;
}
final override = Map<String, dynamic>.from(raw);
if (override['workoutTemplateProgramId'] == workoutTemplateProgramId &&
override['snapshotProgramExerciseId'] == snapshotProgramExerciseId) {
final value = override['autoStartNextTimedStepOverride'];
return value is bool ? value : null;
}
}
return null;
}
List<_SetPositionSnapshot> _listSetSnapshots(
String resolvedTemplateSnapshotJson,
) {
@ -3422,6 +3487,7 @@ final class _StepSequenceContext {
required this.setIndex,
required this.steps,
required this.expectedPassages,
required this.autoStartNextTimedStep,
});
final String sessionId;
@ -3432,6 +3498,7 @@ final class _StepSequenceContext {
final int setIndex;
final List<ExerciseStep> steps;
final int expectedPassages;
final bool autoStartNextTimedStep;
}
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
@ -3523,6 +3590,10 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
autoStartNextTimedStepEffective:
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true,
);
}
}
@ -3546,6 +3617,7 @@ final class _ResolvedExerciseSnapshot {
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
this.steps = const [],
this.autoStartNextTimedStepEffective = true,
});
final String programSnapshotId;
@ -3563,6 +3635,7 @@ final class _ResolvedExerciseSnapshot {
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective;
}
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {

View File

@ -3047,8 +3047,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
defaultTargetReps: payload['defaultTargetReps'] as int?,
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
autoStartNextTimedStep:
payload['autoStartNextTimedStep'] as bool? ?? true,
autoStartNextTimedStep: payload['autoStartNextTimedStep'] as bool? ?? true,
steps: _stepsFromPayload(payload['steps']),
archivedAt: _dateTimeFromPayload(payload['archivedAt']),
);

View File

@ -404,6 +404,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _importingImage = false;
var _importingVideo = false;
var _stepsEnabled = false;
var _autoStartNextTimedStep = true;
var _nextStepDraftId = 0;
final _stepDrafts = <_ExerciseStepDraft>[];
String? _measureError;
@ -442,6 +443,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
_autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true;
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
@ -742,6 +744,25 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
},
),
if (_stepsEnabled) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
_autoStartNextTimedStep
? 'Le chrono suivant démarre dès que le précédent arrive à 0.'
: "L'app attendra ton démarrage avant de lancer le chrono "
'suivant.',
),
value: _autoStartNextTimedStep,
onChanged: (value) {
setState(() {
_autoStartNextTimedStep = value;
});
},
),
const SizedBox(height: 8),
ReorderableListView.builder(
shrinkWrap: true,
@ -1312,6 +1333,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
);
} else {
@ -1332,6 +1354,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
);
}

View File

@ -660,6 +660,11 @@ final class _ProgramExerciseCustomizationScreenState
: scoreUnit == null
? 'Score'
: 'Score ($scoreUnit)';
final autoStartNextTimedStepCustomized =
_draft.autoStartNextTimedStepOverride != null;
final autoStartNextTimedStepStateLabel = autoStartNextTimedStepCustomized
? 'Personnalisé pour ce programme'
: "Réglage de l'exercice";
return Scaffold(
appBar: AppBar(title: const Text("Personnaliser l'exercice")),
bottomNavigationBar: SafeArea(
@ -785,6 +790,36 @@ final class _ProgramExerciseCustomizationScreenState
_draft.targetScoreTimeMs = null;
},
),
if (_draft.exerciseStepsSnapshot.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Séquence', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
'$autoStartNextTimedStepStateLabel\n'
'${_autoStartHelpText(_draft.autoStartNextTimedStepEffective)}',
),
value: _draft.autoStartNextTimedStepEffective,
onChanged: (value) {
setState(() {
_draft.autoStartNextTimedStepOverride = value;
});
},
),
if (autoStartNextTimedStepCustomized)
TextButton(
onPressed: () {
setState(() {
_draft.autoStartNextTimedStepOverride = null;
});
},
child: const Text("Revenir au réglage de l'exercice"),
),
],
const SizedBox(height: 24),
Text('Repos', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
@ -916,6 +951,8 @@ final class _ProgramExerciseDraft {
this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
this.exerciseStepsSnapshot = const [],
this.autoStartNextTimedStepSnapshot = true,
this.autoStartNextTimedStepOverride,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
required this.availableRepsSnapshot,
@ -946,6 +983,7 @@ final class _ProgramExerciseDraft {
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseStepsSnapshot: exercise.steps,
autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStep,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
@ -978,6 +1016,8 @@ final class _ProgramExerciseDraft {
exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exercise.exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
availableTimeSnapshot: exercise.availableTimeSnapshot,
availableRepsSnapshot: exercise.availableRepsSnapshot,
@ -1008,6 +1048,7 @@ final class _ProgramExerciseDraft {
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool autoStartNextTimedStepSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
final bool availableRepsSnapshot;
@ -1022,6 +1063,11 @@ final class _ProgramExerciseDraft {
double? targetScore;
int? targetScoreTimeMs;
int? restSecondsOverride;
bool? autoStartNextTimedStepOverride;
bool get autoStartNextTimedStepEffective {
return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot;
}
_ProgramExerciseDraft copy() {
return _ProgramExerciseDraft(
@ -1034,6 +1080,8 @@ final class _ProgramExerciseDraft {
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
@ -1063,6 +1111,8 @@ final class _ProgramExerciseDraft {
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
@ -1083,6 +1133,12 @@ final class _ProgramExerciseDraft {
}
}
String _autoStartHelpText(bool active) {
return active
? 'Le chrono suivant démarre dès que le précédent arrive à 0.'
: "L'app attendra ton démarrage avant de lancer le chrono suivant.";
}
double? _millisecondsToSeconds(int? milliseconds) {
return milliseconds == null ? null : milliseconds / 1000;
}

View File

@ -502,6 +502,12 @@ final class _WorkoutTemplateProgramDetailScreenState
itemCount: widget.program.exercises.length,
itemBuilder: (context, index) {
final exercise = widget.program.exercises[index];
final autoStartNextTimedStepCustomized =
exercise.autoStartNextTimedStepOverride != null;
final autoStartNextTimedStepStateLabel =
autoStartNextTimedStepCustomized
? 'Personnalisé pour cette séance'
: 'Réglage du programme';
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@ -564,6 +570,38 @@ final class _WorkoutTemplateProgramDetailScreenState
});
},
),
if (exercise.exerciseStepsSnapshot.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Séquence',
style: Theme.of(context).textTheme.titleMedium,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
'$autoStartNextTimedStepStateLabel\n'
'${_autoStartHelpText(exercise.autoStartNextTimedStepEffective)}',
),
value: exercise.autoStartNextTimedStepEffective,
onChanged: (value) {
setState(() {
exercise.autoStartNextTimedStepOverride = value;
});
},
),
if (autoStartNextTimedStepCustomized)
TextButton(
onPressed: () {
setState(() {
exercise.autoStartNextTimedStepOverride = null;
});
},
child: const Text('Revenir au réglage du programme'),
),
],
],
),
),
@ -621,6 +659,8 @@ final class WorkoutTemplateProgramDraft {
override.targetTimeSecondsOverride;
exercise.targetRepsOverride = override.targetRepsOverride;
exercise.targetScoreOverride = override.targetScoreOverride;
exercise.autoStartNextTimedStepOverride =
override.autoStartNextTimedStepOverride;
}
}
}
@ -667,6 +707,7 @@ final class WorkoutTemplateProgramDraft {
targetTimeSecondsOverride: exercise.targetTimeSecondsOverride,
targetRepsOverride: exercise.targetRepsOverride,
targetScoreOverride: exercise.targetScoreOverride,
autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride,
);
}
}
@ -680,6 +721,8 @@ final class WorkoutTemplateExerciseDraft {
required this.timeEnabled,
required this.repsEnabled,
required this.scoreEnabled,
this.exerciseStepsSnapshot = const [],
this.autoStartNextTimedStepSnapshot = true,
this.scoreUnitSnapshot,
this.targetTimeSeconds,
this.targetReps,
@ -689,6 +732,7 @@ final class WorkoutTemplateExerciseDraft {
this.targetTimeSecondsOverride,
this.targetRepsOverride,
this.targetScoreOverride,
this.autoStartNextTimedStepOverride,
});
final String snapshotExerciseId;
@ -697,6 +741,8 @@ final class WorkoutTemplateExerciseDraft {
final bool timeEnabled;
final bool repsEnabled;
final bool scoreEnabled;
final List<ExerciseStep> exerciseStepsSnapshot;
final bool autoStartNextTimedStepSnapshot;
final String? scoreUnitSnapshot;
final int? targetTimeSeconds;
final int? targetReps;
@ -706,12 +752,18 @@ final class WorkoutTemplateExerciseDraft {
int? targetTimeSecondsOverride;
int? targetRepsOverride;
double? targetScoreOverride;
bool? autoStartNextTimedStepOverride;
bool get autoStartNextTimedStepEffective {
return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot;
}
bool get hasOverride {
return setsCountOverride != null ||
targetTimeSecondsOverride != null ||
targetRepsOverride != null ||
targetScoreOverride != null;
targetScoreOverride != null ||
autoStartNextTimedStepOverride != null;
}
}
@ -746,6 +798,10 @@ List<WorkoutTemplateExerciseDraft> _parseExercises(
final rawExercises = snapshot['exercises'] as List<dynamic>? ?? const [];
return rawExercises.map((raw) {
final exercise = raw as Map<String, dynamic>;
final autoStartNextTimedStepSnapshot =
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
(exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
true;
return WorkoutTemplateExerciseDraft(
snapshotExerciseId: exercise['id'] as String,
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String,
@ -753,6 +809,10 @@ List<WorkoutTemplateExerciseDraft> _parseExercises(
timeEnabled: exercise['timeEnabled'] as bool,
repsEnabled: exercise['repsEnabled'] as bool,
scoreEnabled: exercise['scoreEnabled'] as bool,
exerciseStepsSnapshot: _parseExerciseSteps(
exercise['exerciseStepsSnapshot'],
),
autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
targetReps: exercise['targetReps'] as int?,
@ -761,6 +821,34 @@ List<WorkoutTemplateExerciseDraft> _parseExercises(
}).toList();
}
List<ExerciseStep> _parseExerciseSteps(Object? value) {
final rawSteps = value as List<dynamic>? ?? const [];
return rawSteps.map((raw) {
final step = raw as Map<String, dynamic>;
return ExerciseStep(
id: step['id'] as String,
position: step['position'] as int,
name: step['name'] as String,
type: ExerciseStepType.values.byName(step['type'] as String),
defaultTargetValue: step['defaultTargetValue'] as int,
hasScore: step['hasScore'] as bool? ?? false,
scoreInputMode: ScoreInputMode.values.byName(
step['scoreInputMode'] as String? ?? ScoreInputMode.manual.name,
),
scoreLabel: step['scoreLabel'] as String?,
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
);
}).toList();
}
String _autoStartHelpText(bool active) {
return active
? 'Le chrono suivant démarre dès que le précédent arrive à 0.'
: "L'app attendra ton démarrage avant de lancer le chrono suivant.";
}
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(

View File

@ -479,49 +479,52 @@ void main() {
);
});
test('program exercise snapshots step chaining setting from exercise', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Circuit',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
test(
'program exercise snapshots step chaining setting from exercise',
() async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Circuit',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
);
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Programme',
defaultRestSeconds: 60,
),
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Programme',
defaultRestSeconds: 60,
),
final snapshot = await useCase.addExercise(
programId: 'program-1',
exerciseId: 'exercise-1',
position: 0,
setsCount: 3,
enabledMeasures: const {WorkoutMeasure.reps},
);
final useCase = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final snapshot = await useCase.addExercise(
programId: 'program-1',
exerciseId: 'exercise-1',
position: 0,
setsCount: 3,
enabledMeasures: const {WorkoutMeasure.reps},
);
expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
expect(snapshot.autoStartNextTimedStepOverride, isNull);
expect(
snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
isFalse,
);
});
expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
expect(snapshot.autoStartNextTimedStepOverride, isNull);
expect(
snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
isFalse,
);
},
);
test('program saveConfigured preserves step chaining override', () async {
final programRepository = _FakeProgramRepository();
@ -596,10 +599,7 @@ void main() {
],
);
expect(
template.overrides.single.autoStartNextTimedStepOverride,
isFalse,
);
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
});
test('delete exercise keeps existing program snapshots unchanged', () async {
@ -1230,6 +1230,318 @@ void main() {
},
);
test(
'step chaining resolution uses template override before program override',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: true,
autoStartNextTimedStepOverride: true,
templateAutoStartNextTimedStepOverride: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.accumulatedMs, 0);
expect(repository.stepResults, hasLength(1));
},
);
test(
'step chaining resolution uses program override before exercise snapshot',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(
view.state.status,
ActiveExerciseStepProgressStatus.sequenceComplete,
);
expect(repository.stepResults, hasLength(2));
},
);
test(
'step chaining defaults to true when snapshot fields are absent',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: null,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 2500));
final view = await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(
view.state.status,
ActiveExerciseStepProgressStatus.sequenceComplete,
);
expect(repository.stepResults, hasLength(2));
},
);
test(
'completeCurrentStep leaves next timed step stopped when chaining disabled',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 500));
await useCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final view = await useCase.readProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.startedAt, isNull);
expect(view.state.accumulatedMs, 0);
},
);
test(
'completeCurrentStep starts next timed step when chaining is enabled',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: true,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 1500));
await useCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final view = await useCase.readProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(view.state.status, ActiveExerciseStepProgressStatus.runningTimer);
expect(view.state.currentStepIndex, 1);
expect(view.state.elapsedMillisecondsAt(clock.now()), 500);
},
);
test('startTimer manually starts a stopped next timed step', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
autoStartNextTimedStepSnapshot: false,
exerciseSteps: _twoTimedSteps(),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
clock.value = clock.value.add(const Duration(milliseconds: 1500));
await useCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final running = await useCase.startTimer(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(running.status, ActiveExerciseStepProgressStatus.runningTimer);
expect(running.currentStepIndex, 1);
expect(running.startedAt, clock.now());
});
test('AuthUseCases register stores token and account session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final tokenStore = _FakeAuthTokenStore();
@ -2622,7 +2934,26 @@ ActiveWorkoutSession _sessionWithSnapshot({
int setsCount = 2,
int targetReps = 10,
List<ExerciseStep> exerciseSteps = const [],
bool? autoStartNextTimedStepSnapshot = true,
bool? autoStartNextTimedStepOverride,
bool? templateAutoStartNextTimedStepOverride,
}) {
final exerciseSnapshot = {
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),
if (autoStartNextTimedStepSnapshot != null)
'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot,
if (autoStartNextTimedStepOverride != null)
'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride,
};
return ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
@ -2638,23 +2969,38 @@ ActiveWorkoutSession _sessionWithSnapshot({
'id': 'program-snapshot-1',
'programNameSnapshot': 'Programme',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-snapshot-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': targetReps,
'exerciseStepsSnapshot': exerciseSteps
.map((step) => step.toSnapshotJson())
.toList(),
},
],
'exercises': [exerciseSnapshot],
}),
},
],
'overrides': [
if (templateAutoStartNextTimedStepOverride != null)
{
'workoutTemplateProgramId': 'program-snapshot-1',
'snapshotProgramExerciseId': 'exercise-snapshot-1',
'autoStartNextTimedStepOverride':
templateAutoStartNextTimedStepOverride,
},
],
}),
);
}
List<ExerciseStep> _twoTimedSteps() {
return [
_step(
id: 'step-1',
position: 0,
name: 'Gauche',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
_step(
id: 'step-2',
position: 1,
name: 'Droite',
type: ExerciseStepType.time,
defaultTargetValue: 1,
),
];
}

View File

@ -98,99 +98,108 @@ void main() {
expect(restored!.defaultTargetScore, 0);
});
test('step chaining settings round-trip through drift repositories', () async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-chain-1', now),
name: 'Circuit',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
);
await exerciseRepository.save(exercise);
test(
'step chaining settings round-trip through drift repositories',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
final exercise = Exercise(
metadata: _metadata('exercise-chain-1', now),
name: 'Circuit',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
autoStartNextTimedStep: false,
);
await exerciseRepository.save(exercise);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
expect(restoredExercise!.autoStartNextTimedStep, isFalse);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
expect(restoredExercise!.autoStartNextTimedStep, isFalse);
final programExercise = _programExercise(
'program-exercise-chain-1',
now,
programId: 'program-chain-1',
position: 0,
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
);
await programRepository.save(
Program(
metadata: _metadata('program-chain-1', now),
name: 'Programme',
defaultRestSeconds: 60,
exercises: [programExercise],
),
);
final programExercise = _programExercise(
'program-exercise-chain-1',
now,
programId: 'program-chain-1',
position: 0,
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
);
await programRepository.save(
Program(
metadata: _metadata('program-chain-1', now),
name: 'Programme',
defaultRestSeconds: 60,
exercises: [programExercise],
),
);
final restoredProgram = await programRepository.findById('program-chain-1');
expect(
restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
isFalse,
);
expect(
restoredProgram.exercises.single.autoStartNextTimedStepOverride,
isTrue,
);
final restoredProgram = await programRepository.findById(
'program-chain-1',
);
expect(
restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
isFalse,
);
expect(
restoredProgram.exercises.single.autoStartNextTimedStepOverride,
isTrue,
);
final templateProgram = WorkoutTemplateProgram(
metadata: _metadata('template-program-chain-1', now),
workoutTemplateId: 'template-chain-1',
sourceProgramId: 'program-chain-1',
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [programExercise.toSnapshotJson()],
}),
);
await templateRepository.save(
WorkoutTemplate(
metadata: _metadata('template-chain-1', now),
name: 'Séance',
programs: [templateProgram],
overrides: [
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-1', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: programExercise.metadata.id,
),
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-2', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: 'program-exercise-chain-2',
autoStartNextTimedStepOverride: false,
),
],
),
);
final templateProgram = WorkoutTemplateProgram(
metadata: _metadata('template-program-chain-1', now),
workoutTemplateId: 'template-chain-1',
sourceProgramId: 'program-chain-1',
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [programExercise.toSnapshotJson()],
}),
);
await templateRepository.save(
WorkoutTemplate(
metadata: _metadata('template-chain-1', now),
name: 'Séance',
programs: [templateProgram],
overrides: [
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-1', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: programExercise.metadata.id,
),
WorkoutTemplateExerciseOverride(
metadata: _metadata('override-chain-2', now),
workoutTemplateProgramId: templateProgram.metadata.id,
snapshotProgramExerciseId: 'program-exercise-chain-2',
autoStartNextTimedStepOverride: false,
),
],
),
);
final restoredTemplate = await templateRepository.findById(
'template-chain-1',
);
expect(
restoredTemplate!.overrides
.singleWhere((override) => override.metadata.id == 'override-chain-1')
.autoStartNextTimedStepOverride,
isNull,
);
expect(
restoredTemplate.overrides
.singleWhere((override) => override.metadata.id == 'override-chain-2')
.autoStartNextTimedStepOverride,
isFalse,
);
});
final restoredTemplate = await templateRepository.findById(
'template-chain-1',
);
expect(
restoredTemplate!.overrides
.singleWhere(
(override) => override.metadata.id == 'override-chain-1',
)
.autoStartNextTimedStepOverride,
isNull,
);
expect(
restoredTemplate.overrides
.singleWhere(
(override) => override.metadata.id == 'override-chain-2',
)
.autoStartNextTimedStepOverride,
isFalse,
);
},
);
test('exercise repository round-trips configured steps', () async {
final now = DateTime.utc(2026, 7, 17, 12);

View File

@ -435,6 +435,58 @@ void main() {
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom'),
'Combo',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'12',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final saved = exerciseRepository.saved.single;
expect(saved.steps, hasLength(1));
expect(saved.autoStartNextTimedStep, isFalse);
},
);
testWidgets('désigner une image comme icône lindique visuellement', (
tester,
) async {

View File

@ -306,6 +306,165 @@ void main() {
},
);
testWidgets(
'personnaliser lenchaînement automatique transmet loverride au programme',
(tester) async {
final steps = [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
_step(
id: 'step-2',
position: 1,
name: 'Retour',
type: ExerciseStepType.time,
),
];
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-steps'),
name: 'Navettes',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
defaultTargetTimeSeconds: 30,
steps: steps,
),
);
await tester.binding.setSurfaceSize(const Size(400, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom'),
'Programme étapes',
);
await tester.tap(find.text('Ajouter un exercice'));
await tester.pumpAndSettle();
await tester.tap(find.text('Navettes'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip("Personnaliser l'exercice"));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
await tester.pump();
expect(
find.textContaining('Personnalisé pour ce programme'),
findsOneWidget,
);
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final savedExercise = programRepository.programs.single.exercises.single;
expect(savedExercise.autoStartNextTimedStepSnapshot, isTrue);
expect(savedExercise.autoStartNextTimedStepOverride, isFalse);
},
);
testWidgets(
'revenir au réglage de lexercice remet loverride programme à null',
(tester) async {
final steps = [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
];
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-steps'),
name: 'Navettes',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
defaultTargetTimeSeconds: 30,
autoStartNextTimedStep: false,
steps: steps,
),
);
await tester.binding.setSurfaceSize(const Size(400, 2400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom'),
'Programme étapes',
);
await tester.tap(find.text('Ajouter un exercice'));
await tester.pumpAndSettle();
await tester.tap(find.text('Navettes'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip("Personnaliser l'exercice"));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
await tester.pump();
expect(
find.textContaining('Personnalisé pour ce programme'),
findsOneWidget,
);
await tester.tap(find.text("Revenir au réglage de l'exercice"));
await tester.pump();
expect(find.textContaining("Réglage de l'exercice"), findsOneWidget);
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final savedExercise = programRepository.programs.single.exercises.single;
expect(savedExercise.autoStartNextTimedStepSnapshot, isFalse);
expect(savedExercise.autoStartNextTimedStepOverride, isNull);
},
);
testWidgets('affiche lobjectif de chrono pour un score en mode chrono', (
tester,
) async {

View File

@ -44,6 +44,117 @@ void main() {
},
);
testWidgets(
'personnaliser lenchaînement automatique transmet loverride séance',
(tester) async {
final program = WorkoutTemplateProgramDraft(
clientKey: 'program-1',
programNameSnapshot: 'Circuit',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: '{}',
exercises: [
WorkoutTemplateExerciseDraft(
snapshotExerciseId: 'exercise-1',
exerciseNameSnapshot: 'Navettes',
setsCount: 3,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: false,
exerciseStepsSnapshot: [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
_step(
id: 'step-2',
position: 1,
name: 'Retour',
type: ExerciseStepType.time,
),
],
autoStartNextTimedStepSnapshot: true,
targetTimeSeconds: 30,
),
],
);
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Enchaîner automatiquement les chronos consécutifs',
),
);
await tester.pump();
expect(
find.textContaining('Personnalisé pour cette séance'),
findsOneWidget,
);
final override = program.toOverrides().single;
expect(override.autoStartNextTimedStepOverride, isFalse);
},
);
testWidgets(
'revenir au réglage du programme remet loverride séance à null',
(tester) async {
final program = WorkoutTemplateProgramDraft(
clientKey: 'program-1',
programNameSnapshot: 'Circuit',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: '{}',
exercises: [
WorkoutTemplateExerciseDraft(
snapshotExerciseId: 'exercise-1',
exerciseNameSnapshot: 'Navettes',
setsCount: 3,
timeEnabled: true,
repsEnabled: false,
scoreEnabled: false,
exerciseStepsSnapshot: [
_step(
id: 'step-1',
position: 0,
name: 'Course',
type: ExerciseStepType.time,
),
],
autoStartNextTimedStepSnapshot: false,
autoStartNextTimedStepOverride: true,
targetTimeSeconds: 30,
),
],
);
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(home: WorkoutTemplateProgramDetailScreen(program: program)),
);
expect(
find.textContaining('Personnalisé pour cette séance'),
findsOneWidget,
);
await tester.tap(find.text('Revenir au réglage du programme'));
await tester.pump();
expect(find.textContaining('Réglage du programme'), findsOneWidget);
expect(program.toOverrides(), isEmpty);
},
);
testWidgets(
'lancer une séance alors quune autre est en cours affiche un message',
(tester) async {
@ -308,6 +419,21 @@ EntityMetadata _metadata(String id) {
);
}
ExerciseStep _step({
required String id,
required int position,
required String name,
required ExerciseStepType type,
}) {
return ExerciseStep(
id: id,
position: position,
name: name,
type: type,
defaultTargetValue: type == ExerciseStepType.time ? 30 : 5,
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);