import 'dart:convert'; import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; import 'example_badge.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.shareUseCases, this.authUseCases, this.syncUseCases, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; final MediaUseCases? mediaUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; @override State createState() => _WorkoutTemplateListScreenState(); } final class _WorkoutTemplateListScreenState extends State { late Future> _templates; @override void initState() { super.initState(); _templates = widget.workoutTemplateUseCases.listActive(); } @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 []; 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(), ); } return ListView.separated( itemCount: templates.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final template = templates[index]; return ListTile( title: Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(template.name), if (template.isExample) const ExampleBadge(), ], ), subtitle: Text(_templateSummary(template)), trailing: Wrap( spacing: 8, children: [ IconButton( tooltip: 'Supprimer la séance', icon: const Icon(Icons.delete_outline), onPressed: () => _confirmDelete(template), ), TextButton( onPressed: () => _start(template), child: const Text('Lancer'), ), const Icon(Icons.chevron_right), ], ), onTap: () => _openForm(template: template), ); }, ); }, ), ); } void _reload() { setState(() { _templates = widget.workoutTemplateUseCases.listActive(); }); } Future _openForm({WorkoutTemplate? template}) async { 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, ), ), ); if (changed == true) { if (!mounted) return; _reload(); } } Future _start(WorkoutTemplate template) async { final openSession = await widget.activeUseCases.findOpen(); if (!mounted) return; if (openSession != null) { 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 session = await widget.activeUseCases.startFromTemplate( template.metadata.id, ); 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, mediaUseCases: widget.mediaUseCases, ), ), ); } 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))); } } final class WorkoutTemplateFormScreen extends StatefulWidget { const WorkoutTemplateFormScreen({ required this.workoutTemplateUseCases, required this.programUseCases, this.shareUseCases, this.authUseCases, this.syncUseCases, this.template, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; final WorkoutTemplate? template; @override State createState() => _WorkoutTemplateFormScreenState(); } final class _WorkoutTemplateFormScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final List _programs; 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 [], ), ]; } @override void dispose() { _nameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { 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: 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), 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 un programme pour composer la séance.', ) : ReorderableListView.builder( buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), itemCount: _programs.length, onReorder: _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(() { if (newIndex > oldIndex) { newIndex -= 1; } 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 (!_formKey.currentState!.validate()) { return; } setState(() => _saving = true); try { await widget.workoutTemplateUseCases.saveConfigured( id: widget.template?.metadata.id, name: _nameController.text.trim(), 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?, ); }).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 = template.programs.fold( 0, (total, program) => total + _exerciseCount(program.programSnapshotJson), ); return '$programCount programme${programCount > 1 ? 's' : ''} · ' '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}'; } 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) { final snapshot = jsonDecode(programSnapshotJson) as Map; return (snapshot['exercises'] as List? ?? const []).length; } 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!)), ], ], ), ), ); } }