merge(main): galerie de 5 images par exercice (ticket #35)

Fusionne feature/#35-plusieurs-images-exercice — analyze propre,
63/63 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:02:53 +02:00
12 changed files with 2262 additions and 308 deletions

View File

@ -1,6 +1,9 @@
--- ---
issueRef: "#35" issueRef: "#35"
version: 5 version: 8
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784329838645 updatedAt: 1784364636506
--- ---
Découpage :
1. [DevBackend] Remplacer le champ unique `imageMediaId` (String?) sur Exercise par une liste ordonnée d'images (max 5) — recommandation : table Drift dédiée `exercise_images` (exerciseId, mediaAssetId, position) plutôt qu'une colonne JSON, pour rester cohérent avec le style relationnel du reste du schéma. Garde `videoMediaId` inchangé (une seule vidéo, pas concerné par ce ticket). Use case pour ajouter/retirer/réordonner une image (max 5, message d'erreur si on tente d'en ajouter une 6e). Migration Drift (schemaVersion+1). Adapter le snapshot existant (exerciseImageMediaIdSnapshot sur ProgramExercise) en conséquence si pertinent — à toi de juger si le snapshot garde juste la première image comme vignette ou la liste complète (le ticket #36 gérera l'affichage complet, mais le snapshot doit au moins rester cohérent et ne pas casser).
2. [DevFrontend] Dans le formulaire d'exercice, remplacer le sélecteur d'image unique par une gestion de galerie (ajouter jusqu'à 5 images, les voir en miniature, les retirer individuellement). Message clair si on atteint la limite de 5.

View File

@ -2,15 +2,15 @@
id: "3f1a583a-320b-43c3-9993-52d249c9c430" id: "3f1a583a-320b-43c3-9993-52d249c9c430"
number: 35 number: 35
title: "Proposer de mettre plsuieurs images pour un exercice" title: "Proposer de mettre plsuieurs images pour un exercice"
status: "open" status: "closed"
priority: "low" priority: "low"
sprint: null sprint: null
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784329532159 createdAt: 1784329532159
updatedAt: 1784329838645 updatedAt: 1784364636506
version: 5 version: 8
--- ---
J'aimerais que lors de la création d'un exercice, ils oit possible d'associer jusqu'a 5 images J'aimerais que lors de la création d'un exercice, ils oit possible d'associer jusqu'a 5 images

View File

@ -393,13 +393,13 @@
"issueRef": "#35", "issueRef": "#35",
"path": "35", "path": "35",
"title": "Proposer de mettre plsuieurs images pour un exercice", "title": "Proposer de mettre plsuieurs images pour un exercice",
"status": "open", "status": "closed",
"priority": "low", "priority": "low",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784329838645 "updatedAt": 1784364636506
}, },
{ {
"issueRef": "#36", "issueRef": "#36",

View File

@ -24,6 +24,7 @@ final class ExerciseUseCases {
required String name, required String name,
String? description, String? description,
String? imageMediaId, String? imageMediaId,
List<String> imageMediaIds = const [],
String? videoMediaId, String? videoMediaId,
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -51,7 +52,7 @@ final class ExerciseUseCases {
metadata: _newMetadata(ids, originDeviceId, now), metadata: _newMetadata(ids, originDeviceId, now),
name: name, name: name,
description: description, description: description,
imageMediaId: imageMediaId, imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds),
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -81,6 +82,7 @@ final class ExerciseUseCases {
required String name, required String name,
String? description, String? description,
String? imageMediaId, String? imageMediaId,
Object? imageMediaIds = _useCaseUnchanged,
String? videoMediaId, String? videoMediaId,
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -125,7 +127,9 @@ final class ExerciseUseCases {
metadata: exercise.metadata.touch(clock.now()), metadata: exercise.metadata.touch(clock.now()),
name: name, name: name,
description: description, description: description,
imageMediaId: imageMediaId, imageMediaIds: imageMediaIds == _useCaseUnchanged
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
: imageMediaIds as List<String>,
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -151,7 +155,62 @@ final class ExerciseUseCases {
throw const DomainException('Exercise not found.'); throw const DomainException('Exercise not found.');
} }
final updated = exercise.copyWith( final updated = exercise.copyWith(
imageMediaId: mediaAssetId, imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> addImage({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> removeImage({
required String exerciseId,
required String mediaAssetId,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
final updated = exercise.copyWith(
imageMediaIds: exercise.imageMediaIds
.where((imageId) => imageId != mediaAssetId)
.toList(),
metadata: exercise.metadata.touch(clock.now()),
);
await repository.save(updated);
return updated;
}
Future<Exercise> reorderImages({
required String exerciseId,
required List<String> orderedMediaIds,
}) async {
final exercise = await repository.findById(exerciseId);
if (exercise == null) {
throw const DomainException('Exercise not found.');
}
if (!_sameImageSet(exercise.imageMediaIds, orderedMediaIds)) {
throw const DomainException(
'Reordered exercise images must match existing images.',
);
}
final updated = exercise.copyWith(
imageMediaIds: orderedMediaIds,
metadata: exercise.metadata.touch(clock.now()), metadata: exercise.metadata.touch(clock.now()),
); );
await repository.save(updated); await repository.save(updated);
@ -275,7 +334,10 @@ final class MediaUseCases {
} }
final updated = switch (media.kind) { final updated = switch (media.kind) {
MediaKind.image => exercise.copyWith( MediaKind.image => exercise.copyWith(
imageMediaId: media.metadata.id, imageMediaIds: _appendExerciseImage(
exercise.imageMediaIds,
media.metadata.id,
),
metadata: exercise.metadata.touch(clock.now()), metadata: exercise.metadata.touch(clock.now()),
), ),
MediaKind.video => exercise.copyWith( MediaKind.video => exercise.copyWith(
@ -1479,6 +1541,33 @@ List<WorkoutTemplateProgram> _repositionWorkoutTemplatePrograms(
]; ];
} }
List<String> _resolveExerciseImageIds(
String? imageMediaId,
List<String> imageMediaIds,
) {
if (imageMediaId != null) {
return [imageMediaId];
}
return imageMediaIds;
}
List<String> _appendExerciseImage(List<String> currentIds, String mediaId) {
if (currentIds.contains(mediaId)) {
return currentIds;
}
if (currentIds.length >= 5) {
throw const DomainException('An exercise cannot have more than 5 images.');
}
return [...currentIds, mediaId];
}
bool _sameImageSet(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
return left.toSet().containsAll(right) && right.toSet().containsAll(left);
}
void _validateExerciseDefaultTargets({ void _validateExerciseDefaultTargets({
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,

View File

@ -124,7 +124,8 @@ final class Exercise {
required this.metadata, required this.metadata,
required String name, required String name,
this.description, this.description,
this.imageMediaId, String? imageMediaId,
List<String> imageMediaIds = const [],
this.videoMediaId, this.videoMediaId,
required this.hasTimeMeasure, required this.hasTimeMeasure,
required this.hasRepsMeasure, required this.hasRepsMeasure,
@ -137,7 +138,12 @@ final class Exercise {
this.defaultTargetScore, this.defaultTargetScore,
this.defaultTargetScoreTimeMs, this.defaultTargetScoreTimeMs,
this.archivedAt, this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name') { }) : name = _nonBlank(name, 'Exercise name'),
imageMediaIds = _validatedImageMediaIds(
imageMediaIds.isEmpty && imageMediaId != null
? [imageMediaId]
: imageMediaIds,
) {
_requireAtLeastOneMeasure( _requireAtLeastOneMeasure(
hasTime: hasTimeMeasure, hasTime: hasTimeMeasure,
hasReps: hasRepsMeasure, hasReps: hasRepsMeasure,
@ -167,7 +173,7 @@ final class Exercise {
final EntityMetadata metadata; final EntityMetadata metadata;
final String name; final String name;
final String? description; final String? description;
final String? imageMediaId; final List<String> imageMediaIds;
final String? videoMediaId; final String? videoMediaId;
final bool hasTimeMeasure; final bool hasTimeMeasure;
final bool hasRepsMeasure; final bool hasRepsMeasure;
@ -187,6 +193,9 @@ final class Exercise {
if (hasScoreMeasure) WorkoutMeasure.score, if (hasScoreMeasure) WorkoutMeasure.score,
}; };
String? get imageMediaId =>
imageMediaIds.isEmpty ? null : imageMediaIds.first;
Exercise archive(DateTime now) { Exercise archive(DateTime now) {
return copyWith(archivedAt: now, metadata: metadata.touch(now)); return copyWith(archivedAt: now, metadata: metadata.touch(now));
} }
@ -196,6 +205,7 @@ final class Exercise {
String? name, String? name,
Object? description = _unchanged, Object? description = _unchanged,
Object? imageMediaId = _unchanged, Object? imageMediaId = _unchanged,
Object? imageMediaIds = _unchanged,
Object? videoMediaId = _unchanged, Object? videoMediaId = _unchanged,
bool? hasTimeMeasure, bool? hasTimeMeasure,
bool? hasRepsMeasure, bool? hasRepsMeasure,
@ -215,9 +225,11 @@ final class Exercise {
description: description == _unchanged description: description == _unchanged
? this.description ? this.description
: description as String?, : description as String?,
imageMediaId: imageMediaId == _unchanged imageMediaIds: imageMediaIds == _unchanged
? this.imageMediaId ? imageMediaId == _unchanged
: imageMediaId as String?, ? this.imageMediaIds
: [if (imageMediaId != null) imageMediaId as String]
: imageMediaIds as List<String>,
videoMediaId: videoMediaId == _unchanged videoMediaId: videoMediaId == _unchanged
? this.videoMediaId ? this.videoMediaId
: videoMediaId as String?, : videoMediaId as String?,
@ -921,6 +933,19 @@ String _nonBlank(String? value, String label) {
return trimmed; return trimmed;
} }
List<String> _validatedImageMediaIds(List<String> ids) {
if (ids.length > 5) {
throw const DomainException('An exercise cannot have more than 5 images.');
}
final normalized = ids
.map((id) => _nonBlank(id, 'Image media id'))
.toList(growable: false);
if (normalized.toSet().length != normalized.length) {
throw const DomainException('Exercise images must be unique.');
}
return normalized;
}
void _requireAtLeastOneMeasure({ void _requireAtLeastOneMeasure({
required bool hasTime, required bool hasTime,
required bool hasReps, required bool hasReps,

View File

@ -13,6 +13,7 @@ part 'app_database.g.dart';
ActiveWorkoutSessions, ActiveWorkoutSessions,
ChangeLogEntries, ChangeLogEntries,
Exercises, Exercises,
ExerciseImages,
MediaAssets, MediaAssets,
ProgramExercises, ProgramExercises,
Programs, Programs,
@ -36,7 +37,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 4; int get schemaVersion => 5;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -63,6 +64,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 4) { if (from < 4) {
await _migrateToSchema4(); await _migrateToSchema4();
} }
if (from < 5) {
await _migrateToSchema5(migrator);
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -95,6 +99,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_program_exercises_program_id ' 'CREATE INDEX IF NOT EXISTS idx_program_exercises_program_id '
'ON program_exercises (program_id)', 'ON program_exercises (program_id)',
); );
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id '
'ON exercise_images (exercise_id)',
);
await customStatement( await customStatement(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)', 'ON workout_template_programs (workout_template_id)',
@ -147,6 +155,7 @@ const _syncableTableNames = [
'active_set_results', 'active_set_results',
'active_workout_sessions', 'active_workout_sessions',
'exercises', 'exercises',
'exercise_images',
'media_assets', 'media_assets',
'program_exercises', 'program_exercises',
'programs', 'programs',
@ -226,4 +235,19 @@ extension on AppDatabase {
'default_target_score_time_ms > 0)', 'default_target_score_time_ms > 0)',
); );
} }
Future<void> _migrateToSchema5(Migrator migrator) async {
await migrator.createTable(exerciseImages);
await customStatement(
'INSERT OR IGNORE INTO exercise_images (id, created_at, updated_at, '
'deleted_at, schema_version, sync_state, local_revision, '
'origin_device_id, future_owner_profile_id, last_synced_at, '
'remote_revision, exercise_id, media_asset_id, position) '
"SELECT 'exercise-image:' || id || ':0', created_at, updated_at, "
'deleted_at, schema_version, sync_state, local_revision, '
'origin_device_id, future_owner_profile_id, last_synced_at, '
'remote_revision, id, image_media_id, 0 FROM exercises '
'WHERE image_media_id IS NOT NULL',
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,9 @@ final class DriftExerciseRepository implements ExerciseRepository {
final row = await (database.select( final row = await (database.select(
database.exercises, database.exercises,
)..where((table) => table.id.equals(id))).getSingleOrNull(); )..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null ? null : _exerciseFromRow(row); return row == null
? null
: _exerciseFromRow(row, await _imageMediaIdsForExercise(id));
} }
@override @override
@ -26,7 +28,13 @@ final class DriftExerciseRepository implements ExerciseRepository {
) )
..orderBy([(table) => OrderingTerm.asc(table.name)])) ..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get(); .get();
return rows.map(_exerciseFromRow).toList(); final exercises = <domain.Exercise>[];
for (final row in rows) {
exercises.add(
_exerciseFromRow(row, await _imageMediaIdsForExercise(row.id)),
);
}
return exercises;
} }
@override @override
@ -45,15 +53,31 @@ final class DriftExerciseRepository implements ExerciseRepository {
@override @override
Future<void> save(domain.Exercise exercise) async { Future<void> save(domain.Exercise exercise) async {
await _upsertWithChangeLog( await database.transaction(() async {
database: database, await _upsertWithChangeLog(
tableName: 'exercises', database: database,
entityType: 'Exercise', tableName: 'exercises',
metadata: exercise.metadata, entityType: 'Exercise',
write: () => database metadata: exercise.metadata,
.into(database.exercises) write: () => database
.insertOnConflictUpdate(_exerciseCompanion(exercise)), .into(database.exercises)
); .insertOnConflictUpdate(_exerciseCompanion(exercise)),
);
await _replaceExerciseImages(database, exercise);
});
}
Future<List<String>> _imageMediaIdsForExercise(String exerciseId) async {
final rows =
await (database.select(database.exerciseImages)
..where(
(table) =>
table.exerciseId.equals(exerciseId) &
table.deletedAt.isNull(),
)
..orderBy([(table) => OrderingTerm.asc(table.position)]))
.get();
return rows.map((row) => row.mediaAssetId).toList();
} }
} }
@ -736,6 +760,100 @@ Future<void> _upsertWithChangeLog({
}); });
} }
Future<void> _replaceExerciseImages(
db.AppDatabase database,
domain.Exercise exercise,
) async {
final rows = await (database.select(
database.exerciseImages,
)..where((table) => table.exerciseId.equals(exercise.metadata.id))).get();
final activeRowsByMediaId = {
for (final row in rows)
if (row.deletedAt == null) row.mediaAssetId: row,
};
final desiredIds = exercise.imageMediaIds.toSet();
final removedRows = activeRowsByMediaId.values
.where((row) => !desiredIds.contains(row.mediaAssetId))
.toList();
await _softDeleteExerciseImageRows(
database,
removedRows,
exercise.metadata.updatedAt,
);
for (var index = 0; index < exercise.imageMediaIds.length; index++) {
final mediaId = exercise.imageMediaIds[index];
final existing = activeRowsByMediaId[mediaId];
final metadata = existing == null
? domain.EntityMetadata(
id: _exerciseImageId(exercise.metadata.id, mediaId),
createdAt: exercise.metadata.updatedAt,
updatedAt: exercise.metadata.updatedAt,
originDeviceId: exercise.metadata.originDeviceId,
)
: _metadataFromRow(existing).touch(exercise.metadata.updatedAt);
await _upsertWithChangeLog(
database: database,
tableName: 'exercise_images',
entityType: 'ExerciseImage',
metadata: metadata,
write: () => database
.into(database.exerciseImages)
.insertOnConflictUpdate(
db.ExerciseImagesCompanion(
id: Value(metadata.id),
createdAt: Value(metadata.createdAt.toUtc()),
updatedAt: Value(metadata.updatedAt.toUtc()),
deletedAt: Value(_utcOrNull(metadata.deletedAt)),
schemaVersion: Value(metadata.schemaVersion),
syncState: Value(_syncStateToDb(metadata.syncState)),
localRevision: Value(metadata.localRevision),
originDeviceId: Value(metadata.originDeviceId),
futureOwnerProfileId: Value(metadata.futureOwnerProfileId),
lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)),
remoteRevision: Value(metadata.remoteRevision),
exerciseId: Value(exercise.metadata.id),
mediaAssetId: Value(mediaId),
position: Value(index),
),
),
);
}
}
Future<void> _softDeleteExerciseImageRows(
db.AppDatabase database,
List<db.ExerciseImage> rows,
DateTime deletedAt,
) async {
for (final row in rows) {
final revision = row.localRevision + 1;
await (database.update(
database.exerciseImages,
)..where((table) => table.id.equals(row.id))).write(
db.ExerciseImagesCompanion(
deletedAt: Value(deletedAt.toUtc()),
updatedAt: Value(deletedAt.toUtc()),
localRevision: Value(revision),
syncState: const Value('deleted'),
),
);
await _writeChangeLog(
database: database,
entityType: 'ExerciseImage',
entityId: row.id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
}
String _exerciseImageId(String exerciseId, String mediaId) {
return 'exercise-image:$exerciseId:$mediaId';
}
Future<String> _operationForMutation({ Future<String> _operationForMutation({
required db.AppDatabase database, required db.AppDatabase database,
required String tableName, required String tableName,
@ -958,7 +1076,6 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
remoteRevision: values[10] as Value<String?>, remoteRevision: values[10] as Value<String?>,
name: Value(exercise.name), name: Value(exercise.name),
description: Value(exercise.description), description: Value(exercise.description),
imageMediaId: Value(exercise.imageMediaId),
videoMediaId: Value(exercise.videoMediaId), videoMediaId: Value(exercise.videoMediaId),
hasTimeMeasure: Value(exercise.hasTimeMeasure), hasTimeMeasure: Value(exercise.hasTimeMeasure),
hasRepsMeasure: Value(exercise.hasRepsMeasure), hasRepsMeasure: Value(exercise.hasRepsMeasure),
@ -974,12 +1091,12 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
); );
} }
domain.Exercise _exerciseFromRow(db.Exercise row) { domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
return domain.Exercise( return domain.Exercise(
metadata: _metadataFromRow(row), metadata: _metadataFromRow(row),
name: row.name, name: row.name,
description: row.description, description: row.description,
imageMediaId: row.imageMediaId, imageMediaIds: imageMediaIds,
videoMediaId: row.videoMediaId, videoMediaId: row.videoMediaId,
hasTimeMeasure: row.hasTimeMeasure, hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure, hasRepsMeasure: row.hasRepsMeasure,

View File

@ -55,10 +55,6 @@ class Exercises extends SyncableTable {
TextColumn get name => text().withLength(min: 1)(); TextColumn get name => text().withLength(min: 1)();
TextColumn get description => text().nullable()(); TextColumn get description => text().nullable()();
@ReferenceName('exerciseImageReferences')
TextColumn get imageMediaId =>
text().nullable().references(MediaAssets, #id)();
@ReferenceName('exerciseVideoReferences') @ReferenceName('exerciseVideoReferences')
TextColumn get videoMediaId => TextColumn get videoMediaId =>
text().nullable().references(MediaAssets, #id)(); text().nullable().references(MediaAssets, #id)();
@ -92,6 +88,22 @@ class Exercises extends SyncableTable {
]; ];
} }
class ExerciseImages extends SyncableTable {
@override
String get tableName => 'exercise_images';
TextColumn get exerciseId => text().references(Exercises, #id)();
TextColumn get mediaAssetId => text().references(MediaAssets, #id)();
IntColumn get position => integer()();
@override
List<String> get customConstraints => [
'UNIQUE (exercise_id, position)',
'UNIQUE (exercise_id, media_asset_id)',
'CHECK (position >= 0 AND position < 5)',
];
}
class Programs extends SyncableTable { class Programs extends SyncableTable {
@override @override
String get tableName => 'programs'; String get tableName => 'programs';

View File

@ -3,6 +3,7 @@ import 'package:image_picker/image_picker.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'theme.dart';
abstract interface class MediaSourcePicker { abstract interface class MediaSourcePicker {
Future<String?> pickPath(MediaKind kind); Future<String?> pickPath(MediaKind kind);
@ -245,9 +246,9 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
void _showSnackBar(String message) { void _showSnackBar(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( final messenger = ScaffoldMessenger.of(context);
context, messenger.hideCurrentSnackBar();
).showSnackBar(SnackBar(content: Text(message))); messenger.showSnackBar(SnackBar(content: Text(message)));
} }
} }
@ -355,9 +356,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
late final TextEditingController _defaultRepsController; late final TextEditingController _defaultRepsController;
late final TextEditingController _defaultScoreController; late final TextEditingController _defaultScoreController;
late final TextEditingController _defaultScoreTimeController; late final TextEditingController _defaultScoreTimeController;
String? _imageMediaId; late List<String> _imageMediaIds;
String? _videoMediaId; String? _videoMediaId;
String? _selectedImageName; final _selectedImageNamesById = <String, String>{};
String? _selectedVideoName; String? _selectedVideoName;
var _hasTime = true; var _hasTime = true;
var _hasReps = false; var _hasReps = false;
@ -393,7 +394,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs), _millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs),
), ),
); );
_imageMediaId = exercise?.imageMediaId; _imageMediaIds = List<String>.of(exercise?.imageMediaIds ?? const []);
_videoMediaId = exercise?.videoMediaId; _videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true; _hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false; _hasReps = exercise?.hasRepsMeasure ?? false;
@ -446,13 +447,12 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_MediaImportField( _ImageGalleryField(
label: 'Image', imageMediaIds: _imageMediaIds,
selectedFileName: _selectedImageName, imageNamesById: _selectedImageNamesById,
imported: _imageMediaId != null,
importing: _importingImage, importing: _importingImage,
actionLabel: 'Choisir une image', onAdd: _importImage,
onPick: () => _importMedia(MediaKind.image), onRemove: _removeImage,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_MediaImportField( _MediaImportField(
@ -461,7 +461,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
imported: _videoMediaId != null, imported: _videoMediaId != null,
importing: _importingVideo, importing: _importingVideo,
actionLabel: 'Choisir une vidéo', actionLabel: 'Choisir une vidéo',
onPick: () => _importMedia(MediaKind.video), onPick: _importVideo,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
@ -668,43 +668,71 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}); });
} }
Future<void> _importMedia(MediaKind kind) async { Future<void> _importImage() async {
final sourcePath = await widget.mediaPicker.pickPath(kind); if (_imageMediaIds.length >= 5) {
_showSnackBar('Maximum 5 images par exercice.');
return;
}
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.image);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() => _importingImage = true);
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: MediaKind.image,
);
if (!mounted) return;
setState(() {
_imageMediaIds = [..._imageMediaIds, asset.metadata.id];
_selectedImageNamesById[asset.metadata.id] = fileName;
});
_showSnackBar('Image ajoutée.');
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _importingImage = false);
}
}
}
void _removeImage(String mediaAssetId) {
setState(() {
_imageMediaIds = _imageMediaIds
.where((imageId) => imageId != mediaAssetId)
.toList();
_selectedImageNamesById.remove(mediaAssetId);
});
}
Future<void> _importVideo() async {
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video);
if (sourcePath == null) { if (sourcePath == null) {
return; return;
} }
final fileName = _fileNameFromPath(sourcePath); final fileName = _fileNameFromPath(sourcePath);
setState(() { setState(() {
if (kind == MediaKind.image) { _importingVideo = true;
_importingImage = true; _selectedVideoName = fileName;
_selectedImageName = fileName;
} else {
_importingVideo = true;
_selectedVideoName = fileName;
}
}); });
try { try {
final asset = await widget.mediaUseCases.importMedia( final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath, sourcePath: sourcePath,
kind: kind, kind: MediaKind.video,
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
if (kind == MediaKind.image) { _videoMediaId = asset.metadata.id;
_imageMediaId = asset.metadata.id;
} else {
_videoMediaId = asset.metadata.id;
}
}); });
_showSnackBar( _showSnackBar('Vidéo importée.');
kind == MediaKind.image ? 'Image importée.' : 'Vidéo importée.',
);
} on Exception catch (error) { } on Exception catch (error) {
_showSnackBar(error.toString()); _showSnackBar(error.toString());
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
_importingImage = false;
_importingVideo = false; _importingVideo = false;
}); });
} }
@ -766,7 +794,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
await widget.exerciseUseCases.create( await widget.exerciseUseCases.create(
name: _nameController.text.trim(), name: _nameController.text.trim(),
description: _optionalText(_descriptionController), description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId, imageMediaIds: _imageMediaIds,
videoMediaId: _videoMediaId, videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime, hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps, hasRepsMeasure: _hasReps,
@ -784,7 +812,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
id: exercise.metadata.id, id: exercise.metadata.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
description: _optionalText(_descriptionController), description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId, imageMediaIds: _imageMediaIds,
videoMediaId: _videoMediaId, videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime, hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps, hasRepsMeasure: _hasReps,
@ -937,6 +965,130 @@ final class _MeasureSwitch extends StatelessWidget {
} }
} }
final class _ImageGalleryField extends StatelessWidget {
const _ImageGalleryField({
required this.imageMediaIds,
required this.imageNamesById,
required this.importing,
required this.onAdd,
required this.onRemove,
});
final List<String> imageMediaIds;
final Map<String, String> imageNamesById;
final bool importing;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
@override
Widget build(BuildContext context) {
final canAdd = imageMediaIds.length < 5;
return InputDecorator(
decoration: const InputDecoration(labelText: 'Images'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (imageMediaIds.isEmpty)
const Text('Aucune image sélectionnée')
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var index = 0; index < imageMediaIds.length; index++)
_ImageThumbnail(
mediaAssetId: imageMediaIds[index],
label:
imageNamesById[imageMediaIds[index]] ??
'Image ${index + 1}',
onRemove: onRemove,
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: importing ? null : onAdd,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_photo_alternate_outlined),
label: const Text('Ajouter une image'),
),
),
if (!canAdd) ...[
const SizedBox(height: 8),
Text(
'Maximum 5 images par exercice.',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
}
final class _ImageThumbnail extends StatelessWidget {
const _ImageThumbnail({
required this.mediaAssetId,
required this.label,
required this.onRemove,
});
final String mediaAssetId;
final String label;
final ValueChanged<String> onRemove;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return SizedBox(
width: 104,
child: Stack(
children: [
Container(
height: 104,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.image_outlined),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
Positioned(
top: 2,
right: 2,
child: IconButton.filledTonal(
tooltip: 'Supprimer limage',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onRemove(mediaAssetId),
),
),
],
),
);
}
}
final class _MediaImportField extends StatelessWidget { final class _MediaImportField extends StatelessWidget {
const _MediaImportField({ const _MediaImportField({
required this.label, required this.label,

View File

@ -156,6 +156,75 @@ void main() {
); );
}); });
test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const [
'media-1',
'media-2',
'media-3',
'media-4',
'media-5',
],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
await expectLater(
useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery reorders images', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.reorderImages(
exerciseId: 'exercise-1',
orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
);
expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
});
test('Exercise image gallery removes an image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2', 'media-3'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.removeImage(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.imageMediaIds, ['media-1', 'media-3']);
expect(updated.imageMediaId, 'media-1');
});
test('delete exercise removes it from referencing programs', () async { test('delete exercise removes it from referencing programs', () async {
final exerciseRepository = _FakeExerciseRepository() final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise( ..exercise = Exercise(

View File

@ -132,23 +132,90 @@ void main() {
home: ExerciseFormScreen( home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository), exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage), mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: const _FakeMediaPicker( mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'),
imagePath: '/tmp/imports/photo.png',
),
), ),
), ),
); );
await tester.tap(find.text('Choisir une image')); await tester.tap(find.text('Ajouter une image'));
await tester.pump(); await tester.pump();
expect(storage.imports.single.sourcePath, '/tmp/imports/photo.png'); expect(storage.imports.single.sourcePath, '/tmp/imports/photo.png');
expect(storage.imports.single.kind, MediaKind.image); expect(storage.imports.single.kind, MediaKind.image);
expect(find.text('photo.png'), findsOneWidget); expect(find.text('photo.png'), findsOneWidget);
expect(find.text('Image importée.'), findsOneWidget); expect(find.text('Image ajoutée.'), findsOneWidget);
}, },
); );
testWidgets('ajouter des images bloque la sixième image', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
final storage = _FakeMediaStorage();
await tester.binding.setSurfaceSize(const Size(400, 1200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: _FakeMediaPicker(
imagePaths: const [
'/tmp/imports/photo-1.png',
'/tmp/imports/photo-2.png',
'/tmp/imports/photo-3.png',
'/tmp/imports/photo-4.png',
'/tmp/imports/photo-5.png',
'/tmp/imports/photo-6.png',
],
),
),
),
);
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
}
expect(storage.imports, hasLength(5));
expect(find.text('photo-5.png'), findsOneWidget);
expect(find.text('Maximum 5 images par exercice.'), findsOneWidget);
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
expect(storage.imports, hasLength(5));
expect(find.text('photo-6.png'), findsNothing);
expect(find.text('Maximum 5 images par exercice.'), findsWidgets);
});
testWidgets('supprimer une image la retire de la galerie', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
final storage = _FakeMediaStorage();
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository, storage: storage),
mediaPicker: _FakeMediaPicker(imagePath: '/tmp/imports/photo.png'),
),
),
);
await tester.tap(find.text('Ajouter une image'));
await tester.pump();
expect(find.text('photo.png'), findsOneWidget);
await tester.tap(find.byTooltip('Supprimer limage'));
await tester.pump();
expect(find.text('photo.png'), findsNothing);
expect(find.text('Aucune image sélectionnée'), findsOneWidget);
});
testWidgets( testWidgets(
'supprimer un exercice demande confirmation puis le retire de la liste', 'supprimer un exercice demande confirmation puis le retire de la liste',
(tester) async { (tester) async {
@ -244,17 +311,31 @@ MediaUseCases _mediaUseCases(
} }
final class _FakeMediaPicker implements MediaSourcePicker { final class _FakeMediaPicker implements MediaSourcePicker {
const _FakeMediaPicker({this.imagePath}); _FakeMediaPicker({this.imagePath, this.imagePaths = const []});
final String? imagePath; final String? imagePath;
final List<String> imagePaths;
var _nextImagePath = 0;
@override @override
Future<String?> pickPath(MediaKind kind) async { Future<String?> pickPath(MediaKind kind) async {
return switch (kind) { return switch (kind) {
MediaKind.image => imagePath, MediaKind.image => _pickImagePath(),
MediaKind.video => null, MediaKind.video => null,
}; };
} }
String? _pickImagePath() {
if (imagePaths.isEmpty) {
return imagePath;
}
if (_nextImagePath >= imagePaths.length) {
return null;
}
final path = imagePaths[_nextImagePath];
_nextImagePath += 1;
return path;
}
} }
EntityMetadata _metadata(String id) { EntityMetadata _metadata(String id) {