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:
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
732
lib/presentation/exercise_library_screen.dart
Normal file
732
lib/presentation/exercise_library_screen.dart
Normal 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 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',
|
||||
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 l’image',
|
||||
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 d’une 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
|
||||
? 'L’unité 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export 'game_time_app.dart';
|
||||
export 'exercise_library_screen.dart';
|
||||
|
||||
@ -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);
|
||||
|
||||
188
test/presentation/exercise_library_screen_test.dart
Normal file
188
test/presentation/exercise_library_screen_test.dart
Normal 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 {};
|
||||
}
|
||||
Reference in New Issue
Block a user