feat(ui): programme simplifié en cartes compactes (ticket #20)

Simplifie l'affichage du programme sur program_screen.dart avec des
cartes compactes. flutter analyze propre, 30/30 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:24:29 +02:00
parent 2e55fc021e
commit bf1134d4ff
2 changed files with 311 additions and 83 deletions

View File

@ -226,7 +226,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
key: ValueKey(exercise.key),
draft: exercise,
defaultRestSeconds: _defaultRestSeconds,
onChanged: () => setState(() {}),
onCustomize: () => _customizeExercise(exercise.key),
index: index,
);
},
@ -246,8 +246,46 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
);
if (exercise == null) return;
if (!mounted) return;
final draft = _ProgramExerciseDraft.fromExercise(
exercise,
restSecondsOverride: _defaultRestSeconds,
);
setState(() {
_exercises.add(_ProgramExerciseDraft.fromExercise(exercise));
_exercises.add(draft);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Exercice ajouté'),
action: SnackBarAction(
label: 'Personnaliser',
onPressed: () => _customizeExercise(draft.key),
),
),
);
}
Future<void> _customizeExercise(String key) async {
final index = _exercises.indexWhere((exercise) => exercise.key == key);
if (index == -1) return;
final result = await Navigator.of(context).push<_ProgramExerciseEditResult>(
MaterialPageRoute(
builder: (context) => _ProgramExerciseCustomizationScreen(
draft: _exercises[index],
defaultRestSeconds: _defaultRestSeconds,
),
),
);
if (result == null || !mounted) return;
setState(() {
final currentIndex = _exercises.indexWhere(
(exercise) => exercise.key == key,
);
if (currentIndex == -1) return;
if (result.deleted) {
_exercises.removeAt(currentIndex);
} else if (result.draft != null) {
_exercises[currentIndex] = result.draft!;
}
});
}
@ -418,116 +456,198 @@ final class _ProgramExerciseCard extends StatelessWidget {
const _ProgramExerciseCard({
required this.draft,
required this.defaultRestSeconds,
required this.onChanged,
required this.onCustomize,
required this.index,
super.key,
});
final _ProgramExerciseDraft draft;
final int defaultRestSeconds;
final VoidCallback onChanged;
final VoidCallback onCustomize;
final int index;
@override
Widget build(BuildContext context) {
final scoreUnit = draft.scoreUnitSnapshot;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
title: Text(draft.exerciseNameSnapshot),
subtitle: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
draft.exerciseNameSnapshot,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (draft.exerciseArchivedSnapshot)
const Chip(label: Text('Exercice archivé')),
],
Text(_restLabel(draft, defaultRestSeconds)),
if (draft.exerciseArchivedSnapshot)
const Chip(label: Text('Exercice archivé')),
],
),
trailing: IconButton(
tooltip: "Personnaliser l'exercice",
onPressed: onCustomize,
icon: const Icon(Icons.tune),
),
),
);
}
}
final class _ProgramExerciseCustomizationScreen extends StatefulWidget {
const _ProgramExerciseCustomizationScreen({
required _ProgramExerciseDraft draft,
required this.defaultRestSeconds,
super.key,
}) : _initialDraft = draft;
final _ProgramExerciseDraft _initialDraft;
final int defaultRestSeconds;
@override
State<_ProgramExerciseCustomizationScreen> createState() =>
_ProgramExerciseCustomizationScreenState();
}
final class _ProgramExerciseCustomizationScreenState
extends State<_ProgramExerciseCustomizationScreen> {
final _formKey = GlobalKey<FormState>();
late final _ProgramExerciseDraft _draft;
late final TextEditingController _setsController;
late final TextEditingController _restController;
String? _measureError;
@override
void initState() {
super.initState();
_draft = widget._initialDraft.copy();
_setsController = TextEditingController(text: '${_draft.setsCount}');
_restController = TextEditingController(
text: '${_draft.restSecondsOverride ?? widget.defaultRestSeconds}',
);
}
@override
void dispose() {
_setsController.dispose();
_restController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scoreUnit = _draft.scoreUnitSnapshot;
return Scaffold(
appBar: AppBar(title: const Text("Personnaliser l'exercice")),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _confirmDelete,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer du programme'),
),
],
),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
_draft.exerciseNameSnapshot,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 4),
Text(_exerciseSummary(draft, defaultRestSeconds)),
const SizedBox(height: 12),
Text(_exerciseSummary(_draft, widget.defaultRestSeconds)),
const SizedBox(height: 24),
Text('Séries', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
initialValue: '${draft.setsCount}',
controller: _setsController,
decoration: const InputDecoration(labelText: 'Nombre de séries'),
keyboardType: TextInputType.number,
onChanged: (value) {
draft.setsCount = int.tryParse(value) ?? draft.setsCount;
onChanged();
},
validator: _positiveIntValidator,
),
const SizedBox(height: 12),
const SizedBox(height: 24),
Text(
'Mesures à suivre',
style: Theme.of(context).textTheme.titleSmall,
style: Theme.of(context).textTheme.titleMedium,
),
if (draft.availableTimeSnapshot)
CheckboxListTile(
if (_draft.availableTimeSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: draft.enabledMeasures.contains(WorkoutMeasure.time),
value: _draft.enabledMeasures.contains(WorkoutMeasure.time),
onChanged: (value) => _toggle(WorkoutMeasure.time, value),
),
if (draft.availableRepsSnapshot)
CheckboxListTile(
if (_draft.availableRepsSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: draft.enabledMeasures.contains(WorkoutMeasure.reps),
value: _draft.enabledMeasures.contains(WorkoutMeasure.reps),
onChanged: (value) => _toggle(WorkoutMeasure.reps, value),
),
if (draft.availableScoreSnapshot)
CheckboxListTile(
if (_draft.availableScoreSnapshot)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'),
value: draft.enabledMeasures.contains(WorkoutMeasure.score),
value: _draft.enabledMeasures.contains(WorkoutMeasure.score),
onChanged: (value) => _toggle(WorkoutMeasure.score, value),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.time))
if (_measureError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_measureError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 24),
Text('Objectifs', style: Theme.of(context).textTheme.titleMedium),
if (_draft.enabledMeasures.contains(WorkoutMeasure.time))
_NumberField(
label: 'Cible temps (s)',
initialValue: draft.targetTimeSeconds,
onChanged: (value) => draft.targetTimeSeconds = value?.round(),
initialValue: _draft.targetTimeSeconds,
onChanged: (value) => _draft.targetTimeSeconds = value?.round(),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.reps))
if (_draft.enabledMeasures.contains(WorkoutMeasure.reps))
_NumberField(
label: 'Cible répétitions',
initialValue: draft.targetReps,
onChanged: (value) => draft.targetReps = value?.round(),
initialValue: _draft.targetReps,
onChanged: (value) => _draft.targetReps = value?.round(),
),
if (draft.enabledMeasures.contains(WorkoutMeasure.score))
if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
_NumberField(
label: scoreUnit == null
? 'Cible score'
: 'Cible score ($scoreUnit)',
initialValue: draft.targetScore,
onChanged: (value) => draft.targetScore = value,
initialValue: _draft.targetScore,
onChanged: (value) => _draft.targetScore = value,
),
const SizedBox(height: 12),
const SizedBox(height: 24),
Text('Repos', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
initialValue:
'${draft.restSecondsOverride ?? defaultRestSeconds}',
controller: _restController,
decoration: const InputDecoration(
labelText: 'Repos après chaque série (s)',
helperText: 'Pas de repos après la toute dernière série.',
),
keyboardType: TextInputType.number,
onChanged: (value) {
draft.restSecondsOverride = int.tryParse(value);
onChanged();
},
validator: _positiveIntValidator,
),
],
),
@ -535,14 +655,75 @@ final class _ProgramExerciseCard extends StatelessWidget {
);
}
void _toggle(WorkoutMeasure measure, bool? value) {
if (value == true) {
draft.enabledMeasures = {...draft.enabledMeasures, measure};
} else if (draft.enabledMeasures.length > 1) {
draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure);
}
onChanged();
void _toggle(WorkoutMeasure measure, bool value) {
setState(() {
_measureError = null;
if (value) {
_draft.enabledMeasures = {..._draft.enabledMeasures, measure};
} else {
_draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure);
}
});
}
String? _positiveIntValidator(String? value) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return 'Saisis un nombre supérieur à 0.';
}
return null;
}
void _save() {
final hasMeasure = _draft.enabledMeasures.isNotEmpty;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
return;
}
_draft.setsCount = int.parse(_setsController.text.trim());
_draft.restSecondsOverride = int.parse(_restController.text.trim());
Navigator.of(context).pop(_ProgramExerciseEditResult.saved(_draft));
}
Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cet exercice ?'),
content: const Text('Il sera retiré de ce programme.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed == true && mounted) {
Navigator.of(context).pop(const _ProgramExerciseEditResult.deleted());
}
}
}
final class _ProgramExerciseEditResult {
const _ProgramExerciseEditResult._({this.draft, required this.deleted});
const _ProgramExerciseEditResult.deleted()
: this._(draft: null, deleted: true);
const _ProgramExerciseEditResult.saved(_ProgramExerciseDraft draft)
: this._(draft: draft, deleted: false);
final _ProgramExerciseDraft? draft;
final bool deleted;
}
final class _NumberField extends StatelessWidget {
@ -593,7 +774,10 @@ final class _ProgramExerciseDraft {
this.restSecondsOverride,
});
factory _ProgramExerciseDraft.fromExercise(Exercise exercise) {
factory _ProgramExerciseDraft.fromExercise(
Exercise exercise, {
int? restSecondsOverride,
}) {
return _ProgramExerciseDraft(
key: UniqueKey().toString(),
sourceExerciseId: exercise.metadata.id,
@ -609,6 +793,7 @@ final class _ProgramExerciseDraft {
scoreUnitSnapshot: exercise.scoreUnit,
setsCount: 3,
enabledMeasures: exercise.availableMeasures,
restSecondsOverride: restSecondsOverride,
);
}
@ -660,6 +845,30 @@ final class _ProgramExerciseDraft {
double? targetScore;
int? restSecondsOverride;
_ProgramExerciseDraft copy() {
return _ProgramExerciseDraft(
key: key,
existingMetadata: existingMetadata,
sourceExerciseId: sourceExerciseId,
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
availableRepsSnapshot: availableRepsSnapshot,
availableScoreSnapshot: availableScoreSnapshot,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
setsCount: setsCount,
enabledMeasures: {...enabledMeasures},
targetTimeSeconds: targetTimeSeconds,
targetReps: targetReps,
targetScore: targetScore,
restSecondsOverride: restSecondsOverride,
);
}
ProgramExerciseConfig toConfig() {
return ProgramExerciseConfig(
existingMetadata: existingMetadata,
@ -710,6 +919,11 @@ String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) {
return '${draft.setsCount} séries · $measures · Repos $rest s';
}
String _restLabel(_ProgramExerciseDraft draft, int defaultRestSeconds) {
final rest = draft.restSecondsOverride ?? defaultRestSeconds;
return 'Repos $rest s';
}
String _measureLabel(WorkoutMeasure measure) {
return switch (measure) {
WorkoutMeasure.time => 'Temps',