Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les entités/use cases/repositories et exercise_library_screen.dart pour supporter jusqu'à 5 images par exercice au lieu d'une seule. flutter analyze propre, 63/63 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1193 lines
38 KiB
Dart
1193 lines
38 KiB
Dart
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 hasMedia =
|
||
exercise.imageMediaId != null || exercise.videoMediaId != null;
|
||
return ListTile(
|
||
onTap: onTap,
|
||
leading: CircleAvatar(
|
||
child: Icon(
|
||
hasMedia ? Icons.perm_media_outlined : Icons.fitness_center,
|
||
),
|
||
),
|
||
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 l’exercice',
|
||
icon: const Icon(Icons.delete_outline),
|
||
onPressed: onDelete,
|
||
),
|
||
const Icon(Icons.chevron_right),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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? _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 []);
|
||
_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 l’exercice' : '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,
|
||
importing: _importingImage,
|
||
onAdd: _importImage,
|
||
onRemove: _removeImage,
|
||
),
|
||
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 d’une 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
|
||
? 'L’unité 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 _positiveDoubleValidator(
|
||
value,
|
||
'Saisis un score supérieur à 0.',
|
||
);
|
||
},
|
||
),
|
||
] 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();
|
||
_selectedImageNamesById.remove(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,
|
||
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,
|
||
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 _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.importing,
|
||
required this.onAdd,
|
||
required this.onRemove,
|
||
});
|
||
|
||
final List<String> imageMediaIds;
|
||
final Map<String, String> imageNamesById;
|
||
final bool importing;
|
||
final VoidCallback onAdd;
|
||
final ValueChanged<String> onRemove;
|
||
|
||
@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],
|
||
label:
|
||
imageNamesById[imageMediaIds[index]] ??
|
||
'Image ${index + 1}',
|
||
onRemove: onRemove,
|
||
),
|
||
],
|
||
),
|
||
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.label,
|
||
required this.onRemove,
|
||
});
|
||
|
||
final String mediaAssetId;
|
||
final String label;
|
||
final ValueChanged<String> onRemove;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return SizedBox(
|
||
width: 104,
|
||
child: Stack(
|
||
children: [
|
||
Container(
|
||
height: 104,
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: tokens.border),
|
||
color: Theme.of(context).colorScheme.surface,
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.image_outlined),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
label,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.labelSmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 2,
|
||
right: 2,
|
||
child: IconButton.filledTonal(
|
||
tooltip: 'Supprimer l’image',
|
||
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',
|
||
};
|
||
}
|
||
}
|