import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'exercise_library_screen.dart'; final class ProgramListScreen extends StatefulWidget { const ProgramListScreen({ required this.programUseCases, required this.exerciseUseCases, super.key, }); final ProgramUseCases programUseCases; final ExerciseUseCases exerciseUseCases; @override State createState() => _ProgramListScreenState(); } final class _ProgramListScreenState extends State { late Future> _programs; @override void initState() { super.initState(); _programs = widget.programUseCases.listActive(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Programmes')), floatingActionButton: FloatingActionButton.extended( onPressed: () => _openForm(), icon: const Icon(Icons.add), label: const Text('Créer'), ), body: FutureBuilder>( future: _programs, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return _CenteredMessage( title: 'Impossible de charger les programmes', actionLabel: 'Réessayer', onAction: _reload, ); } final programs = snapshot.data ?? const []; if (programs.isEmpty) { return _CenteredMessage( title: 'Aucun programme pour le moment', message: 'Crée un programme pour organiser tes exercices en séries.', actionLabel: 'Créer un programme', onAction: () => _openForm(), ); } return ListView.separated( itemCount: programs.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final program = programs[index]; return ListTile( title: Text(program.name), subtitle: Text(_programSummary(program)), trailing: const Icon(Icons.chevron_right), onTap: () => _openForm(program: program), ); }, ); }, ), ); } void _reload() { setState(() { _programs = widget.programUseCases.listActive(); }); } Future _openForm({Program? program}) async { final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ProgramFormScreen( programUseCases: widget.programUseCases, exerciseUseCases: widget.exerciseUseCases, program: program, ), ), ); if (changed == true) { if (!mounted) return; _reload(); } } } final class ProgramFormScreen extends StatefulWidget { const ProgramFormScreen({ required this.programUseCases, required this.exerciseUseCases, this.program, super.key, }); final ProgramUseCases programUseCases; final ExerciseUseCases exerciseUseCases; final Program? program; @override State createState() => _ProgramFormScreenState(); } final class _ProgramFormScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final TextEditingController _defaultRestController; late final List<_ProgramExerciseDraft> _exercises; var _saving = false; bool get _isEditing => widget.program != null; int get _defaultRestSeconds { return int.tryParse(_defaultRestController.text.trim()) ?? 0; } @override void initState() { super.initState(); final program = widget.program; _nameController = TextEditingController(text: program?.name); _defaultRestController = TextEditingController( text: '${program?.defaultRestSeconds ?? 60}', ); _exercises = [ for (final exercise in program?.exercises ?? const []) _ProgramExerciseDraft.fromProgramExercise(exercise), ]; } @override void dispose() { _nameController.dispose(); _defaultRestController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( _isEditing ? 'Modifier le programme' : 'Créer un programme', ), ), bottomNavigationBar: SafeArea( minimum: const EdgeInsets.all(16), child: FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check), label: const Text('Enregistrer'), ), ), body: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( children: [ TextFormField( controller: _nameController, decoration: const InputDecoration(labelText: 'Nom'), validator: (value) => value == null || value.trim().isEmpty ? 'Le nom est obligatoire.' : null, ), const SizedBox(height: 12), TextFormField( controller: _defaultRestController, decoration: const InputDecoration( labelText: 'Repos par défaut (s)', ), keyboardType: TextInputType.number, validator: _nonNegativeIntValidator, onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _addExercise, icon: const Icon(Icons.add), label: const Text('Ajouter un exercice'), ), ), ], ), ), ), Expanded( child: _exercises.isEmpty ? const _CenteredMessage( title: 'Aucun exercice ajouté', message: 'Ajoute un exercice depuis la bibliothèque.', ) : ReorderableListView.builder( buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _exercises.length, onReorder: _reorderExercise, itemBuilder: (context, index) { final exercise = _exercises[index]; return _ProgramExerciseCard( key: ValueKey(exercise.key), draft: exercise, defaultRestSeconds: _defaultRestSeconds, onChanged: () => setState(() {}), index: index, ); }, ), ), ], ), ); } Future _addExercise() async { final exercise = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ExerciseSelectionScreen(exerciseUseCases: widget.exerciseUseCases), ), ); if (exercise == null) return; if (!mounted) return; setState(() { _exercises.add(_ProgramExerciseDraft.fromExercise(exercise)); }); } void _reorderExercise(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { newIndex -= 1; } final item = _exercises.removeAt(oldIndex); _exercises.insert(newIndex, item); }); } Future _save() async { if (!_formKey.currentState!.validate()) { return; } setState(() => _saving = true); try { await widget.programUseCases.saveConfigured( id: widget.program?.metadata.id, name: _nameController.text.trim(), defaultRestSeconds: _defaultRestSeconds, exercises: _exercises.map((exercise) => exercise.toConfig()).toList(), ); if (mounted) { Navigator.of(context).pop(true); } } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() => _saving = false); } } } String? _nonNegativeIntValidator(String? value) { final number = int.tryParse(value?.trim() ?? ''); if (number == null || number < 0) { return 'Saisis un nombre positif.'; } return null; } void _showSnackBar(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } final class ExerciseSelectionScreen extends StatefulWidget { const ExerciseSelectionScreen({required this.exerciseUseCases, super.key}); final ExerciseUseCases exerciseUseCases; @override State createState() => _ExerciseSelectionScreenState(); } final class _ExerciseSelectionScreenState extends State { final _searchController = TextEditingController(); var _selectedMeasures = {}; late Future> _exercises; @override void initState() { super.initState(); _exercises = widget.exerciseUseCases.listActive(); _searchController.addListener(() => setState(() {})); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Ajouter un exercice')), body: FutureBuilder>( future: _exercises, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final exercises = _filter(snapshot.data ?? const []); return ListView( children: [ Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextField( controller: _searchController, decoration: const InputDecoration( labelText: 'Rechercher', prefixIcon: Icon(Icons.search), ), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: WorkoutMeasure.values.map((measure) { return FilterChip( label: Text(_measureLabel(measure)), selected: _selectedMeasures.contains(measure), onSelected: (selected) { setState(() { if (selected) { _selectedMeasures = { ..._selectedMeasures, measure, }; } else { _selectedMeasures = {..._selectedMeasures} ..remove(measure); } }); }, ); }).toList(), ), ], ), ), if (exercises.isEmpty) const _CenteredMessage( title: 'Aucun exercice disponible', message: 'Crée un exercice dans la bibliothèque.', ) else for (final exercise in exercises) ExerciseListTile( exercise: exercise, onTap: () => Navigator.of(context).pop(exercise), ), ], ); }, ), ); } List _filter(List exercises) { final query = _searchController.text.trim().toLowerCase(); return exercises.where((exercise) { final matchesQuery = query.isEmpty || exercise.name.toLowerCase().contains(query) || (exercise.description?.toLowerCase().contains(query) ?? false); final matchesMeasures = _selectedMeasures.isEmpty || _selectedMeasures.every(exercise.availableMeasures.contains); return matchesQuery && matchesMeasures; }).toList(); } } final class _ProgramExerciseCard extends StatelessWidget { const _ProgramExerciseCard({ required this.draft, required this.defaultRestSeconds, required this.onChanged, required this.index, super.key, }); final _ProgramExerciseDraft draft; final int defaultRestSeconds; final VoidCallback onChanged; 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, 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é')), ], ), const SizedBox(height: 4), Text(_exerciseSummary(draft, defaultRestSeconds)), const SizedBox(height: 12), TextFormField( initialValue: '${draft.setsCount}', decoration: const InputDecoration(labelText: 'Nombre de séries'), keyboardType: TextInputType.number, onChanged: (value) { draft.setsCount = int.tryParse(value) ?? draft.setsCount; onChanged(); }, ), const SizedBox(height: 12), Text( 'Mesures à suivre', style: Theme.of(context).textTheme.titleSmall, ), if (draft.availableTimeSnapshot) CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Temps'), value: draft.enabledMeasures.contains(WorkoutMeasure.time), onChanged: (value) => _toggle(WorkoutMeasure.time, value), ), if (draft.availableRepsSnapshot) CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Répétitions'), value: draft.enabledMeasures.contains(WorkoutMeasure.reps), onChanged: (value) => _toggle(WorkoutMeasure.reps, value), ), if (draft.availableScoreSnapshot) CheckboxListTile( contentPadding: EdgeInsets.zero, title: Text(scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'), value: draft.enabledMeasures.contains(WorkoutMeasure.score), onChanged: (value) => _toggle(WorkoutMeasure.score, value), ), if (draft.enabledMeasures.contains(WorkoutMeasure.time)) _NumberField( label: 'Cible temps (s)', initialValue: draft.targetTimeSeconds, onChanged: (value) => draft.targetTimeSeconds = value?.round(), ), if (draft.enabledMeasures.contains(WorkoutMeasure.reps)) _NumberField( label: 'Cible répétitions', initialValue: draft.targetReps, onChanged: (value) => draft.targetReps = value?.round(), ), if (draft.enabledMeasures.contains(WorkoutMeasure.score)) _NumberField( label: scoreUnit == null ? 'Cible score' : 'Cible score ($scoreUnit)', initialValue: draft.targetScore, onChanged: (value) => draft.targetScore = value, ), const SizedBox(height: 12), TextFormField( initialValue: '${draft.restSecondsOverride ?? defaultRestSeconds}', 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(); }, ), ], ), ), ); } 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(); } } final class _NumberField extends StatelessWidget { const _NumberField({ required this.label, required this.initialValue, required this.onChanged, }); final String label; final num? initialValue; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8), child: TextFormField( initialValue: initialValue?.toString(), decoration: InputDecoration(labelText: label), keyboardType: TextInputType.number, onChanged: (value) => onChanged(double.tryParse(value)), ), ); } } final class _ProgramExerciseDraft { _ProgramExerciseDraft({ required this.key, this.existingMetadata, this.sourceExerciseId, required this.exerciseNameSnapshot, this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, this.exerciseVideoMediaIdSnapshot, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, required this.availableRepsSnapshot, required this.availableScoreSnapshot, this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, required this.enabledMeasures, this.targetTimeSeconds, this.targetReps, this.targetScore, this.restSecondsOverride, }); factory _ProgramExerciseDraft.fromExercise(Exercise exercise) { return _ProgramExerciseDraft( key: UniqueKey().toString(), sourceExerciseId: exercise.metadata.id, exerciseNameSnapshot: exercise.name, exerciseDescriptionSnapshot: exercise.description, exerciseImageMediaIdSnapshot: exercise.imageMediaId, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, availableScoreSnapshot: exercise.hasScoreMeasure, scoreLabelSnapshot: exercise.scoreLabel, scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, enabledMeasures: exercise.availableMeasures, ); } factory _ProgramExerciseDraft.fromProgramExercise(ProgramExercise exercise) { return _ProgramExerciseDraft( key: exercise.metadata.id, existingMetadata: exercise.metadata, sourceExerciseId: exercise.sourceExerciseId, exerciseNameSnapshot: exercise.exerciseNameSnapshot, exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot, exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot, availableTimeSnapshot: exercise.availableTimeSnapshot, availableRepsSnapshot: exercise.availableRepsSnapshot, availableScoreSnapshot: exercise.availableScoreSnapshot, scoreLabelSnapshot: exercise.scoreLabelSnapshot, scoreUnitSnapshot: exercise.scoreUnitSnapshot, setsCount: exercise.setsCount, enabledMeasures: { if (exercise.timeEnabled) WorkoutMeasure.time, if (exercise.repsEnabled) WorkoutMeasure.reps, if (exercise.scoreEnabled) WorkoutMeasure.score, }, targetTimeSeconds: exercise.targetTimeSeconds, targetReps: exercise.targetReps, targetScore: exercise.targetScore, restSecondsOverride: exercise.restSecondsOverride, ); } final String key; final EntityMetadata? existingMetadata; final String? sourceExerciseId; final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; final bool availableRepsSnapshot; final bool availableScoreSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; int setsCount; Set enabledMeasures; int? targetTimeSeconds; int? targetReps; double? targetScore; int? restSecondsOverride; ProgramExerciseConfig toConfig() { return ProgramExerciseConfig( 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, ); } } String _programSummary(Program program) { final exerciseCount = program.exercises.length; final setsCount = program.exercises.fold( 0, (total, exercise) => total + exercise.setsCount, ); return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''} · ' '$setsCount série${setsCount > 1 ? 's' : ''}'; } String _exerciseSummary(_ProgramExerciseDraft draft, int defaultRestSeconds) { final measures = draft.enabledMeasures .map((measure) { final unit = draft.scoreUnitSnapshot?.trim(); if (measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty) { return 'Score ($unit)'; } return _measureLabel(measure); }) .join(' + '); final rest = draft.restSecondsOverride ?? defaultRestSeconds; return '${draft.setsCount} séries · $measures · Repos $rest s'; } String _measureLabel(WorkoutMeasure measure) { return switch (measure) { WorkoutMeasure.time => 'Temps', WorkoutMeasure.reps => 'Répétitions', WorkoutMeasure.score => 'Score', }; } final class _CenteredMessage extends StatelessWidget { const _CenteredMessage({ required this.title, this.message, this.actionLabel, this.onAction, }); final String title; final String? message; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), if (message != null) ...[ const SizedBox(height: 8), Text(message!, textAlign: TextAlign.center), ], if (actionLabel != null && onAction != null) ...[ const SizedBox(height: 16), FilledButton(onPressed: onAction, child: Text(actionLabel!)), ], ], ), ), ); } }