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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1078,6 +1078,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
remoteRevision: values[10] as Value<String?>,
name: Value(exercise.name),
description: Value(exercise.description),
iconMediaId: Value(exercise.iconMediaId),
videoMediaId: Value(exercise.videoMediaId),
hasTimeMeasure: Value(exercise.hasTimeMeasure),
hasRepsMeasure: Value(exercise.hasRepsMeasure),
@ -1099,6 +1100,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
name: row.name,
description: row.description,
imageMediaIds: imageMediaIds,
iconMediaId: row.iconMediaId,
videoMediaId: row.videoMediaId,
hasTimeMeasure: row.hasTimeMeasure,
hasRepsMeasure: row.hasRepsMeasure,

View File

@ -55,6 +55,9 @@ class Exercises extends SyncableTable {
TextColumn get name => text().withLength(min: 1)();
TextColumn get description => text().nullable()();
@ReferenceName('exerciseIconReferences')
TextColumn get iconMediaId =>
text().nullable().references(MediaAssets, #id)();
@ReferenceName('exerciseVideoReferences')
TextColumn get videoMediaId =>
text().nullable().references(MediaAssets, #id)();

View File

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

View File

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

View File

@ -225,6 +225,72 @@ void main() {
expect(updated.imageMediaId, 'media-1');
});
test('Exercise icon can be set to an image from the gallery', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1', 'media-2'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
final updated = await useCase.setIcon(
exerciseId: 'exercise-1',
mediaAssetId: 'media-2',
);
expect(updated.iconMediaId, 'media-2');
expect(repository.saved.single.iconMediaId, 'media-2');
});
test('Exercise icon rejects images outside the gallery', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
metadata: _metadata('exercise-1'),
name: 'Squat',
imageMediaIds: const ['media-1'],
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
);
final useCase = _exerciseUseCase(repository);
await expectLater(
useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'),
throwsA(isA<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 {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(

View File

@ -216,6 +216,72 @@ void main() {
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(
'supprimer un exercice demande confirmation puis le retire de la liste',
(tester) async {