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

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

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

View File

@ -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,