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 {