Intègre le commit de clôture du lot #91 resté non fusionné. Conflits attendus sur entities.dart, workout_execution_screen.dart et watch_companion_projection_test.dart : arbitrage Architect - garder la base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter uniquement les invariants chronos utiles (Durée / Temps réalisé). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> # Conflicts: # lib/domain/entities.dart # lib/presentation/workout_execution_screen.dart # test/application/watch_companion_projection_test.dart
2039 lines
67 KiB
Dart
2039 lines
67 KiB
Dart
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 'library_tags.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>{};
|
||
var _selectedTags = <String>{};
|
||
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 tagSuggestions = tagSuggestionsFor(
|
||
exercises,
|
||
(exercise) => exercise.tags,
|
||
).map((usage) => usage.tag).toList();
|
||
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 (tagSuggestions.isNotEmpty) ...[
|
||
const SizedBox(height: 12),
|
||
LibraryTagFilters(
|
||
suggestions: tagSuggestions,
|
||
selectedTags: _selectedTags,
|
||
onToggle: _toggleTag,
|
||
),
|
||
],
|
||
if (_filtersActive) ...[
|
||
const SizedBox(height: 8),
|
||
TextButton(
|
||
onPressed: _clearFilters,
|
||
child: const Text('Effacer les filtres'),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
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)
|
||
SliverFillRemaining(
|
||
hasScrollBody: false,
|
||
child: _CenteredMessage(
|
||
title: 'Aucun exercice ne correspond',
|
||
message:
|
||
'Modifie la recherche, les mesures ou les tags sélectionnés.',
|
||
actionLabel: 'Effacer les filtres',
|
||
onAction: _clearFilters,
|
||
),
|
||
)
|
||
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();
|
||
final matchingQueryAndMeasures = 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();
|
||
return filterByRequiredTags(
|
||
matchingQueryAndMeasures,
|
||
_selectedTags,
|
||
(exercise) => exercise.tags,
|
||
);
|
||
}
|
||
|
||
bool get _filtersActive {
|
||
return _searchController.text.trim().isNotEmpty ||
|
||
_selectedMeasures.isNotEmpty ||
|
||
_selectedTags.isNotEmpty;
|
||
}
|
||
|
||
void _toggleTag(String tag) {
|
||
setState(() {
|
||
if (_selectedTags.contains(tag)) {
|
||
_selectedTags = {..._selectedTags}..remove(tag);
|
||
} else {
|
||
_selectedTags = {..._selectedTags, tag};
|
||
}
|
||
});
|
||
}
|
||
|
||
void _clearFilters() {
|
||
setState(() {
|
||
_searchController.clear();
|
||
_selectedMeasures = {};
|
||
_selectedTags = {};
|
||
});
|
||
}
|
||
|
||
void _reload() {
|
||
setState(() {
|
||
_exercises = widget.exerciseUseCases.listActive();
|
||
});
|
||
}
|
||
|
||
Future<void> _openForm({Exercise? exercise}) async {
|
||
final exercises = await _exercises;
|
||
if (!mounted) return;
|
||
final changed = await Navigator.of(context).push<bool>(
|
||
MaterialPageRoute(
|
||
builder: (context) => ExerciseFormScreen(
|
||
exerciseUseCases: widget.exerciseUseCases,
|
||
mediaUseCases: widget.mediaUseCases,
|
||
exercise: exercise,
|
||
tagSuggestions: tagSuggestionsFor(
|
||
exercises,
|
||
(exercise) => exercise.tags,
|
||
).map((usage) => usage.tag).toList(),
|
||
),
|
||
),
|
||
);
|
||
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,
|
||
),
|
||
),
|
||
...exercise.tags.take(3).map((tag) => TagChip(label: tag)),
|
||
if (exercise.tags.length > 3)
|
||
TagChip(label: '+${exercise.tags.length - 3}'),
|
||
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 _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 d’exercice $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,
|
||
this.tagSuggestions = const [],
|
||
super.key,
|
||
});
|
||
|
||
final ExerciseUseCases exerciseUseCases;
|
||
final MediaUseCases mediaUseCases;
|
||
final MediaSourcePicker mediaPicker;
|
||
final Exercise? exercise;
|
||
final List<String> tagSuggestions;
|
||
|
||
@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>[];
|
||
late List<String> _tags;
|
||
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;
|
||
_tags = List<String>.of(exercise?.tags ?? const []);
|
||
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 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),
|
||
EditableTagsSection(
|
||
tags: _tags,
|
||
suggestions: widget.tagSuggestions,
|
||
description:
|
||
'Ajoute quelques mots pour retrouver cet exercice plus vite.',
|
||
onChanged: (tags) => setState(() => _tags = tags),
|
||
),
|
||
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 d’une série.',
|
||
value: _hasTime,
|
||
onChanged: (value) => _setMeasure(() => _hasTime = value),
|
||
),
|
||
if (_hasTime) ...[
|
||
const SizedBox(height: 8),
|
||
TextFormField(
|
||
controller: _defaultTimeController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Durée 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,
|
||
),
|
||
RadioGroup<ScoreInputMode>(
|
||
groupValue: _scoreInputMode,
|
||
onChanged: (value) {
|
||
if (value == null) return;
|
||
setState(() => _scoreInputMode = value);
|
||
},
|
||
child: const Column(
|
||
children: [
|
||
RadioListTile<ScoreInputMode>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Saisie libre'),
|
||
value: ScoreInputMode.manual,
|
||
),
|
||
RadioListTile<ScoreInputMode>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Chrono intégré'),
|
||
subtitle: Text('Temps réalisé'),
|
||
value: ScoreInputMode.stopwatch,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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 _nonNegativeDoubleValidator(
|
||
value,
|
||
'Saisis un score positif ou nul.',
|
||
);
|
||
},
|
||
),
|
||
] else ...[
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: _defaultScoreTimeController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Objectif chrono (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 l’ordre. Si la série '
|
||
'suit des répétitions, une répétition correspond à un '
|
||
'passage complet.'
|
||
: 'Ajoute des étapes si l’exercice 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,
|
||
onReorderItem: _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 canLinkToSeriesScore =
|
||
_hasScore && _scoreInputMode == ScoreInputMode.manual;
|
||
if ((!canLinkToSeriesScore ||
|
||
draft.scoreInputMode != ScoreInputMode.manual) &&
|
||
draft.linkedToSeriesScore) {
|
||
draft.linkedToSeriesScore = false;
|
||
}
|
||
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),
|
||
RadioGroup<ExerciseStepType>(
|
||
groupValue: draft.type,
|
||
onChanged: (value) {
|
||
if (value == null) return;
|
||
setState(() => draft.type = value);
|
||
},
|
||
child: const Column(
|
||
children: [
|
||
RadioListTile<ExerciseStepType>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Temps'),
|
||
value: ExerciseStepType.time,
|
||
),
|
||
RadioListTile<ExerciseStepType>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Répétitions'),
|
||
value: ExerciseStepType.reps,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
RadioGroup<ScoreInputMode>(
|
||
groupValue: draft.scoreInputMode,
|
||
onChanged: (value) {
|
||
if (value == null) return;
|
||
setState(() {
|
||
draft.scoreInputMode = value;
|
||
draft.linkedToSeriesScore = false;
|
||
});
|
||
},
|
||
child: const Column(
|
||
children: [
|
||
RadioListTile<ScoreInputMode>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Saisie libre'),
|
||
value: ScoreInputMode.manual,
|
||
),
|
||
RadioListTile<ScoreInputMode>(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text('Chrono intégré'),
|
||
value: ScoreInputMode.stopwatch,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
|
||
if (canLinkToSeriesScore) ...[
|
||
SwitchListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('Utiliser le score de la série'),
|
||
subtitle: const Text(
|
||
'Le score de cette étape alimente directement le score '
|
||
'de la série.',
|
||
),
|
||
value: draft.linkedToSeriesScore,
|
||
onChanged: (value) {
|
||
setState(() => draft.linkedToSeriesScore = value);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (!draft.linkedToSeriesScore) ...[
|
||
TextFormField(
|
||
controller: draft.scoreLabelController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Score à saisir',
|
||
),
|
||
validator: (value) {
|
||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||
if (draft.scoreInputMode != ScoreInputMode.manual ||
|
||
draft.linkedToSeriesScore) {
|
||
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 ||
|
||
draft.linkedToSeriesScore) {
|
||
return null;
|
||
}
|
||
return value == null || value.trim().isEmpty
|
||
? 'L’unité 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 ||
|
||
draft.linkedToSeriesScore ||
|
||
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 chrono (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(() {
|
||
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,
|
||
linkedToSeriesScore: step.linkedToSeriesScore,
|
||
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,
|
||
seriesScoreLabel: _scoreLabelController.text.trim(),
|
||
seriesScoreUnit: _scoreUnitController.text.trim(),
|
||
canLinkToSeriesScore:
|
||
_hasScore && _scoreInputMode == ScoreInputMode.manual,
|
||
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);
|
||
try {
|
||
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();
|
||
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,
|
||
tags: _tags,
|
||
);
|
||
} 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,
|
||
tags: _tags,
|
||
);
|
||
}
|
||
if (mounted) {
|
||
final navigator = Navigator.of(context);
|
||
if (navigator.canPop()) {
|
||
navigator.pop(true);
|
||
}
|
||
}
|
||
} on Exception {
|
||
_showSnackBar(
|
||
'Impossible d’enregistrer l’exercice. Vérifie les champs puis réessaie.',
|
||
);
|
||
} 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,
|
||
this.linkedToSeriesScore = false,
|
||
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;
|
||
bool linkedToSeriesScore;
|
||
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 ||
|
||
linkedToSeriesScore ||
|
||
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,
|
||
linkedToSeriesScore: linkedToSeriesScore,
|
||
scoreLabel: scoreLabelController.text,
|
||
scoreUnit: scoreUnitController.text,
|
||
scoreTarget: scoreTargetController.text,
|
||
);
|
||
}
|
||
|
||
ExerciseStep toExerciseStep({
|
||
required int position,
|
||
required String seriesScoreLabel,
|
||
required String seriesScoreUnit,
|
||
required bool canLinkToSeriesScore,
|
||
required int? defaultTargetScoreTimeMs,
|
||
}) {
|
||
final scoreTargetText = scoreTargetController.text.trim();
|
||
final linksToSeries =
|
||
hasScore &&
|
||
scoreInputMode == ScoreInputMode.manual &&
|
||
canLinkToSeriesScore &&
|
||
linkedToSeriesScore;
|
||
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
|
||
? linksToSeries
|
||
? seriesScoreLabel
|
||
: scoreLabelController.text.trim()
|
||
: null,
|
||
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
|
||
? linksToSeries
|
||
? seriesScoreUnit
|
||
: scoreUnitController.text.trim()
|
||
: null,
|
||
defaultTargetScore:
|
||
hasScore &&
|
||
scoreInputMode == ScoreInputMode.manual &&
|
||
!linksToSeries &&
|
||
scoreTargetText.isNotEmpty
|
||
? double.parse(scoreTargetText)
|
||
: null,
|
||
defaultTargetScoreTimeMs:
|
||
hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||
? defaultTargetScoreTimeMs
|
||
: null,
|
||
linkedToSeriesScore: linksToSeries,
|
||
);
|
||
}
|
||
|
||
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 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 ??
|
||
(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é',
|
||
};
|
||
}
|
||
}
|