import 'dart:async'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.dart'; import 'exercise_library_screen.dart'; import 'library_tags.dart'; import 'share_screen.dart'; final class ProgramListScreen extends StatefulWidget { const ProgramListScreen({ required this.programUseCases, required this.exerciseUseCases, this.shareUseCases, this.authUseCases, this.syncUseCases, super.key, }); final ProgramUseCases programUseCases; final ExerciseUseCases exerciseUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; @override State createState() => _ProgramListScreenState(); } final class _ProgramListScreenState extends State { final _searchController = TextEditingController(); var _selectedTags = {}; late Future> _programs; @override void initState() { super.initState(); _programs = widget.programUseCases.listActive(); _searchController.addListener(() => setState(() {})); } @override void dispose() { _searchController.dispose(); super.dispose(); } @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 []; final tagSuggestions = tagSuggestionsFor( programs, (program) => program.tags, ).map((usage) => usage.tag).toList(); if (programs.isEmpty) { return _CenteredMessage( title: 'Aucun programme', message: 'Assemble quelques exercices pour préparer une séance.', actionLabel: 'Créer un programme', onAction: () => _openForm(), ); } final filtered = _filter(programs); return ListView.separated( itemCount: filtered.isEmpty ? 2 : filtered.length + 1, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) { return _ProgramFilters( controller: _searchController, tagSuggestions: tagSuggestions, selectedTags: _selectedTags, filtersActive: _filtersActive, onToggleTag: _toggleTag, onClearFilters: _clearFilters, ); } if (filtered.isEmpty) { return _CenteredMessage( title: 'Aucun programme ne correspond', message: 'Modifie la recherche ou les tags sélectionnés.', actionLabel: 'Effacer les filtres', onAction: _clearFilters, ); } final program = filtered[index - 1]; return ListTile( title: Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(program.name), if (program.isExample) const ExampleBadge(), ], ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_programSummary(program)), if (program.tags.isNotEmpty) ...[ const SizedBox(height: 6), TagBadgeWrap(tags: program.tags), ], ], ), trailing: Wrap( spacing: 8, children: [ PopupMenuButton<_ProgramItemAction>( tooltip: 'Actions', onSelected: (action) { switch (action) { case _ProgramItemAction.duplicate: unawaited(_duplicate(program)); case _ProgramItemAction.delete: unawaited(_confirmDelete(program)); } }, itemBuilder: (context) => const [ PopupMenuItem( value: _ProgramItemAction.duplicate, child: Text('Dupliquer'), ), PopupMenuItem( value: _ProgramItemAction.delete, child: Text('Supprimer'), ), ], ), const Icon(Icons.chevron_right), ], ), onTap: () => _openForm(program: program), ); }, ); }, ), ); } void _reload() { setState(() { _programs = widget.programUseCases.listActive(); }); } List _filter(List programs) { final query = _searchController.text.trim().toLowerCase(); final matchingQuery = programs.where((program) { return query.isEmpty || program.name.toLowerCase().contains(query) || _programSummary(program).toLowerCase().contains(query); }).toList(); return filterByRequiredTags( matchingQuery, _selectedTags, (program) => program.tags, ); } bool get _filtersActive { return _searchController.text.trim().isNotEmpty || _selectedTags.isNotEmpty; } void _toggleTag(String tag) { setState(() { if (_selectedTags.contains(tag)) { _selectedTags = {..._selectedTags}..remove(tag); } else { _selectedTags = {..._selectedTags, tag}; } }); } void _clearFilters() { setState(() { _searchController.clear(); _selectedTags = {}; }); } Future _openForm({Program? program, String? initialSnackBar}) async { final programs = await _programs; if (!mounted) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ProgramFormScreen( programUseCases: widget.programUseCases, exerciseUseCases: widget.exerciseUseCases, shareUseCases: widget.shareUseCases, authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, program: program, tagSuggestions: tagSuggestionsFor( programs, (program) => program.tags, ).map((usage) => usage.tag).toList(), initialSnackBar: initialSnackBar, ), ), ); if (changed == true) { if (!mounted) return; _reload(); } } Future _duplicate(Program program) async { try { final copy = await widget.programUseCases.duplicate(program.metadata.id); if (!mounted) return; await _openForm( program: copy, initialSnackBar: "Programme dupliqué. Ajuste la copie avant de l'utiliser.", ); if (!mounted) return; _reload(); } on Exception { _showSnackBar('Duplication impossible pour le moment.'); } } Future _confirmDelete(Program program) async { final used = await widget.programUseCases.isUsedInWorkoutTemplates( program.metadata.id, ); if (!mounted) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Supprimer ${program.name} ?'), content: Text( used ? 'Ce programme est utilisé dans une ou plusieurs séances. ' 'Le supprimer le retirera de ces séances. Continuer ?' : 'Cette action est irréversible.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton.icon( onPressed: () => Navigator.of(context).pop(true), icon: const Icon(Icons.delete_outline), label: const Text('Supprimer'), ), ], ), ); if (confirmed != true) return; try { await widget.programUseCases.delete(program.metadata.id); if (!mounted) return; _reload(); } on Exception catch (error) { _showSnackBar(error.toString()); } } void _showSnackBar(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } enum _ProgramItemAction { duplicate, delete } final class _ProgramFilters extends StatelessWidget { const _ProgramFilters({ required this.controller, required this.tagSuggestions, required this.selectedTags, required this.filtersActive, required this.onToggleTag, required this.onClearFilters, }); final TextEditingController controller; final List tagSuggestions; final Set selectedTags; final bool filtersActive; final ValueChanged onToggleTag; final VoidCallback onClearFilters; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: controller, decoration: const InputDecoration( labelText: 'Rechercher', prefixIcon: Icon(Icons.search), ), ), if (tagSuggestions.isNotEmpty) ...[ const SizedBox(height: 12), LibraryTagFilters( suggestions: tagSuggestions, selectedTags: selectedTags, onToggle: onToggleTag, ), ], if (filtersActive) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: TextButton( onPressed: onClearFilters, child: const Text('Effacer les filtres'), ), ), ], ], ), ); } } final class ProgramFormScreen extends StatefulWidget { const ProgramFormScreen({ required this.programUseCases, required this.exerciseUseCases, this.shareUseCases, this.authUseCases, this.syncUseCases, this.program, this.tagSuggestions = const [], this.initialSnackBar, super.key, }); final ProgramUseCases programUseCases; final ExerciseUseCases exerciseUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final Program? program; final List tagSuggestions; final String? initialSnackBar; @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; late List _tags; Timer? _addedSnackBarTimer; 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), ]; _tags = List.of(program?.tags ?? const []); final initialSnackBar = widget.initialSnackBar; if (initialSnackBar != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showSnackBar(initialSnackBar); }); } } @override void dispose() { _addedSnackBarTimer?.cancel(); _nameController.dispose(); _defaultRestController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final canSave = !_saving && _exercises.isNotEmpty; return Scaffold( appBar: AppBar( title: Text( _isEditing ? 'Modifier le programme' : 'Créer un programme', ), actions: [ if (_isEditing && widget.shareUseCases != null) IconButton( tooltip: 'Partager', icon: const Icon(Icons.ios_share), onPressed: _shareProgram, ), ], ), bottomNavigationBar: SafeArea( minimum: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FilledButton.icon( onPressed: canSave ? _save : null, 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), EditableTagsSection( tags: _tags, suggestions: widget.tagSuggestions, onChanged: (tags) => setState(() => _tags = tags), ), 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 au moins un exercice pour enregistrer ce programme.', ) : ReorderableListView.builder( buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _exercises.length, onReorderItem: _reorderExercise, itemBuilder: (context, index) { final exercise = _exercises[index]; return _ProgramExerciseCard( key: ValueKey(exercise.key), draft: exercise, defaultRestSeconds: _defaultRestSeconds, onCustomize: () => _customizeExercise(exercise.key), 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; final draft = _ProgramExerciseDraft.fromExercise( exercise, restSecondsOverride: _defaultRestSeconds, ); setState(() { _exercises.add(draft); }); final messenger = ScaffoldMessenger.of(context); _addedSnackBarTimer?.cancel(); _addedSnackBarTimer = Timer(const Duration(seconds: 3), () { if (mounted) { messenger.hideCurrentSnackBar(); } }); messenger.showSnackBar( SnackBar( duration: const Duration(seconds: 3), content: const Text('Exercice ajouté'), action: SnackBarAction( label: 'Personnaliser', onPressed: () { _addedSnackBarTimer?.cancel(); _addedSnackBarTimer = null; messenger.hideCurrentSnackBar(); _customizeExercise(draft.key); }, ), ), ); } Future _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!; } }); } 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 (_exercises.isEmpty) { return; } if (!_formKey.currentState!.validate()) { return; } setState(() => _saving = true); try { await widget.programUseCases.saveConfigured( id: widget.program?.metadata.id, name: _nameController.text.trim(), defaultRestSeconds: _defaultRestSeconds, tags: _tags, 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); } } } Future _shareProgram() async { final program = widget.program; final shareUseCases = widget.shareUseCases; if (program == null || shareUseCases == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ShareFormScreen( shareUseCases: shareUseCases, authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, resourceType: ShareResourceType.program, localResourceId: program.metadata.id, resourceName: program.name, resourceSummary: _programSummary(program), ), ), ); } 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.onCustomize, required this.index, super.key, }); final _ProgramExerciseDraft draft; final int defaultRestSeconds; final VoidCallback onCustomize; final int index; @override Widget build(BuildContext context) { return Card( 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: [ 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, }) : _initialDraft = draft; final _ProgramExerciseDraft _initialDraft; final int defaultRestSeconds; @override State<_ProgramExerciseCustomizationScreen> createState() => _ProgramExerciseCustomizationScreenState(); } final class _ProgramExerciseCustomizationScreenState extends State<_ProgramExerciseCustomizationScreen> { final _formKey = GlobalKey(); 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; final hasStopwatchScore = _draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch; final scoreTitle = hasStopwatchScore ? 'Score chrono' : scoreUnit == null ? 'Score' : 'Score ($scoreUnit)'; final autoStartNextTimedStepCustomized = _draft.autoStartNextTimedStepOverride != null; final autoStartNextTimedStepStateLabel = autoStartNextTimedStepCustomized ? 'Personnalisé pour ce programme' : "Réglage de l'exercice"; 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, widget.defaultRestSeconds)), const SizedBox(height: 24), Text('Séries', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), TextFormField( controller: _setsController, decoration: const InputDecoration(labelText: 'Nombre de séries'), keyboardType: TextInputType.number, validator: _positiveIntValidator, ), const SizedBox(height: 24), Text( 'Mesures à suivre', style: Theme.of(context).textTheme.titleMedium, ), if (_draft.availableTimeSnapshot) SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Temps'), value: _draft.enabledMeasures.contains(WorkoutMeasure.time), onChanged: (value) => _toggle(WorkoutMeasure.time, value), ), if (_draft.availableRepsSnapshot) SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Répétitions'), value: _draft.enabledMeasures.contains(WorkoutMeasure.reps), onChanged: (value) => _toggle(WorkoutMeasure.reps, value), ), if (_draft.availableScoreSnapshot) SwitchListTile( contentPadding: EdgeInsets.zero, title: Text(scoreTitle), value: _draft.enabledMeasures.contains(WorkoutMeasure.score), onChanged: (value) => _toggle(WorkoutMeasure.score, value), ), if (_draft.enabledMeasures.contains(WorkoutMeasure.time) && _draft.enabledMeasures.contains(WorkoutMeasure.score) && hasStopwatchScore) ...[ const SizedBox(height: 8), const Text( "Temps sert d'objectif de durée ; Score chrono enregistre " 'le temps réalisé.', ), ], 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(), ), 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)) if (hasStopwatchScore) _NumberField( label: 'Objectif de chrono', helperText: 'Le résultat réel sera mesuré pendant la série.', initialValue: _millisecondsToSeconds( _draft.targetScoreTimeMs, ), onChanged: (value) { _draft.targetScore = null; _draft.targetScoreTimeMs = value == null ? null : (value * 1000).round(); }, ) else _NumberField( label: scoreUnit == null ? 'Cible score' : 'Cible score ($scoreUnit)', initialValue: _draft.targetScore, onChanged: (value) { _draft.targetScore = value; _draft.targetScoreTimeMs = null; }, ), if (_draft.exerciseStepsSnapshot.isNotEmpty) ...[ const SizedBox(height: 24), Text('Séquence', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text( 'Enchaîner automatiquement les chronos consécutifs', ), subtitle: Text( '$autoStartNextTimedStepStateLabel\n' '${_autoStartHelpText(_draft.autoStartNextTimedStepEffective)}', ), value: _draft.autoStartNextTimedStepEffective, onChanged: (value) { setState(() { _draft.autoStartNextTimedStepOverride = value; }); }, ), if (autoStartNextTimedStepCustomized) TextButton( onPressed: () { setState(() { _draft.autoStartNextTimedStepOverride = null; }); }, child: const Text("Revenir au réglage de l'exercice"), ), ], const SizedBox(height: 24), Text('Repos', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), TextFormField( 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, validator: _positiveIntValidator, ), ], ), ), ); } void _toggle(WorkoutMeasure measure, bool value) { setState(() { _measureError = null; if (value) { _draft.enabledMeasures = {..._draft.enabledMeasures, measure}; } else { _draft.enabledMeasures = {..._draft.enabledMeasures}..remove(measure); if (measure == WorkoutMeasure.score) { _draft.targetScore = null; _draft.targetScoreTimeMs = null; } } }); } 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 _confirmDelete() async { final confirmed = await showDialog( 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 { const _NumberField({ required this.label, required this.initialValue, required this.onChanged, this.helperText, }); final String label; final num? initialValue; final ValueChanged onChanged; final String? helperText; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8), child: TextFormField( initialValue: initialValue?.toString(), decoration: InputDecoration(labelText: label, helperText: helperText), 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.exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, this.exerciseStepsSnapshot = const [], this.autoStartNextTimedStepSnapshot = true, this.autoStartNextTimedStepOverride, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, required this.availableRepsSnapshot, required this.availableScoreSnapshot, this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, required this.enabledMeasures, this.targetTimeSeconds, this.targetReps, this.targetScore, this.targetScoreTimeMs, this.restSecondsOverride, }); factory _ProgramExerciseDraft.fromExercise( Exercise exercise, { int? restSecondsOverride, }) { return _ProgramExerciseDraft( key: UniqueKey().toString(), sourceExerciseId: exercise.metadata.id, exerciseNameSnapshot: exercise.name, exerciseDescriptionSnapshot: exercise.description, exerciseImageMediaIdSnapshot: exercise.iconMediaId ?? exercise.imageMediaId, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseStepsSnapshot: exercise.steps, autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStep, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, availableRepsSnapshot: exercise.hasRepsMeasure, availableScoreSnapshot: exercise.hasScoreMeasure, scoreInputModeSnapshot: exercise.scoreInputMode, scoreLabelSnapshot: exercise.scoreLabel, scoreUnitSnapshot: exercise.scoreUnit, setsCount: 3, enabledMeasures: exercise.availableMeasures, targetTimeSeconds: exercise.defaultTargetTimeSeconds, targetReps: exercise.defaultTargetReps, targetScore: exercise.scoreInputMode == ScoreInputMode.manual ? exercise.defaultTargetScore : null, targetScoreTimeMs: exercise.scoreInputMode == ScoreInputMode.stopwatch ? exercise.defaultTargetScoreTimeMs : null, restSecondsOverride: restSecondsOverride, ); } 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, exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, exerciseStepsSnapshot: exercise.exerciseStepsSnapshot, autoStartNextTimedStepSnapshot: exercise.autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride, exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot, availableTimeSnapshot: exercise.availableTimeSnapshot, availableRepsSnapshot: exercise.availableRepsSnapshot, availableScoreSnapshot: exercise.availableScoreSnapshot, scoreInputModeSnapshot: exercise.scoreInputModeSnapshot, 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, targetScoreTimeMs: exercise.targetScoreTimeMs, restSecondsOverride: exercise.restSecondsOverride, ); } final String key; final EntityMetadata? existingMetadata; final String? sourceExerciseId; final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final List exerciseStepsSnapshot; final bool autoStartNextTimedStepSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; final bool availableRepsSnapshot; final bool availableScoreSnapshot; final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; int setsCount; Set enabledMeasures; int? targetTimeSeconds; int? targetReps; double? targetScore; int? targetScoreTimeMs; int? restSecondsOverride; bool? autoStartNextTimedStepOverride; bool get autoStartNextTimedStepEffective { return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot; } _ProgramExerciseDraft copy() { return _ProgramExerciseDraft( key: key, existingMetadata: existingMetadata, sourceExerciseId: sourceExerciseId, exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseStepsSnapshot: exerciseStepsSnapshot, autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: autoStartNextTimedStepOverride, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, availableRepsSnapshot: availableRepsSnapshot, availableScoreSnapshot: availableScoreSnapshot, scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, setsCount: setsCount, enabledMeasures: {...enabledMeasures}, targetTimeSeconds: targetTimeSeconds, targetReps: targetReps, targetScore: targetScore, targetScoreTimeMs: targetScoreTimeMs, restSecondsOverride: restSecondsOverride, ); } ProgramExerciseConfig toConfig() { final scoreEnabled = enabledMeasures.contains(WorkoutMeasure.score); final stopwatchScore = scoreInputModeSnapshot == ScoreInputMode.stopwatch; return ProgramExerciseConfig( existingMetadata: existingMetadata, sourceExerciseId: sourceExerciseId, exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseStepsSnapshot: exerciseStepsSnapshot, autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: autoStartNextTimedStepOverride, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, availableRepsSnapshot: availableRepsSnapshot, availableScoreSnapshot: availableScoreSnapshot, scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, setsCount: setsCount, enabledMeasures: enabledMeasures, targetTimeSeconds: targetTimeSeconds, targetReps: targetReps, targetScore: scoreEnabled && !stopwatchScore ? targetScore : null, targetScoreTimeMs: scoreEnabled && stopwatchScore ? targetScoreTimeMs : null, restSecondsOverride: restSecondsOverride, ); } } String _autoStartHelpText(bool active) { return active ? 'Le chrono suivant démarre dès que le précédent arrive à 0.' : "L'app attendra ton démarrage avant de lancer le chrono suivant."; } double? _millisecondsToSeconds(int? milliseconds) { return milliseconds == null ? null : milliseconds / 1000; } 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 && draft.scoreInputModeSnapshot == ScoreInputMode.stopwatch) { return 'Score chrono'; } 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 _restLabel(_ProgramExerciseDraft draft, int defaultRestSeconds) { final rest = draft.restSecondsOverride ?? defaultRestSeconds; return '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: SingleChildScrollView( 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!)), ], ], ), ), ); } }