feat(exercice): affichage des médias pendant l'exécution (ticket #36)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:16:31 +02:00
parent 7385c96517
commit ff6b79b66e
10 changed files with 654 additions and 1 deletions

View File

@ -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<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
@ -1501,6 +1504,8 @@ List<ProgramExercise> _repositionProgramExercises(
exercises[index].exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot:
exercises[index].exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot:
exercises[index].exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot:
exercises[index].exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot,

View File

@ -303,6 +303,7 @@ final class ProgramExercise {
required String exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
List<String> 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<String> 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,

View File

@ -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<void> _migrateToSchema6() async {
await customStatement(
'ALTER TABLE program_exercises ADD COLUMN '
'exercise_image_media_ids_snapshot_json TEXT',
);
}
}

View File

@ -11148,6 +11148,17 @@ class $ProgramExercisesTable extends ProgramExercises
'REFERENCES media_assets (id)',
),
);
static const VerificationMeta _exerciseImageMediaIdsSnapshotJsonMeta =
const VerificationMeta('exerciseImageMediaIdsSnapshotJson');
@override
late final GeneratedColumn<String> exerciseImageMediaIdsSnapshotJson =
GeneratedColumn<String>(
'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<ProgramExercise> {
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<ProgramExercise> {
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<ProgramExercise> {
exerciseImageMediaIdSnapshot,
);
}
if (!nullToAbsent || exerciseImageMediaIdsSnapshotJson != null) {
map['exercise_image_media_ids_snapshot_json'] = Variable<String>(
exerciseImageMediaIdsSnapshotJson,
);
}
if (!nullToAbsent || exerciseVideoMediaIdSnapshot != null) {
map['exercise_video_media_id_snapshot'] = Variable<String>(
exerciseVideoMediaIdSnapshot,
@ -12056,6 +12088,10 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
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<ProgramExercise> {
exerciseImageMediaIdSnapshot: serializer.fromJson<String?>(
json['exerciseImageMediaIdSnapshot'],
),
exerciseImageMediaIdsSnapshotJson: serializer.fromJson<String?>(
json['exerciseImageMediaIdsSnapshotJson'],
),
exerciseVideoMediaIdSnapshot: serializer.fromJson<String?>(
json['exerciseVideoMediaIdSnapshot'],
),
@ -12186,6 +12225,9 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
'exerciseImageMediaIdSnapshot': serializer.toJson<String?>(
exerciseImageMediaIdSnapshot,
),
'exerciseImageMediaIdsSnapshotJson': serializer.toJson<String?>(
exerciseImageMediaIdsSnapshotJson,
),
'exerciseVideoMediaIdSnapshot': serializer.toJson<String?>(
exerciseVideoMediaIdSnapshot,
),
@ -12230,6 +12272,7 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
String? exerciseNameSnapshot,
Value<String?> exerciseDescriptionSnapshot = const Value.absent(),
Value<String?> exerciseImageMediaIdSnapshot = const Value.absent(),
Value<String?> exerciseImageMediaIdsSnapshotJson = const Value.absent(),
Value<String?> exerciseVideoMediaIdSnapshot = const Value.absent(),
bool? exerciseArchivedSnapshot,
bool? availableTimeSnapshot,
@ -12275,6 +12318,9 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
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<ProgramExercise> {
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<ProgramExercise> {
..write(
'exerciseImageMediaIdSnapshot: $exerciseImageMediaIdSnapshot, ',
)
..write(
'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ',
)
..write(
'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ',
)
@ -12463,6 +12516,7 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
exerciseNameSnapshot,
exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshotJson,
exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot,
availableTimeSnapshot,
@ -12504,6 +12558,8 @@ class ProgramExercise extends DataClass implements Insertable<ProgramExercise> {
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<ProgramExercise> {
final Value<String> exerciseNameSnapshot;
final Value<String?> exerciseDescriptionSnapshot;
final Value<String?> exerciseImageMediaIdSnapshot;
final Value<String?> exerciseImageMediaIdsSnapshotJson;
final Value<String?> exerciseVideoMediaIdSnapshot;
final Value<bool> exerciseArchivedSnapshot;
final Value<bool> availableTimeSnapshot;
@ -12578,6 +12635,7 @@ class ProgramExercisesCompanion extends UpdateCompanion<ProgramExercise> {
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<ProgramExercise> {
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<ProgramExercise> {
Expression<String>? exerciseNameSnapshot,
Expression<String>? exerciseDescriptionSnapshot,
Expression<String>? exerciseImageMediaIdSnapshot,
Expression<String>? exerciseImageMediaIdsSnapshotJson,
Expression<String>? exerciseVideoMediaIdSnapshot,
Expression<bool>? exerciseArchivedSnapshot,
Expression<bool>? availableTimeSnapshot,
@ -12708,6 +12768,9 @@ class ProgramExercisesCompanion extends UpdateCompanion<ProgramExercise> {
'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<ProgramExercise> {
Value<String>? exerciseNameSnapshot,
Value<String?>? exerciseDescriptionSnapshot,
Value<String?>? exerciseImageMediaIdSnapshot,
Value<String?>? exerciseImageMediaIdsSnapshotJson,
Value<String?>? exerciseVideoMediaIdSnapshot,
Value<bool>? exerciseArchivedSnapshot,
Value<bool>? availableTimeSnapshot,
@ -12794,6 +12858,9 @@ class ProgramExercisesCompanion extends UpdateCompanion<ProgramExercise> {
exerciseDescriptionSnapshot ?? this.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot:
exerciseImageMediaIdSnapshot ?? this.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshotJson:
exerciseImageMediaIdsSnapshotJson ??
this.exerciseImageMediaIdsSnapshotJson,
exerciseVideoMediaIdSnapshot:
exerciseVideoMediaIdSnapshot ?? this.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot:
@ -12883,6 +12950,11 @@ class ProgramExercisesCompanion extends UpdateCompanion<ProgramExercise> {
exerciseImageMediaIdSnapshot.value,
);
}
if (exerciseImageMediaIdsSnapshotJson.present) {
map['exercise_image_media_ids_snapshot_json'] = Variable<String>(
exerciseImageMediaIdsSnapshotJson.value,
);
}
if (exerciseVideoMediaIdSnapshot.present) {
map['exercise_video_media_id_snapshot'] = Variable<String>(
exerciseVideoMediaIdSnapshot.value,
@ -12974,6 +13046,9 @@ class ProgramExercisesCompanion extends UpdateCompanion<ProgramExercise> {
..write(
'exerciseImageMediaIdSnapshot: $exerciseImageMediaIdSnapshot, ',
)
..write(
'exerciseImageMediaIdsSnapshotJson: $exerciseImageMediaIdsSnapshotJson, ',
)
..write(
'exerciseVideoMediaIdSnapshot: $exerciseVideoMediaIdSnapshot, ',
)
@ -25793,6 +25868,7 @@ typedef $$ProgramExercisesTableCreateCompanionBuilder =
required String exerciseNameSnapshot,
Value<String?> exerciseDescriptionSnapshot,
Value<String?> exerciseImageMediaIdSnapshot,
Value<String?> exerciseImageMediaIdsSnapshotJson,
Value<String?> exerciseVideoMediaIdSnapshot,
Value<bool> exerciseArchivedSnapshot,
required bool availableTimeSnapshot,
@ -25831,6 +25907,7 @@ typedef $$ProgramExercisesTableUpdateCompanionBuilder =
Value<String> exerciseNameSnapshot,
Value<String?> exerciseDescriptionSnapshot,
Value<String?> exerciseImageMediaIdSnapshot,
Value<String?> exerciseImageMediaIdsSnapshotJson,
Value<String?> exerciseVideoMediaIdSnapshot,
Value<bool> exerciseArchivedSnapshot,
Value<bool> availableTimeSnapshot,
@ -26019,6 +26096,12 @@ class $$ProgramExercisesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get exerciseImageMediaIdsSnapshotJson =>
$composableBuilder(
column: $table.exerciseImageMediaIdsSnapshotJson,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get exerciseArchivedSnapshot => $composableBuilder(
column: $table.exerciseArchivedSnapshot,
builder: (column) => ColumnFilters(column),
@ -26271,6 +26354,12 @@ class $$ProgramExercisesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get exerciseImageMediaIdsSnapshotJson =>
$composableBuilder(
column: $table.exerciseImageMediaIdsSnapshotJson,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get exerciseArchivedSnapshot => $composableBuilder(
column: $table.exerciseArchivedSnapshot,
builder: (column) => ColumnOrderings(column),
@ -26511,6 +26600,12 @@ class $$ProgramExercisesTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get exerciseImageMediaIdsSnapshotJson =>
$composableBuilder(
column: $table.exerciseImageMediaIdsSnapshotJson,
builder: (column) => column,
);
GeneratedColumn<bool> get exerciseArchivedSnapshot => $composableBuilder(
column: $table.exerciseArchivedSnapshot,
builder: (column) => column,
@ -26736,6 +26831,8 @@ class $$ProgramExercisesTableTableManager
const Value.absent(),
Value<String?> exerciseImageMediaIdSnapshot =
const Value.absent(),
Value<String?> exerciseImageMediaIdsSnapshotJson =
const Value.absent(),
Value<String?> exerciseVideoMediaIdSnapshot =
const Value.absent(),
Value<bool> 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<String?> exerciseImageMediaIdSnapshot =
const Value.absent(),
Value<String?> exerciseImageMediaIdsSnapshotJson =
const Value.absent(),
Value<String?> exerciseVideoMediaIdSnapshot =
const Value.absent(),
Value<bool> exerciseArchivedSnapshot = const Value.absent(),
@ -26850,6 +26951,8 @@ class $$ProgramExercisesTableTableManager
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshotJson:
exerciseImageMediaIdsSnapshotJson,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,

View File

@ -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<String> imageMediaIds) {
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
}
List<String> _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<String>()
.where((id) => id.trim().isNotEmpty)
.toList(growable: false);
}
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
'running' => domain.ActiveWorkoutStatus.running,
'paused' => domain.ActiveWorkoutStatus.paused,

View File

@ -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 =>

View File

@ -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<String> 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,

View File

@ -130,6 +130,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
),
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 lexercice',
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<WorkoutExecutionScreen> {
fontSize: 30,
),
),
if (nextExercise.hasMedia) ...[
const SizedBox(height: 12),
Center(
child: Tooltip(
message: 'Voir les médias de lexercice',
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<WorkoutExecutionScreen> {
);
}
Future<void> _openExerciseMedia(ExecutionExercise exercise) async {
if (!exercise.hasMedia) {
return;
}
await showModalBottomSheet<void>(
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 lexercice'),
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<String> imageMediaIds;
final PageController pageController;
final int currentIndex;
final ValueChanged<int> 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<String> 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<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
final imageMediaIds =
exercise['imageMediaIdsSnapshot'] ??
exercise['exerciseImageMediaIdsSnapshot'];
if (imageMediaIds is List) {
return imageMediaIds
.whereType<String>()
.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) {

View File

@ -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(

View File

@ -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 lexercice'), 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 lexercice'), findsOneWidget);
await tester.tap(find.text('Voir médias'));
await tester.pumpAndSettle();
expect(find.text('Médias de lexercice'), 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<String> 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,
},
],
}),