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:
@ -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,
|
||||
|
||||
@ -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 l’exercice',
|
||||
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 l’exercice',
|
||||
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 l’exercice'),
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user