diff --git a/.ideai/tickets/6/carnet.md b/.ideai/tickets/6/carnet.md index 6e6eba0..67ed89f 100644 --- a/.ideai/tickets/6/carnet.md +++ b/.ideai/tickets/6/carnet.md @@ -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). \ No newline at end of file diff --git a/.ideai/tickets/6/issue.md b/.ideai/tickets/6/issue.md index aa3319c..9b7f9eb 100644 --- a/.ideai/tickets/6/issue.md +++ b/.ideai/tickets/6/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 22ac8df..3d009dc 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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", diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index b49cb7c..e3cf965 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -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 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 dispose() => database.close(); diff --git a/lib/application/ports.dart b/lib/application/ports.dart index f6f74bd..96492b6 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -18,6 +18,7 @@ abstract interface class IdGenerator { abstract interface class ExerciseRepository { Future findById(String id); Future> listActive(); + Future isReferencedByProgram(String id); Future save(Exercise exercise); } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index b2d37db..78b054b 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -46,6 +46,44 @@ final class ExerciseUseCases { Future save(Exercise exercise) => repository.save(exercise); + Future> listActive() => repository.listActive(); + + Future isUsedInPrograms(String id) { + return repository.isReferencedByProgram(id); + } + + Future 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 attachImage({ required String exerciseId, required String mediaAssetId, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 5281155..70f48f1 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -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, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 7ebdcf2..3d14139 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -21,12 +21,28 @@ final class DriftExerciseRepository implements ExerciseRepository { Future> 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 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 save(domain.Exercise exercise) async { await database diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart new file mode 100644 index 0000000..46a0433 --- /dev/null +++ b/lib/presentation/exercise_library_screen.dart @@ -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 createState() => _ExerciseLibraryScreenState(); +} + +final class _ExerciseLibraryScreenState extends State { + final _searchController = TextEditingController(); + var _selectedMeasures = {}; + late Future> _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>( + 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 []; + final filtered = _filter(exercises); + return RefreshIndicator( + onRefresh: () { + _reload(); + return Future.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 _filter(List 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 _openForm({Exercise? exercise}) async { + final changed = await Navigator.of(context).push( + 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 createState() => _ExerciseFormScreenState(); +} + +final class _ExerciseFormScreenState extends State { + final _formKey = GlobalKey(); + 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 _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 _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 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', + }; + } +} diff --git a/lib/presentation/game_time_app.dart b/lib/presentation/game_time_app.dart index 9c7308f..b51c371 100644 --- a/lib/presentation/game_time_app.dart +++ b/lib/presentation/game_time_app.dart @@ -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, ), ); } diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index 448fcf4..c3884d0 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -1 +1,2 @@ export 'game_time_app.dart'; +export 'exercise_library_screen.dart'; diff --git a/test/application/media_use_cases_test.dart b/test/application/media_use_cases_test.dart index ebd5a55..f66caf7 100644 --- a/test/application/media_use_cases_test.dart +++ b/test/application/media_use_cases_test.dart @@ -180,6 +180,9 @@ final class _FakeExerciseRepository implements ExerciseRepository { return exercise == null ? const [] : [exercise!]; } + @override + Future isReferencedByProgram(String id) async => false; + @override Future save(Exercise exercise) async { saved.add(exercise); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart new file mode 100644 index 0000000..1f8ca56 --- /dev/null +++ b/test/presentation/exercise_library_screen_test.dart @@ -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 = []; + final saved = []; + var referenced = false; + + @override + Future findById(String id) async { + for (final exercise in exercises) { + if (exercise.metadata.id == id) { + return exercise; + } + } + return null; + } + + @override + Future isReferencedByProgram(String id) async => referenced; + + @override + Future> listActive() async => exercises; + + @override + Future 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 = []; + + @override + Future findById(String id) async { + for (final asset in saved) { + if (asset.metadata.id == id) { + return asset; + } + } + return null; + } + + @override + Future> listActive() async => saved; + + @override + Future save(MediaAsset mediaAsset) async { + saved.add(mediaAsset); + } +} + +final class _FakeMediaStorage implements LocalMediaStorage { + @override + Future deleteByLocalUri(String localUri) async {} + + @override + Future 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> listManagedLocalUris() async => const {}; +}