feat(exercice): icône d'exercice (ticket #38)

Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et les écrans bibliothèque d'exercices
et programme pour associer une icône à chaque exercice. flutter
analyze propre, 72/72 tests verts, build APK debug validé. Dernier
ticket du backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:25:26 +02:00
parent c92bded211
commit bdd7877ea5
10 changed files with 589 additions and 27 deletions

View File

@ -25,6 +25,7 @@ final class ExerciseUseCases {
String? description, String? description,
String? imageMediaId, String? imageMediaId,
List<String> imageMediaIds = const [], List<String> imageMediaIds = const [],
String? iconMediaId,
String? videoMediaId, String? videoMediaId,
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -53,6 +54,7 @@ final class ExerciseUseCases {
name: name, name: name,
description: description, description: description,
imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds), imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds),
iconMediaId: iconMediaId,
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -83,6 +85,7 @@ final class ExerciseUseCases {
String? description, String? description,
String? imageMediaId, String? imageMediaId,
Object? imageMediaIds = _useCaseUnchanged, Object? imageMediaIds = _useCaseUnchanged,
Object? iconMediaId = _useCaseUnchanged,
String? videoMediaId, String? videoMediaId,
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -123,13 +126,21 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
); );
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
: imageMediaIds as List<String>;
final resolvedIconMediaId = iconMediaId == _useCaseUnchanged
? exercise.iconMediaId != null &&
resolvedImageMediaIds.contains(exercise.iconMediaId)
? exercise.iconMediaId
: null
: iconMediaId as String?;
final updated = exercise.copyWith( final updated = exercise.copyWith(
metadata: exercise.metadata.touch(clock.now()), metadata: exercise.metadata.touch(clock.now()),
name: name, name: name,
description: description, description: description,
imageMediaIds: imageMediaIds == _useCaseUnchanged imageMediaIds: resolvedImageMediaIds,
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds) iconMediaId: resolvedIconMediaId,
: imageMediaIds as List<String>,
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -190,6 +201,9 @@ final class ExerciseUseCases {
imageMediaIds: exercise.imageMediaIds imageMediaIds: exercise.imageMediaIds
.where((imageId) => imageId != mediaAssetId) .where((imageId) => imageId != mediaAssetId)
.toList(), .toList(),
iconMediaId: exercise.iconMediaId == mediaAssetId
? null
: exercise.iconMediaId,
metadata: exercise.metadata.touch(clock.now()), metadata: exercise.metadata.touch(clock.now()),
); );
await repository.save(updated); await repository.save(updated);
@ -217,6 +231,22 @@ final class ExerciseUseCases {
return updated; return updated;
} }
Future<Exercise> 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<Exercise> attachVideo({ Future<Exercise> attachVideo({
required String exerciseId, required String exerciseId,
required String mediaAssetId, required String mediaAssetId,

View File

@ -126,6 +126,7 @@ final class Exercise {
this.description, this.description,
String? imageMediaId, String? imageMediaId,
List<String> imageMediaIds = const [], List<String> imageMediaIds = const [],
String? iconMediaId,
this.videoMediaId, this.videoMediaId,
required this.hasTimeMeasure, required this.hasTimeMeasure,
required this.hasRepsMeasure, required this.hasRepsMeasure,
@ -139,10 +140,13 @@ final class Exercise {
this.defaultTargetScoreTimeMs, this.defaultTargetScoreTimeMs,
this.archivedAt, this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name'), }) : name = _nonBlank(name, 'Exercise name'),
imageMediaIds = _validatedImageMediaIds( imageMediaIds = _resolvedExerciseImageMediaIds(
imageMediaIds.isEmpty && imageMediaId != null imageMediaId,
? [imageMediaId] imageMediaIds,
: imageMediaIds, ),
iconMediaId = _validatedIconMediaId(
iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
) { ) {
_requireAtLeastOneMeasure( _requireAtLeastOneMeasure(
hasTime: hasTimeMeasure, hasTime: hasTimeMeasure,
@ -174,6 +178,7 @@ final class Exercise {
final String name; final String name;
final String? description; final String? description;
final List<String> imageMediaIds; final List<String> imageMediaIds;
final String? iconMediaId;
final String? videoMediaId; final String? videoMediaId;
final bool hasTimeMeasure; final bool hasTimeMeasure;
final bool hasRepsMeasure; final bool hasRepsMeasure;
@ -206,6 +211,7 @@ final class Exercise {
Object? description = _unchanged, Object? description = _unchanged,
Object? imageMediaId = _unchanged, Object? imageMediaId = _unchanged,
Object? imageMediaIds = _unchanged, Object? imageMediaIds = _unchanged,
Object? iconMediaId = _unchanged,
Object? videoMediaId = _unchanged, Object? videoMediaId = _unchanged,
bool? hasTimeMeasure, bool? hasTimeMeasure,
bool? hasRepsMeasure, bool? hasRepsMeasure,
@ -219,17 +225,25 @@ final class Exercise {
Object? defaultTargetScoreTimeMs = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged,
Object? archivedAt = _unchanged, Object? archivedAt = _unchanged,
}) { }) {
final resolvedImageMediaIds = imageMediaIds == _unchanged
? imageMediaId == _unchanged
? this.imageMediaIds
: [if (imageMediaId != null) imageMediaId as String]
: imageMediaIds as List<String>;
final resolvedIconMediaId = iconMediaId == _unchanged
? this.iconMediaId != null &&
resolvedImageMediaIds.contains(this.iconMediaId)
? this.iconMediaId
: null
: iconMediaId as String?;
return Exercise( return Exercise(
metadata: metadata ?? this.metadata, metadata: metadata ?? this.metadata,
name: name ?? this.name, name: name ?? this.name,
description: description == _unchanged description: description == _unchanged
? this.description ? this.description
: description as String?, : description as String?,
imageMediaIds: imageMediaIds == _unchanged imageMediaIds: resolvedImageMediaIds,
? imageMediaId == _unchanged iconMediaId: resolvedIconMediaId,
? 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?,
@ -957,6 +971,30 @@ List<String> _validatedImageMediaIds(List<String> ids) {
return normalized; return normalized;
} }
List<String> _resolvedExerciseImageMediaIds(
String? imageMediaId,
List<String> imageMediaIds,
) {
return _validatedImageMediaIds(
imageMediaIds.isEmpty && imageMediaId != null
? [imageMediaId]
: imageMediaIds,
);
}
String? _validatedIconMediaId(String? iconMediaId, List<String> 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({ void _requireAtLeastOneMeasure({
required bool hasTime, required bool hasTime,
required bool hasReps, required bool hasReps,

View File

@ -37,7 +37,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 6; int get schemaVersion => 7;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -70,6 +70,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 6) { if (from < 6) {
await _migrateToSchema6(); await _migrateToSchema6();
} }
if (from < 7) {
await _migrateToSchema7();
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -260,4 +263,11 @@ extension on AppDatabase {
'exercise_image_media_ids_snapshot_json TEXT', 'exercise_image_media_ids_snapshot_json TEXT',
); );
} }
Future<void> _migrateToSchema7() async {
await customStatement(
'ALTER TABLE exercises ADD COLUMN icon_media_id TEXT '
'REFERENCES media_assets(id)',
);
}
} }

View File

@ -7959,6 +7959,20 @@ class $ExercisesTable extends Exercises
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _iconMediaIdMeta = const VerificationMeta(
'iconMediaId',
);
@override
late final GeneratedColumn<String> iconMediaId = GeneratedColumn<String>(
'icon_media_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES media_assets (id)',
),
);
static const VerificationMeta _videoMediaIdMeta = const VerificationMeta( static const VerificationMeta _videoMediaIdMeta = const VerificationMeta(
'videoMediaId', 'videoMediaId',
); );
@ -8119,6 +8133,7 @@ class $ExercisesTable extends Exercises
remoteRevision, remoteRevision,
name, name,
description, description,
iconMediaId,
videoMediaId, videoMediaId,
hasTimeMeasure, hasTimeMeasure,
hasRepsMeasure, 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')) { if (data.containsKey('video_media_id')) {
context.handle( context.handle(
_videoMediaIdMeta, _videoMediaIdMeta,
@ -8420,6 +8444,10 @@ class $ExercisesTable extends Exercises
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}description'], data['${effectivePrefix}description'],
), ),
iconMediaId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}icon_media_id'],
),
videoMediaId: attachedDatabase.typeMapping.read( videoMediaId: attachedDatabase.typeMapping.read(
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}video_media_id'], data['${effectivePrefix}video_media_id'],
@ -8491,6 +8519,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
final String? remoteRevision; final String? remoteRevision;
final String name; final String name;
final String? description; final String? description;
final String? iconMediaId;
final String? videoMediaId; final String? videoMediaId;
final bool hasTimeMeasure; final bool hasTimeMeasure;
final bool hasRepsMeasure; final bool hasRepsMeasure;
@ -8517,6 +8546,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
this.remoteRevision, this.remoteRevision,
required this.name, required this.name,
this.description, this.description,
this.iconMediaId,
this.videoMediaId, this.videoMediaId,
required this.hasTimeMeasure, required this.hasTimeMeasure,
required this.hasRepsMeasure, required this.hasRepsMeasure,
@ -8556,6 +8586,9 @@ class Exercise extends DataClass implements Insertable<Exercise> {
if (!nullToAbsent || description != null) { if (!nullToAbsent || description != null) {
map['description'] = Variable<String>(description); map['description'] = Variable<String>(description);
} }
if (!nullToAbsent || iconMediaId != null) {
map['icon_media_id'] = Variable<String>(iconMediaId);
}
if (!nullToAbsent || videoMediaId != null) { if (!nullToAbsent || videoMediaId != null) {
map['video_media_id'] = Variable<String>(videoMediaId); map['video_media_id'] = Variable<String>(videoMediaId);
} }
@ -8616,6 +8649,9 @@ class Exercise extends DataClass implements Insertable<Exercise> {
description: description == null && nullToAbsent description: description == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(description), : Value(description),
iconMediaId: iconMediaId == null && nullToAbsent
? const Value.absent()
: Value(iconMediaId),
videoMediaId: videoMediaId == null && nullToAbsent videoMediaId: videoMediaId == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(videoMediaId), : Value(videoMediaId),
@ -8668,6 +8704,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
remoteRevision: serializer.fromJson<String?>(json['remoteRevision']), remoteRevision: serializer.fromJson<String?>(json['remoteRevision']),
name: serializer.fromJson<String>(json['name']), name: serializer.fromJson<String>(json['name']),
description: serializer.fromJson<String?>(json['description']), description: serializer.fromJson<String?>(json['description']),
iconMediaId: serializer.fromJson<String?>(json['iconMediaId']),
videoMediaId: serializer.fromJson<String?>(json['videoMediaId']), videoMediaId: serializer.fromJson<String?>(json['videoMediaId']),
hasTimeMeasure: serializer.fromJson<bool>(json['hasTimeMeasure']), hasTimeMeasure: serializer.fromJson<bool>(json['hasTimeMeasure']),
hasRepsMeasure: serializer.fromJson<bool>(json['hasRepsMeasure']), hasRepsMeasure: serializer.fromJson<bool>(json['hasRepsMeasure']),
@ -8705,6 +8742,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
'remoteRevision': serializer.toJson<String?>(remoteRevision), 'remoteRevision': serializer.toJson<String?>(remoteRevision),
'name': serializer.toJson<String>(name), 'name': serializer.toJson<String>(name),
'description': serializer.toJson<String?>(description), 'description': serializer.toJson<String?>(description),
'iconMediaId': serializer.toJson<String?>(iconMediaId),
'videoMediaId': serializer.toJson<String?>(videoMediaId), 'videoMediaId': serializer.toJson<String?>(videoMediaId),
'hasTimeMeasure': serializer.toJson<bool>(hasTimeMeasure), 'hasTimeMeasure': serializer.toJson<bool>(hasTimeMeasure),
'hasRepsMeasure': serializer.toJson<bool>(hasRepsMeasure), 'hasRepsMeasure': serializer.toJson<bool>(hasRepsMeasure),
@ -8738,6 +8776,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
Value<String?> remoteRevision = const Value.absent(), Value<String?> remoteRevision = const Value.absent(),
String? name, String? name,
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<String?> iconMediaId = const Value.absent(),
Value<String?> videoMediaId = const Value.absent(), Value<String?> videoMediaId = const Value.absent(),
bool? hasTimeMeasure, bool? hasTimeMeasure,
bool? hasRepsMeasure, bool? hasRepsMeasure,
@ -8768,6 +8807,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
: this.remoteRevision, : this.remoteRevision,
name: name ?? this.name, name: name ?? this.name,
description: description.present ? description.value : this.description, description: description.present ? description.value : this.description,
iconMediaId: iconMediaId.present ? iconMediaId.value : this.iconMediaId,
videoMediaId: videoMediaId.present ? videoMediaId.value : this.videoMediaId, videoMediaId: videoMediaId.present ? videoMediaId.value : this.videoMediaId,
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure, hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure, hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
@ -8818,6 +8858,9 @@ class Exercise extends DataClass implements Insertable<Exercise> {
description: data.description.present description: data.description.present
? data.description.value ? data.description.value
: this.description, : this.description,
iconMediaId: data.iconMediaId.present
? data.iconMediaId.value
: this.iconMediaId,
videoMediaId: data.videoMediaId.present videoMediaId: data.videoMediaId.present
? data.videoMediaId.value ? data.videoMediaId.value
: this.videoMediaId, : this.videoMediaId,
@ -8871,6 +8914,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
..write('remoteRevision: $remoteRevision, ') ..write('remoteRevision: $remoteRevision, ')
..write('name: $name, ') ..write('name: $name, ')
..write('description: $description, ') ..write('description: $description, ')
..write('iconMediaId: $iconMediaId, ')
..write('videoMediaId: $videoMediaId, ') ..write('videoMediaId: $videoMediaId, ')
..write('hasTimeMeasure: $hasTimeMeasure, ') ..write('hasTimeMeasure: $hasTimeMeasure, ')
..write('hasRepsMeasure: $hasRepsMeasure, ') ..write('hasRepsMeasure: $hasRepsMeasure, ')
@ -8902,6 +8946,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
remoteRevision, remoteRevision,
name, name,
description, description,
iconMediaId,
videoMediaId, videoMediaId,
hasTimeMeasure, hasTimeMeasure,
hasRepsMeasure, hasRepsMeasure,
@ -8932,6 +8977,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
other.remoteRevision == this.remoteRevision && other.remoteRevision == this.remoteRevision &&
other.name == this.name && other.name == this.name &&
other.description == this.description && other.description == this.description &&
other.iconMediaId == this.iconMediaId &&
other.videoMediaId == this.videoMediaId && other.videoMediaId == this.videoMediaId &&
other.hasTimeMeasure == this.hasTimeMeasure && other.hasTimeMeasure == this.hasTimeMeasure &&
other.hasRepsMeasure == this.hasRepsMeasure && other.hasRepsMeasure == this.hasRepsMeasure &&
@ -8960,6 +9006,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
final Value<String?> remoteRevision; final Value<String?> remoteRevision;
final Value<String> name; final Value<String> name;
final Value<String?> description; final Value<String?> description;
final Value<String?> iconMediaId;
final Value<String?> videoMediaId; final Value<String?> videoMediaId;
final Value<bool> hasTimeMeasure; final Value<bool> hasTimeMeasure;
final Value<bool> hasRepsMeasure; final Value<bool> hasRepsMeasure;
@ -8987,6 +9034,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
this.remoteRevision = const Value.absent(), this.remoteRevision = const Value.absent(),
this.name = const Value.absent(), this.name = const Value.absent(),
this.description = const Value.absent(), this.description = const Value.absent(),
this.iconMediaId = const Value.absent(),
this.videoMediaId = const Value.absent(), this.videoMediaId = const Value.absent(),
this.hasTimeMeasure = const Value.absent(), this.hasTimeMeasure = const Value.absent(),
this.hasRepsMeasure = const Value.absent(), this.hasRepsMeasure = const Value.absent(),
@ -9015,6 +9063,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
this.remoteRevision = const Value.absent(), this.remoteRevision = const Value.absent(),
required String name, required String name,
this.description = const Value.absent(), this.description = const Value.absent(),
this.iconMediaId = const Value.absent(),
this.videoMediaId = const Value.absent(), this.videoMediaId = const Value.absent(),
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -9052,6 +9101,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
Expression<String>? remoteRevision, Expression<String>? remoteRevision,
Expression<String>? name, Expression<String>? name,
Expression<String>? description, Expression<String>? description,
Expression<String>? iconMediaId,
Expression<String>? videoMediaId, Expression<String>? videoMediaId,
Expression<bool>? hasTimeMeasure, Expression<bool>? hasTimeMeasure,
Expression<bool>? hasRepsMeasure, Expression<bool>? hasRepsMeasure,
@ -9081,6 +9131,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
if (remoteRevision != null) 'remote_revision': remoteRevision, if (remoteRevision != null) 'remote_revision': remoteRevision,
if (name != null) 'name': name, if (name != null) 'name': name,
if (description != null) 'description': description, if (description != null) 'description': description,
if (iconMediaId != null) 'icon_media_id': iconMediaId,
if (videoMediaId != null) 'video_media_id': videoMediaId, if (videoMediaId != null) 'video_media_id': videoMediaId,
if (hasTimeMeasure != null) 'has_time_measure': hasTimeMeasure, if (hasTimeMeasure != null) 'has_time_measure': hasTimeMeasure,
if (hasRepsMeasure != null) 'has_reps_measure': hasRepsMeasure, if (hasRepsMeasure != null) 'has_reps_measure': hasRepsMeasure,
@ -9114,6 +9165,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
Value<String?>? remoteRevision, Value<String?>? remoteRevision,
Value<String>? name, Value<String>? name,
Value<String?>? description, Value<String?>? description,
Value<String?>? iconMediaId,
Value<String?>? videoMediaId, Value<String?>? videoMediaId,
Value<bool>? hasTimeMeasure, Value<bool>? hasTimeMeasure,
Value<bool>? hasRepsMeasure, Value<bool>? hasRepsMeasure,
@ -9142,6 +9194,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
remoteRevision: remoteRevision ?? this.remoteRevision, remoteRevision: remoteRevision ?? this.remoteRevision,
name: name ?? this.name, name: name ?? this.name,
description: description ?? this.description, description: description ?? this.description,
iconMediaId: iconMediaId ?? this.iconMediaId,
videoMediaId: videoMediaId ?? this.videoMediaId, videoMediaId: videoMediaId ?? this.videoMediaId,
hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure, hasTimeMeasure: hasTimeMeasure ?? this.hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure, hasRepsMeasure: hasRepsMeasure ?? this.hasRepsMeasure,
@ -9204,6 +9257,9 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
if (description.present) { if (description.present) {
map['description'] = Variable<String>(description.value); map['description'] = Variable<String>(description.value);
} }
if (iconMediaId.present) {
map['icon_media_id'] = Variable<String>(iconMediaId.value);
}
if (videoMediaId.present) { if (videoMediaId.present) {
map['video_media_id'] = Variable<String>(videoMediaId.value); map['video_media_id'] = Variable<String>(videoMediaId.value);
} }
@ -9266,6 +9322,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
..write('remoteRevision: $remoteRevision, ') ..write('remoteRevision: $remoteRevision, ')
..write('name: $name, ') ..write('name: $name, ')
..write('description: $description, ') ..write('description: $description, ')
..write('iconMediaId: $iconMediaId, ')
..write('videoMediaId: $videoMediaId, ') ..write('videoMediaId: $videoMediaId, ')
..write('hasTimeMeasure: $hasTimeMeasure, ') ..write('hasTimeMeasure: $hasTimeMeasure, ')
..write('hasRepsMeasure: $hasRepsMeasure, ') ..write('hasRepsMeasure: $hasRepsMeasure, ')
@ -22861,6 +22918,27 @@ final class $$MediaAssetsTableReferences
extends BaseReferences<_$AppDatabase, $MediaAssetsTable, MediaAsset> { extends BaseReferences<_$AppDatabase, $MediaAssetsTable, MediaAsset> {
$$MediaAssetsTableReferences(super.$_db, super.$_table, super.$_typedResult); $$MediaAssetsTableReferences(super.$_db, super.$_table, super.$_typedResult);
static MultiTypedResultKey<$ExercisesTable, List<Exercise>>
_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<String>('id')!));
final cache = $_typedResult.readTableOrNull(
_exerciseIconReferencesTable($_db),
);
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache),
);
}
static MultiTypedResultKey<$ExercisesTable, List<Exercise>> static MultiTypedResultKey<$ExercisesTable, List<Exercise>>
_exerciseVideoReferencesTable(_$AppDatabase db) => _exerciseVideoReferencesTable(_$AppDatabase db) =>
MultiTypedResultKey.fromTable( MultiTypedResultKey.fromTable(
@ -23067,6 +23145,31 @@ class $$MediaAssetsTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
Expression<bool> exerciseIconReferences(
Expression<bool> 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<bool> exerciseVideoReferences( Expression<bool> exerciseVideoReferences(
Expression<bool> Function($$ExercisesTableFilterComposer f) f, Expression<bool> Function($$ExercisesTableFilterComposer f) f,
) { ) {
@ -23371,6 +23474,31 @@ class $$MediaAssetsTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
Expression<T> exerciseIconReferences<T extends Object>(
Expression<T> 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<T> exerciseVideoReferences<T extends Object>( Expression<T> exerciseVideoReferences<T extends Object>(
Expression<T> Function($$ExercisesTableAnnotationComposer a) f, Expression<T> Function($$ExercisesTableAnnotationComposer a) f,
) { ) {
@ -23486,6 +23614,7 @@ class $$MediaAssetsTableTableManager
(MediaAsset, $$MediaAssetsTableReferences), (MediaAsset, $$MediaAssetsTableReferences),
MediaAsset, MediaAsset,
PrefetchHooks Function({ PrefetchHooks Function({
bool exerciseIconReferences,
bool exerciseVideoReferences, bool exerciseVideoReferences,
bool exerciseImagesRefs, bool exerciseImagesRefs,
bool programExerciseImageSnapshotReferences, bool programExerciseImageSnapshotReferences,
@ -23609,6 +23738,7 @@ class $$MediaAssetsTableTableManager
.toList(), .toList(),
prefetchHooksCallback: prefetchHooksCallback:
({ ({
exerciseIconReferences = false,
exerciseVideoReferences = false, exerciseVideoReferences = false,
exerciseImagesRefs = false, exerciseImagesRefs = false,
programExerciseImageSnapshotReferences = false, programExerciseImageSnapshotReferences = false,
@ -23617,6 +23747,7 @@ class $$MediaAssetsTableTableManager
return PrefetchHooks( return PrefetchHooks(
db: db, db: db,
explicitlyWatchedTables: [ explicitlyWatchedTables: [
if (exerciseIconReferences) db.exercises,
if (exerciseVideoReferences) db.exercises, if (exerciseVideoReferences) db.exercises,
if (exerciseImagesRefs) db.exerciseImages, if (exerciseImagesRefs) db.exerciseImages,
if (programExerciseImageSnapshotReferences) if (programExerciseImageSnapshotReferences)
@ -23627,6 +23758,27 @@ class $$MediaAssetsTableTableManager
addJoins: null, addJoins: null,
getPrefetchedDataCallback: (items) async { getPrefetchedDataCallback: (items) async {
return [ 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) if (exerciseVideoReferences)
await $_getPrefetchedData< await $_getPrefetchedData<
MediaAsset, MediaAsset,
@ -23734,6 +23886,7 @@ typedef $$MediaAssetsTableProcessedTableManager =
(MediaAsset, $$MediaAssetsTableReferences), (MediaAsset, $$MediaAssetsTableReferences),
MediaAsset, MediaAsset,
PrefetchHooks Function({ PrefetchHooks Function({
bool exerciseIconReferences,
bool exerciseVideoReferences, bool exerciseVideoReferences,
bool exerciseImagesRefs, bool exerciseImagesRefs,
bool programExerciseImageSnapshotReferences, bool programExerciseImageSnapshotReferences,
@ -23755,6 +23908,7 @@ typedef $$ExercisesTableCreateCompanionBuilder =
Value<String?> remoteRevision, Value<String?> remoteRevision,
required String name, required String name,
Value<String?> description, Value<String?> description,
Value<String?> iconMediaId,
Value<String?> videoMediaId, Value<String?> videoMediaId,
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -23784,6 +23938,7 @@ typedef $$ExercisesTableUpdateCompanionBuilder =
Value<String?> remoteRevision, Value<String?> remoteRevision,
Value<String> name, Value<String> name,
Value<String?> description, Value<String?> description,
Value<String?> iconMediaId,
Value<String?> videoMediaId, Value<String?> videoMediaId,
Value<bool> hasTimeMeasure, Value<bool> hasTimeMeasure,
Value<bool> hasRepsMeasure, Value<bool> hasRepsMeasure,
@ -23803,6 +23958,23 @@ final class $$ExercisesTableReferences
extends BaseReferences<_$AppDatabase, $ExercisesTable, Exercise> { extends BaseReferences<_$AppDatabase, $ExercisesTable, Exercise> {
$$ExercisesTableReferences(super.$_db, super.$_table, super.$_typedResult); $$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<String>('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) => static $MediaAssetsTable _videoMediaIdTable(_$AppDatabase db) =>
db.mediaAssets.createAlias('exercises__video_media_id__media_assets__id'); db.mediaAssets.createAlias('exercises__video_media_id__media_assets__id');
@ -23988,6 +24160,29 @@ class $$ExercisesTableFilterComposer
builder: (column) => ColumnFilters(column), 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 { $$MediaAssetsTableFilterComposer get videoMediaId {
final $$MediaAssetsTableFilterComposer composer = $composerBuilder( final $$MediaAssetsTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@ -24191,6 +24386,29 @@ class $$ExercisesTableOrderingComposer
builder: (column) => ColumnOrderings(column), 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 { $$MediaAssetsTableOrderingComposer get videoMediaId {
final $$MediaAssetsTableOrderingComposer composer = $composerBuilder( final $$MediaAssetsTableOrderingComposer composer = $composerBuilder(
composer: this, composer: this,
@ -24330,6 +24548,29 @@ class $$ExercisesTableAnnotationComposer
builder: (column) => column, 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 { $$MediaAssetsTableAnnotationComposer get videoMediaId {
final $$MediaAssetsTableAnnotationComposer composer = $composerBuilder( final $$MediaAssetsTableAnnotationComposer composer = $composerBuilder(
composer: this, composer: this,
@ -24418,6 +24659,7 @@ class $$ExercisesTableTableManager
(Exercise, $$ExercisesTableReferences), (Exercise, $$ExercisesTableReferences),
Exercise, Exercise,
PrefetchHooks Function({ PrefetchHooks Function({
bool iconMediaId,
bool videoMediaId, bool videoMediaId,
bool exerciseImagesRefs, bool exerciseImagesRefs,
bool programExercisesRefs, bool programExercisesRefs,
@ -24449,6 +24691,7 @@ class $$ExercisesTableTableManager
Value<String?> remoteRevision = const Value.absent(), Value<String?> remoteRevision = const Value.absent(),
Value<String> name = const Value.absent(), Value<String> name = const Value.absent(),
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<String?> iconMediaId = const Value.absent(),
Value<String?> videoMediaId = const Value.absent(), Value<String?> videoMediaId = const Value.absent(),
Value<bool> hasTimeMeasure = const Value.absent(), Value<bool> hasTimeMeasure = const Value.absent(),
Value<bool> hasRepsMeasure = const Value.absent(), Value<bool> hasRepsMeasure = const Value.absent(),
@ -24476,6 +24719,7 @@ class $$ExercisesTableTableManager
remoteRevision: remoteRevision, remoteRevision: remoteRevision,
name: name, name: name,
description: description, description: description,
iconMediaId: iconMediaId,
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -24505,6 +24749,7 @@ class $$ExercisesTableTableManager
Value<String?> remoteRevision = const Value.absent(), Value<String?> remoteRevision = const Value.absent(),
required String name, required String name,
Value<String?> description = const Value.absent(), Value<String?> description = const Value.absent(),
Value<String?> iconMediaId = const Value.absent(),
Value<String?> videoMediaId = const Value.absent(), Value<String?> videoMediaId = const Value.absent(),
required bool hasTimeMeasure, required bool hasTimeMeasure,
required bool hasRepsMeasure, required bool hasRepsMeasure,
@ -24532,6 +24777,7 @@ class $$ExercisesTableTableManager
remoteRevision: remoteRevision, remoteRevision: remoteRevision,
name: name, name: name,
description: description, description: description,
iconMediaId: iconMediaId,
videoMediaId: videoMediaId, videoMediaId: videoMediaId,
hasTimeMeasure: hasTimeMeasure, hasTimeMeasure: hasTimeMeasure,
hasRepsMeasure: hasRepsMeasure, hasRepsMeasure: hasRepsMeasure,
@ -24556,6 +24802,7 @@ class $$ExercisesTableTableManager
.toList(), .toList(),
prefetchHooksCallback: prefetchHooksCallback:
({ ({
iconMediaId = false,
videoMediaId = false, videoMediaId = false,
exerciseImagesRefs = false, exerciseImagesRefs = false,
programExercisesRefs = false, programExercisesRefs = false,
@ -24582,6 +24829,19 @@ class $$ExercisesTableTableManager
dynamic dynamic
> >
>(state) { >(state) {
if (iconMediaId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.iconMediaId,
referencedTable: $$ExercisesTableReferences
._iconMediaIdTable(db),
referencedColumn: $$ExercisesTableReferences
._iconMediaIdTable(db)
.id,
)
as T;
}
if (videoMediaId) { if (videoMediaId) {
state = state =
state.withJoin( state.withJoin(
@ -24663,6 +24923,7 @@ typedef $$ExercisesTableProcessedTableManager =
(Exercise, $$ExercisesTableReferences), (Exercise, $$ExercisesTableReferences),
Exercise, Exercise,
PrefetchHooks Function({ PrefetchHooks Function({
bool iconMediaId,
bool videoMediaId, bool videoMediaId,
bool exerciseImagesRefs, bool exerciseImagesRefs,
bool programExercisesRefs, bool programExercisesRefs,

View File

@ -1078,6 +1078,7 @@ 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),
iconMediaId: Value(exercise.iconMediaId),
videoMediaId: Value(exercise.videoMediaId), videoMediaId: Value(exercise.videoMediaId),
hasTimeMeasure: Value(exercise.hasTimeMeasure), hasTimeMeasure: Value(exercise.hasTimeMeasure),
hasRepsMeasure: Value(exercise.hasRepsMeasure), hasRepsMeasure: Value(exercise.hasRepsMeasure),
@ -1099,6 +1100,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
name: row.name, name: row.name,
description: row.description, description: row.description,
imageMediaIds: imageMediaIds, imageMediaIds: imageMediaIds,
iconMediaId: row.iconMediaId,
videoMediaId: row.videoMediaId, videoMediaId: row.videoMediaId,
hasTimeMeasure: row.hasTimeMeasure, hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure, hasRepsMeasure: row.hasRepsMeasure,

View File

@ -55,6 +55,9 @@ 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('exerciseIconReferences')
TextColumn get iconMediaId =>
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)();

View File

@ -267,15 +267,11 @@ final class ExerciseListTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final description = exercise.description?.trim(); final description = exercise.description?.trim();
final hasMedia = final iconMediaId = exercise.iconMediaId ?? exercise.imageMediaId;
exercise.imageMediaId != null || exercise.videoMediaId != null; final hasMedia = iconMediaId != null || exercise.videoMediaId != null;
return ListTile( return ListTile(
onTap: onTap, onTap: onTap,
leading: CircleAvatar( leading: _ExerciseIconAvatar(iconMediaId: iconMediaId),
child: Icon(
hasMedia ? Icons.perm_media_outlined : Icons.fitness_center,
),
),
title: Text(exercise.name), title: Text(exercise.name),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, 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 dexercice $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 { final class ExerciseFormScreen extends StatefulWidget {
const ExerciseFormScreen({ const ExerciseFormScreen({
required this.exerciseUseCases, required this.exerciseUseCases,
@ -357,6 +389,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
late final TextEditingController _defaultScoreController; late final TextEditingController _defaultScoreController;
late final TextEditingController _defaultScoreTimeController; late final TextEditingController _defaultScoreTimeController;
late List<String> _imageMediaIds; late List<String> _imageMediaIds;
String? _iconMediaId;
String? _videoMediaId; String? _videoMediaId;
final _selectedImageNamesById = <String, String>{}; final _selectedImageNamesById = <String, String>{};
String? _selectedVideoName; String? _selectedVideoName;
@ -395,6 +428,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
), ),
); );
_imageMediaIds = List<String>.of(exercise?.imageMediaIds ?? const []); _imageMediaIds = List<String>.of(exercise?.imageMediaIds ?? const []);
_iconMediaId = exercise?.iconMediaId;
_videoMediaId = exercise?.videoMediaId; _videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true; _hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false; _hasReps = exercise?.hasRepsMeasure ?? false;
@ -450,9 +484,11 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_ImageGalleryField( _ImageGalleryField(
imageMediaIds: _imageMediaIds, imageMediaIds: _imageMediaIds,
imageNamesById: _selectedImageNamesById, imageNamesById: _selectedImageNamesById,
iconMediaId: _iconMediaId,
importing: _importingImage, importing: _importingImage,
onAdd: _importImage, onAdd: _importImage,
onRemove: _removeImage, onRemove: _removeImage,
onSetIcon: _setIcon,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_MediaImportField( _MediaImportField(
@ -704,10 +740,17 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_imageMediaIds = _imageMediaIds _imageMediaIds = _imageMediaIds
.where((imageId) => imageId != mediaAssetId) .where((imageId) => imageId != mediaAssetId)
.toList(); .toList();
if (_iconMediaId == mediaAssetId) {
_iconMediaId = null;
}
_selectedImageNamesById.remove(mediaAssetId); _selectedImageNamesById.remove(mediaAssetId);
}); });
} }
void _setIcon(String mediaAssetId) {
setState(() => _iconMediaId = mediaAssetId);
}
Future<void> _importVideo() async { Future<void> _importVideo() async {
final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video); final sourcePath = await widget.mediaPicker.pickPath(MediaKind.video);
if (sourcePath == null) { if (sourcePath == null) {
@ -795,6 +838,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
name: _nameController.text.trim(), name: _nameController.text.trim(),
description: _optionalText(_descriptionController), description: _optionalText(_descriptionController),
imageMediaIds: _imageMediaIds, imageMediaIds: _imageMediaIds,
iconMediaId: _iconMediaId,
videoMediaId: _videoMediaId, videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime, hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps, hasRepsMeasure: _hasReps,
@ -813,6 +857,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
name: _nameController.text.trim(), name: _nameController.text.trim(),
description: _optionalText(_descriptionController), description: _optionalText(_descriptionController),
imageMediaIds: _imageMediaIds, imageMediaIds: _imageMediaIds,
iconMediaId: _iconMediaId,
videoMediaId: _videoMediaId, videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime, hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps, hasRepsMeasure: _hasReps,
@ -969,16 +1014,20 @@ final class _ImageGalleryField extends StatelessWidget {
const _ImageGalleryField({ const _ImageGalleryField({
required this.imageMediaIds, required this.imageMediaIds,
required this.imageNamesById, required this.imageNamesById,
required this.iconMediaId,
required this.importing, required this.importing,
required this.onAdd, required this.onAdd,
required this.onRemove, required this.onRemove,
required this.onSetIcon,
}); });
final List<String> imageMediaIds; final List<String> imageMediaIds;
final Map<String, String> imageNamesById; final Map<String, String> imageNamesById;
final String? iconMediaId;
final bool importing; final bool importing;
final VoidCallback onAdd; final VoidCallback onAdd;
final ValueChanged<String> onRemove; final ValueChanged<String> onRemove;
final ValueChanged<String> onSetIcon;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -998,10 +1047,12 @@ final class _ImageGalleryField extends StatelessWidget {
for (var index = 0; index < imageMediaIds.length; index++) for (var index = 0; index < imageMediaIds.length; index++)
_ImageThumbnail( _ImageThumbnail(
mediaAssetId: imageMediaIds[index], mediaAssetId: imageMediaIds[index],
selectedAsIcon: imageMediaIds[index] == iconMediaId,
label: label:
imageNamesById[imageMediaIds[index]] ?? imageNamesById[imageMediaIds[index]] ??
'Image ${index + 1}', 'Image ${index + 1}',
onRemove: onRemove, onRemove: onRemove,
onSetIcon: onSetIcon,
), ),
], ],
), ),
@ -1035,44 +1086,78 @@ final class _ImageGalleryField extends StatelessWidget {
final class _ImageThumbnail extends StatelessWidget { final class _ImageThumbnail extends StatelessWidget {
const _ImageThumbnail({ const _ImageThumbnail({
required this.mediaAssetId, required this.mediaAssetId,
required this.selectedAsIcon,
required this.label, required this.label,
required this.onRemove, required this.onRemove,
required this.onSetIcon,
}); });
final String mediaAssetId; final String mediaAssetId;
final bool selectedAsIcon;
final String label; final String label;
final ValueChanged<String> onRemove; final ValueChanged<String> onRemove;
final ValueChanged<String> onSetIcon;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = courtBlazerTokensOf(context); final tokens = courtBlazerTokensOf(context);
return SizedBox( return SizedBox(
width: 104, width: 132,
child: Stack( child: Stack(
children: [ children: [
Container( Container(
height: 104, height: 154,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border), border: Border.all(
color: Theme.of(context).colorScheme.surface, color: selectedAsIcon
? theme.colorScheme.primary
: tokens.border,
width: selectedAsIcon ? 2 : 1,
),
color: theme.colorScheme.surface,
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.image_outlined), Icon(
Icons.image_outlined,
color: selectedAsIcon ? theme.colorScheme.primary : null,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
label, label,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, 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( Positioned(
top: 2, top: 2,
right: 2, right: 2,

View File

@ -897,7 +897,8 @@ final class _ProgramExerciseDraft {
sourceExerciseId: exercise.metadata.id, sourceExerciseId: exercise.metadata.id,
exerciseNameSnapshot: exercise.name, exerciseNameSnapshot: exercise.name,
exerciseDescriptionSnapshot: exercise.description, exerciseDescriptionSnapshot: exercise.description,
exerciseImageMediaIdSnapshot: exercise.imageMediaId, exerciseImageMediaIdSnapshot:
exercise.iconMediaId ?? exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds, exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId, exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseArchivedSnapshot: exercise.archivedAt != null, exerciseArchivedSnapshot: exercise.archivedAt != null,

View File

@ -225,6 +225,72 @@ void main() {
expect(updated.imageMediaId, 'media-1'); 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<DomainException>()),
);
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 { test('program exercise snapshot keeps the full image gallery', () async {
final exerciseRepository = _FakeExerciseRepository() final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise( ..exercise = Exercise(

View File

@ -216,6 +216,72 @@ void main() {
expect(find.text('Aucune image sélectionnée'), findsOneWidget); expect(find.text('Aucune image sélectionnée'), findsOneWidget);
}); });
testWidgets('désigner une image comme icône lindique 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 licô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 dexercice image-2'), 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 {