merge(develop): intègre l'écran Bibliothèque d'exercices (ticket #6)

Fusionne feature/#6-ecran-bibliotheque-exercices — 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:49 +02:00
13 changed files with 1048 additions and 25 deletions

View File

@ -1,7 +1,7 @@
---
issueRef: "#6"
version: 4
version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784301382963
updatedAt: 1784306547848
---
Référence : mémoire "gametime-ux-conception" section 1. Point critique UX : la section "Mesures disponibles" doit rester très lisible (toggles Temps/Répétitions/Score avec aide courte par toggle, champs label+unité seulement si Score activé). Règle bloquante : au moins une mesure doit être active pour enregistrer. Message non bloquant si on modifie les mesures d'un exercice déjà utilisé ailleurs (les programmes existants restent inchangés grâce aux snapshots).

View File

@ -2,7 +2,7 @@
id: "c748c16e-b782-47a8-accb-aa5d88b9adfb"
number: 6
title: "[DevFrontend] Écran Bibliothèque d'exercices"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#4","kind":"dependsOn"},{"target":"#5","kind":"dependsOn"}]
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784301298045
updatedAt: 1784301382963
version: 4
updatedAt: 1784306547848
version: 6
---
Liste avec recherche et filtres chips par mesure disponible. Écran création/édition d'exercice : nom, description, image/vidéo optionnelles, section "Mesures disponibles" (toggles Temps/Répétitions/Score cumulables, label+unité si Score). Règle : au moins une mesure obligatoire. Avertissement non bloquant si modification des mesures d'un exercice déjà utilisé. Cf. mémoire "gametime-ux-conception" section 1.

View File

@ -65,13 +65,13 @@
"issueRef": "#6",
"path": "6",
"title": "[DevFrontend] Écran Bibliothèque d'exercices",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784301382963
"updatedAt": 1784306547848
},
{
"issueRef": "#7",

View File

@ -1,15 +1,43 @@
import '../infrastructure/local/app_database.dart';
import '../infrastructure/local/local.dart';
import 'application.dart';
final class AppBootstrap {
AppBootstrap._({required this.database});
AppBootstrap._({
required this.database,
required this.exerciseUseCases,
required this.mediaUseCases,
});
final AppDatabase database;
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
static Future<AppBootstrap> create() async {
final database = AppDatabase.open();
await database.customSelect('SELECT 1').get();
final exerciseRepository = DriftExerciseRepository(database);
final mediaRepository = DriftMediaAssetRepository(database);
final ids = LocalIdGenerator();
const clock = SystemClock();
const originDeviceId = 'local-device';
return AppBootstrap._(database: database);
return AppBootstrap._(
database: database,
exerciseUseCases: ExerciseUseCases(
repository: exerciseRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
mediaUseCases: MediaUseCases(
mediaRepository: mediaRepository,
exerciseRepository: exerciseRepository,
storage: const PathProviderLocalMediaStorage(),
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
);
}
Future<void> dispose() => database.close();

View File

@ -18,6 +18,7 @@ abstract interface class IdGenerator {
abstract interface class ExerciseRepository {
Future<Exercise?> findById(String id);
Future<List<Exercise>> listActive();
Future<bool> isReferencedByProgram(String id);
Future<void> save(Exercise exercise);
}

View File

@ -46,6 +46,44 @@ final class ExerciseUseCases {
Future<void> save(Exercise exercise) => repository.save(exercise);
Future<List<Exercise>> listActive() => repository.listActive();
Future<bool> isUsedInPrograms(String id) {
return repository.isReferencedByProgram(id);
}
Future<Exercise> update({
required String id,
required String name,
String? description,
String? imageMediaId,
String? videoMediaId,
required bool hasTimeMeasure,
required bool hasRepsMeasure,
required bool hasScoreMeasure,
String? scoreLabel,
String? scoreUnit,
}) async {
final exercise = await repository.findById(id);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
metadata: exercise.metadata.touch(clock.now()),
name: name,
description: description,
imageMediaId: imageMediaId,
videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
);
await repository.save(updated);
return updated;
}
Future<Exercise> attachImage({
required String exerciseId,
required String mediaAssetId,

View File

@ -163,32 +163,46 @@ final class Exercise {
Exercise copyWith({
EntityMetadata? metadata,
String? name,
String? description,
String? imageMediaId,
String? videoMediaId,
Object? description = _unchanged,
Object? imageMediaId = _unchanged,
Object? videoMediaId = _unchanged,
bool? hasTimeMeasure,
bool? hasRepsMeasure,
bool? hasScoreMeasure,
String? scoreLabel,
String? scoreUnit,
DateTime? archivedAt,
Object? scoreLabel = _unchanged,
Object? scoreUnit = _unchanged,
Object? archivedAt = _unchanged,
}) {
return Exercise(
metadata: metadata ?? this.metadata,
name: name ?? this.name,
description: description ?? this.description,
imageMediaId: imageMediaId ?? this.imageMediaId,
videoMediaId: videoMediaId ?? this.videoMediaId,
description: description == _unchanged
? this.description
: description as String?,
imageMediaId: imageMediaId == _unchanged
? this.imageMediaId
: imageMediaId as String?,
videoMediaId: videoMediaId == _unchanged
? this.videoMediaId
: videoMediaId as String?,
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
hasScoreMeasure: hasScoreMeasure ?? this.hasScoreMeasure,
scoreLabel: scoreLabel ?? this.scoreLabel,
scoreUnit: scoreUnit ?? this.scoreUnit,
archivedAt: archivedAt ?? this.archivedAt,
scoreLabel: scoreLabel == _unchanged
? this.scoreLabel
: scoreLabel as String?,
scoreUnit: scoreUnit == _unchanged
? this.scoreUnit
: scoreUnit as String?,
archivedAt: archivedAt == _unchanged
? this.archivedAt
: archivedAt as DateTime?,
);
}
}
const Object _unchanged = Object();
final class Program {
Program({
required this.metadata,

View File

@ -21,12 +21,28 @@ final class DriftExerciseRepository implements ExerciseRepository {
Future<List<domain.Exercise>> listActive() async {
final rows =
await (database.select(database.exercises)
..where((table) => table.deletedAt.isNull())
..where(
(table) => table.deletedAt.isNull() & table.archivedAt.isNull(),
)
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
return rows.map(_exerciseFromRow).toList();
}
@override
Future<bool> isReferencedByProgram(String id) async {
final row =
await (database.select(database.programExercises)
..where(
(table) =>
table.deletedAt.isNull() &
table.sourceExerciseId.equals(id),
)
..limit(1))
.getSingleOrNull();
return row != null;
}
@override
Future<void> save(domain.Exercise exercise) async {
await database

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';

View File

@ -180,6 +180,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
return exercise == null ? const [] : [exercise!];
}
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override
Future<void> save(Exercise exercise) async {
saved.add(exercise);

View File

@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
void main() {
testWidgets('la validation au moins une mesure active bloque la sauvegarde', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
final exerciseUseCases = _exerciseUseCases(exerciseRepository);
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: exerciseUseCases,
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.enterText(find.byType(TextFormField).first, 'Gainage');
await tester.tap(find.widgetWithText(SwitchListTile, 'Temps'));
await tester.pump();
await tester.dragUntilVisible(
find.text('Enregistrer'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(
find.text('Active au moins une mesure pour enregistrer.'),
findsOneWidget,
);
expect(exerciseRepository.saved, isEmpty);
});
testWidgets('la liste affiche les badges des mesures disponibles', (
tester,
) async {
final exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
description: 'Jambes · charge libre',
hasTimeMeasure: true,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreLabel: 'Charge',
scoreUnit: 'kg',
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: ExerciseListTile(exercise: exercise)),
),
);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Jambes · charge libre'), findsOneWidget);
expect(find.text('Temps'), findsOneWidget);
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('Score (kg)'), findsOneWidget);
});
}
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
return ExerciseUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
MediaUseCases _mediaUseCases(_FakeExerciseRepository exerciseRepository) {
return MediaUseCases(
mediaRepository: _FakeMediaAssetRepository(),
exerciseRepository: exerciseRepository,
storage: _FakeMediaStorage(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeExerciseRepository implements ExerciseRepository {
final exercises = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
@override
Future<Exercise?> findById(String id) async {
for (final exercise in exercises) {
if (exercise.metadata.id == id) {
return exercise;
}
}
return null;
}
@override
Future<bool> isReferencedByProgram(String id) async => referenced;
@override
Future<List<Exercise>> listActive() async => exercises;
@override
Future<void> save(Exercise exercise) async {
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);
}
}
final class _FakeMediaAssetRepository implements MediaAssetRepository {
final saved = <MediaAsset>[];
@override
Future<MediaAsset?> findById(String id) async {
for (final asset in saved) {
if (asset.metadata.id == id) {
return asset;
}
}
return null;
}
@override
Future<List<MediaAsset>> listActive() async => saved;
@override
Future<void> save(MediaAsset mediaAsset) async {
saved.add(mediaAsset);
}
}
final class _FakeMediaStorage implements LocalMediaStorage {
@override
Future<void> deleteByLocalUri(String localUri) async {}
@override
Future<StoredMediaFile> importFile({
required String sourcePath,
required MediaKind kind,
required String stableFileName,
}) async {
return StoredMediaFile(
localUri: 'file:///tmp/$stableFileName',
mimeType: kind == MediaKind.image ? 'image/png' : 'video/mp4',
sizeBytes: 1,
);
}
@override
Future<Set<String>> listManagedLocalUris() async => const {};
}