diff --git a/.ideai/tickets/75/carnet.md b/.ideai/tickets/75/carnet.md index b028b1b..b7fa4d1 100644 --- a/.ideai/tickets/75/carnet.md +++ b/.ideai/tickets/75/carnet.md @@ -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 --- diff --git a/.ideai/tickets/75/issue.md b/.ideai/tickets/75/issue.md index b0483b3..186bd40 100644 --- a/.ideai/tickets/75/issue.md +++ b/.ideai/tickets/75/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 8cc4da8..aba4585 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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", diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 1edae8a..8852541 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -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; 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 resolvedTemplateSnapshot, { + required String workoutTemplateProgramId, + required String snapshotProgramExerciseId, +}) { + final overrides = + (resolvedTemplateSnapshot['overrides'] as List? ?? const []); + for (final raw in overrides) { + if (raw is! Map) { + continue; + } + final override = Map.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 steps; final int expectedPassages; + final bool autoStartNextTimedStep; } List _historyResultsFromActiveResults({ @@ -3523,6 +3590,10 @@ Map _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 steps; + final bool autoStartNextTimedStepEffective; } List _exerciseStepsFromSnapshot(Object? value) { diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 5085468..cd27ddf 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -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']), ); diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 6216a1e..e1e06ac 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -404,6 +404,7 @@ final class _ExerciseFormScreenState extends State { 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 { _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 []) { _stepDrafts.add(_draftFromStep(step)); } @@ -742,6 +744,25 @@ final class _ExerciseFormScreenState extends State { }, ), 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 { defaultTargetReps: defaultTargetReps, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, ); } else { @@ -1332,6 +1354,7 @@ final class _ExerciseFormScreenState extends State { defaultTargetReps: defaultTargetReps, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + autoStartNextTimedStep: _autoStartNextTimedStep, steps: steps, ); } diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 886d8fb..a9bf91b 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -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 exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final List 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; } diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 7d15f7f..aec5123 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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 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 _parseExercises( final rawExercises = snapshot['exercises'] as List? ?? const []; return rawExercises.map((raw) { final exercise = raw as Map; + 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 _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 _parseExercises( }).toList(); } +List _parseExerciseSteps(Object? value) { + final rawSteps = value as List? ?? const []; + return rawSteps.map((raw) { + final step = raw as Map; + 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( diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 4bb6a3f..c70cda7 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -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 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 _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, + ), + ]; +} diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index bfaab8f..d2c0094 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -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); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 957b32c..7e56be5 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -435,6 +435,58 @@ void main() { expect(saved.steps.single.defaultTargetValue, 12); }); + testWidgets( + 'le réglage d’enchaî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 l’indique visuellement', ( tester, ) async { diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 717c852..7f5c390 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -306,6 +306,165 @@ void main() { }, ); + testWidgets( + 'personnaliser l’enchaînement automatique transmet l’override 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 l’exercice remet l’override 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 l’objectif de chrono pour un score en mode chrono', ( tester, ) async { diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 8c00744..8e92469 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -44,6 +44,117 @@ void main() { }, ); + testWidgets( + 'personnaliser l’enchaînement automatique transmet l’override 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 l’override 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 qu’une 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);