feat(exercice): affichage des médias pendant l'exécution (ticket #36)

Expose la galerie d'images d'exercice (backend du ticket #35) pendant
l'exécution de séance sur workout_execution_screen.dart et
program_screen.dart, avec les ajustements domain/use cases/repositories
nécessaires. flutter analyze propre, 67/67 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 14:16:31 +02:00
parent 7385c96517
commit ff6b79b66e
10 changed files with 654 additions and 1 deletions

View File

@ -870,6 +870,7 @@ final class _ProgramExerciseDraft {
required this.exerciseNameSnapshot,
this.exerciseDescriptionSnapshot,
this.exerciseImageMediaIdSnapshot,
this.exerciseImageMediaIdsSnapshot = const [],
this.exerciseVideoMediaIdSnapshot,
this.exerciseArchivedSnapshot = false,
required this.availableTimeSnapshot,
@ -897,6 +898,7 @@ final class _ProgramExerciseDraft {
exerciseNameSnapshot: exercise.name,
exerciseDescriptionSnapshot: exercise.description,
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
exerciseArchivedSnapshot: exercise.archivedAt != null,
availableTimeSnapshot: exercise.hasTimeMeasure,
@ -927,6 +929,7 @@ final class _ProgramExerciseDraft {
exerciseNameSnapshot: exercise.exerciseNameSnapshot,
exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
availableTimeSnapshot: exercise.availableTimeSnapshot,
@ -955,6 +958,7 @@ final class _ProgramExerciseDraft {
final String exerciseNameSnapshot;
final String? exerciseDescriptionSnapshot;
final String? exerciseImageMediaIdSnapshot;
final List<String> exerciseImageMediaIdsSnapshot;
final String? exerciseVideoMediaIdSnapshot;
final bool exerciseArchivedSnapshot;
final bool availableTimeSnapshot;
@ -979,6 +983,7 @@ final class _ProgramExerciseDraft {
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,
@ -1006,6 +1011,7 @@ final class _ProgramExerciseDraft {
exerciseNameSnapshot: exerciseNameSnapshot,
exerciseDescriptionSnapshot: exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exerciseVideoMediaIdSnapshot,
exerciseArchivedSnapshot: exerciseArchivedSnapshot,
availableTimeSnapshot: availableTimeSnapshot,

View File

@ -130,6 +130,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
),
const SizedBox(height: 16),
Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium),
if (_exercise.hasMedia) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Tooltip(
message: 'Voir les médias de lexercice',
child: OutlinedButton.icon(
onPressed: () => _openExerciseMedia(_exercise),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Voir médias'),
),
),
),
],
const SizedBox(height: 16),
SetMeasureInput(
exercise: _exercise,
@ -183,6 +197,19 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
fontSize: 30,
),
),
if (nextExercise.hasMedia) ...[
const SizedBox(height: 12),
Center(
child: Tooltip(
message: 'Voir les médias de lexercice',
child: OutlinedButton.icon(
onPressed: () => _openExerciseMedia(nextExercise),
icon: const Icon(Icons.perm_media_outlined),
label: const Text('Voir médias'),
),
),
),
],
],
const Spacer(),
Row(
@ -282,6 +309,23 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
);
}
Future<void> _openExerciseMedia(ExecutionExercise exercise) async {
if (!exercise.hasMedia) {
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => _ExerciseMediaSheet(
exercise: exercise,
restRemainingSeconds: _mode == WorkoutExecutionMode.rest
? _remainingRestSeconds
: null,
),
);
}
bool get _canPauseFromNavigation {
return _mode == WorkoutExecutionMode.active ||
_mode == WorkoutExecutionMode.rest;
@ -816,6 +860,228 @@ final class _RestPlanBanner extends StatelessWidget {
}
}
enum _MediaSheetSection { images, video }
final class _ExerciseMediaSheet extends StatefulWidget {
const _ExerciseMediaSheet({
required this.exercise,
this.restRemainingSeconds,
});
final ExecutionExercise exercise;
final int? restRemainingSeconds;
@override
State<_ExerciseMediaSheet> createState() => _ExerciseMediaSheetState();
}
final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> {
late final PageController _pageController;
late _MediaSheetSection _section;
var _imageIndex = 0;
@override
void initState() {
super.initState();
_pageController = PageController();
_section = widget.exercise.imageMediaIds.isNotEmpty
? _MediaSheetSection.images
: _MediaSheetSection.video;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final exercise = widget.exercise;
final hasImages = exercise.imageMediaIds.isNotEmpty;
final hasVideo = exercise.videoMediaId != null;
return SizedBox(
height: MediaQuery.of(context).size.height * 0.95,
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Médias de lexercice'),
Text(exercise.name, style: Theme.of(context).textTheme.bodySmall),
],
),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.restRemainingSeconds != null) ...[
_RestPlanBanner(remainingSeconds: widget.restRemainingSeconds!),
const SizedBox(height: 16),
],
if (hasImages && hasVideo) ...[
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('Images'),
selected: _section == _MediaSheetSection.images,
onSelected: (_) {
setState(() => _section = _MediaSheetSection.images);
},
),
ChoiceChip(
label: const Text('Vidéo'),
selected: _section == _MediaSheetSection.video,
onSelected: (_) {
setState(() => _section = _MediaSheetSection.video);
},
),
],
),
const SizedBox(height: 16),
],
if (_section == _MediaSheetSection.images && hasImages)
_ImageMediaGallery(
imageMediaIds: exercise.imageMediaIds,
pageController: _pageController,
currentIndex: _imageIndex,
onPageChanged: (index) => setState(() => _imageIndex = index),
)
else if (hasVideo)
_VideoMediaPanel(videoMediaId: exercise.videoMediaId!),
],
),
),
);
}
}
final class _ImageMediaGallery extends StatelessWidget {
const _ImageMediaGallery({
required this.imageMediaIds,
required this.pageController,
required this.currentIndex,
required this.onPageChanged,
});
final List<String> imageMediaIds;
final PageController pageController;
final int currentIndex;
final ValueChanged<int> onPageChanged;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: Text(
'${currentIndex + 1}/${imageMediaIds.length}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: PageView.builder(
controller: pageController,
itemCount: imageMediaIds.length,
onPageChanged: onPageChanged,
itemBuilder: (context, index) {
return _ImageMediaPage(
mediaAssetId: imageMediaIds[index],
index: index,
);
},
),
),
],
),
);
}
}
final class _ImageMediaPage extends StatelessWidget {
const _ImageMediaPage({required this.mediaAssetId, required this.index});
final String mediaAssetId;
final int index;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: tokens.border),
color: Theme.of(context).colorScheme.surface,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Image ${index + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(mediaAssetId, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
final class _VideoMediaPanel extends StatelessWidget {
const _VideoMediaPanel({required this.videoMediaId});
final String videoMediaId;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 260,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_outline,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text('Vidéo', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(videoMediaId, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}
final class _WorkoutPlanSetTile extends StatelessWidget {
const _WorkoutPlanSetTile({
required this.position,
@ -1364,6 +1630,8 @@ final class ExecutionExercise {
required this.repsEnabled,
required this.scoreEnabled,
required this.restSeconds,
this.imageMediaIds = const [],
this.videoMediaId,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreLabel,
this.targetTimeSeconds,
@ -1386,6 +1654,10 @@ final class ExecutionExercise {
repsEnabled: exercise['repsEnabled'] as bool,
scoreEnabled: exercise['scoreEnabled'] as bool,
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
imageMediaIds: _imageMediaIdsFromSnapshot(exercise),
videoMediaId:
exercise['videoMediaIdSnapshot'] as String? ??
exercise['exerciseVideoMediaIdSnapshot'] as String?,
scoreInputMode: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
@ -1410,6 +1682,8 @@ final class ExecutionExercise {
final bool repsEnabled;
final bool scoreEnabled;
final int restSeconds;
final List<String> imageMediaIds;
final String? videoMediaId;
final ScoreInputMode scoreInputMode;
final String? scoreLabel;
final int? targetTimeSeconds;
@ -1420,6 +1694,26 @@ final class ExecutionExercise {
bool get manualScoreEnabled {
return scoreEnabled && scoreInputMode == ScoreInputMode.manual;
}
bool get hasMedia {
return imageMediaIds.isNotEmpty || videoMediaId != null;
}
}
List<String> _imageMediaIdsFromSnapshot(Map<String, dynamic> exercise) {
final imageMediaIds =
exercise['imageMediaIdsSnapshot'] ??
exercise['exerciseImageMediaIdsSnapshot'];
if (imageMediaIds is List) {
return imageMediaIds
.whereType<String>()
.where((id) => id.trim().isNotEmpty)
.toList(growable: false);
}
final imageMediaId = exercise['exerciseImageMediaIdSnapshot'] as String?;
return [
if (imageMediaId != null && imageMediaId.trim().isNotEmpty) imageMediaId,
];
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {