From ff6b79b66e1877d2ba93cabe01ba8c0870c052f4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:16:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(exercice):=20affichage=20des=20m=C3=A9?= =?UTF-8?q?dias=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, }, ], }), From 93ff1a06e0d494888f0380a49ed76f8b0fea215e Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 14:16:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20la=20m?= =?UTF-8?q?=C3=A9moire=20et=20le=20ticket=20#36=20apr=C3=A8s=20l'affichage?= =?UTF-8?q?=20des=20m=C3=A9dias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/memory/MEMORY.md | 1 + .../gametime-ux-exercise-media-viewer.md | 24 +++++++++++++++++++ .ideai/tickets/36/carnet.md | 11 ++++++--- .ideai/tickets/36/issue.md | 8 +++---- .ideai/tickets/index.json | 4 ++-- 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 .ideai/memory/gametime-ux-exercise-media-viewer.md diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 584096c..ff1decd 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -12,3 +12,4 @@ - [gametime-resume-plan-2026-07-18](gametime-resume-plan-2026-07-18.md) — memory note gametime-resume-plan-2026-07-18 - [gametime-ux-exercise-default-targets](gametime-ux-exercise-default-targets.md) — memory note gametime-ux-exercise-default-targets - [gametime-ux-series-counter](gametime-ux-series-counter.md) — memory note gametime-ux-series-counter +- [gametime-ux-exercise-media-viewer](gametime-ux-exercise-media-viewer.md) — memory note gametime-ux-exercise-media-viewer diff --git a/.ideai/memory/gametime-ux-exercise-media-viewer.md b/.ideai/memory/gametime-ux-exercise-media-viewer.md new file mode 100644 index 0000000..e88b331 --- /dev/null +++ b/.ideai/memory/gametime-ux-exercise-media-viewer.md @@ -0,0 +1,24 @@ +--- +name: gametime-ux-exercise-media-viewer +description: memory note gametime-ux-exercise-media-viewer +metadata: + type: project +--- +# GameTime — Affichage des médias pendant l'exercice (ticket #36, UX 2026-07-18) + +Décision : pas d'affichage permanent des médias dans le flux principal. Bouton secondaire "Voir médias" (OutlinedButton.icon, icône `Icons.perm_media_outlined`) proche du nom de l'exercice actif, affiché UNIQUEMENT si l'exercice a au moins 1 image ou 1 vidéo (sinon pas de bouton du tout, ni désactivé). + +## Vue médias +Bottom sheet plein écran, titre "Médias de l'exercice" + nom. Si images ET vidéo : tabs/segmented "Images"/"Vidéo". Images : galerie swipe horizontal avec indicateur "1/5". Vidéo : lecteur avec contrôles natifs. Icône Fermer, pas d'action de modification depuis l'exécution. + +## Écran Repos +Sous "Ensuite : ", afficher aussi "Voir médias" si le prochain exercice en a — bon moment pour consulter les consignes sans gêner la série active. + +## Comportement séance +Ouvrir les médias NE met PAS la séance en pause ; le repos continue en arrière-plan. Bandeau "Repos en cours · MM:SS" dans la sheet si repos actif ; devient "Repos terminé" avec action "Reprendre la séance" s'il se termine pendant la consultation. + +## Style Court Blazer +Sheet fond surface, liseré supérieur crimson 2px sur le conteneur média, icônes/compteur en or, rayon 6px, pas d'ombre lourde — le média reste une aide contextuelle, pas l'élément dominant. + +## Impact technique important signalé par UX +Aujourd'hui seul `exerciseImageMediaIdSnapshot` (singulier) est propagé dans le snapshot de séance/exécution. Il faut l'étendre pour porter la galerie complète (`imageMediaIdsSnapshot`, jusqu'à 5, ordonnée) + `videoMediaIdSnapshot`, cohérent avec la galerie ajoutée au ticket #35. \ No newline at end of file diff --git a/.ideai/tickets/36/carnet.md b/.ideai/tickets/36/carnet.md index 790b1b8..261beaf 100644 --- a/.ideai/tickets/36/carnet.md +++ b/.ideai/tickets/36/carnet.md @@ -1,6 +1,11 @@ --- issueRef: "#36" -version: 6 -updatedBy: {"kind":"user"} -updatedAt: 1784329810425 +version: 9 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784376975122 --- +Décision UX consignée dans la mémoire "gametime-ux-exercise-media-viewer" — lis-la en entier avant d'implémenter. + +Découpage : +1. [DevBackend] Étendre le snapshot d'exécution pour porter la galerie complète d'images (imageMediaIdsSnapshot, jusqu'à 5, ordonnée, cohérente avec le ticket #35) + videoMediaIdSnapshot, au lieu du seul exerciseImageMediaIdSnapshot actuel. Propager jusqu'à ActiveWorkoutSession/l'écran d'exécution. +2. [DevFrontend] Bouton "Voir médias" (affiché seulement si médias présents) sur l'écran d'exécution actif ET sur l'écran Repos (pour le prochain exercice). Bottom sheet plein écran avec galerie d'images (swipe + indicateur X/Y) et lecteur vidéo si présente. N'affecte pas l'état pause/repos de la séance. \ No newline at end of file diff --git a/.ideai/tickets/36/issue.md b/.ideai/tickets/36/issue.md index e19d665..0eb7fd8 100644 --- a/.ideai/tickets/36/issue.md +++ b/.ideai/tickets/36/issue.md @@ -2,15 +2,15 @@ id: "1d0445c0-c8f7-4527-b948-42d894a9e265" number: 36 title: "Afficher les images et les vidéos" -status: "open" +status: "closed" priority: "medium" sprint: null links: [{"target":"#35","kind":"dependsOn"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784329666884 -updatedAt: 1784329810425 -version: 6 +updatedAt: 1784376975122 +version: 9 --- Pour chaque exercice on a la possibilité d'upload des images et une vidéo. J'iamerais que ces images et cette vidéo, s'il y en a, soit affichés pendant l'exercie, ou au moins qu'on ai la possibilité de les voir. Ces images et cette vidéo peuvent être dans le but de présenter et d'expliquer l'exercice, ils sont donc importants. Laisser UX décider de la façon de les afficher pour que ça ne surchage pas non plus l'interface et que ça reste dans la DA \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index e996d56..8ebcaa6 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -405,13 +405,13 @@ "issueRef": "#36", "path": "36", "title": "Afficher les images et les vidéos", - "status": "open", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784329810425 + "updatedAt": 1784376975122 }, { "issueRef": "#37",