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

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et exercise_library_screen.dart pour
supporter jusqu'à 5 images par exercice au lieu d'une seule. flutter
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:40 +02:00
parent 6419e2d9cc
commit 8bda4758f2
9 changed files with 2250 additions and 299 deletions

View File

@ -13,6 +13,7 @@ part 'app_database.g.dart';
ActiveWorkoutSessions,
ChangeLogEntries,
Exercises,
ExerciseImages,
MediaAssets,
ProgramExercises,
Programs,
@ -36,7 +37,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 4;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@ -63,6 +64,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 4) {
await _migrateToSchema4();
}
if (from < 5) {
await _migrateToSchema5(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -95,6 +99,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_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(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)',
@ -147,6 +155,7 @@ const _syncableTableNames = [
'active_set_results',
'active_workout_sessions',
'exercises',
'exercise_images',
'media_assets',
'program_exercises',
'programs',
@ -226,4 +235,19 @@ extension on AppDatabase {
'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(
database.exercises,
)..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null ? null : _exerciseFromRow(row);
return row == null
? null
: _exerciseFromRow(row, await _imageMediaIdsForExercise(id));
}
@override
@ -26,7 +28,13 @@ final class DriftExerciseRepository implements ExerciseRepository {
)
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.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
@ -45,15 +53,31 @@ final class DriftExerciseRepository implements ExerciseRepository {
@override
Future<void> save(domain.Exercise exercise) async {
await _upsertWithChangeLog(
database: database,
tableName: 'exercises',
entityType: 'Exercise',
metadata: exercise.metadata,
write: () => database
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise)),
);
await database.transaction(() async {
await _upsertWithChangeLog(
database: database,
tableName: 'exercises',
entityType: 'Exercise',
metadata: exercise.metadata,
write: () => database
.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({
required db.AppDatabase database,
required String tableName,
@ -958,7 +1076,6 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
remoteRevision: values[10] as Value<String?>,
name: Value(exercise.name),
description: Value(exercise.description),
imageMediaId: Value(exercise.imageMediaId),
videoMediaId: Value(exercise.videoMediaId),
hasTimeMeasure: Value(exercise.hasTimeMeasure),
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(
metadata: _metadataFromRow(row),
name: row.name,
description: row.description,
imageMediaId: row.imageMediaId,
imageMediaIds: imageMediaIds,
videoMediaId: row.videoMediaId,
hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure,

View File

@ -55,10 +55,6 @@ class Exercises extends SyncableTable {
TextColumn get name => text().withLength(min: 1)();
TextColumn get description => text().nullable()();
@ReferenceName('exerciseImageReferences')
TextColumn get imageMediaId =>
text().nullable().references(MediaAssets, #id)();
@ReferenceName('exerciseVideoReferences')
TextColumn get videoMediaId =>
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 {
@override
String get tableName => 'programs';