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:
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user