diff --git a/.ideai/tickets/38/carnet.md b/.ideai/tickets/38/carnet.md index 516adb2..1aafaa2 100644 --- a/.ideai/tickets/38/carnet.md +++ b/.ideai/tickets/38/carnet.md @@ -1,6 +1,9 @@ --- issueRef: "#38" -version: 3 -updatedBy: {"kind":"user"} -updatedAt: 1784330204091 +version: 6 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784377509677 --- +Découpage : +1. [DevBackend] Ajouter un champ `iconMediaId` (String?, nullable) sur Exercise, distinct de la galerie `imageMediaIds` (ticket #35) — l'icône doit référencer l'un des médias déjà présents dans la galerie de l'exercice (contrainte : iconMediaId, si renseigné, doit faire partie de imageMediaIds). Si iconMediaId est null, l'affichage de la liste retombe sur la première image de la galerie (comportement actuel) ou aucune icône si la galerie est vide. Migration Drift. +2. [DevFrontend] Dans le formulaire d'exercice, une fois qu'il y a au moins une image dans la galerie, permettre de désigner l'une d'elles comme icône (ex: tap longue ou bouton "Définir comme icône" sur chaque miniature, avec un indicateur visuel sur celle actuellement choisie). Utiliser cette icône dans ExerciseListTile et partout où l'exercice est affiché en liste compacte (programme, sélection d'exercice). \ No newline at end of file diff --git a/.ideai/tickets/38/issue.md b/.ideai/tickets/38/issue.md index 35df071..5f7b358 100644 --- a/.ideai/tickets/38/issue.md +++ b/.ideai/tickets/38/issue.md @@ -2,15 +2,15 @@ id: "51e8769d-7b2b-4b4d-8de1-dc784881e575" number: 38 title: "Proposer de mettre une image pour l'icone de l'exercice" -status: "open" +status: "closed" priority: "medium" sprint: null links: [] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784330169746 -updatedAt: 1784330204091 -version: 3 +updatedAt: 1784377509677 +version: 6 --- J'aimerais qu'en plus des images que l'on peut ajouter, qu'il soit possible de mettre une image comme icone de l'exercice \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 8ebcaa6..06e576c 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -429,13 +429,13 @@ "issueRef": "#38", "path": "38", "title": "Proposer de mettre une image pour l'icone de l'exercice", - "status": "open", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "57695b92-24d0-4876-837c-76116e70a6ae" ], - "updatedAt": 1784330204091 + "updatedAt": 1784377509677 }, { "issueRef": "#39", diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 10ab7ed..ece6697 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -25,6 +25,7 @@ final class ExerciseUseCases { String? description, String? imageMediaId, List imageMediaIds = const [], + String? iconMediaId, String? videoMediaId, required bool hasTimeMeasure, required bool hasRepsMeasure, @@ -53,6 +54,7 @@ final class ExerciseUseCases { name: name, description: description, imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds), + iconMediaId: iconMediaId, videoMediaId: videoMediaId, hasTimeMeasure: hasTimeMeasure, hasRepsMeasure: hasRepsMeasure, @@ -83,6 +85,7 @@ final class ExerciseUseCases { String? description, String? imageMediaId, Object? imageMediaIds = _useCaseUnchanged, + Object? iconMediaId = _useCaseUnchanged, String? videoMediaId, required bool hasTimeMeasure, required bool hasRepsMeasure, @@ -123,13 +126,21 @@ final class ExerciseUseCases { defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, ); + final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged + ? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds) + : imageMediaIds as List; + final resolvedIconMediaId = iconMediaId == _useCaseUnchanged + ? exercise.iconMediaId != null && + resolvedImageMediaIds.contains(exercise.iconMediaId) + ? exercise.iconMediaId + : null + : iconMediaId as String?; final updated = exercise.copyWith( metadata: exercise.metadata.touch(clock.now()), name: name, description: description, - imageMediaIds: imageMediaIds == _useCaseUnchanged - ? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds) - : imageMediaIds as List, + imageMediaIds: resolvedImageMediaIds, + iconMediaId: resolvedIconMediaId, videoMediaId: videoMediaId, hasTimeMeasure: hasTimeMeasure, hasRepsMeasure: hasRepsMeasure, @@ -190,6 +201,9 @@ final class ExerciseUseCases { imageMediaIds: exercise.imageMediaIds .where((imageId) => imageId != mediaAssetId) .toList(), + iconMediaId: exercise.iconMediaId == mediaAssetId + ? null + : exercise.iconMediaId, metadata: exercise.metadata.touch(clock.now()), ); await repository.save(updated); @@ -217,6 +231,22 @@ final class ExerciseUseCases { return updated; } + Future setIcon({ + 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( + iconMediaId: mediaAssetId, + metadata: exercise.metadata.touch(clock.now()), + ); + await repository.save(updated); + return updated; + } + Future attachVideo({ required String exerciseId, required String mediaAssetId, diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 7945d18..1b34f88 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -126,6 +126,7 @@ final class Exercise { this.description, String? imageMediaId, List imageMediaIds = const [], + String? iconMediaId, this.videoMediaId, required this.hasTimeMeasure, required this.hasRepsMeasure, @@ -139,10 +140,13 @@ final class Exercise { this.defaultTargetScoreTimeMs, this.archivedAt, }) : name = _nonBlank(name, 'Exercise name'), - imageMediaIds = _validatedImageMediaIds( - imageMediaIds.isEmpty && imageMediaId != null - ? [imageMediaId] - : imageMediaIds, + imageMediaIds = _resolvedExerciseImageMediaIds( + imageMediaId, + imageMediaIds, + ), + iconMediaId = _validatedIconMediaId( + iconMediaId, + _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), ) { _requireAtLeastOneMeasure( hasTime: hasTimeMeasure, @@ -174,6 +178,7 @@ final class Exercise { final String name; final String? description; final List imageMediaIds; + final String? iconMediaId; final String? videoMediaId; final bool hasTimeMeasure; final bool hasRepsMeasure; @@ -206,6 +211,7 @@ final class Exercise { Object? description = _unchanged, Object? imageMediaId = _unchanged, Object? imageMediaIds = _unchanged, + Object? iconMediaId = _unchanged, Object? videoMediaId = _unchanged, bool? hasTimeMeasure, bool? hasRepsMeasure, @@ -219,17 +225,25 @@ final class Exercise { Object? defaultTargetScoreTimeMs = _unchanged, Object? archivedAt = _unchanged, }) { + final resolvedImageMediaIds = imageMediaIds == _unchanged + ? imageMediaId == _unchanged + ? this.imageMediaIds + : [if (imageMediaId != null) imageMediaId as String] + : imageMediaIds as List; + final resolvedIconMediaId = iconMediaId == _unchanged + ? this.iconMediaId != null && + resolvedImageMediaIds.contains(this.iconMediaId) + ? this.iconMediaId + : null + : iconMediaId as String?; return Exercise( metadata: metadata ?? this.metadata, name: name ?? this.name, description: description == _unchanged ? this.description : description as String?, - imageMediaIds: imageMediaIds == _unchanged - ? imageMediaId == _unchanged - ? this.imageMediaIds - : [if (imageMediaId != null) imageMediaId as String] - : imageMediaIds as List, + imageMediaIds: resolvedImageMediaIds, + iconMediaId: resolvedIconMediaId, videoMediaId: videoMediaId == _unchanged ? this.videoMediaId : videoMediaId as String?, @@ -957,6 +971,30 @@ List _validatedImageMediaIds(List ids) { return normalized; } +List _resolvedExerciseImageMediaIds( + String? imageMediaId, + List imageMediaIds, +) { + return _validatedImageMediaIds( + imageMediaIds.isEmpty && imageMediaId != null + ? [imageMediaId] + : imageMediaIds, + ); +} + +String? _validatedIconMediaId(String? iconMediaId, List imageMediaIds) { + if (iconMediaId == null) { + return null; + } + final normalized = _nonBlank(iconMediaId, 'Icon media id'); + if (!imageMediaIds.contains(normalized)) { + throw const DomainException( + 'Exercise icon must be one of the exercise images.', + ); + } + return normalized; +} + void _requireAtLeastOneMeasure({ required bool hasTime, required bool hasReps, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 1338dbf..fb1f104 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -37,7 +37,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration { @@ -70,6 +70,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 6) { await _migrateToSchema6(); } + if (from < 7) { + await _migrateToSchema7(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -260,4 +263,11 @@ extension on AppDatabase { 'exercise_image_media_ids_snapshot_json TEXT', ); } + + Future _migrateToSchema7() async { + await customStatement( + 'ALTER TABLE exercises ADD COLUMN icon_media_id TEXT ' + 'REFERENCES media_assets(id)', + ); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 1751d8d..6415a96 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -7959,6 +7959,20 @@ class $ExercisesTable extends Exercises type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _iconMediaIdMeta = const VerificationMeta( + 'iconMediaId', + ); + @override + late final GeneratedColumn iconMediaId = GeneratedColumn( + 'icon_media_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES media_assets (id)', + ), + ); static const VerificationMeta _videoMediaIdMeta = const VerificationMeta( 'videoMediaId', ); @@ -8119,6 +8133,7 @@ class $ExercisesTable extends Exercises remoteRevision, name, description, + iconMediaId, videoMediaId, hasTimeMeasure, hasRepsMeasure, @@ -8254,6 +8269,15 @@ class $ExercisesTable extends Exercises ), ); } + if (data.containsKey('icon_media_id')) { + context.handle( + _iconMediaIdMeta, + iconMediaId.isAcceptableOrUnknown( + data['icon_media_id']!, + _iconMediaIdMeta, + ), + ); + } if (data.containsKey('video_media_id')) { context.handle( _videoMediaIdMeta, @@ -8420,6 +8444,10 @@ class $ExercisesTable extends Exercises DriftSqlType.string, data['${effectivePrefix}description'], ), + iconMediaId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon_media_id'], + ), videoMediaId: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}video_media_id'], @@ -8491,6 +8519,7 @@ class Exercise extends DataClass implements Insertable { final String? remoteRevision; final String name; final String? description; + final String? iconMediaId; final String? videoMediaId; final bool hasTimeMeasure; final bool hasRepsMeasure; @@ -8517,6 +8546,7 @@ class Exercise extends DataClass implements Insertable { this.remoteRevision, required this.name, this.description, + this.iconMediaId, this.videoMediaId, required this.hasTimeMeasure, required this.hasRepsMeasure, @@ -8556,6 +8586,9 @@ class Exercise extends DataClass implements Insertable { if (!nullToAbsent || description != null) { map['description'] = Variable(description); } + if (!nullToAbsent || iconMediaId != null) { + map['icon_media_id'] = Variable(iconMediaId); + } if (!nullToAbsent || videoMediaId != null) { map['video_media_id'] = Variable(videoMediaId); } @@ -8616,6 +8649,9 @@ class Exercise extends DataClass implements Insertable { description: description == null && nullToAbsent ? const Value.absent() : Value(description), + iconMediaId: iconMediaId == null && nullToAbsent + ? const Value.absent() + : Value(iconMediaId), videoMediaId: videoMediaId == null && nullToAbsent ? const Value.absent() : Value(videoMediaId), @@ -8668,6 +8704,7 @@ class Exercise extends DataClass implements Insertable { remoteRevision: serializer.fromJson(json['remoteRevision']), name: serializer.fromJson(json['name']), description: serializer.fromJson(json['description']), + iconMediaId: serializer.fromJson(json['iconMediaId']), videoMediaId: serializer.fromJson(json['videoMediaId']), hasTimeMeasure: serializer.fromJson(json['hasTimeMeasure']), hasRepsMeasure: serializer.fromJson(json['hasRepsMeasure']), @@ -8705,6 +8742,7 @@ class Exercise extends DataClass implements Insertable { 'remoteRevision': serializer.toJson(remoteRevision), 'name': serializer.toJson(name), 'description': serializer.toJson(description), + 'iconMediaId': serializer.toJson(iconMediaId), 'videoMediaId': serializer.toJson(videoMediaId), 'hasTimeMeasure': serializer.toJson(hasTimeMeasure), 'hasRepsMeasure': serializer.toJson(hasRepsMeasure), @@ -8738,6 +8776,7 @@ class Exercise extends DataClass implements Insertable { Value remoteRevision = const Value.absent(), String? name, Value description = const Value.absent(), + Value iconMediaId = const Value.absent(), Value videoMediaId = const Value.absent(), bool? hasTimeMeasure, bool? hasRepsMeasure, @@ -8768,6 +8807,7 @@ class Exercise extends DataClass implements Insertable { : this.remoteRevision, name: name ?? this.name, description: description.present ? description.value : this.description, + iconMediaId: iconMediaId.present ? iconMediaId.value : this.iconMediaId, videoMediaId: videoMediaId.present ? videoMediaId.value : this.videoMediaId, hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure, hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure, @@ -8818,6 +8858,9 @@ class Exercise extends DataClass implements Insertable { description: data.description.present ? data.description.value : this.description, + iconMediaId: data.iconMediaId.present + ? data.iconMediaId.value + : this.iconMediaId, videoMediaId: data.videoMediaId.present ? data.videoMediaId.value : this.videoMediaId, @@ -8871,6 +8914,7 @@ class Exercise extends DataClass implements Insertable { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('description: $description, ') + ..write('iconMediaId: $iconMediaId, ') ..write('videoMediaId: $videoMediaId, ') ..write('hasTimeMeasure: $hasTimeMeasure, ') ..write('hasRepsMeasure: $hasRepsMeasure, ') @@ -8902,6 +8946,7 @@ class Exercise extends DataClass implements Insertable { remoteRevision, name, description, + iconMediaId, videoMediaId, hasTimeMeasure, hasRepsMeasure, @@ -8932,6 +8977,7 @@ class Exercise extends DataClass implements Insertable { other.remoteRevision == this.remoteRevision && other.name == this.name && other.description == this.description && + other.iconMediaId == this.iconMediaId && other.videoMediaId == this.videoMediaId && other.hasTimeMeasure == this.hasTimeMeasure && other.hasRepsMeasure == this.hasRepsMeasure && @@ -8960,6 +9006,7 @@ class ExercisesCompanion extends UpdateCompanion { final Value remoteRevision; final Value name; final Value description; + final Value iconMediaId; final Value videoMediaId; final Value hasTimeMeasure; final Value hasRepsMeasure; @@ -8987,6 +9034,7 @@ class ExercisesCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), this.name = const Value.absent(), this.description = const Value.absent(), + this.iconMediaId = const Value.absent(), this.videoMediaId = const Value.absent(), this.hasTimeMeasure = const Value.absent(), this.hasRepsMeasure = const Value.absent(), @@ -9015,6 +9063,7 @@ class ExercisesCompanion extends UpdateCompanion { this.remoteRevision = const Value.absent(), required String name, this.description = const Value.absent(), + this.iconMediaId = const Value.absent(), this.videoMediaId = const Value.absent(), required bool hasTimeMeasure, required bool hasRepsMeasure, @@ -9052,6 +9101,7 @@ class ExercisesCompanion extends UpdateCompanion { Expression? remoteRevision, Expression? name, Expression? description, + Expression? iconMediaId, Expression? videoMediaId, Expression? hasTimeMeasure, Expression? hasRepsMeasure, @@ -9081,6 +9131,7 @@ class ExercisesCompanion extends UpdateCompanion { if (remoteRevision != null) 'remote_revision': remoteRevision, if (name != null) 'name': name, if (description != null) 'description': description, + if (iconMediaId != null) 'icon_media_id': iconMediaId, if (videoMediaId != null) 'video_media_id': videoMediaId, if (hasTimeMeasure != null) 'has_time_measure': hasTimeMeasure, if (hasRepsMeasure != null) 'has_reps_measure': hasRepsMeasure, @@ -9114,6 +9165,7 @@ class ExercisesCompanion extends UpdateCompanion { Value? remoteRevision, Value? name, Value? description, + Value? iconMediaId, Value? videoMediaId, Value? hasTimeMeasure, Value? hasRepsMeasure, @@ -9142,6 +9194,7 @@ class ExercisesCompanion extends UpdateCompanion { remoteRevision: remoteRevision ?? this.remoteRevision, name: name ?? this.name, description: description ?? this.description, + iconMediaId: iconMediaId ?? this.iconMediaId, videoMediaId: videoMediaId ?? this.videoMediaId, hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure, hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure, @@ -9204,6 +9257,9 @@ class ExercisesCompanion extends UpdateCompanion { if (description.present) { map['description'] = Variable(description.value); } + if (iconMediaId.present) { + map['icon_media_id'] = Variable(iconMediaId.value); + } if (videoMediaId.present) { map['video_media_id'] = Variable(videoMediaId.value); } @@ -9266,6 +9322,7 @@ class ExercisesCompanion extends UpdateCompanion { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('description: $description, ') + ..write('iconMediaId: $iconMediaId, ') ..write('videoMediaId: $videoMediaId, ') ..write('hasTimeMeasure: $hasTimeMeasure, ') ..write('hasRepsMeasure: $hasRepsMeasure, ') @@ -22861,6 +22918,27 @@ final class $$MediaAssetsTableReferences extends BaseReferences<_$AppDatabase, $MediaAssetsTable, MediaAsset> { $$MediaAssetsTableReferences(super.$_db, super.$_table, super.$_typedResult); + static MultiTypedResultKey<$ExercisesTable, List> + _exerciseIconReferencesTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.exercises, + aliasName: 'media_assets__id__exercises__icon_media_id', + ); + + $$ExercisesTableProcessedTableManager get exerciseIconReferences { + final manager = $$ExercisesTableTableManager( + $_db, + $_db.exercises, + ).filter((f) => f.iconMediaId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _exerciseIconReferencesTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey<$ExercisesTable, List> _exerciseVideoReferencesTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( @@ -23067,6 +23145,31 @@ class $$MediaAssetsTableFilterComposer builder: (column) => ColumnFilters(column), ); + Expression exerciseIconReferences( + Expression Function($$ExercisesTableFilterComposer f) f, + ) { + final $$ExercisesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.exercises, + getReferencedColumn: (t) => t.iconMediaId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExercisesTableFilterComposer( + $db: $db, + $table: $db.exercises, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression exerciseVideoReferences( Expression Function($$ExercisesTableFilterComposer f) f, ) { @@ -23371,6 +23474,31 @@ class $$MediaAssetsTableAnnotationComposer builder: (column) => column, ); + Expression exerciseIconReferences( + Expression Function($$ExercisesTableAnnotationComposer a) f, + ) { + final $$ExercisesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.exercises, + getReferencedColumn: (t) => t.iconMediaId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ExercisesTableAnnotationComposer( + $db: $db, + $table: $db.exercises, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression exerciseVideoReferences( Expression Function($$ExercisesTableAnnotationComposer a) f, ) { @@ -23486,6 +23614,7 @@ class $$MediaAssetsTableTableManager (MediaAsset, $$MediaAssetsTableReferences), MediaAsset, PrefetchHooks Function({ + bool exerciseIconReferences, bool exerciseVideoReferences, bool exerciseImagesRefs, bool programExerciseImageSnapshotReferences, @@ -23609,6 +23738,7 @@ class $$MediaAssetsTableTableManager .toList(), prefetchHooksCallback: ({ + exerciseIconReferences = false, exerciseVideoReferences = false, exerciseImagesRefs = false, programExerciseImageSnapshotReferences = false, @@ -23617,6 +23747,7 @@ class $$MediaAssetsTableTableManager return PrefetchHooks( db: db, explicitlyWatchedTables: [ + if (exerciseIconReferences) db.exercises, if (exerciseVideoReferences) db.exercises, if (exerciseImagesRefs) db.exerciseImages, if (programExerciseImageSnapshotReferences) @@ -23627,6 +23758,27 @@ class $$MediaAssetsTableTableManager addJoins: null, getPrefetchedDataCallback: (items) async { return [ + if (exerciseIconReferences) + await $_getPrefetchedData< + MediaAsset, + $MediaAssetsTable, + Exercise + >( + currentTable: table, + referencedTable: $$MediaAssetsTableReferences + ._exerciseIconReferencesTable(db), + managerFromTypedResult: (p0) => + $$MediaAssetsTableReferences( + db, + table, + p0, + ).exerciseIconReferences, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.iconMediaId == item.id, + ), + typedResults: items, + ), if (exerciseVideoReferences) await $_getPrefetchedData< MediaAsset, @@ -23734,6 +23886,7 @@ typedef $$MediaAssetsTableProcessedTableManager = (MediaAsset, $$MediaAssetsTableReferences), MediaAsset, PrefetchHooks Function({ + bool exerciseIconReferences, bool exerciseVideoReferences, bool exerciseImagesRefs, bool programExerciseImageSnapshotReferences, @@ -23755,6 +23908,7 @@ typedef $$ExercisesTableCreateCompanionBuilder = Value remoteRevision, required String name, Value description, + Value iconMediaId, Value videoMediaId, required bool hasTimeMeasure, required bool hasRepsMeasure, @@ -23784,6 +23938,7 @@ typedef $$ExercisesTableUpdateCompanionBuilder = Value remoteRevision, Value name, Value description, + Value iconMediaId, Value videoMediaId, Value hasTimeMeasure, Value hasRepsMeasure, @@ -23803,6 +23958,23 @@ final class $$ExercisesTableReferences extends BaseReferences<_$AppDatabase, $ExercisesTable, Exercise> { $$ExercisesTableReferences(super.$_db, super.$_table, super.$_typedResult); + static $MediaAssetsTable _iconMediaIdTable(_$AppDatabase db) => + db.mediaAssets.createAlias('exercises__icon_media_id__media_assets__id'); + + $$MediaAssetsTableProcessedTableManager? get iconMediaId { + final $_column = $_itemColumn('icon_media_id'); + if ($_column == null) return null; + final manager = $$MediaAssetsTableTableManager( + $_db, + $_db.mediaAssets, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_iconMediaIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + static $MediaAssetsTable _videoMediaIdTable(_$AppDatabase db) => db.mediaAssets.createAlias('exercises__video_media_id__media_assets__id'); @@ -23988,6 +24160,29 @@ class $$ExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + $$MediaAssetsTableFilterComposer get iconMediaId { + final $$MediaAssetsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.iconMediaId, + referencedTable: $db.mediaAssets, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MediaAssetsTableFilterComposer( + $db: $db, + $table: $db.mediaAssets, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$MediaAssetsTableFilterComposer get videoMediaId { final $$MediaAssetsTableFilterComposer composer = $composerBuilder( composer: this, @@ -24191,6 +24386,29 @@ class $$ExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + $$MediaAssetsTableOrderingComposer get iconMediaId { + final $$MediaAssetsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.iconMediaId, + referencedTable: $db.mediaAssets, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MediaAssetsTableOrderingComposer( + $db: $db, + $table: $db.mediaAssets, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$MediaAssetsTableOrderingComposer get videoMediaId { final $$MediaAssetsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -24330,6 +24548,29 @@ class $$ExercisesTableAnnotationComposer builder: (column) => column, ); + $$MediaAssetsTableAnnotationComposer get iconMediaId { + final $$MediaAssetsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.iconMediaId, + referencedTable: $db.mediaAssets, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MediaAssetsTableAnnotationComposer( + $db: $db, + $table: $db.mediaAssets, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$MediaAssetsTableAnnotationComposer get videoMediaId { final $$MediaAssetsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -24418,6 +24659,7 @@ class $$ExercisesTableTableManager (Exercise, $$ExercisesTableReferences), Exercise, PrefetchHooks Function({ + bool iconMediaId, bool videoMediaId, bool exerciseImagesRefs, bool programExercisesRefs, @@ -24449,6 +24691,7 @@ class $$ExercisesTableTableManager Value remoteRevision = const Value.absent(), Value name = const Value.absent(), Value description = const Value.absent(), + Value iconMediaId = const Value.absent(), Value videoMediaId = const Value.absent(), Value hasTimeMeasure = const Value.absent(), Value hasRepsMeasure = const Value.absent(), @@ -24476,6 +24719,7 @@ class $$ExercisesTableTableManager remoteRevision: remoteRevision, name: name, description: description, + iconMediaId: iconMediaId, videoMediaId: videoMediaId, hasTimeMeasure: hasTimeMeasure, hasRepsMeasure: hasRepsMeasure, @@ -24505,6 +24749,7 @@ class $$ExercisesTableTableManager Value remoteRevision = const Value.absent(), required String name, Value description = const Value.absent(), + Value iconMediaId = const Value.absent(), Value videoMediaId = const Value.absent(), required bool hasTimeMeasure, required bool hasRepsMeasure, @@ -24532,6 +24777,7 @@ class $$ExercisesTableTableManager remoteRevision: remoteRevision, name: name, description: description, + iconMediaId: iconMediaId, videoMediaId: videoMediaId, hasTimeMeasure: hasTimeMeasure, hasRepsMeasure: hasRepsMeasure, @@ -24556,6 +24802,7 @@ class $$ExercisesTableTableManager .toList(), prefetchHooksCallback: ({ + iconMediaId = false, videoMediaId = false, exerciseImagesRefs = false, programExercisesRefs = false, @@ -24582,6 +24829,19 @@ class $$ExercisesTableTableManager dynamic > >(state) { + if (iconMediaId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.iconMediaId, + referencedTable: $$ExercisesTableReferences + ._iconMediaIdTable(db), + referencedColumn: $$ExercisesTableReferences + ._iconMediaIdTable(db) + .id, + ) + as T; + } if (videoMediaId) { state = state.withJoin( @@ -24663,6 +24923,7 @@ typedef $$ExercisesTableProcessedTableManager = (Exercise, $$ExercisesTableReferences), Exercise, PrefetchHooks Function({ + bool iconMediaId, bool videoMediaId, bool exerciseImagesRefs, bool programExercisesRefs, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index bbdcc1f..b220b6f 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1078,6 +1078,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { remoteRevision: values[10] as Value, name: Value(exercise.name), description: Value(exercise.description), + iconMediaId: Value(exercise.iconMediaId), videoMediaId: Value(exercise.videoMediaId), hasTimeMeasure: Value(exercise.hasTimeMeasure), hasRepsMeasure: Value(exercise.hasRepsMeasure), @@ -1099,6 +1100,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List imageMediaIds) { name: row.name, description: row.description, imageMediaIds: imageMediaIds, + iconMediaId: row.iconMediaId, videoMediaId: row.videoMediaId, hasTimeMeasure: row.hasTimeMeasure, hasRepsMeasure: row.hasRepsMeasure, diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index d1120ff..af9cf36 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -55,6 +55,9 @@ class Exercises extends SyncableTable { TextColumn get name => text().withLength(min: 1)(); TextColumn get description => text().nullable()(); + @ReferenceName('exerciseIconReferences') + TextColumn get iconMediaId => + text().nullable().references(MediaAssets, #id)(); @ReferenceName('exerciseVideoReferences') TextColumn get videoMediaId => text().nullable().references(MediaAssets, #id)(); diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 2b71eee..ce155d9 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -267,15 +267,11 @@ final class ExerciseListTile extends StatelessWidget { @override Widget build(BuildContext context) { final description = exercise.description?.trim(); - final hasMedia = - exercise.imageMediaId != null || exercise.videoMediaId != null; + final iconMediaId = exercise.iconMediaId ?? exercise.imageMediaId; + final hasMedia = iconMediaId != null || exercise.videoMediaId != null; return ListTile( onTap: onTap, - leading: CircleAvatar( - child: Icon( - hasMedia ? Icons.perm_media_outlined : Icons.fitness_center, - ), - ), + leading: _ExerciseIconAvatar(iconMediaId: iconMediaId), title: Text(exercise.name), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -328,6 +324,42 @@ final class ExerciseListTile extends StatelessWidget { } } +final class _ExerciseIconAvatar extends StatelessWidget { + const _ExerciseIconAvatar({required this.iconMediaId}); + + final String? iconMediaId; + + @override + Widget build(BuildContext context) { + final icon = CircleAvatar( + child: Icon( + iconMediaId == null ? Icons.fitness_center : Icons.perm_media_outlined, + ), + ); + if (iconMediaId == null) { + return icon; + } + return Tooltip( + message: 'Icône d’exercice $iconMediaId', + child: Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.star, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ); + } +} + final class ExerciseFormScreen extends StatefulWidget { const ExerciseFormScreen({ required this.exerciseUseCases, @@ -357,6 +389,7 @@ final class _ExerciseFormScreenState extends State { late final TextEditingController _defaultScoreController; late final TextEditingController _defaultScoreTimeController; late List _imageMediaIds; + String? _iconMediaId; String? _videoMediaId; final _selectedImageNamesById = {}; String? _selectedVideoName; @@ -395,6 +428,7 @@ final class _ExerciseFormScreenState extends State { ), ); _imageMediaIds = List.of(exercise?.imageMediaIds ?? const []); + _iconMediaId = exercise?.iconMediaId; _videoMediaId = exercise?.videoMediaId; _hasTime = exercise?.hasTimeMeasure ?? true; _hasReps = exercise?.hasRepsMeasure ?? false; @@ -450,9 +484,11 @@ final class _ExerciseFormScreenState extends State { _ImageGalleryField( imageMediaIds: _imageMediaIds, imageNamesById: _selectedImageNamesById, + iconMediaId: _iconMediaId, importing: _importingImage, onAdd: _importImage, onRemove: _removeImage, + onSetIcon: _setIcon, ), const SizedBox(height: 8), _MediaImportField( @@ -704,10 +740,17 @@ final class _ExerciseFormScreenState extends State { _imageMediaIds = _imageMediaIds .where((imageId) => imageId != mediaAssetId) .toList(); + if (_iconMediaId == mediaAssetId) { + _iconMediaId = null; + } _selectedImageNamesById.remove(mediaAssetId); }); } + void _setIcon(String mediaAssetId) { + setState(() => _iconMediaId = mediaAssetId); + } + Future _importVideo() async { final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video); if (sourcePath == null) { @@ -795,6 +838,7 @@ final class _ExerciseFormScreenState extends State { name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaIds: _imageMediaIds, + iconMediaId: _iconMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, @@ -813,6 +857,7 @@ final class _ExerciseFormScreenState extends State { name: _nameController.text.trim(), description: _optionalText(_descriptionController), imageMediaIds: _imageMediaIds, + iconMediaId: _iconMediaId, videoMediaId: _videoMediaId, hasTimeMeasure: _hasTime, hasRepsMeasure: _hasReps, @@ -969,16 +1014,20 @@ final class _ImageGalleryField extends StatelessWidget { const _ImageGalleryField({ required this.imageMediaIds, required this.imageNamesById, + required this.iconMediaId, required this.importing, required this.onAdd, required this.onRemove, + required this.onSetIcon, }); final List imageMediaIds; final Map imageNamesById; + final String? iconMediaId; final bool importing; final VoidCallback onAdd; final ValueChanged onRemove; + final ValueChanged onSetIcon; @override Widget build(BuildContext context) { @@ -998,10 +1047,12 @@ final class _ImageGalleryField extends StatelessWidget { for (var index = 0; index < imageMediaIds.length; index++) _ImageThumbnail( mediaAssetId: imageMediaIds[index], + selectedAsIcon: imageMediaIds[index] == iconMediaId, label: imageNamesById[imageMediaIds[index]] ?? 'Image ${index + 1}', onRemove: onRemove, + onSetIcon: onSetIcon, ), ], ), @@ -1035,44 +1086,78 @@ final class _ImageGalleryField extends StatelessWidget { final class _ImageThumbnail extends StatelessWidget { const _ImageThumbnail({ required this.mediaAssetId, + required this.selectedAsIcon, required this.label, required this.onRemove, + required this.onSetIcon, }); final String mediaAssetId; + final bool selectedAsIcon; final String label; final ValueChanged onRemove; + final ValueChanged onSetIcon; @override Widget build(BuildContext context) { + final theme = Theme.of(context); final tokens = courtBlazerTokensOf(context); return SizedBox( - width: 104, + width: 132, child: Stack( children: [ Container( - height: 104, + height: 154, padding: const EdgeInsets.all(8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), - border: Border.all(color: tokens.border), - color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: selectedAsIcon + ? theme.colorScheme.primary + : tokens.border, + width: selectedAsIcon ? 2 : 1, + ), + color: theme.colorScheme.surface, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.image_outlined), + Icon( + Icons.image_outlined, + color: selectedAsIcon ? theme.colorScheme.primary : null, + ), const SizedBox(height: 8), Text( label, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall, + style: theme.textTheme.labelSmall, + ), + const SizedBox(height: 8), + IconButton( + tooltip: 'Définir comme icône', + onPressed: selectedAsIcon + ? null + : () => onSetIcon(mediaAssetId), + icon: const Icon(Icons.star_outline, size: 18), ), ], ), ), + if (selectedAsIcon) + Positioned( + top: 4, + left: 4, + child: Tooltip( + message: 'Icône choisie', + child: Icon( + Icons.star, + color: theme.colorScheme.primary, + size: 18, + ), + ), + ), Positioned( top: 2, right: 2, diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index d6103de..61d3bad 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -897,7 +897,8 @@ final class _ProgramExerciseDraft { sourceExerciseId: exercise.metadata.id, exerciseNameSnapshot: exercise.name, exerciseDescriptionSnapshot: exercise.description, - exerciseImageMediaIdSnapshot: exercise.imageMediaId, + exerciseImageMediaIdSnapshot: + exercise.iconMediaId ?? exercise.imageMediaId, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseArchivedSnapshot: exercise.archivedAt != null, diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index ea3a585..b40aa42 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -225,6 +225,72 @@ void main() { expect(updated.imageMediaId, 'media-1'); }); + test('Exercise icon can be set to an image from the gallery', () async { + final repository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + imageMediaIds: const ['media-1', 'media-2'], + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final useCase = _exerciseUseCase(repository); + + final updated = await useCase.setIcon( + exerciseId: 'exercise-1', + mediaAssetId: 'media-2', + ); + + expect(updated.iconMediaId, 'media-2'); + expect(repository.saved.single.iconMediaId, 'media-2'); + }); + + test('Exercise icon rejects images outside the gallery', () async { + final repository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + imageMediaIds: const ['media-1'], + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ); + final useCase = _exerciseUseCase(repository); + + await expectLater( + useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'), + throwsA(isA()), + ); + expect(repository.saved, isEmpty); + }); + + test('Exercise icon is reset when its image is removed', () async { + final repository = _FakeExerciseRepository() + ..exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + imageMediaIds: const ['media-1', 'media-2'], + iconMediaId: 'media-2', + 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']); + expect(updated.iconMediaId, isNull); + expect(repository.saved.single.iconMediaId, isNull); + }); + test('program exercise snapshot keeps the full image gallery', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 497faa1..53000f9 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -216,6 +216,72 @@ void main() { expect(find.text('Aucune image sélectionnée'), findsOneWidget); }); + testWidgets('désigner une image comme icône l’indique visuellement', ( + tester, + ) async { + final exerciseRepository = _FakeExerciseRepository(); + final storage = _FakeMediaStorage(); + + await tester.binding.setSurfaceSize(const Size(400, 1600)); + 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', + ], + ), + ), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Squat'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + '45', + ); + await tester.tap(find.text('Ajouter une image')); + await tester.pump(); + await tester.tap(find.text('Ajouter une image')); + await tester.pump(); + + await tester.tap(find.byTooltip('Définir comme icône').last); + await tester.pump(); + + expect(find.byTooltip('Icône choisie'), findsOneWidget); + + await tester.tap(find.text('Enregistrer')); + await tester.pumpAndSettle(); + + expect(exerciseRepository.saved.single.iconMediaId, 'id-2'); + }); + + testWidgets('ExerciseListTile utilise l’icône choisie', (tester) async { + final exercise = Exercise( + metadata: _metadata('exercise-1'), + name: 'Squat', + imageMediaIds: const ['image-1', 'image-2'], + iconMediaId: 'image-2', + hasTimeMeasure: true, + hasRepsMeasure: false, + hasScoreMeasure: false, + defaultTargetTimeSeconds: 45, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ExerciseListTile(exercise: exercise)), + ), + ); + + expect(find.byTooltip('Icône d’exercice image-2'), findsOneWidget); + }); + testWidgets( 'supprimer un exercice demande confirmation puis le retire de la liste', (tester) async {