Files
GameTime/lib/presentation/exercise_library_screen.dart
Blomios e05405157a feat(bibliothèque): badge Exemple, catégories et états vides bibliothèque (ticket #80)
Ajoute le badge "Exemple" (example_badge.dart) affiché sur les
exercices/programmes/séances-modèles seedés, le filtrage par catégorie
dans la bibliothèque d'exercices et les états vides dédiés. Préserve
isExample lors de l'édition d'un programme ou d'une séance-modèle
existants (ProgramUseCases/WorkoutTemplateUseCases).

Validé GO par Main : dart analyze propre, 173 tests (4 échecs
préexistants sans rapport avec ce lot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:39:26 +02:00

1907 lines
62 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'example_badge.dart';
import 'theme.dart';
abstract interface class MediaSourcePicker {
Future<String?> pickPath(MediaKind kind);
}
final class ImagePickerMediaSourcePicker implements MediaSourcePicker {
const ImagePickerMediaSourcePicker();
@override
Future<String?> pickPath(MediaKind kind) async {
final picker = ImagePicker();
final file = switch (kind) {
MediaKind.image => await picker.pickImage(source: ImageSource.gallery),
MediaKind.video => await picker.pickVideo(source: ImageSource.gallery),
};
return file?.path;
}
}
final class ExerciseLibraryScreen extends StatefulWidget {
const ExerciseLibraryScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
@override
State<ExerciseLibraryScreen> createState() => _ExerciseLibraryScreenState();
}
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
final _searchController = TextEditingController();
var _selectedMeasures = <WorkoutMeasure>{};
late Future<List<Exercise>> _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('Exercices')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<Exercise>>(
future: _exercises,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _CenteredMessage(
title: 'Impossible de charger les exercices',
actionLabel: 'Réessayer',
onAction: _reload,
);
}
final exercises = snapshot.data ?? const <Exercise>[];
final filtered = _filter(exercises);
return RefreshIndicator(
onRefresh: () {
_reload();
return Future<void>.value();
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(measure.label),
selected: _selectedMeasures.contains(measure),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedMeasures = {
..._selectedMeasures,
measure,
};
} else {
_selectedMeasures = {..._selectedMeasures}
..remove(measure);
}
});
},
);
}).toList(),
),
],
),
),
),
if (exercises.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _CenteredMessage(
title: 'Aucun exercice',
message:
'Crée ton premier exercice ou restaure les exemples.',
actionLabel: 'Créer un exercice',
onAction: () => _openForm(),
),
)
else if (filtered.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: _CenteredMessage(
title: 'Aucun exercice ne correspond',
message:
'Modifie la recherche ou les filtres de mesures.',
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index.isOdd) {
return const Divider(height: 1, indent: 72);
}
final exercise = filtered[index ~/ 2];
return ExerciseListTile(
exercise: exercise,
onTap: () => _openForm(exercise: exercise),
onDelete: () => _confirmDelete(exercise),
);
}, childCount: filtered.length * 2 - 1),
),
],
),
);
},
),
);
}
List<Exercise> _filter(List<Exercise> 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();
}
void _reload() {
setState(() {
_exercises = widget.exerciseUseCases.listActive();
});
}
Future<void> _openForm({Exercise? exercise}) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ExerciseFormScreen(
exerciseUseCases: widget.exerciseUseCases,
mediaUseCases: widget.mediaUseCases,
exercise: exercise,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
Future<void> _confirmDelete(Exercise exercise) async {
final used = await widget.exerciseUseCases.isUsedInPrograms(
exercise.metadata.id,
);
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Supprimer ${exercise.name} ?'),
content: Text(
used
? 'Cet exercice est utilisé dans un ou plusieurs programmes. '
'Il sera retiré de la bibliothèque. '
'Les programmes existants restent inchangés. 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.exerciseUseCases.delete(exercise.metadata.id);
if (!mounted) return;
_reload();
} on Exception catch (error) {
_showSnackBar(error.toString());
}
}
void _showSnackBar(String message) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(content: Text(message)));
}
}
final class ExerciseListTile extends StatelessWidget {
const ExerciseListTile({
required this.exercise,
this.onTap,
this.onDelete,
super.key,
});
final Exercise exercise;
final VoidCallback? onTap;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final description = exercise.description?.trim();
final iconMediaId = exercise.iconMediaId ?? exercise.imageMediaId;
final hasMedia = iconMediaId != null || exercise.videoMediaId != null;
return ListTile(
onTap: onTap,
leading: _ExerciseIconAvatar(iconMediaId: iconMediaId),
title: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(exercise.name),
if (exercise.isExample) const ExampleBadge(),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (description != null && description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
)
else
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_CategoryBadge(category: exercise.category),
...exercise.availableMeasures.map(
(measure) => MeasureBadge(
measure: measure,
scoreUnit: exercise.scoreUnit,
scoreInputMode: exercise.scoreInputMode,
),
),
if (hasMedia)
const Tooltip(
message: 'Média associé',
child: Icon(Icons.image_outlined, size: 18),
),
],
),
],
),
trailing: onDelete == null
? const Icon(Icons.chevron_right)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Supprimer lexercice',
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
),
const Icon(Icons.chevron_right),
],
),
);
}
}
final class _CategoryBadge extends StatelessWidget {
const _CategoryBadge({required this.category});
final ExerciseCategory category;
@override
Widget build(BuildContext context) {
return Chip(
visualDensity: VisualDensity.compact,
label: Text(category.label),
);
}
}
final class _ExerciseIconAvatar extends StatelessWidget {
const _ExerciseIconAvatar({required this.iconMediaId});
final String? iconMediaId;
@override
Widget build(BuildContext context) {
final icon = CircleAvatar(
child: Icon(
iconMediaId == null ? Icons.fitness_center : Icons.perm_media_outlined,
),
);
if (iconMediaId == null) {
return icon;
}
return Tooltip(
message: 'Icône dexercice $iconMediaId',
child: Stack(
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -2,
bottom: -2,
child: Icon(
Icons.star,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
);
}
}
final class ExerciseFormScreen extends StatefulWidget {
const ExerciseFormScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
this.mediaPicker = const ImagePickerMediaSourcePicker(),
this.exercise,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
final MediaSourcePicker mediaPicker;
final Exercise? exercise;
@override
State<ExerciseFormScreen> createState() => _ExerciseFormScreenState();
}
final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _descriptionController;
late final TextEditingController _scoreLabelController;
late final TextEditingController _scoreUnitController;
late final TextEditingController _defaultTimeController;
late final TextEditingController _defaultRepsController;
late final TextEditingController _defaultScoreController;
late final TextEditingController _defaultScoreTimeController;
late List<String> _imageMediaIds;
String? _iconMediaId;
String? _videoMediaId;
final _selectedImageNamesById = <String, String>{};
String? _selectedVideoName;
var _hasTime = true;
var _hasReps = false;
var _hasScore = false;
var _scoreInputMode = ScoreInputMode.manual;
var _saving = false;
var _importingImage = false;
var _importingVideo = false;
var _stepsEnabled = false;
var _autoStartNextTimedStep = true;
final _stepDrafts = <_ExerciseStepDraft>[];
String? _measureError;
String? _stepError;
String? _warning;
bool get _isEditing => widget.exercise != null;
@override
void initState() {
super.initState();
final exercise = widget.exercise;
_nameController = TextEditingController(text: exercise?.name);
_descriptionController = TextEditingController(text: exercise?.description);
_scoreLabelController = TextEditingController(text: exercise?.scoreLabel);
_scoreUnitController = TextEditingController(text: exercise?.scoreUnit);
_defaultTimeController = TextEditingController(
text: _optionalIntText(exercise?.defaultTargetTimeSeconds),
);
_defaultRepsController = TextEditingController(
text: _optionalIntText(exercise?.defaultTargetReps),
);
_defaultScoreController = TextEditingController(
text: _optionalDoubleText(exercise?.defaultTargetScore),
);
_defaultScoreTimeController = TextEditingController(
text: _optionalDoubleText(
_millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs),
),
);
_imageMediaIds = List<String>.of(exercise?.imageMediaIds ?? const []);
_iconMediaId = exercise?.iconMediaId;
_videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
_autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true;
for (final step in exercise?.steps ?? const <ExerciseStep>[]) {
_stepDrafts.add(_draftFromStep(step));
}
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_scoreLabelController.dispose();
_scoreUnitController.dispose();
_defaultTimeController.dispose();
_defaultRepsController.dispose();
_defaultScoreController.dispose();
_defaultScoreTimeController.dispose();
for (final draft in _stepDrafts) {
draft.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier lexercice' : 'Créer un exercice'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nom'),
textInputAction: TextInputAction.next,
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 24),
Text(
'Média optionnel',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_ImageGalleryField(
imageMediaIds: _imageMediaIds,
imageNamesById: _selectedImageNamesById,
iconMediaId: _iconMediaId,
importing: _importingImage,
onAdd: _importImage,
onRemove: _removeImage,
onSetIcon: _setIcon,
),
const SizedBox(height: 8),
_MediaImportField(
label: 'Vidéo',
selectedFileName: _selectedVideoName,
imported: _videoMediaId != null,
importing: _importingVideo,
actionLabel: 'Choisir une vidéo',
onPick: _importVideo,
),
const SizedBox(height: 24),
Text(
'Mesures disponibles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MeasureSwitch(
title: 'Temps',
subtitle: 'Chronométrer la durée dune série.',
value: _hasTime,
onChanged: (value) => _setMeasure(() => _hasTime = value),
),
if (_hasTime) ...[
const SizedBox(height: 8),
TextFormField(
controller: _defaultTimeController,
decoration: const InputDecoration(
labelText: 'Temps par défaut (s)',
),
keyboardType: TextInputType.number,
validator: (value) => _hasTime
? _positiveIntValidator(
value,
'Saisis un temps supérieur à 0.',
)
: null,
),
],
_MeasureSwitch(
title: 'Répétitions',
subtitle: 'Compter le nombre de mouvements réalisés.',
value: _hasReps,
onChanged: (value) => _setMeasure(() => _hasReps = value),
),
if (_hasReps) ...[
const SizedBox(height: 8),
TextFormField(
controller: _defaultRepsController,
decoration: const InputDecoration(
labelText: 'Répétitions par défaut',
),
keyboardType: TextInputType.number,
validator: (value) => _hasReps
? _positiveIntValidator(
value,
'Saisis un nombre de répétitions supérieur à 0.',
)
: null,
),
],
_MeasureSwitch(
title: 'Score',
subtitle: 'Saisir une valeur libre avec son unité.',
value: _hasScore,
onChanged: (value) => _setMeasure(() => _hasScore = value),
),
if (_measureError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_measureError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
if (_hasScore) ...[
const SizedBox(height: 12),
Text(
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _defaultScoreController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score positif ou nul.',
);
},
),
] else ...[
const SizedBox(height: 12),
TextFormField(
controller: _defaultScoreTimeController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
const SizedBox(height: 24),
_buildExerciseStepsSection(context),
if (_warning != null) ...[
const SizedBox(height: 16),
MaterialBanner(
content: Text(_warning!),
leading: const Icon(Icons.info_outline),
actions: [
TextButton(
onPressed: () => setState(() => _warning = null),
child: const Text('OK'),
),
],
),
],
const SizedBox(height: 24),
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'),
),
],
),
),
);
}
Widget _buildExerciseStepsSection(BuildContext context) {
final reachedLimit = _stepDrafts.length >= 8;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Séquence détapes',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Rythmer cet exercice avec des étapes'),
subtitle: Text(
_stepsEnabled
? 'Chaque passage suit ces étapes dans lordre. Si la série '
'suit des répétitions, une répétition correspond à un '
'passage complet.'
: 'Ajoute des étapes si lexercice doit suivre un ordre précis '
'pendant chaque série.',
),
value: _stepsEnabled,
onChanged: (value) {
setState(() {
_stepsEnabled = value;
_stepError = null;
});
},
),
if (_stepsEnabled) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'Enchaîner automatiquement les chronos consécutifs',
),
subtitle: Text(
_autoStartNextTimedStep
? '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.',
),
value: _autoStartNextTimedStep,
onChanged: (value) {
setState(() {
_autoStartNextTimedStep = value;
});
},
),
const SizedBox(height: 8),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
),
if (_stepError != null) ...[
const SizedBox(height: 8),
Text(
_stepError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
if (reachedLimit)
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.info_outline),
title: Text('Limite atteinte'),
subtitle: Text('Un exercice peut contenir jusquà 8 étapes.'),
)
else
OutlinedButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add),
label: const Text('Ajouter une étape'),
),
],
],
);
}
Widget _buildStepCard(
BuildContext context,
int index,
_ExerciseStepDraft draft,
) {
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
final targetMessage = draft.type == ExerciseStepType.time
? 'Saisis une durée supérieure à 0.'
: 'Saisis un nombre de répétitions supérieur à 0.';
final summaryUnit = draft.type == ExerciseStepType.time ? 's' : '';
return CourtBlazerAccentPanel(
key: ValueKey(draft.id),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
child: Material(
type: MaterialType.transparency,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(Icons.drag_handle),
),
),
Expanded(
child: Text(
'${index + 1}. ${draft.displayName}',
style: Theme.of(context).textTheme.titleSmall,
),
),
PopupMenuButton<_StepAction>(
tooltip: 'Actions de létape ${index + 1}',
onSelected: (action) => _handleStepAction(index, action),
itemBuilder: (context) => [
PopupMenuItem(
value: _StepAction.moveUp,
enabled: index > 0,
child: const Text('Monter'),
),
PopupMenuItem(
value: _StepAction.moveDown,
enabled: index < _stepDrafts.length - 1,
child: const Text('Descendre'),
),
const PopupMenuItem(
value: _StepAction.duplicate,
child: Text('Dupliquer'),
),
const PopupMenuItem(
value: _StepAction.delete,
child: Text('Supprimer'),
),
],
),
],
),
const SizedBox(height: 8),
Text(
draft.type == ExerciseStepType.time
? 'Temps · ${draft.targetController.text.trim()} $summaryUnit'
: 'Répétitions · ${draft.targetController.text.trim()}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextFormField(
controller: draft.nameController,
decoration: const InputDecoration(labelText: 'Nom de létape'),
validator: (value) {
if (!_stepsEnabled) return null;
return value == null || value.trim().isEmpty
? 'Le nom de létape est obligatoire.'
: null;
},
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
TextFormField(
controller: draft.targetController,
decoration: InputDecoration(labelText: targetLabel),
keyboardType: TextInputType.number,
validator: (value) => _stepsEnabled
? _positiveIntValidator(value, targetMessage)
: null,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Score détape'),
subtitle: const Text('Ajouter un score pour cette étape'),
value: draft.hasScore,
onChanged: (value) => setState(() => draft.hasScore = value),
),
if (draft.hasScore) ...[
Text(
'Mode de score',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
const Text(
'Cette étape utilise déjà un compte à rebours. Le score '
'chrono ajoute un second temps mesuré ; garde-le seulement '
'si tu veux enregistrer une performance distincte.',
),
],
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
],
),
),
);
}
void _setMeasure(VoidCallback update) {
setState(() {
update();
_measureError = null;
_warning = null;
});
}
void _addStep() {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.add(
_ExerciseStepDraft(
id: widget.exerciseUseCases.ids.newId(),
name: 'Nouvelle étape',
defaultTargetValue: '10',
),
);
});
}
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
}
void _handleStepAction(int index, _StepAction action) {
switch (action) {
case _StepAction.moveUp:
if (index <= 0) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index - 1, draft);
});
break;
case _StepAction.moveDown:
if (index >= _stepDrafts.length - 1) return;
setState(() {
final draft = _stepDrafts.removeAt(index);
_stepDrafts.insert(index + 1, draft);
});
break;
case _StepAction.duplicate:
_duplicateStep(index);
break;
case _StepAction.delete:
unawaited(_confirmRemoveStep(index));
break;
}
}
void _duplicateStep(int index) {
if (_stepDrafts.length >= 8) {
setState(() {
_stepError = 'Un exercice peut contenir jusquà 8 étapes.';
});
return;
}
setState(() {
_stepError = null;
_stepDrafts.insert(
index + 1,
_stepDrafts[index].duplicate(widget.exerciseUseCases.ids.newId()),
);
});
}
Future<void> _confirmRemoveStep(int index) async {
final draft = _stepDrafts[index];
var confirmed = true;
if (draft.hasFilledContent) {
confirmed =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer cette étape ?'),
content: const Text('Cette étape sera retirée de la séquence.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
) ??
false;
}
if (!confirmed || !mounted) return;
setState(() {
final removed = _stepDrafts.removeAt(index);
removed.dispose();
_stepError = null;
});
}
_ExerciseStepDraft _draftFromStep(ExerciseStep step) {
return _ExerciseStepDraft(
id: step.id,
name: step.name,
type: step.type,
defaultTargetValue: step.defaultTargetValue.toString(),
hasScore: step.hasScore,
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
)
: _optionalDoubleText(step.defaultTargetScore),
);
}
List<ExerciseStep> _buildSteps() {
if (!_stepsEnabled) return const [];
return [
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
defaultTargetScoreTimeMs:
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
_stepDrafts[index].scoreTargetController.text,
)
: null,
),
];
}
Future<void> _importImage() async {
if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.');
return;
}
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.image);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() => _importingImage = true);
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: MediaKind.image,
);
if (!mounted) return;
setState(() {
_imageMediaIds = [..._imageMediaIds, asset.metadata.id];
_selectedImageNamesById[asset.metadata.id] = fileName;
});
_showSnackBar('Image ajoutée.');
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _importingImage = false);
}
}
}
void _removeImage(String mediaAssetId) {
setState(() {
_imageMediaIds = _imageMediaIds
.where((imageId) => imageId != mediaAssetId)
.toList();
if (_iconMediaId == mediaAssetId) {
_iconMediaId = null;
}
_selectedImageNamesById.remove(mediaAssetId);
});
}
void _setIcon(String mediaAssetId) {
setState(() => _iconMediaId = mediaAssetId);
}
Future<void> _importVideo() async {
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() {
_importingVideo = true;
_selectedVideoName = fileName;
});
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: MediaKind.video,
);
if (!mounted) return;
setState(() {
_videoMediaId = asset.metadata.id;
});
_showSnackBar('Vidéo importée.');
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() {
_importingVideo = false;
});
}
}
}
Future<void> _save() async {
final hasMeasure = _hasTime || _hasReps || _hasScore;
final hasValidSteps = !_stepsEnabled || _stepDrafts.isNotEmpty;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
_stepError = hasValidSteps
? null
: 'Ajoute au moins une étape ou désactive la séquence.';
});
if (!_formKey.currentState!.validate() || !hasMeasure || !hasValidSteps) {
return;
}
final exercise = widget.exercise;
if (exercise != null &&
_measuresChanged(exercise) &&
_warning == null &&
await widget.exerciseUseCases.isUsedInPrograms(exercise.metadata.id)) {
if (!mounted) return;
setState(() {
_warning =
'Cet exercice est déjà utilisé dans un programme. '
'Les programmes existants restent inchangés.';
});
return;
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaIds: _imageMediaIds,
iconMediaId: _iconMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
);
} else {
await widget.exerciseUseCases.update(
id: exercise.metadata.id,
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaIds: _imageMediaIds,
iconMediaId: _iconMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep,
steps: steps,
);
}
if (mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
bool _measuresChanged(Exercise exercise) {
return exercise.hasTimeMeasure != _hasTime ||
exercise.hasRepsMeasure != _hasReps ||
exercise.hasScoreMeasure != _hasScore ||
exercise.scoreInputMode != _scoreInputMode;
}
String? _optionalText(TextEditingController controller) {
final text = controller.text.trim();
return text.isEmpty ? null : text;
}
String? _positiveIntValidator(String? value, String message) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return message;
}
return null;
}
String? _positiveDoubleValidator(String? value, String message) {
final number = double.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return message;
}
return null;
}
String? _nonNegativeDoubleValidator(String? value, String message) {
final number = double.tryParse(value?.trim() ?? '');
if (number == null || number < 0) {
return message;
}
return null;
}
String _optionalIntText(int? value) {
return value == null ? '' : '$value';
}
String _optionalDoubleText(double? value) {
if (value == null) {
return '';
}
if (value == value.roundToDouble()) {
return '${value.round()}';
}
return '$value';
}
double? _millisecondsToSeconds(int? milliseconds) {
return milliseconds == null ? null : milliseconds / 1000;
}
int? _optionalSecondsToMilliseconds(String value) {
final text = value.trim();
if (text.isEmpty) {
return null;
}
return (double.parse(text) * 1000).round();
}
String _fileNameFromPath(String path) {
final parts = path.split(RegExp(r'[/\\]'));
return parts.isEmpty ? path : parts.last;
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
enum _StepAction { moveUp, moveDown, duplicate, delete }
final class _ExerciseStepDraft {
_ExerciseStepDraft({
required this.id,
required String name,
this.type = ExerciseStepType.time,
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
}) : nameController = TextEditingController(text: name),
targetController = TextEditingController(text: defaultTargetValue),
scoreLabelController = TextEditingController(text: scoreLabel),
scoreUnitController = TextEditingController(text: scoreUnit),
scoreTargetController = TextEditingController(text: scoreTarget);
final String id;
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
final TextEditingController scoreUnitController;
final TextEditingController scoreTargetController;
String get displayName {
final text = nameController.text.trim();
return text.isEmpty ? 'Étape sans nom' : text;
}
bool get hasFilledContent {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
}
_ExerciseStepDraft duplicate(String id) {
return _ExerciseStepDraft(
id: id,
name: nameController.text,
type: type,
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
);
}
ExerciseStep toExerciseStep({
required int position,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
return ExerciseStep(
id: id,
position: position,
name: nameController.text.trim(),
type: type,
defaultTargetValue: int.parse(targetController.text.trim()),
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
defaultTargetScoreTimeMs:
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
);
}
void dispose() {
nameController.dispose();
targetController.dispose();
scoreLabelController.dispose();
scoreUnitController.dispose();
scoreTargetController.dispose();
}
}
final class MeasureBadge extends StatelessWidget {
const MeasureBadge({
required this.measure,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreUnit,
super.key,
});
final WorkoutMeasure measure;
final ScoreInputMode scoreInputMode;
final String? scoreUnit;
@override
Widget build(BuildContext context) {
final unit = scoreUnit?.trim();
var label = measure.label;
if (measure == WorkoutMeasure.score &&
scoreInputMode == ScoreInputMode.stopwatch) {
label = 'Score chrono';
} else if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {
label = '${measure.label} ($unit)';
}
return Semantics(
label: label,
excludeSemantics: true,
child: Chip(visualDensity: VisualDensity.compact, label: Text(label)),
);
}
}
final class _MeasureSwitch extends StatelessWidget {
const _MeasureSwitch({
required this.title,
required this.subtitle,
required this.value,
required this.onChanged,
});
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(title),
subtitle: Text(subtitle),
value: value,
onChanged: onChanged,
);
}
}
final class _ImageGalleryField extends StatelessWidget {
const _ImageGalleryField({
required this.imageMediaIds,
required this.imageNamesById,
required this.iconMediaId,
required this.importing,
required this.onAdd,
required this.onRemove,
required this.onSetIcon,
});
final List<String> imageMediaIds;
final Map<String, String> imageNamesById;
final String? iconMediaId;
final bool importing;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
final ValueChanged<String> onSetIcon;
@override
Widget build(BuildContext context) {
final canAdd = imageMediaIds.length < 5;
return InputDecorator(
decoration: const InputDecoration(labelText: 'Images'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (imageMediaIds.isEmpty)
const Text('Aucune image sélectionnée')
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var index = 0; index < imageMediaIds.length; index++)
_ImageThumbnail(
mediaAssetId: imageMediaIds[index],
selectedAsIcon: imageMediaIds[index] == iconMediaId,
label:
imageNamesById[imageMediaIds[index]] ??
'Image ${index + 1}',
onRemove: onRemove,
onSetIcon: onSetIcon,
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: importing ? null : onAdd,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_photo_alternate_outlined),
label: const Text('Ajouter une image'),
),
),
if (!canAdd) ...[
const SizedBox(height: 8),
Text(
'Maximum 5 images par exercice.',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
}
final class _ImageThumbnail extends StatelessWidget {
const _ImageThumbnail({
required this.mediaAssetId,
required this.selectedAsIcon,
required this.label,
required this.onRemove,
required this.onSetIcon,
});
final String mediaAssetId;
final bool selectedAsIcon;
final String label;
final ValueChanged<String> onRemove;
final ValueChanged<String> onSetIcon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context);
return SizedBox(
width: 132,
child: Stack(
children: [
Container(
height: 154,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selectedAsIcon
? theme.colorScheme.primary
: tokens.border,
width: selectedAsIcon ? 2 : 1,
),
color: theme.colorScheme.surface,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
color: selectedAsIcon ? theme.colorScheme.primary : null,
),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall,
),
const SizedBox(height: 8),
IconButton(
tooltip: 'Définir comme icône',
onPressed: selectedAsIcon
? null
: () => onSetIcon(mediaAssetId),
icon: const Icon(Icons.star_outline, size: 18),
),
],
),
),
if (selectedAsIcon)
Positioned(
top: 4,
left: 4,
child: Tooltip(
message: 'Icône choisie',
child: Icon(
Icons.star,
color: theme.colorScheme.primary,
size: 18,
),
),
),
Positioned(
top: 2,
right: 2,
child: IconButton.filledTonal(
tooltip: 'Supprimer limage',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onRemove(mediaAssetId),
),
),
],
),
);
}
}
final class _MediaImportField extends StatelessWidget {
const _MediaImportField({
required this.label,
required this.selectedFileName,
required this.imported,
required this.importing,
required this.actionLabel,
required this.onPick,
});
final String label;
final String? selectedFileName;
final bool imported;
final bool importing;
final String actionLabel;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
final fileName = selectedFileName;
final status =
fileName ??
(imported ? 'Média déjà importé' : 'Aucun fichier sélectionné');
return InputDecorator(
decoration: InputDecoration(labelText: label),
child: Row(
children: [
Icon(imported ? Icons.check_circle_outline : Icons.perm_media),
const SizedBox(width: 12),
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: importing ? null : onPick,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(actionLabel),
),
],
),
);
}
}
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!)),
],
],
),
),
);
}
}
extension on WorkoutMeasure {
String get label {
return switch (this) {
WorkoutMeasure.time => 'Temps',
WorkoutMeasure.reps => 'Répétitions',
WorkoutMeasure.score => 'Score',
};
}
}
extension on ExerciseCategory {
String get label {
return switch (this) {
ExerciseCategory.shoot => 'Tir',
ExerciseCategory.freeThrows => 'Lancers francs',
ExerciseCategory.dribble => 'Dribble',
ExerciseCategory.finishing => 'Finition',
ExerciseCategory.conditioning => 'Condition physique',
ExerciseCategory.defense => 'Défense',
ExerciseCategory.mobility => 'Mobilité',
ExerciseCategory.uncategorized => 'Non catégorisé',
};
}
}