import 'dart:async'; 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; 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), ]; } @override void dispose() { _addedSnackBarTimer?.cancel(); _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, 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 (!_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.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, super.key, }) : _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)'; 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; }, ), 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.exerciseVideoMediaIdSnapshot, 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.imageMediaId, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, 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, 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, exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, 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 String? exerciseVideoMediaIdSnapshot; 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; _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, 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, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, 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, ); } } 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: 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!)), ], ], ), ), ); } }