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

@ -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 {};
}