From ff6b79b66e1877d2ba93cabe01ba8c0870c052f4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:16:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(exercice):=20affichage=20des=20m=C3=A9dias?= =?UTF-8?q?=20pendant=20l'ex=C3=A9cution=20(ticket=20#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose la galerie d'images d'exercice (backend du ticket #35) pendant l'exécution de séance sur workout_execution_screen.dart et program_screen.dart, avec les ajustements domain/use cases/repositories nécessaires. flutter analyze propre, 67/67 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/use_cases.dart | 5 + lib/domain/entities.dart | 11 + lib/infrastructure/local/app_database.dart | 12 +- lib/infrastructure/local/app_database.g.dart | 103 ++++++ .../local/drift_repositories.dart | 32 ++ lib/infrastructure/local/tables.dart | 1 + lib/presentation/program_screen.dart | 6 + .../workout_execution_screen.dart | 294 ++++++++++++++++++ test/application/use_cases_test.dart | 60 ++++ .../workout_execution_screen_test.dart | 131 ++++++++ 10 files changed, 654 insertions(+), 1 deletion(-) diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index e6742f0..10ab7ed 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -439,6 +439,7 @@ final class ProgramUseCases { exerciseNameSnapshot: input.exerciseNameSnapshot, exerciseDescriptionSnapshot: input.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: input.exerciseArchivedSnapshot, availableTimeSnapshot: input.availableTimeSnapshot, @@ -555,6 +556,7 @@ final class ProgramExerciseConfig { required this.exerciseNameSnapshot, this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, + this.exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, @@ -577,6 +579,7 @@ final class ProgramExerciseConfig { final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; + final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; @@ -1501,6 +1504,8 @@ List _repositionProgramExercises( exercises[index].exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exercises[index].exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: + exercises[index].exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exercises[index].exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 07de34a..7945d18 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -303,6 +303,7 @@ final class ProgramExercise { required String exerciseNameSnapshot, this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, + List exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, @@ -323,6 +324,12 @@ final class ProgramExercise { }) : exerciseNameSnapshot = _nonBlank( exerciseNameSnapshot, 'Exercise snapshot name', + ), + exerciseImageMediaIdsSnapshot = _validatedImageMediaIds( + exerciseImageMediaIdsSnapshot.isEmpty && + exerciseImageMediaIdSnapshot != null + ? [exerciseImageMediaIdSnapshot] + : exerciseImageMediaIdsSnapshot, ) { _requireNonNegative(position, 'Position'); _requirePositive(setsCount, 'Sets count'); @@ -360,6 +367,7 @@ final class ProgramExercise { final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; + final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; @@ -405,6 +413,7 @@ final class ProgramExercise { exerciseNameSnapshot: exercise.name, exerciseDescriptionSnapshot: exercise.description, exerciseImageMediaIdSnapshot: exercise.imageMediaId, + exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, @@ -432,6 +441,8 @@ final class ProgramExercise { 'exerciseNameSnapshot': exerciseNameSnapshot, 'exerciseDescriptionSnapshot': exerciseDescriptionSnapshot, 'exerciseImageMediaIdSnapshot': exerciseImageMediaIdSnapshot, + 'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, + 'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot, 'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot, 'exerciseArchivedSnapshot': exerciseArchivedSnapshot, 'availableTimeSnapshot': availableTimeSnapshot, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 1899ec1..1338dbf 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -37,7 +37,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 5; + int get schemaVersion => 6; @override MigrationStrategy get migration { @@ -67,6 +67,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 5) { await _migrateToSchema5(migrator); } + if (from < 6) { + await _migrateToSchema6(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -250,4 +253,11 @@ extension on AppDatabase { 'WHERE image_media_id IS NOT NULL', ); } + + Future _migrateToSchema6() async { + await customStatement( + 'ALTER TABLE program_exercises ADD COLUMN ' + 'exercise_image_media_ids_snapshot_json TEXT', + ); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 75ad807..1751d8d 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -11148,6 +11148,17 @@ class $ProgramExercisesTable extends ProgramExercises 'REFERENCES media_assets (id)', ), ); + static const VerificationMeta _exerciseImageMediaIdsSnapshotJsonMeta = + const VerificationMeta('exerciseImageMediaIdsSnapshotJson'); + @override + late final GeneratedColumn exerciseImageMediaIdsSnapshotJson = + GeneratedColumn( + 'exercise_image_media_ids_snapshot_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _exerciseVideoMediaIdSnapshotMeta = const VerificationMeta('exerciseVideoMediaIdSnapshot'); @override @@ -11380,6 +11391,7 @@ class $ProgramExercisesTable extends ProgramExercises exerciseNameSnapshot, exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot, availableTimeSnapshot, @@ -11557,6 +11569,15 @@ class $ProgramExercisesTable extends ProgramExercises ), ); } + if (data.containsKey('exercise_image_media_ids_snapshot_json')) { + context.handle( + _exerciseImageMediaIdsSnapshotJsonMeta, + exerciseImageMediaIdsSnapshotJson.isAcceptableOrUnknown( + data['exercise_image_media_ids_snapshot_json']!, + _exerciseImageMediaIdsSnapshotJsonMeta, + ), + ); + } if (data.containsKey('exercise_video_media_id_snapshot')) { context.handle( _exerciseVideoMediaIdSnapshotMeta, @@ -11795,6 +11816,10 @@ class $ProgramExercisesTable extends ProgramExercises DriftSqlType.string, data['${effectivePrefix}exercise_image_media_id_snapshot'], ), + exerciseImageMediaIdsSnapshotJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exercise_image_media_ids_snapshot_json'], + ), exerciseVideoMediaIdSnapshot: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}exercise_video_media_id_snapshot'], @@ -11890,6 +11915,7 @@ class ProgramExercise extends DataClass implements Insertable { final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; + final String? exerciseImageMediaIdsSnapshotJson; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; @@ -11925,6 +11951,7 @@ class ProgramExercise extends DataClass implements Insertable { required this.exerciseNameSnapshot, this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, + this.exerciseImageMediaIdsSnapshotJson, this.exerciseVideoMediaIdSnapshot, required this.exerciseArchivedSnapshot, required this.availableTimeSnapshot, @@ -11981,6 +12008,11 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdSnapshot, ); } + if (!nullToAbsent || exerciseImageMediaIdsSnapshotJson != null) { + map['exercise_image_media_ids_snapshot_json'] = Variable( + exerciseImageMediaIdsSnapshotJson, + ); + } if (!nullToAbsent || exerciseVideoMediaIdSnapshot != null) { map['exercise_video_media_id_snapshot'] = Variable( exerciseVideoMediaIdSnapshot, @@ -12056,6 +12088,10 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdSnapshot == null && nullToAbsent ? const Value.absent() : Value(exerciseImageMediaIdSnapshot), + exerciseImageMediaIdsSnapshotJson: + exerciseImageMediaIdsSnapshotJson == null && nullToAbsent + ? const Value.absent() + : Value(exerciseImageMediaIdsSnapshotJson), exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot == null && nullToAbsent ? const Value.absent() @@ -12124,6 +12160,9 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdSnapshot: serializer.fromJson( json['exerciseImageMediaIdSnapshot'], ), + exerciseImageMediaIdsSnapshotJson: serializer.fromJson( + json['exerciseImageMediaIdsSnapshotJson'], + ), exerciseVideoMediaIdSnapshot: serializer.fromJson( json['exerciseVideoMediaIdSnapshot'], ), @@ -12186,6 +12225,9 @@ class ProgramExercise extends DataClass implements Insertable { 'exerciseImageMediaIdSnapshot': serializer.toJson( exerciseImageMediaIdSnapshot, ), + 'exerciseImageMediaIdsSnapshotJson': serializer.toJson( + exerciseImageMediaIdsSnapshotJson, + ), 'exerciseVideoMediaIdSnapshot': serializer.toJson( exerciseVideoMediaIdSnapshot, ), @@ -12230,6 +12272,7 @@ class ProgramExercise extends DataClass implements Insertable { String? exerciseNameSnapshot, Value exerciseDescriptionSnapshot = const Value.absent(), Value exerciseImageMediaIdSnapshot = const Value.absent(), + Value exerciseImageMediaIdsSnapshotJson = const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), bool? exerciseArchivedSnapshot, bool? availableTimeSnapshot, @@ -12275,6 +12318,9 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot.present ? exerciseImageMediaIdSnapshot.value : this.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson: exerciseImageMediaIdsSnapshotJson.present + ? exerciseImageMediaIdsSnapshotJson.value + : this.exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot.present ? exerciseVideoMediaIdSnapshot.value : this.exerciseVideoMediaIdSnapshot, @@ -12347,6 +12393,10 @@ class ProgramExercise extends DataClass implements Insertable { exerciseImageMediaIdSnapshot: data.exerciseImageMediaIdSnapshot.present ? data.exerciseImageMediaIdSnapshot.value : this.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson: + data.exerciseImageMediaIdsSnapshotJson.present + ? data.exerciseImageMediaIdsSnapshotJson.value + : this.exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot: data.exerciseVideoMediaIdSnapshot.present ? data.exerciseVideoMediaIdSnapshot.value : this.exerciseVideoMediaIdSnapshot, @@ -12421,6 +12471,9 @@ class ProgramExercise extends DataClass implements Insertable { ..write( 'exerciseImageMediaIdSnapshot: $exerciseImageMediaIdSnapshot, ', ) + ..write( + 'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ', + ) ..write( 'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ', ) @@ -12463,6 +12516,7 @@ class ProgramExercise extends DataClass implements Insertable { exerciseNameSnapshot, exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot, availableTimeSnapshot, @@ -12504,6 +12558,8 @@ class ProgramExercise extends DataClass implements Insertable { this.exerciseDescriptionSnapshot && other.exerciseImageMediaIdSnapshot == this.exerciseImageMediaIdSnapshot && + other.exerciseImageMediaIdsSnapshotJson == + this.exerciseImageMediaIdsSnapshotJson && other.exerciseVideoMediaIdSnapshot == this.exerciseVideoMediaIdSnapshot && other.exerciseArchivedSnapshot == this.exerciseArchivedSnapshot && @@ -12542,6 +12598,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { final Value exerciseNameSnapshot; final Value exerciseDescriptionSnapshot; final Value exerciseImageMediaIdSnapshot; + final Value exerciseImageMediaIdsSnapshotJson; final Value exerciseVideoMediaIdSnapshot; final Value exerciseArchivedSnapshot; final Value availableTimeSnapshot; @@ -12578,6 +12635,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { this.exerciseNameSnapshot = const Value.absent(), this.exerciseDescriptionSnapshot = const Value.absent(), this.exerciseImageMediaIdSnapshot = const Value.absent(), + this.exerciseImageMediaIdsSnapshotJson = const Value.absent(), this.exerciseVideoMediaIdSnapshot = const Value.absent(), this.exerciseArchivedSnapshot = const Value.absent(), this.availableTimeSnapshot = const Value.absent(), @@ -12615,6 +12673,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { required String exerciseNameSnapshot, this.exerciseDescriptionSnapshot = const Value.absent(), this.exerciseImageMediaIdSnapshot = const Value.absent(), + this.exerciseImageMediaIdsSnapshotJson = const Value.absent(), this.exerciseVideoMediaIdSnapshot = const Value.absent(), this.exerciseArchivedSnapshot = const Value.absent(), required bool availableTimeSnapshot, @@ -12667,6 +12726,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { Expression? exerciseNameSnapshot, Expression? exerciseDescriptionSnapshot, Expression? exerciseImageMediaIdSnapshot, + Expression? exerciseImageMediaIdsSnapshotJson, Expression? exerciseVideoMediaIdSnapshot, Expression? exerciseArchivedSnapshot, Expression? availableTimeSnapshot, @@ -12708,6 +12768,9 @@ class ProgramExercisesCompanion extends UpdateCompanion { 'exercise_description_snapshot': exerciseDescriptionSnapshot, if (exerciseImageMediaIdSnapshot != null) 'exercise_image_media_id_snapshot': exerciseImageMediaIdSnapshot, + if (exerciseImageMediaIdsSnapshotJson != null) + 'exercise_image_media_ids_snapshot_json': + exerciseImageMediaIdsSnapshotJson, if (exerciseVideoMediaIdSnapshot != null) 'exercise_video_media_id_snapshot': exerciseVideoMediaIdSnapshot, if (exerciseArchivedSnapshot != null) @@ -12755,6 +12818,7 @@ class ProgramExercisesCompanion extends UpdateCompanion { Value? exerciseNameSnapshot, Value? exerciseDescriptionSnapshot, Value? exerciseImageMediaIdSnapshot, + Value? exerciseImageMediaIdsSnapshotJson, Value? exerciseVideoMediaIdSnapshot, Value? exerciseArchivedSnapshot, Value? availableTimeSnapshot, @@ -12794,6 +12858,9 @@ class ProgramExercisesCompanion extends UpdateCompanion { exerciseDescriptionSnapshot ?? this.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot ?? this.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson: + exerciseImageMediaIdsSnapshotJson ?? + this.exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot ?? this.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: @@ -12883,6 +12950,11 @@ class ProgramExercisesCompanion extends UpdateCompanion { exerciseImageMediaIdSnapshot.value, ); } + if (exerciseImageMediaIdsSnapshotJson.present) { + map['exercise_image_media_ids_snapshot_json'] = Variable( + exerciseImageMediaIdsSnapshotJson.value, + ); + } if (exerciseVideoMediaIdSnapshot.present) { map['exercise_video_media_id_snapshot'] = Variable( exerciseVideoMediaIdSnapshot.value, @@ -12974,6 +13046,9 @@ class ProgramExercisesCompanion extends UpdateCompanion { ..write( 'exerciseImageMediaIdSnapshot: $exerciseImageMediaIdSnapshot, ', ) + ..write( + 'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ', + ) ..write( 'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ', ) @@ -25793,6 +25868,7 @@ typedef $$ProgramExercisesTableCreateCompanionBuilder = required String exerciseNameSnapshot, Value exerciseDescriptionSnapshot, Value exerciseImageMediaIdSnapshot, + Value exerciseImageMediaIdsSnapshotJson, Value exerciseVideoMediaIdSnapshot, Value exerciseArchivedSnapshot, required bool availableTimeSnapshot, @@ -25831,6 +25907,7 @@ typedef $$ProgramExercisesTableUpdateCompanionBuilder = Value exerciseNameSnapshot, Value exerciseDescriptionSnapshot, Value exerciseImageMediaIdSnapshot, + Value exerciseImageMediaIdsSnapshotJson, Value exerciseVideoMediaIdSnapshot, Value exerciseArchivedSnapshot, Value availableTimeSnapshot, @@ -26019,6 +26096,12 @@ class $$ProgramExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get exerciseImageMediaIdsSnapshotJson => + $composableBuilder( + column: $table.exerciseImageMediaIdsSnapshotJson, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => ColumnFilters(column), @@ -26271,6 +26354,12 @@ class $$ProgramExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get exerciseImageMediaIdsSnapshotJson => + $composableBuilder( + column: $table.exerciseImageMediaIdsSnapshotJson, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => ColumnOrderings(column), @@ -26511,6 +26600,12 @@ class $$ProgramExercisesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get exerciseImageMediaIdsSnapshotJson => + $composableBuilder( + column: $table.exerciseImageMediaIdsSnapshotJson, + builder: (column) => column, + ); + GeneratedColumn get exerciseArchivedSnapshot => $composableBuilder( column: $table.exerciseArchivedSnapshot, builder: (column) => column, @@ -26736,6 +26831,8 @@ class $$ProgramExercisesTableTableManager const Value.absent(), Value exerciseImageMediaIdSnapshot = const Value.absent(), + Value exerciseImageMediaIdsSnapshotJson = + const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), Value exerciseArchivedSnapshot = const Value.absent(), @@ -26773,6 +26870,8 @@ class $$ProgramExercisesTableTableManager exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson: + exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, @@ -26813,6 +26912,8 @@ class $$ProgramExercisesTableTableManager const Value.absent(), Value exerciseImageMediaIdSnapshot = const Value.absent(), + Value exerciseImageMediaIdsSnapshotJson = + const Value.absent(), Value exerciseVideoMediaIdSnapshot = const Value.absent(), Value exerciseArchivedSnapshot = const Value.absent(), @@ -26850,6 +26951,8 @@ class $$ProgramExercisesTableTableManager exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshotJson: + exerciseImageMediaIdsSnapshotJson, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 4137bcb..bbdcc1f 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import '../../application/application.dart'; @@ -1208,6 +1210,9 @@ db.ProgramExercisesCompanion _programExerciseCompanion( exerciseNameSnapshot: Value(exercise.exerciseNameSnapshot), exerciseDescriptionSnapshot: Value(exercise.exerciseDescriptionSnapshot), exerciseImageMediaIdSnapshot: Value(exercise.exerciseImageMediaIdSnapshot), + exerciseImageMediaIdsSnapshotJson: Value( + _encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot), + ), exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot), exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot), availableTimeSnapshot: Value(exercise.availableTimeSnapshot), @@ -1239,6 +1244,10 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) { exerciseNameSnapshot: row.exerciseNameSnapshot, exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: _decodeImageMediaIdsSnapshot( + row.exerciseImageMediaIdsSnapshotJson, + row.exerciseImageMediaIdSnapshot, + ), exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, availableTimeSnapshot: row.availableTimeSnapshot, @@ -1766,6 +1775,29 @@ domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) => ), }; +String? _encodeImageMediaIdsSnapshot(List imageMediaIds) { + return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds); +} + +List _decodeImageMediaIdsSnapshot( + String? encoded, + String? fallbackImageMediaId, +) { + if (encoded == null || encoded.trim().isEmpty) { + return [if (fallbackImageMediaId != null) fallbackImageMediaId]; + } + final decoded = jsonDecode(encoded); + if (decoded is! List) { + throw const domain.DomainException( + 'Invalid exercise image media ids snapshot.', + ); + } + return decoded + .whereType() + .where((id) => id.trim().isNotEmpty) + .toList(growable: false); +} + domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveWorkoutStatus.running, 'paused' => domain.ActiveWorkoutStatus.paused, diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 2c9a705..d1120ff 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -128,6 +128,7 @@ class ProgramExercises extends SyncableTable { @ReferenceName('programExerciseImageSnapshotReferences') TextColumn get exerciseImageMediaIdSnapshot => text().nullable().references(MediaAssets, #id)(); + TextColumn get exerciseImageMediaIdsSnapshotJson => text().nullable()(); @ReferenceName('programExerciseVideoSnapshotReferences') TextColumn get exerciseVideoMediaIdSnapshot => diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index a358b66..d6103de 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -870,6 +870,7 @@ final class _ProgramExerciseDraft { required this.exerciseNameSnapshot, this.exerciseDescriptionSnapshot, this.exerciseImageMediaIdSnapshot, + this.exerciseImageMediaIdsSnapshot = const [], this.exerciseVideoMediaIdSnapshot, this.exerciseArchivedSnapshot = false, required this.availableTimeSnapshot, @@ -897,6 +898,7 @@ final class _ProgramExerciseDraft { exerciseNameSnapshot: exercise.name, exerciseDescriptionSnapshot: exercise.description, exerciseImageMediaIdSnapshot: exercise.imageMediaId, + exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseArchivedSnapshot: exercise.archivedAt != null, availableTimeSnapshot: exercise.hasTimeMeasure, @@ -927,6 +929,7 @@ final class _ProgramExerciseDraft { exerciseNameSnapshot: exercise.exerciseNameSnapshot, exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot, availableTimeSnapshot: exercise.availableTimeSnapshot, @@ -955,6 +958,7 @@ final class _ProgramExerciseDraft { final String exerciseNameSnapshot; final String? exerciseDescriptionSnapshot; final String? exerciseImageMediaIdSnapshot; + final List exerciseImageMediaIdsSnapshot; final String? exerciseVideoMediaIdSnapshot; final bool exerciseArchivedSnapshot; final bool availableTimeSnapshot; @@ -979,6 +983,7 @@ final class _ProgramExerciseDraft { exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, @@ -1006,6 +1011,7 @@ final class _ProgramExerciseDraft { exerciseNameSnapshot: exerciseNameSnapshot, exerciseDescriptionSnapshot: exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot, + exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot, exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: exerciseArchivedSnapshot, availableTimeSnapshot: availableTimeSnapshot, diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 5dec571..5366a10 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -130,6 +130,20 @@ final class _WorkoutExecutionScreenState extends State { ), const SizedBox(height: 16), Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium), + if (_exercise.hasMedia) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Tooltip( + message: 'Voir les médias de l’exercice', + child: OutlinedButton.icon( + onPressed: () => _openExerciseMedia(_exercise), + icon: const Icon(Icons.perm_media_outlined), + label: const Text('Voir médias'), + ), + ), + ), + ], const SizedBox(height: 16), SetMeasureInput( exercise: _exercise, @@ -183,6 +197,19 @@ final class _WorkoutExecutionScreenState extends State { fontSize: 30, ), ), + if (nextExercise.hasMedia) ...[ + const SizedBox(height: 12), + Center( + child: Tooltip( + message: 'Voir les médias de l’exercice', + child: OutlinedButton.icon( + onPressed: () => _openExerciseMedia(nextExercise), + icon: const Icon(Icons.perm_media_outlined), + label: const Text('Voir médias'), + ), + ), + ), + ], ], const Spacer(), Row( @@ -282,6 +309,23 @@ final class _WorkoutExecutionScreenState extends State { ); } + Future _openExerciseMedia(ExecutionExercise exercise) async { + if (!exercise.hasMedia) { + return; + } + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _ExerciseMediaSheet( + exercise: exercise, + restRemainingSeconds: _mode == WorkoutExecutionMode.rest + ? _remainingRestSeconds + : null, + ), + ); + } + bool get _canPauseFromNavigation { return _mode == WorkoutExecutionMode.active || _mode == WorkoutExecutionMode.rest; @@ -816,6 +860,228 @@ final class _RestPlanBanner extends StatelessWidget { } } +enum _MediaSheetSection { images, video } + +final class _ExerciseMediaSheet extends StatefulWidget { + const _ExerciseMediaSheet({ + required this.exercise, + this.restRemainingSeconds, + }); + + final ExecutionExercise exercise; + final int? restRemainingSeconds; + + @override + State<_ExerciseMediaSheet> createState() => _ExerciseMediaSheetState(); +} + +final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> { + late final PageController _pageController; + late _MediaSheetSection _section; + var _imageIndex = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + _section = widget.exercise.imageMediaIds.isNotEmpty + ? _MediaSheetSection.images + : _MediaSheetSection.video; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final exercise = widget.exercise; + final hasImages = exercise.imageMediaIds.isNotEmpty; + final hasVideo = exercise.videoMediaId != null; + return SizedBox( + height: MediaQuery.of(context).size.height * 0.95, + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Médias de l’exercice'), + Text(exercise.name, style: Theme.of(context).textTheme.bodySmall), + ], + ), + automaticallyImplyLeading: false, + actions: [ + IconButton( + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (widget.restRemainingSeconds != null) ...[ + _RestPlanBanner(remainingSeconds: widget.restRemainingSeconds!), + const SizedBox(height: 16), + ], + if (hasImages && hasVideo) ...[ + Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: const Text('Images'), + selected: _section == _MediaSheetSection.images, + onSelected: (_) { + setState(() => _section = _MediaSheetSection.images); + }, + ), + ChoiceChip( + label: const Text('Vidéo'), + selected: _section == _MediaSheetSection.video, + onSelected: (_) { + setState(() => _section = _MediaSheetSection.video); + }, + ), + ], + ), + const SizedBox(height: 16), + ], + if (_section == _MediaSheetSection.images && hasImages) + _ImageMediaGallery( + imageMediaIds: exercise.imageMediaIds, + pageController: _pageController, + currentIndex: _imageIndex, + onPageChanged: (index) => setState(() => _imageIndex = index), + ) + else if (hasVideo) + _VideoMediaPanel(videoMediaId: exercise.videoMediaId!), + ], + ), + ), + ); + } +} + +final class _ImageMediaGallery extends StatelessWidget { + const _ImageMediaGallery({ + required this.imageMediaIds, + required this.pageController, + required this.currentIndex, + required this.onPageChanged, + }); + + final List imageMediaIds; + final PageController pageController; + final int currentIndex; + final ValueChanged onPageChanged; + + @override + Widget build(BuildContext context) { + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: Text( + '${currentIndex + 1}/${imageMediaIds.length}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 320, + child: PageView.builder( + controller: pageController, + itemCount: imageMediaIds.length, + onPageChanged: onPageChanged, + itemBuilder: (context, index) { + return _ImageMediaPage( + mediaAssetId: imageMediaIds[index], + index: index, + ); + }, + ), + ), + ], + ), + ); + } +} + +final class _ImageMediaPage extends StatelessWidget { + const _ImageMediaPage({required this.mediaAssetId, required this.index}); + + final String mediaAssetId; + final int index; + + @override + Widget build(BuildContext context) { + final tokens = courtBlazerTokensOf(context); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: tokens.border), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_outlined, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Image ${index + 1}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text(mediaAssetId, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); + } +} + +final class _VideoMediaPanel extends StatelessWidget { + const _VideoMediaPanel({required this.videoMediaId}); + + final String videoMediaId; + + @override + Widget build(BuildContext context) { + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(16), + child: SizedBox( + height: 260, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.play_circle_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text('Vidéo', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text(videoMediaId, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ); + } +} + final class _WorkoutPlanSetTile extends StatelessWidget { const _WorkoutPlanSetTile({ required this.position, @@ -1364,6 +1630,8 @@ final class ExecutionExercise { required this.repsEnabled, required this.scoreEnabled, required this.restSeconds, + this.imageMediaIds = const [], + this.videoMediaId, this.scoreInputMode = ScoreInputMode.manual, this.scoreLabel, this.targetTimeSeconds, @@ -1386,6 +1654,10 @@ final class ExecutionExercise { repsEnabled: exercise['repsEnabled'] as bool, scoreEnabled: exercise['scoreEnabled'] as bool, restSeconds: exercise['restSecondsOverride'] as int? ?? 0, + imageMediaIds: _imageMediaIdsFromSnapshot(exercise), + videoMediaId: + exercise['videoMediaIdSnapshot'] as String? ?? + exercise['exerciseVideoMediaIdSnapshot'] as String?, scoreInputMode: _scoreInputModeFromSnapshot( exercise['scoreInputModeSnapshot'], ), @@ -1410,6 +1682,8 @@ final class ExecutionExercise { final bool repsEnabled; final bool scoreEnabled; final int restSeconds; + final List imageMediaIds; + final String? videoMediaId; final ScoreInputMode scoreInputMode; final String? scoreLabel; final int? targetTimeSeconds; @@ -1420,6 +1694,26 @@ final class ExecutionExercise { bool get manualScoreEnabled { return scoreEnabled && scoreInputMode == ScoreInputMode.manual; } + + bool get hasMedia { + return imageMediaIds.isNotEmpty || videoMediaId != null; + } +} + +List _imageMediaIdsFromSnapshot(Map exercise) { + final imageMediaIds = + exercise['imageMediaIdsSnapshot'] ?? + exercise['exerciseImageMediaIdsSnapshot']; + if (imageMediaIds is List) { + return imageMediaIds + .whereType() + .where((id) => id.trim().isNotEmpty) + .toList(growable: false); + } + final imageMediaId = exercise['exerciseImageMediaIdSnapshot'] as String?; + return [ + if (imageMediaId != null && imageMediaId.trim().isNotEmpty) imageMediaId, + ]; } ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 1beef85..ea3a585 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -225,6 +225,66 @@ void main() { expect(updated.imageMediaId, 'media-1'); }); + test('program exercise snapshot keeps the full image gallery', () async { + final exerciseRepository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + imageMediaIds: const ['media-1', 'media-2', 'media-3'], + videoMediaId: 'video-1', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final programRepository = _FakeProgramRepository() + ..programs.add( + Program( + metadata: _metadata('program-1'), + name: 'Jambes', + defaultRestSeconds: 60, + ), + ); + final useCase = ProgramUseCases( + programRepository: programRepository, + exerciseRepository: exerciseRepository, + templateRepository: _FakeWorkoutTemplateRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final snapshot = await useCase.addExercise( + programId: 'program-1', + exerciseId: 'exercise-1', + position: 0, + setsCount: 3, + enabledMeasures: const {WorkoutMeasure.reps}, + ); + + expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1'); + expect(snapshot.exerciseImageMediaIdsSnapshot, [ + 'media-1', + 'media-2', + 'media-3', + ]); + expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1'); + expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [ + 'media-1', + 'media-2', + 'media-3', + ]); + expect( + programRepository + .programs + .single + .exercises + .single + .exerciseImageMediaIdsSnapshot, + snapshot.exerciseImageMediaIdsSnapshot, + ); + }); + test('delete exercise removes it from referencing programs', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 4586fd3..0f2ef33 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -116,6 +116,131 @@ void main() { expect(find.text('Pas de repos'), findsOneWidget); }); + testWidgets('le bouton médias est absent sans média', (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot(), + ); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases( + _FakeActiveSessionRepository(), + clock, + ), + closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + expect(find.text('Voir médias'), findsNothing); + expect(find.byTooltip('Voir les médias de l’exercice'), findsNothing); + }); + + testWidgets('le bouton médias ouvre la sheet si médias présents', ( + tester, + ) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + imageMediaIds: const ['image-1'], + videoMediaId: 'video-1', + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases( + _FakeActiveSessionRepository(), + clock, + ), + closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + expect(find.byTooltip('Voir les médias de l’exercice'), findsOneWidget); + + await tester.tap(find.text('Voir médias')); + await tester.pumpAndSettle(); + + expect(find.text('Médias de l’exercice'), findsOneWidget); + expect(find.text('Images'), findsOneWidget); + expect(find.text('Vidéo'), findsOneWidget); + expect(find.text('1/1'), findsOneWidget); + expect(find.text('Image 1'), findsOneWidget); + }); + + testWidgets('la galerie médias navigue entre les images', (tester) async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + sourceWorkoutTemplateId: 'template-1', + status: ActiveWorkoutStatus.running, + startedAt: DateTime.utc(2026, 7, 17, 12), + lastPersistedAt: DateTime.utc(2026, 7, 17, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _sessionSnapshot( + imageMediaIds: const ['image-1', 'image-2', 'image-3'], + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases( + _FakeActiveSessionRepository(), + clock, + ), + closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + ), + ), + ); + + await tester.tap(find.text('Voir médias')); + await tester.pumpAndSettle(); + + expect(find.text('1/3'), findsOneWidget); + + await tester.drag(find.byType(PageView), const Offset(-400, 0)); + await tester.pumpAndSettle(); + + expect(find.text('2/3'), findsOneWidget); + expect(find.text('Image 2'), findsOneWidget); + }); + testWidgets('un repos actif est repris avec le temps restant recalculé', ( tester, ) async { @@ -559,6 +684,8 @@ String _sessionSnapshot({ double targetScore = 80, String scoreUnit = 'kg', ScoreInputMode scoreInputMode = ScoreInputMode.manual, + List imageMediaIds = const [], + String? videoMediaId, }) { return jsonEncode({ 'name': 'Séance jambes', @@ -582,6 +709,10 @@ String _sessionSnapshot({ 'scoreLabelSnapshot': 'Score', 'scoreUnitSnapshot': scoreUnit, 'restSecondsOverride': restSeconds, + 'exerciseImageMediaIdsSnapshot': imageMediaIds, + 'imageMediaIdsSnapshot': imageMediaIds, + 'exerciseVideoMediaIdSnapshot': videoMediaId, + 'videoMediaIdSnapshot': videoMediaId, }, ], }),