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:
@ -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.
|
||||||
@ -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
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
@ -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,6 +53,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(domain.Exercise exercise) async {
|
Future<void> save(domain.Exercise exercise) async {
|
||||||
|
await database.transaction(() async {
|
||||||
await _upsertWithChangeLog(
|
await _upsertWithChangeLog(
|
||||||
database: database,
|
database: database,
|
||||||
tableName: 'exercises',
|
tableName: 'exercises',
|
||||||
@ -54,6 +63,21 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
|||||||
.into(database.exercises)
|
.into(database.exercises)
|
||||||
.insertOnConflictUpdate(_exerciseCompanion(exercise)),
|
.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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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) {
|
|
||||||
_importingImage = true;
|
|
||||||
_selectedImageName = fileName;
|
|
||||||
} else {
|
|
||||||
_importingVideo = true;
|
_importingVideo = true;
|
||||||
_selectedVideoName = fileName;
|
_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) {
|
|
||||||
_imageMediaId = asset.metadata.id;
|
|
||||||
} else {
|
|
||||||
_videoMediaId = asset.metadata.id;
|
_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 l’image',
|
||||||
|
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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 l’image'));
|
||||||
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user