Files
GameTime/lib/presentation/exercise_library_screen.dart
Blomios 1aa2304328 fix(exercice): score par défaut à 0 et affichage des répétitions en séance (tickets #44, #45)
Autorise un score par défaut à 0 (#44) et corrige l'affichage des
répétitions à 0 pendant l'exécution au lieu de la vraie cible (#45),
sur exercise_library_screen.dart et workout_execution_screen.dart,
avec les ajustements domain/use cases associés. flutter analyze
propre (mêmes 8 infos préexistantes), 79/79 tests verts, build APK
debug validé.

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

1286 lines
40 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 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.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 pour le moment',
message:
'Crée ton premier exercice pour composer tes programmes.',
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. '
'Le supprimer le retirera de ces programmes. 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: Text(exercise.name),
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: [
...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 _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;
String? _measureError;
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;
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_scoreLabelController.dispose();
_scoreUnitController.dispose();
_defaultTimeController.dispose();
_defaultRepsController.dispose();
_defaultScoreController.dispose();
_defaultScoreTimeController.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.',
);
},
),
],
],
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'),
),
],
),
),
);
}
void _setMeasure(VoidCallback update) {
setState(() {
update();
_measureError = null;
_warning = 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;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
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;
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,
);
} 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,
);
}
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)));
}
}
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 == null
? imported
? 'Média déjà importé'
: 'Aucun fichier sélectionné'
: fileName;
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',
};
}
}