feat(ui): écran Bibliothèque d'exercices

Ajoute l'écran de bibliothèque d'exercices (presentation/exercise_library_screen.dart)
et ajuste la couche application/domaine (bootstrap, ports, use cases,
entités, repositories Drift) pour l'alimenter. flutter analyze propre,
9/9 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:42:41 +02:00
parent f474eb500b
commit 318fc6fcb9
10 changed files with 1041 additions and 18 deletions

View File

@ -0,0 +1,732 @@
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
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),
border: OutlineInputBorder(),
),
),
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),
);
}, 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();
}
}
}
final class ExerciseListTile extends StatelessWidget {
const ExerciseListTile({required this.exercise, this.onTap, super.key});
final Exercise exercise;
final VoidCallback? onTap;
@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,
),
),
if (hasMedia)
const Tooltip(
message: 'Média associé',
child: Icon(Icons.image_outlined, size: 18),
),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
);
}
}
final class ExerciseFormScreen extends StatefulWidget {
const ExerciseFormScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
this.exercise,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
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;
final _imagePathController = TextEditingController();
final _videoPathController = TextEditingController();
String? _imageMediaId;
String? _videoMediaId;
var _hasTime = true;
var _hasReps = false;
var _hasScore = false;
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);
_imageMediaId = exercise?.imageMediaId;
_videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_scoreLabelController.dispose();
_scoreUnitController.dispose();
_imagePathController.dispose();
_videoPathController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier lexercice' : 'Créer un exercice'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
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',
border: OutlineInputBorder(),
),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 24),
Text(
'Média optionnel',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MediaImportField(
controller: _imagePathController,
label: 'Chemin de limage',
imported: _imageMediaId != null,
importing: _importingImage,
onImport: () => _importMedia(MediaKind.image),
),
const SizedBox(height: 8),
_MediaImportField(
controller: _videoPathController,
label: 'Chemin de la vidéo',
imported: _videoMediaId != null,
importing: _importingVideo,
onImport: () => _importMedia(MediaKind.video),
),
const SizedBox(height: 24),
Text(
'Mesures disponibles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MeasureSwitch(
title: 'Temps',
subtitle: 'Chronométrer la durée dune série.',
value: _hasTime,
onChanged: (value) => _setMeasure(() => _hasTime = value),
),
_MeasureSwitch(
title: 'Répétitions',
subtitle: 'Compter le nombre de mouvements réalisés.',
value: _hasReps,
onChanged: (value) => _setMeasure(() => _hasReps = value),
),
_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),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
border: OutlineInputBorder(),
),
validator: (value) {
if (!_hasScore) 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é',
border: OutlineInputBorder(),
),
validator: (value) {
if (!_hasScore) return null;
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
],
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> _importMedia(MediaKind kind) async {
final controller = kind == MediaKind.image
? _imagePathController
: _videoPathController;
final sourcePath = controller.text.trim();
if (sourcePath.isEmpty) {
_showSnackBar('Indique un chemin de fichier à importer.');
return;
}
setState(() {
if (kind == MediaKind.image) {
_importingImage = true;
} else {
_importingVideo = true;
}
});
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: kind,
);
if (!mounted) return;
setState(() {
if (kind == MediaKind.image) {
_imageMediaId = asset.metadata.id;
} else {
_videoMediaId = asset.metadata.id;
}
});
_showSnackBar(
kind == MediaKind.image ? 'Image importée.' : 'Vidéo importée.',
);
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() {
_importingImage = false;
_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);
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
);
} else {
await widget.exerciseUseCases.update(
id: exercise.metadata.id,
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
);
}
if (mounted) {
Navigator.of(context).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;
}
String? _optionalText(TextEditingController controller) {
final text = controller.text.trim();
return text.isEmpty ? null : text;
}
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.scoreUnit, super.key});
final WorkoutMeasure measure;
final String? scoreUnit;
@override
Widget build(BuildContext context) {
final unit = scoreUnit?.trim();
final label =
measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty
? '${measure.label} ($unit)'
: measure.label;
return Semantics(
label: label,
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 _MediaImportField extends StatelessWidget {
const _MediaImportField({
required this.controller,
required this.label,
required this.imported,
required this.importing,
required this.onImport,
});
final TextEditingController controller;
final String label;
final bool imported;
final bool importing;
final VoidCallback onImport;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (imported)
const Tooltip(
message: 'Média importé',
child: Icon(Icons.check_circle_outline),
),
IconButton(
tooltip: 'Importer',
onPressed: importing ? null : onImport,
icon: importing
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
),
],
),
),
);
}
}
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',
};
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../application/app_bootstrap.dart';
import 'exercise_library_screen.dart';
final class GameTimeApp extends StatelessWidget {
const GameTimeApp({required this.bootstrap, super.key});
@ -15,8 +16,9 @@ final class GameTimeApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)),
useMaterial3: true,
),
home: const Scaffold(
body: SizedBox.shrink(),
home: ExerciseLibraryScreen(
exerciseUseCases: bootstrap.exerciseUseCases,
mediaUseCases: bootstrap.mediaUseCases,
),
);
}

View File

@ -1 +1,2 @@
export 'game_time_app.dart';
export 'exercise_library_screen.dart';