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

Fusionne feature/#38-icone-exercice — analyze propre, 72/72 tests
verts, build APK debug validé. Dernier ticket du backlog, clôturé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:25:33 +02:00
13 changed files with 601 additions and 36 deletions

View File

@ -1,6 +1,9 @@
--- ---
issueRef: "#38" issueRef: "#38"
version: 3 version: 6
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784330204091 updatedAt: 1784377509677
--- ---
Découpage :
1. [DevBackend] Ajouter un champ `iconMediaId` (String?, nullable) sur Exercise, distinct de la galerie `imageMediaIds` (ticket #35) — l'icône doit référencer l'un des médias déjà présents dans la galerie de l'exercice (contrainte : iconMediaId, si renseigné, doit faire partie de imageMediaIds). Si iconMediaId est null, l'affichage de la liste retombe sur la première image de la galerie (comportement actuel) ou aucune icône si la galerie est vide. Migration Drift.
2. [DevFrontend] Dans le formulaire d'exercice, une fois qu'il y a au moins une image dans la galerie, permettre de désigner l'une d'elles comme icône (ex: tap longue ou bouton "Définir comme icône" sur chaque miniature, avec un indicateur visuel sur celle actuellement choisie). Utiliser cette icône dans ExerciseListTile et partout où l'exercice est affiché en liste compacte (programme, sélection d'exercice).

View File

@ -2,15 +2,15 @@
id: "51e8769d-7b2b-4b4d-8de1-dc784881e575" id: "51e8769d-7b2b-4b4d-8de1-dc784881e575"
number: 38 number: 38
title: "Proposer de mettre une image pour l'icone de l'exercice" title: "Proposer de mettre une image pour l'icone de l'exercice"
status: "open" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [] links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"} createdBy: {"kind":"user"}
updatedBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784330169746 createdAt: 1784330169746
updatedAt: 1784330204091 updatedAt: 1784377509677
version: 3 version: 6
--- ---
J'aimerais qu'en plus des images que l'on peut ajouter, qu'il soit possible de mettre une image comme icone de l'exercice J'aimerais qu'en plus des images que l'on peut ajouter, qu'il soit possible de mettre une image comme icone de l'exercice

View File

@ -429,13 +429,13 @@
"issueRef": "#38", "issueRef": "#38",
"path": "38", "path": "38",
"title": "Proposer de mettre une image pour l'icone de l'exercice", "title": "Proposer de mettre une image pour l'icone de l'exercice",
"status": "open", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784330204091 "updatedAt": 1784377509677
}, },
{ {
"issueRef": "#39", "issueRef": "#39",

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 {