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

@ -1,15 +1,43 @@
import '../infrastructure/local/app_database.dart'; import '../infrastructure/local/local.dart';
import 'application.dart';
final class AppBootstrap { final class AppBootstrap {
AppBootstrap._({required this.database}); AppBootstrap._({
required this.database,
required this.exerciseUseCases,
required this.mediaUseCases,
});
final AppDatabase database; final AppDatabase database;
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
static Future<AppBootstrap> create() async { static Future<AppBootstrap> create() async {
final database = AppDatabase.open(); final database = AppDatabase.open();
await database.customSelect('SELECT 1').get(); 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(); Future<void> dispose() => database.close();

View File

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

View File

@ -46,6 +46,44 @@ final class ExerciseUseCases {
Future<void> save(Exercise exercise) => repository.save(exercise); 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({ Future<Exercise> attachImage({
required String exerciseId, required String exerciseId,
required String mediaAssetId, required String mediaAssetId,

View File

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

View File

@ -21,12 +21,28 @@ final class DriftExerciseRepository implements ExerciseRepository {
Future<List<domain.Exercise>> listActive() async { Future<List<domain.Exercise>> listActive() async {
final rows = final rows =
await (database.select(database.exercises) await (database.select(database.exercises)
..where((table) => table.deletedAt.isNull()) ..where(
(table) => table.deletedAt.isNull() & table.archivedAt.isNull(),
)
..orderBy([(table) => OrderingTerm.asc(table.name)])) ..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get(); .get();
return rows.map(_exerciseFromRow).toList(); 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 @override
Future<void> save(domain.Exercise exercise) async { Future<void> save(domain.Exercise exercise) async {
await database 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 'package:flutter/material.dart';
import '../application/app_bootstrap.dart'; import '../application/app_bootstrap.dart';
import 'exercise_library_screen.dart';
final class GameTimeApp extends StatelessWidget { final class GameTimeApp extends StatelessWidget {
const GameTimeApp({required this.bootstrap, super.key}); const GameTimeApp({required this.bootstrap, super.key});
@ -15,8 +16,9 @@ final class GameTimeApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)), colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)),
useMaterial3: true, useMaterial3: true,
), ),
home: const Scaffold( home: ExerciseLibraryScreen(
body: SizedBox.shrink(), exerciseUseCases: bootstrap.exerciseUseCases,
mediaUseCases: bootstrap.mediaUseCases,
), ),
); );
} }

View File

@ -1 +1,2 @@
export 'game_time_app.dart'; 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!]; return exercise == null ? const [] : [exercise!];
} }
@override
Future<bool> isReferencedByProgram(String id) async => false;
@override @override
Future<void> save(Exercise exercise) async { Future<void> save(Exercise exercise) async {
saved.add(exercise); 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 {};
}