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), key: ValueKey(exercise.key),
draft: exercise, draft: exercise,
defaultRestSeconds: _defaultRestSeconds, defaultRestSeconds: _defaultRestSeconds,
onChanged: () => setState(() {}), onCustomize: () => _customizeExercise(exercise.key),
index: index, index: index,
); );
}, },
@ -246,8 +246,46 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
); );
if (exercise == null) return; if (exercise == null) return;
if (!mounted) return; if (!mounted) return;
final draft = _ProgramExerciseDraft.fromExercise(
exercise,
restSecondsOverride: _defaultRestSeconds,
);
setState(() { 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({ const _ProgramExerciseCard({
required this.draft, required this.draft,
required this.defaultRestSeconds, required this.defaultRestSeconds,
required this.onChanged, required this.onCustomize,
required this.index, required this.index,
super.key, super.key,
}); });
final _ProgramExerciseDraft draft; final _ProgramExerciseDraft draft;
final int defaultRestSeconds; final int defaultRestSeconds;
final VoidCallback onChanged; final VoidCallback onCustomize;
final int index; final int index;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scoreUnit = draft.scoreUnitSnapshot;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 8),
child: Padding( child: ListTile(
padding: const EdgeInsets.all(12), leading: ReorderableDragStartListener(
child: Column( index: index,
crossAxisAlignment: CrossAxisAlignment.start, child: const Icon(Icons.drag_handle),
),
title: Text(draft.exerciseNameSnapshot),
subtitle: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Row( Text(_restLabel(draft, defaultRestSeconds)),
children: [ if (draft.exerciseArchivedSnapshot)
ReorderableDragStartListener( const Chip(label: Text('Exercice archivé')),
index: index, ],
child: const Padding( ),
padding: EdgeInsets.only(right: 8), trailing: IconButton(
child: Icon(Icons.drag_handle), tooltip: "Personnaliser l'exercice",
), onPressed: onCustomize,
), icon: const Icon(Icons.tune),
Expanded( ),
child: Text( ),
draft.exerciseNameSnapshot, );
style: Theme.of(context).textTheme.titleMedium, }
), }
),
if (draft.exerciseArchivedSnapshot) final class _ProgramExerciseCustomizationScreen extends StatefulWidget {
const Chip(label: Text('Exercice archivé')), 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), const SizedBox(height: 4),
Text(_exerciseSummary(draft, defaultRestSeconds)), Text(_exerciseSummary(_draft, widget.defaultRestSeconds)),
const SizedBox(height: 12), const SizedBox(height: 24),
Text('Séries', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField( TextFormField(
initialValue: '${draft.setsCount}', controller: _setsController,
decoration: const InputDecoration(labelText: 'Nombre de séries'), decoration: const InputDecoration(labelText: 'Nombre de séries'),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (value) { validator: _positiveIntValidator,
draft.setsCount = int.tryParse(value) ?? draft.setsCount;
onChanged();
},
), ),
const SizedBox(height: 12), const SizedBox(height: 24),
Text( Text(
'Mesures à suivre', 'Mesures à suivre',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleMedium,
), ),
if (draft.availableTimeSnapshot) if (_draft.availableTimeSnapshot)
CheckboxListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Temps'), title: const Text('Temps'),
value: draft.enabledMeasures.contains(WorkoutMeasure.time), value: _draft.enabledMeasures.contains(WorkoutMeasure.time),
onChanged: (value) => _toggle(WorkoutMeasure.time, value), onChanged: (value) => _toggle(WorkoutMeasure.time, value),
), ),
if (draft.availableRepsSnapshot) if (_draft.availableRepsSnapshot)
CheckboxListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'), title: const Text('Répétitions'),
value: draft.enabledMeasures.contains(WorkoutMeasure.reps), value: _draft.enabledMeasures.contains(WorkoutMeasure.reps),
onChanged: (value) => _toggle(WorkoutMeasure.reps, value), onChanged: (value) => _toggle(WorkoutMeasure.reps, value),
), ),
if (draft.availableScoreSnapshot) if (_draft.availableScoreSnapshot)
CheckboxListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'), 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), 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( _NumberField(
label: 'Cible temps (s)', label: 'Cible temps (s)',
initialValue: draft.targetTimeSeconds, initialValue: _draft.targetTimeSeconds,
onChanged: (value) => draft.targetTimeSeconds = value?.round(), onChanged: (value) => _draft.targetTimeSeconds = value?.round(),
), ),
if (draft.enabledMeasures.contains(WorkoutMeasure.reps)) if (_draft.enabledMeasures.contains(WorkoutMeasure.reps))
_NumberField( _NumberField(
label: 'Cible répétitions', label: 'Cible répétitions',
initialValue: draft.targetReps, initialValue: _draft.targetReps,
onChanged: (value) => draft.targetReps = value?.round(), onChanged: (value) => _draft.targetReps = value?.round(),
), ),
if (draft.enabledMeasures.contains(WorkoutMeasure.score)) if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
_NumberField( _NumberField(
label: scoreUnit == null label: scoreUnit == null
? 'Cible score' ? 'Cible score'
: 'Cible score ($scoreUnit)', : 'Cible score ($scoreUnit)',
initialValue: draft.targetScore, initialValue: _draft.targetScore,
onChanged: (value) => draft.targetScore = value, 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( TextFormField(
initialValue: controller: _restController,
'${draft.restSecondsOverride ?? defaultRestSeconds}',
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Repos après chaque série (s)', labelText: 'Repos après chaque série (s)',
helperText: 'Pas de repos après la toute dernière série.', helperText: 'Pas de repos après la toute dernière série.',
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (value) { validator: _positiveIntValidator,
draft.restSecondsOverride = int.tryParse(value);
onChanged();
},
), ),
], ],
), ),
@ -535,14 +655,75 @@ final class _ProgramExerciseCard extends StatelessWidget {
); );
} }
void _toggle(WorkoutMeasure measure, bool? value) { void _toggle(WorkoutMeasure measure, bool value) {
if (value == true) { setState(() {
draft.enabledMeasures = {...draft.enabledMeasures, measure}; _measureError = null;
} else if (draft.enabledMeasures.length > 1) { if (value) {
draft.enabledMeasures = {...draft.enabledMeasures}..remove(measure); _draft.enabledMeasures = {..._draft.enabledMeasures, measure};
} } else {
onChanged(); _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 { final class _NumberField extends StatelessWidget {
@ -593,7 +774,10 @@ final class _ProgramExerciseDraft {
this.restSecondsOverride, this.restSecondsOverride,
}); });
factory _ProgramExerciseDraft.fromExercise(Exercise exercise) { factory _ProgramExerciseDraft.fromExercise(
Exercise exercise, {
int? restSecondsOverride,
}) {
return _ProgramExerciseDraft( return _ProgramExerciseDraft(
key: UniqueKey().toString(), key: UniqueKey().toString(),
sourceExerciseId: exercise.metadata.id, sourceExerciseId: exercise.metadata.id,
@ -609,6 +793,7 @@ final class _ProgramExerciseDraft {
scoreUnitSnapshot: exercise.scoreUnit, scoreUnitSnapshot: exercise.scoreUnit,
setsCount: 3, setsCount: 3,
enabledMeasures: exercise.availableMeasures, enabledMeasures: exercise.availableMeasures,
restSecondsOverride: restSecondsOverride,
); );
} }
@ -660,6 +845,30 @@ final class _ProgramExerciseDraft {
double? targetScore; double? targetScore;
int? restSecondsOverride; 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() { ProgramExerciseConfig toConfig() {
return ProgramExerciseConfig( return ProgramExerciseConfig(
existingMetadata: existingMetadata, existingMetadata: existingMetadata,
@ -710,6 +919,11 @@ String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) {
return '${draft.setsCount} séries · $measures · Repos $rest s'; 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) { String _measureLabel(WorkoutMeasure measure) {
return switch (measure) { return switch (measure) {
WorkoutMeasure.time => 'Temps', WorkoutMeasure.time => 'Temps',

View File

@ -8,6 +8,7 @@ void main() {
testWidgets( testWidgets(
'ajoute un exercice au programme avec sa configuration par défaut', 'ajoute un exercice au programme avec sa configuration par défaut',
(tester) async { (tester) async {
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository() final exerciseRepository = _FakeExerciseRepository()
..exercises.add( ..exercises.add(
Exercise( Exercise(
@ -23,7 +24,7 @@ void main() {
MaterialApp( MaterialApp(
home: ProgramFormScreen( home: ProgramFormScreen(
programUseCases: _programUseCases( programUseCases: _programUseCases(
_FakeProgramRepository(), programRepository,
exerciseRepository, exerciseRepository,
), ),
exerciseUseCases: _exerciseUseCases(exerciseRepository), exerciseUseCases: _exerciseUseCases(exerciseRepository),
@ -31,21 +32,30 @@ void main() {
), ),
); );
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom'),
'Jambes',
);
await tester.tap(find.text('Ajouter un exercice')); await tester.tap(find.text('Ajouter un exercice'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Squat')); await tester.tap(find.text('Squat'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Squat'), findsOneWidget); expect(find.text('Squat'), findsOneWidget);
expect( expect(find.text('Repos 60 s'), findsOneWidget);
find.text('3 séries · Temps + Répétitions · Repos 60 s'), expect(find.text('Exercice ajouté'), findsOneWidget);
findsOneWidget, expect(find.byTooltip("Personnaliser l'exercice"), findsOneWidget);
); expect(find.byType(CheckboxListTile), findsNothing);
expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsOneWidget);
expect( await tester.tap(find.text('Enregistrer'));
find.widgetWithText(CheckboxListTile, 'Répétitions'), await tester.pumpAndSettle();
findsOneWidget,
); final savedExercise = programRepository.programs.single.exercises.single;
expect(savedExercise.setsCount, 3);
expect(savedExercise.timeEnabled, isTrue);
expect(savedExercise.repsEnabled, isTrue);
expect(savedExercise.scoreEnabled, isFalse);
expect(savedExercise.restSecondsOverride, 60);
}, },
); );
@ -80,12 +90,16 @@ void main() {
await tester.tap(find.text('Pompes')); await tester.tap(find.text('Pompes'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip("Personnaliser l'exercice"));
await tester.pumpAndSettle();
expect(find.text("Personnaliser l'exercice"), findsOneWidget);
expect( expect(
find.widgetWithText(CheckboxListTile, 'Répétitions'), find.widgetWithText(SwitchListTile, 'Répétitions'),
findsOneWidget, findsOneWidget,
); );
expect(find.widgetWithText(CheckboxListTile, 'Temps'), findsNothing); expect(find.widgetWithText(SwitchListTile, 'Temps'), findsNothing);
expect(find.widgetWithText(CheckboxListTile, 'Score'), findsNothing); expect(find.widgetWithText(SwitchListTile, 'Score'), findsNothing);
}, },
); );
} }