import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.dart'; import 'library_tags.dart'; import 'share_screen.dart'; import 'workout_execution_screen.dart'; final class WorkoutTemplateListScreen extends StatefulWidget { const WorkoutTemplateListScreen({ required this.workoutTemplateUseCases, required this.programUseCases, required this.activeUseCases, required this.closeUseCase, required this.historyUseCases, this.mediaUseCases, this.stepUseCases, this.sensorUseCases, this.telemetryUseCases, this.performanceReferenceUseCase, this.shareUseCases, this.authUseCases, this.syncUseCases, this.watchAlertPublisher, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases; final WorkoutTelemetryUseCases? telemetryUseCases; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; final MediaUseCases? mediaUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final WatchAlertPublisher? watchAlertPublisher; @override State createState() => _WorkoutTemplateListScreenState(); } final class _WorkoutTemplateListScreenState extends State { final _searchController = TextEditingController(); var _selectedTags = {}; late Future> _templates; @override void initState() { super.initState(); _templates = widget.workoutTemplateUseCases.listActive(); _searchController.addListener(() => setState(() {})); } @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Séances')), floatingActionButton: FloatingActionButton.extended( onPressed: () => _openForm(), icon: const Icon(Icons.add), label: const Text('Créer'), ), body: FutureBuilder>( future: _templates, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final templates = snapshot.data ?? const []; final tagSuggestions = tagSuggestionsFor( templates, (template) => template.tags, ).map((usage) => usage.tag).toList(); if (templates.isEmpty) { return _CenteredMessage( title: 'Aucune séance', message: 'Crée une séance-modèle pour lancer ton entraînement plus vite.', actionLabel: 'Créer une séance', onAction: () => _openForm(), ); } final filtered = _filter(templates); return ListView.separated( itemCount: filtered.isEmpty ? 2 : filtered.length + 1, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) { return _WorkoutTemplateFilters( controller: _searchController, tagSuggestions: tagSuggestions, selectedTags: _selectedTags, filtersActive: _filtersActive, onToggleTag: _toggleTag, onClearFilters: _clearFilters, ); } if (filtered.isEmpty) { return _CenteredMessage( title: 'Aucune séance ne correspond', message: 'Modifie la recherche ou les tags sélectionnés.', actionLabel: 'Effacer les filtres', onAction: _clearFilters, ); } final template = filtered[index - 1]; return ListTile( title: Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(template.name), if (template.isExample) const ExampleBadge(), ], ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(_templateSummary(template)), if (template.tags.isNotEmpty) ...[ const SizedBox(height: 6), TagBadgeWrap(tags: template.tags), ], ], ), trailing: Wrap( spacing: 8, children: [ TextButton( onPressed: () => _start(template), child: const Text('Lancer'), ), PopupMenuButton<_WorkoutTemplateItemAction>( tooltip: 'Actions', onSelected: (action) { switch (action) { case _WorkoutTemplateItemAction.duplicate: unawaited(_duplicate(template)); case _WorkoutTemplateItemAction.delete: unawaited(_confirmDelete(template)); } }, itemBuilder: (context) => const [ PopupMenuItem( value: _WorkoutTemplateItemAction.duplicate, child: Text('Dupliquer'), ), PopupMenuItem( value: _WorkoutTemplateItemAction.delete, child: Text('Supprimer'), ), ], ), const Icon(Icons.chevron_right), ], ), onTap: () => _openForm(template: template), ); }, ); }, ), ); } void _reload() { setState(() { _templates = widget.workoutTemplateUseCases.listActive(); }); } List _filter(List templates) { final query = _searchController.text.trim().toLowerCase(); final matchingQuery = templates.where((template) { return query.isEmpty || template.name.toLowerCase().contains(query) || _templateSummary(template).toLowerCase().contains(query); }).toList(); return filterByRequiredTags( matchingQuery, _selectedTags, (template) => template.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({ WorkoutTemplate? template, String? initialSnackBar, }) async { final templates = await _templates; if (!mounted) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutTemplateFormScreen( workoutTemplateUseCases: widget.workoutTemplateUseCases, programUseCases: widget.programUseCases, shareUseCases: widget.shareUseCases, authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, template: template, tagSuggestions: tagSuggestionsFor( templates, (template) => template.tags, ).map((usage) => usage.tag).toList(), initialSnackBar: initialSnackBar, ), ), ); if (changed == true) { if (!mounted) return; _reload(); } } Future _duplicate(WorkoutTemplate template) async { try { final copy = await widget.workoutTemplateUseCases.duplicate( template.metadata.id, ); if (!mounted) return; await _openForm( template: copy, initialSnackBar: 'Séance dupliquée. Ajuste la copie avant de la lancer.', ); if (!mounted) return; _reload(); } on Exception { _showSnackBar('Duplication impossible pour le moment.'); } } Future _start(WorkoutTemplate template) async { if (_templateExerciseCount(template) == 0) { _showSnackBar( 'Cette séance ne contient aucun exercice. Ajoute un programme avec ' 'au moins un exercice avant de la lancer.', ); return; } final hasBlockingOpenSession = await _hasBlockingOpenSession(); if (!mounted) return; if (hasBlockingOpenSession) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Une séance est déjà en cours. Termine-la ou reprends-la avant ' 'd’en lancer une nouvelle.', ), ), ); return; } final ActiveWorkoutSession session; try { session = await widget.activeUseCases.startFromTemplate( template.metadata.id, ); } on DomainException { _showSnackBar( 'Cette séance ne contient aucun exercice. Ajoute un programme avec ' 'au moins un exercice avant de la lancer.', ); return; } if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, performanceReferenceUseCase: widget.performanceReferenceUseCase, mediaUseCases: widget.mediaUseCases, sensorUseCases: widget.sensorUseCases, telemetryUseCases: widget.telemetryUseCases, watchAlertPublisher: widget.watchAlertPublisher, ), ), ); } Future _hasBlockingOpenSession() async { final openSession = await widget.activeUseCases.findOpen(); if (openSession == null) { return false; } if (WorkoutExecutionPlan.tryFromSession(openSession) != null) { return true; } try { await widget.activeUseCases.abandon(openSession.metadata.id); } on DomainException { // Legacy invalid sessions must not block a new start. } return false; } Future _confirmDelete(WorkoutTemplate template) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('Supprimer ${template.name} ?'), content: const Text('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.workoutTemplateUseCases.delete(template.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 _WorkoutTemplateItemAction { duplicate, delete } final class _WorkoutTemplateFilters extends StatelessWidget { const _WorkoutTemplateFilters({ 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 WorkoutTemplateFormScreen extends StatefulWidget { const WorkoutTemplateFormScreen({ required this.workoutTemplateUseCases, required this.programUseCases, this.shareUseCases, this.authUseCases, this.syncUseCases, this.template, this.tagSuggestions = const [], this.initialSnackBar, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final WorkoutTemplate? template; final List tagSuggestions; final String? initialSnackBar; @override State createState() => _WorkoutTemplateFormScreenState(); } final class _WorkoutTemplateFormScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final List _programs; late List _tags; var _saving = false; bool get _isEditing => widget.template != null; @override void initState() { super.initState(); final template = widget.template; _nameController = TextEditingController(text: template?.name); _programs = [ for (final program in template?.programs ?? const []) WorkoutTemplateProgramDraft.fromTemplateProgram( program, template?.overrides ?? const [], ), ]; _tags = List.of(template?.tags ?? const []); final initialSnackBar = widget.initialSnackBar; if (initialSnackBar != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showSnackBar(initialSnackBar); }); } } @override void dispose() { _nameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final canSave = !_saving && _programs.isNotEmpty; return Scaffold( appBar: AppBar( title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'), actions: [ if (_isEditing && widget.shareUseCases != null) IconButton( tooltip: 'Partager', icon: const Icon(Icons.ios_share), onPressed: _shareTemplate, ), ], ), 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), EditableTagsSection( tags: _tags, suggestions: widget.tagSuggestions, onChanged: (tags) => setState(() => _tags = tags), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _addProgram, icon: const Icon(Icons.add), label: const Text('Ajouter un programme'), ), ), ], ), ), ), Expanded( child: _programs.isEmpty ? const _CenteredMessage( title: 'Aucun programme ajouté', message: 'Ajoute au moins un programme pour enregistrer cette séance.', ) : ReorderableListView.builder( buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _programs.length, onReorderItem: _reorderProgram, itemBuilder: (context, index) { final program = _programs[index]; return Card( key: ValueKey(program.clientKey), child: ListTile( leading: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_handle), ), title: Text(program.programNameSnapshot), subtitle: Text(_programDraftSummary(program)), trailing: const Icon(Icons.chevron_right), onTap: () => _openProgramDetail(program), ), ); }, ), ), ], ), ); } Future _addProgram() async { final program = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ProgramSelectionScreen(programUseCases: widget.programUseCases), ), ); if (program == null) return; if (!mounted) return; final shouldExplainSnapshot = _programs.isEmpty; setState(() { _programs.add(WorkoutTemplateProgramDraft.fromProgram(program)); }); if (shouldExplainSnapshot) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Une copie de ce programme sera enregistrée dans la séance. ' 'Les futures modifications du programme original ne changeront ' 'pas cette séance.', ), ), ); } } void _reorderProgram(int oldIndex, int newIndex) { setState(() { final item = _programs.removeAt(oldIndex); _programs.insert(newIndex, item); }); } Future _openProgramDetail(WorkoutTemplateProgramDraft program) async { await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutTemplateProgramDetailScreen(program: program), ), ); if (!mounted) return; setState(() {}); } Future _save() async { if (_programs.isEmpty) { return; } if (!_formKey.currentState!.validate()) { return; } setState(() => _saving = true); try { await widget.workoutTemplateUseCases.saveConfigured( id: widget.template?.metadata.id, name: _nameController.text.trim(), tags: _tags, programs: _programs.map((program) => program.toConfig()).toList(), overrides: _programs .expand((program) => program.toOverrides()) .toList(), ); if (mounted) { Navigator.of(context).pop(true); } } on Exception catch (error) { _showSnackBar(error.toString()); } finally { if (mounted) { setState(() => _saving = false); } } } Future _shareTemplate() async { final template = widget.template; final shareUseCases = widget.shareUseCases; if (template == null || shareUseCases == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => ShareFormScreen( shareUseCases: shareUseCases, authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, resourceType: ShareResourceType.workoutTemplate, localResourceId: template.metadata.id, resourceName: template.name, resourceSummary: _templateSummary(template), ), ), ); } void _showSnackBar(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } } final class ProgramSelectionScreen extends StatelessWidget { const ProgramSelectionScreen({required this.programUseCases, super.key}); final ProgramUseCases programUseCases; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Ajouter un programme')), body: FutureBuilder>( future: programUseCases.listActive(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final programs = snapshot.data ?? const []; if (programs.isEmpty) { return const _CenteredMessage( title: 'Aucun programme disponible', message: 'Crée un programme avant de composer une séance.', ); } 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)), onTap: () => Navigator.of(context).pop(program), ); }, ); }, ), ); } } final class WorkoutTemplateProgramDetailScreen extends StatefulWidget { const WorkoutTemplateProgramDetailScreen({required this.program, super.key}); final WorkoutTemplateProgramDraft program; @override State createState() => _WorkoutTemplateProgramDetailScreenState(); } final class _WorkoutTemplateProgramDetailScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.program.programNameSnapshot)), body: ListView.builder( padding: const EdgeInsets.all(16), itemCount: widget.program.exercises.length, itemBuilder: (context, index) { final exercise = widget.program.exercises[index]; final autoStartNextTimedStepCustomized = exercise.autoStartNextTimedStepOverride != null; final autoStartNextTimedStepStateLabel = autoStartNextTimedStepCustomized ? 'Personnalisé pour cette séance' : 'Réglage du programme'; return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( exercise.exerciseNameSnapshot, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text(_exerciseDraftSummary(exercise)), const SizedBox(height: 12), TextFormField( initialValue: '${exercise.setsCountOverride ?? exercise.setsCount}', decoration: const InputDecoration( labelText: 'Nombre de séries', ), keyboardType: TextInputType.number, onChanged: (value) { setState(() { exercise.setsCountOverride = int.tryParse(value); }); }, ), if (exercise.timeEnabled) _NumberField( label: 'Cible temps (s)', initialValue: exercise.targetTimeSecondsOverride ?? exercise.targetTimeSeconds, onChanged: (value) { setState(() { exercise.targetTimeSecondsOverride = value?.round(); }); }, ), if (exercise.repsEnabled) _NumberField( label: 'Cible répétitions', initialValue: exercise.targetRepsOverride ?? exercise.targetReps, onChanged: (value) { setState(() { exercise.targetRepsOverride = value?.round(); }); }, ), if (exercise.scoreEnabled) _NumberField( label: exercise.scoreUnitSnapshot == null ? 'Cible score' : 'Cible score (${exercise.scoreUnitSnapshot})', initialValue: exercise.targetScoreOverride ?? exercise.targetScore, onChanged: (value) { setState(() { exercise.targetScoreOverride = value; }); }, ), if (exercise.exerciseStepsSnapshot.isNotEmpty) ...[ const SizedBox(height: 16), Text( 'Séquence', style: Theme.of(context).textTheme.titleMedium, ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text( 'Enchaîner automatiquement les chronos consécutifs', ), subtitle: Text( '$autoStartNextTimedStepStateLabel\n' '${_autoStartHelpText(exercise.autoStartNextTimedStepEffective)}', ), value: exercise.autoStartNextTimedStepEffective, onChanged: (value) { setState(() { exercise.autoStartNextTimedStepOverride = value; }); }, ), if (autoStartNextTimedStepCustomized) TextButton( onPressed: () { setState(() { exercise.autoStartNextTimedStepOverride = null; }); }, child: const Text('Revenir au réglage du programme'), ), ], ], ), ), ); }, ), ); } } final class WorkoutTemplateProgramDraft { WorkoutTemplateProgramDraft({ required this.clientKey, this.existingMetadata, this.sourceProgramId, required this.programNameSnapshot, required this.defaultRestSecondsSnapshot, required this.programSnapshotJson, required this.exercises, }); factory WorkoutTemplateProgramDraft.fromProgram(Program program) { final snapshot = { 'programId': program.metadata.id, 'name': program.name, 'defaultRestSeconds': program.defaultRestSeconds, 'exercises': program.exercises .map((exercise) => exercise.toSnapshotJson()) .toList(), }; return WorkoutTemplateProgramDraft( clientKey: UniqueKey().toString(), sourceProgramId: program.metadata.id, programNameSnapshot: program.name, defaultRestSecondsSnapshot: program.defaultRestSeconds, programSnapshotJson: jsonEncode(snapshot), exercises: _parseExercises(snapshot), ); } factory WorkoutTemplateProgramDraft.fromTemplateProgram( WorkoutTemplateProgram program, List overrides, ) { final snapshot = jsonDecode(program.programSnapshotJson) as Map; final exercises = _parseExercises(snapshot); for (final exercise in exercises) { for (final override in overrides) { if (override.workoutTemplateProgramId == program.metadata.id && override.snapshotProgramExerciseId == exercise.snapshotExerciseId) { exercise.existingOverrideMetadata = override.metadata; exercise.setsCountOverride = override.setsCountOverride; exercise.targetTimeSecondsOverride = override.targetTimeSecondsOverride; exercise.targetRepsOverride = override.targetRepsOverride; exercise.targetScoreOverride = override.targetScoreOverride; exercise.autoStartNextTimedStepOverride = override.autoStartNextTimedStepOverride; } } } return WorkoutTemplateProgramDraft( clientKey: program.metadata.id, existingMetadata: program.metadata, sourceProgramId: program.sourceProgramId, programNameSnapshot: program.programNameSnapshot, defaultRestSecondsSnapshot: program.defaultRestSecondsSnapshot, programSnapshotJson: program.programSnapshotJson, exercises: exercises, ); } final String clientKey; final EntityMetadata? existingMetadata; final String? sourceProgramId; final String programNameSnapshot; final int defaultRestSecondsSnapshot; final String programSnapshotJson; final List exercises; WorkoutTemplateProgramConfig toConfig() { return WorkoutTemplateProgramConfig( clientKey: clientKey, existingMetadata: existingMetadata, sourceProgramId: sourceProgramId, programNameSnapshot: programNameSnapshot, defaultRestSecondsSnapshot: defaultRestSecondsSnapshot, programSnapshotJson: programSnapshotJson, ); } Iterable toOverrides() sync* { for (final exercise in exercises) { if (!exercise.hasOverride) { continue; } yield WorkoutTemplateExerciseOverrideConfig( existingMetadata: exercise.existingOverrideMetadata, workoutTemplateProgramClientKey: clientKey, snapshotProgramExerciseId: exercise.snapshotExerciseId, setsCountOverride: exercise.setsCountOverride, targetTimeSecondsOverride: exercise.targetTimeSecondsOverride, targetRepsOverride: exercise.targetRepsOverride, targetScoreOverride: exercise.targetScoreOverride, autoStartNextTimedStepOverride: exercise.autoStartNextTimedStepOverride, ); } } } final class WorkoutTemplateExerciseDraft { WorkoutTemplateExerciseDraft({ required this.snapshotExerciseId, required this.exerciseNameSnapshot, required this.setsCount, required this.timeEnabled, required this.repsEnabled, required this.scoreEnabled, this.exerciseStepsSnapshot = const [], this.autoStartNextTimedStepSnapshot = true, this.scoreUnitSnapshot, this.targetTimeSeconds, this.targetReps, this.targetScore, this.existingOverrideMetadata, this.setsCountOverride, this.targetTimeSecondsOverride, this.targetRepsOverride, this.targetScoreOverride, this.autoStartNextTimedStepOverride, }); final String snapshotExerciseId; final String exerciseNameSnapshot; final int setsCount; final bool timeEnabled; final bool repsEnabled; final bool scoreEnabled; final List exerciseStepsSnapshot; final bool autoStartNextTimedStepSnapshot; final String? scoreUnitSnapshot; final int? targetTimeSeconds; final int? targetReps; final double? targetScore; EntityMetadata? existingOverrideMetadata; int? setsCountOverride; int? targetTimeSecondsOverride; int? targetRepsOverride; double? targetScoreOverride; bool? autoStartNextTimedStepOverride; bool get autoStartNextTimedStepEffective { return autoStartNextTimedStepOverride ?? autoStartNextTimedStepSnapshot; } bool get hasOverride { return setsCountOverride != null || targetTimeSecondsOverride != null || targetRepsOverride != null || targetScoreOverride != null || autoStartNextTimedStepOverride != null; } } 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)), ), ); } } List _parseExercises( Map snapshot, ) { final rawExercises = snapshot['exercises'] as List? ?? const []; return rawExercises.map((raw) { final exercise = raw as Map; final autoStartNextTimedStepSnapshot = (exercise['autoStartNextTimedStepOverride'] as bool?) ?? (exercise['autoStartNextTimedStepSnapshot'] as bool?) ?? true; return WorkoutTemplateExerciseDraft( snapshotExerciseId: exercise['id'] as String, exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String, setsCount: exercise['setsCount'] as int, timeEnabled: exercise['timeEnabled'] as bool, repsEnabled: exercise['repsEnabled'] as bool, scoreEnabled: exercise['scoreEnabled'] as bool, exerciseStepsSnapshot: _parseExerciseSteps( exercise['exerciseStepsSnapshot'], ), autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, targetTimeSeconds: exercise['targetTimeSeconds'] as int?, targetReps: exercise['targetReps'] as int?, targetScore: (exercise['targetScore'] as num?)?.toDouble(), ); }).toList(); } List _parseExerciseSteps(Object? value) { final rawSteps = value as List? ?? const []; return rawSteps.map((raw) { final step = raw as Map; return ExerciseStep( id: step['id'] as String, position: step['position'] as int, name: step['name'] as String, type: ExerciseStepType.values.byName(step['type'] as String), defaultTargetValue: step['defaultTargetValue'] as int, hasScore: step['hasScore'] as bool? ?? false, scoreInputMode: ScoreInputMode.values.byName( step['scoreInputMode'] as String? ?? ScoreInputMode.manual.name, ), scoreLabel: step['scoreLabel'] as String?, scoreUnit: step['scoreUnit'] as String?, defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, linkedToSeriesScore: step['linkedToSeriesScore'] == true, ); }).toList(); } 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."; } String _templateSummary(WorkoutTemplate template) { final programCount = template.programs.length; final exerciseCount = _templateExerciseCount(template); return '$programCount programme${programCount > 1 ? 's' : ''} · ' '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}'; } int _templateExerciseCount(WorkoutTemplate template) { return template.programs.fold( 0, (total, program) => total + _exerciseCount(program.programSnapshotJson), ); } String _programDraftSummary(WorkoutTemplateProgramDraft program) { final exerciseCount = program.exercises.length; return '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}'; } 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 _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) { final measures = [ if (exercise.timeEnabled) 'Temps', if (exercise.repsEnabled) 'Répétitions', if (exercise.scoreEnabled) exercise.scoreUnitSnapshot == null ? 'Score' : 'Score (${exercise.scoreUnitSnapshot})', ].join(' + '); final sets = exercise.setsCountOverride ?? exercise.setsCount; return '$sets séries · $measures'; } int _exerciseCount(String programSnapshotJson) { try { final snapshot = jsonDecode(programSnapshotJson) as Map; return (snapshot['exercises'] as List? ?? const []).length; } on Exception { return 0; } } 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!)), ], ], ), ), ); } }