feat(exercice): exécution persistante des étapes et éditeur d'étapes (tickets #58, #59)

Ajoute la persistance de l'exécution par étapes avec résultats par
passage (ports, use cases, entités, modèle Drift avec migration) et
l'éditeur d'exercice avec séquence d'étapes sur
exercise_library_screen.dart. Développés dans le même worktree
partagé par DevBackend et DevFrontend ; commit combiné plutôt que
séparé car les fakes de repository de test corrigés (nouvelles
interfaces #58) touchent plusieurs écrans sans lien avec l'éditeur
lui-même (historique, accueil, exécution, séance-modèle). Corrige au
passage un ListTile sans ancêtre Material dans le nouvel éditeur
d'étapes (Material(type: MaterialType.transparency)). flutter pub get
OK, build_runner OK, analyze propre (mêmes infos préexistantes),
93/93 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:52:43 +02:00
parent 4731a257a1
commit ebba22db56
14 changed files with 11371 additions and 2 deletions

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
@ -400,7 +402,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _saving = false;
var _importingImage = false;
var _importingVideo = false;
var _stepsEnabled = false;
var _nextStepDraftId = 0;
final _stepDrafts = <_ExerciseStepDraft>[];
String? _measureError;
String? _stepError;
String? _warning;
bool get _isEditing => widget.exercise != null;
@ -434,6 +440,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
}
@override
@ -446,6 +456,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_defaultRepsController.dispose();
_defaultScoreController.dispose();
_defaultScoreTimeController.dispose();
for (final draft in _stepDrafts) {
draft.dispose();
}
super.dispose();
}
@ -666,6 +679,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
],
],
const SizedBox(height: 24),
_buildExerciseStepsSection(context),
if (_warning != null) ...[
const SizedBox(height: 16),
MaterialBanner(
@ -696,6 +711,305 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
);
}
Widget _buildExerciseStepsSection(BuildContext context) {
final reachedLimit = _stepDrafts.length >= 8;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence détapes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Rythmer cet exercice avec des étapes'),
subtitle: Text(
_stepsEnabled
? 'Chaque passage suit ces étapes dans lordre. Si la série '
'suit des répétitions, une répétition correspond à un '
'passage complet.'
: 'Ajoute des étapes si lexercice doit suivre un ordre précis '
'pendant chaque série.',
),
value: _stepsEnabled,
onChanged: (value) {
setState(() {
_stepsEnabled = value;
_stepError = null;
});
},
),
if (_stepsEnabled) ...[
const SizedBox(height: 8),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
),
if (_stepError != null) ...[
const SizedBox(height: 8),
Text(
_stepError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
if (reachedLimit)
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.info_outline),
title: Text('Limite atteinte'),
subtitle: Text('Un exercice peut contenir jusquà 8 étapes.'),
)
else
OutlinedButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add),
label: const Text('Ajouter une étape'),
),
],
],
);
}
Widget _buildStepCard(
BuildContext context,
int index,
_ExerciseStepDraft draft,
) {
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
final targetMessage = draft.type == ExerciseStepType.time
? 'Saisis une durée supérieure à 0.'
: 'Saisis un nombre de répétitions supérieur à 0.';
final summaryUnit = draft.type == ExerciseStepType.time ? 's' : '';
return CourtBlazerAccentPanel(
key: ValueKey(draft.id),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
child: Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
'${index + 1}. ${draft.displayName}',
style: Theme.of(context).textTheme.titleSmall,
),
),
PopupMenuButton<_StepAction>(
tooltip: 'Actions de létape ${index + 1}',
onSelected: (action) => _handleStepAction(index, action),
itemBuilder: (context) => [
PopupMenuItem(
value: _StepAction.moveUp,
enabled: index > 0,
child: const Text('Monter'),
),
PopupMenuItem(
value: _StepAction.moveDown,
enabled: index < _stepDrafts.length - 1,
child: const Text('Descendre'),
),
const PopupMenuItem(
value: _StepAction.duplicate,
child: Text('Dupliquer'),
),
const PopupMenuItem(
value: _StepAction.delete,
child: Text('Supprimer'),
),
],
),
],
),
const SizedBox(height: 8),
Text(
draft.type == ExerciseStepType.time
? 'Temps · ${draft.targetController.text.trim()} $summaryUnit'
: 'Répétitions · ${draft.targetController.text.trim()}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextFormField(
controller: draft.nameController,
decoration: const InputDecoration(labelText: 'Nom de létape'),
validator: (value) {
if (!_stepsEnabled) return null;
return value == null || value.trim().isEmpty
? 'Le nom de létape est obligatoire.'
: null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
TextFormField(
controller: draft.targetController,
decoration: InputDecoration(labelText: targetLabel),
keyboardType: TextInputType.number,
validator: (value) =>
_stepsEnabled ? _positiveIntValidator(value, targetMessage) : null,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Score détape'),
subtitle: const Text('Ajouter un score pour cette étape'),
value: draft.hasScore,
onChanged: (value) => setState(() => draft.hasScore = value),
),
if (draft.hasScore) ...[
Text('Mode de score', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(labelText: 'Score à saisir'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
const Text(
'Cette étape utilise déjà un compte à rebours. Le score '
'chrono ajoute un second temps mesuré ; garde-le seulement '
'si tu veux enregistrer une performance distincte.',
),
],
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
],
),
),
);
}
void _setMeasure(VoidCallback update) {
setState(() {
update();
@ -704,6 +1018,141 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
});
}
void _addStep() {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.add(
_ExerciseStepDraft(
id: 'step-draft-${_nextStepDraftId++}',
name: 'Nouvelle étape',
defaultTargetValue: '10',
),
);
});
}
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
}
void _handleStepAction(int index, _StepAction action) {
switch (action) {
case _StepAction.moveUp:
if (index <= 0) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index - 1, draft);
});
break;
case _StepAction.moveDown:
if (index >= _stepDrafts.length - 1) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index + 1, draft);
});
break;
case _StepAction.duplicate:
_duplicateStep(index);
break;
case _StepAction.delete:
unawaited(_confirmRemoveStep(index));
break;
}
}
void _duplicateStep(int index) {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.insert(index + 1, _stepDrafts[index].duplicate(
'step-draft-${_nextStepDraftId++}',
));
});
}
Future<void> _confirmRemoveStep(int index) async {
final draft = _stepDrafts[index];
var confirmed = true;
if (draft.hasFilledContent) {
confirmed =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cette étape ?'),
content: const Text('Cette étape sera retirée de la séquence.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
) ??
false;
}
if (!confirmed || !mounted) return;
setState(() {
final removed = _stepDrafts.removeAt(index);
removed.dispose();
_stepError = null;
});
}
_ExerciseStepDraft _draftFromStep(ExerciseStep step) {
return _ExerciseStepDraft(
id: step.id,
name: step.name,
type: step.type,
defaultTargetValue: step.defaultTargetValue.toString(),
hasScore: step.hasScore,
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
)
: _optionalDoubleText(step.defaultTargetScore),
);
}
List<ExerciseStep> _buildSteps() {
if (!_stepsEnabled) return const [];
return [
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
defaultTargetScoreTimeMs: _stepDrafts[index].scoreInputMode ==
ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
_stepDrafts[index].scoreTargetController.text,
)
: null,
),
];
}
Future<void> _importImage() async {
if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.');
@ -784,12 +1233,16 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
Future<void> _save() async {
final hasMeasure = _hasTime || _hasReps || _hasScore;
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
_stepError = hasValidSteps
? null
: 'Ajoute au moins une étape ou désactive la séquence.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) {
return;
}
@ -832,6 +1285,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
@ -850,6 +1304,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
);
} else {
await widget.exerciseUseCases.update(
@ -869,6 +1324,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
steps: steps,
);
}
if (mounted) {
@ -961,6 +1417,104 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
}
enum _StepAction { moveUp, moveDown, duplicate, delete }
final class _ExerciseStepDraft {
_ExerciseStepDraft({
required this.id,
required String name,
this.type = ExerciseStepType.time,
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
}) : nameController = TextEditingController(text: name),
targetController = TextEditingController(text: defaultTargetValue),
scoreLabelController = TextEditingController(text: scoreLabel),
scoreUnitController = TextEditingController(text: scoreUnit),
scoreTargetController = TextEditingController(text: scoreTarget);
final String id;
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
final TextEditingController scoreUnitController;
final TextEditingController scoreTargetController;
String get displayName {
final text = nameController.text.trim();
return text.isEmpty ? 'Étape sans nom' : text;
}
bool get hasFilledContent {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
}
_ExerciseStepDraft duplicate(String id) {
return _ExerciseStepDraft(
id: id,
name: nameController.text,
type: type,
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
);
}
ExerciseStep toExerciseStep({
required int position,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
return ExerciseStep(
id: id,
position: position,
name: nameController.text.trim(),
type: type,
defaultTargetValue: int.parse(targetController.text.trim()),
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
defaultTargetScoreTimeMs:
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
);
}
void dispose() {
nameController.dispose();
targetController.dispose();
scoreLabelController.dispose();
scoreUnitController.dispose();
scoreTargetController.dispose();
}
}
final class MeasureBadge extends StatelessWidget {
const MeasureBadge({
required this.measure,