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:
@ -267,15 +267,11 @@ final class ExerciseListTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final description = exercise.description?.trim();
|
||||
final hasMedia =
|
||||
exercise.imageMediaId != null || exercise.videoMediaId != null;
|
||||
final iconMediaId = exercise.iconMediaId ?? exercise.imageMediaId;
|
||||
final hasMedia = iconMediaId != null || exercise.videoMediaId != null;
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: CircleAvatar(
|
||||
child: Icon(
|
||||
hasMedia ? Icons.perm_media_outlined : Icons.fitness_center,
|
||||
),
|
||||
),
|
||||
leading: _ExerciseIconAvatar(iconMediaId: iconMediaId),
|
||||
title: Text(exercise.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -328,6 +324,42 @@ final class ExerciseListTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _ExerciseIconAvatar extends StatelessWidget {
|
||||
const _ExerciseIconAvatar({required this.iconMediaId});
|
||||
|
||||
final String? iconMediaId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = CircleAvatar(
|
||||
child: Icon(
|
||||
iconMediaId == null ? Icons.fitness_center : Icons.perm_media_outlined,
|
||||
),
|
||||
);
|
||||
if (iconMediaId == null) {
|
||||
return icon;
|
||||
}
|
||||
return Tooltip(
|
||||
message: 'Icône d’exercice $iconMediaId',
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
icon,
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Icon(
|
||||
Icons.star,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ExerciseFormScreen extends StatefulWidget {
|
||||
const ExerciseFormScreen({
|
||||
required this.exerciseUseCases,
|
||||
@ -357,6 +389,7 @@ final class _ExerciseFormScreenState extends State<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,
|
||||
|
||||
Reference in New Issue
Block a user