fix(execution): carrousel de médias et chrono du score (tickets #41, #42, #43)

Corrige l'affichage des images (#41) et de la vidéo (#42) dans le
carrousel de médias, et le format du chrono de score qui n'affichait
que les secondes (#43) sur workout_execution_screen.dart. Ajoute la
dépendance video_player (2.13.0) pour la lecture vidéo. Ajuste
home_screen.dart, history_screen.dart et workout_template_screen.dart
en conséquence. flutter analyze propre, flutter test 77/77 vert (plus
aucun hang), pub get résolu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:28:52 +02:00
parent 1489db6a18
commit 3605b3d99a
8 changed files with 430 additions and 64 deletions

View File

@ -350,6 +350,10 @@ final class MediaUseCases {
return asset;
}
Future<MediaAsset?> findById(String id) {
return mediaRepository.findById(id);
}
Future<Exercise> attachToExercise({
required String exerciseId,
required String mediaAssetId,

View File

@ -13,6 +13,7 @@ final class HistoryListScreen extends StatefulWidget {
required this.workoutTemplateUseCases,
required this.activeUseCases,
required this.closeUseCase,
this.mediaUseCases,
super.key,
});
@ -20,6 +21,7 @@ final class HistoryListScreen extends StatefulWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@override
State<HistoryListScreen> createState() => _HistoryListScreenState();
@ -93,6 +95,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
),
),
);
@ -111,6 +114,7 @@ final class HistoryDetailScreen extends StatelessWidget {
required this.workoutTemplateUseCases,
required this.activeUseCases,
required this.closeUseCase,
this.mediaUseCases,
super.key,
});
@ -119,6 +123,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@override
Widget build(BuildContext context) {
@ -191,6 +196,7 @@ final class HistoryDetailScreen extends StatelessWidget {
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
mediaUseCases: mediaUseCases,
),
),
);

View File

@ -154,6 +154,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),
),
@ -170,6 +171,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.workoutTemplateUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),
),
@ -188,6 +190,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),
);

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../application/application.dart';
import '../domain/domain.dart';
@ -10,6 +12,8 @@ import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished }
typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset);
final class WorkoutExecutionScreen extends StatefulWidget {
const WorkoutExecutionScreen({
required this.initialSession,
@ -17,6 +21,9 @@ final class WorkoutExecutionScreen extends StatefulWidget {
required this.closeUseCase,
required this.historyUseCases,
required this.workoutTemplateUseCases,
this.mediaUseCases,
this.mediaAssetLoader,
this.videoMediaBuilder,
super.key,
});
@ -25,6 +32,9 @@ final class WorkoutExecutionScreen extends StatefulWidget {
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final MediaUseCases? mediaUseCases;
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
@ -36,6 +46,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
late WorkoutExecutionMode _mode;
Timer? _ticker;
Timer? _restTicker;
Timer? _scoreStopwatchTicker;
WorkoutHistory? _completedHistory;
String? _activeRestStateId;
ActiveScoreStopwatchState? _scoreStopwatch;
@ -73,6 +84,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
void dispose() {
_ticker?.cancel();
_restTicker?.cancel();
_scoreStopwatchTicker?.cancel();
_scoreController.dispose();
super.dispose();
}
@ -339,10 +351,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
restRemainingSeconds: _mode == WorkoutExecutionMode.rest
? _remainingRestSeconds
: null,
mediaAssetLoader: _loadMediaAsset,
videoMediaBuilder: widget.videoMediaBuilder,
),
);
}
Future<MediaAsset?> _loadMediaAsset(String id) {
final loader = widget.mediaAssetLoader;
if (loader != null) return loader(id);
final mediaUseCases = widget.mediaUseCases;
if (mediaUseCases == null) return Future<MediaAsset?>.value();
return mediaUseCases.findById(id);
}
int get _scoreStopwatchElapsedMs {
final manual = _manualScoreTimeMs;
if (manual != null) {
@ -359,6 +381,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!_exercise.stopwatchScoreEnabled) {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
_refreshScoreStopwatchTicker();
return;
}
final state = await widget.activeUseCases.findScoreStopwatch(
@ -369,6 +392,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
);
if (!mounted) return;
setState(() => _scoreStopwatch = state);
_refreshScoreStopwatchTicker();
}
Future<void> _startScoreStopwatch() async {
@ -383,6 +407,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = state;
_manualScoreTimeMs = null;
});
_refreshScoreStopwatchTicker();
}
Future<ActiveScoreStopwatchState?> _stopScoreStopwatch() async {
@ -396,6 +421,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
);
if (!mounted) return stopped;
setState(() => _scoreStopwatch = stopped);
_refreshScoreStopwatchTicker();
return stopped;
}
@ -411,6 +437,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = state;
_manualScoreTimeMs = null;
});
_refreshScoreStopwatchTicker();
}
Future<void> _resetScoreStopwatch() async {
@ -445,6 +472,23 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
});
_refreshScoreStopwatchTicker();
}
void _refreshScoreStopwatchTicker() {
final shouldTick =
_scoreStopwatch?.status == ActiveScoreStopwatchStatus.running;
if (!shouldTick) {
_scoreStopwatchTicker?.cancel();
_scoreStopwatchTicker = null;
return;
}
if (_scoreStopwatchTicker != null) return;
_scoreStopwatchTicker = Timer.periodic(const Duration(milliseconds: 100), (
_,
) {
if (mounted) setState(() {});
});
}
Future<void> _editScoreStopwatchTime() async {
@ -575,6 +619,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = null;
_manualScoreTimeMs = null;
});
_refreshScoreStopwatchTicker();
}
if (!skipped &&
_exercise.stopwatchScoreEnabled &&
@ -767,6 +812,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_activeRestStateId = null;
_scoreStopwatch = null;
_manualScoreTimeMs = null;
_refreshScoreStopwatchTicker();
_seriesStartedAt = DateTime.now().toUtc();
_session = await widget.activeUseCases.updateProgress(
sessionId: _session.metadata.id,
@ -836,6 +882,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
mediaUseCases: widget.mediaUseCases,
mediaAssetLoader: widget.mediaAssetLoader,
videoMediaBuilder: widget.videoMediaBuilder,
),
),
);
@ -1256,10 +1305,14 @@ enum _MediaSheetSection { images, video }
final class _ExerciseMediaSheet extends StatefulWidget {
const _ExerciseMediaSheet({
required this.exercise,
required this.mediaAssetLoader,
this.videoMediaBuilder,
this.restRemainingSeconds,
});
final ExecutionExercise exercise;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
final int? restRemainingSeconds;
@override
@ -1346,9 +1399,14 @@ final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> {
pageController: _pageController,
currentIndex: _imageIndex,
onPageChanged: (index) => setState(() => _imageIndex = index),
mediaAssetLoader: widget.mediaAssetLoader,
)
else if (hasVideo)
_VideoMediaPanel(videoMediaId: exercise.videoMediaId!),
_VideoMediaPanel(
videoMediaId: exercise.videoMediaId!,
mediaAssetLoader: widget.mediaAssetLoader,
videoMediaBuilder: widget.videoMediaBuilder,
),
],
),
),
@ -1362,12 +1420,14 @@ final class _ImageMediaGallery extends StatelessWidget {
required this.pageController,
required this.currentIndex,
required this.onPageChanged,
required this.mediaAssetLoader,
});
final List<String> imageMediaIds;
final PageController pageController;
final int currentIndex;
final ValueChanged<int> onPageChanged;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
@override
Widget build(BuildContext context) {
@ -1396,6 +1456,7 @@ final class _ImageMediaGallery extends StatelessWidget {
return _ImageMediaPage(
mediaAssetId: imageMediaIds[index],
index: index,
mediaAssetLoader: mediaAssetLoader,
);
},
),
@ -1407,10 +1468,15 @@ final class _ImageMediaGallery extends StatelessWidget {
}
final class _ImageMediaPage extends StatelessWidget {
const _ImageMediaPage({required this.mediaAssetId, required this.index});
const _ImageMediaPage({
required this.mediaAssetId,
required this.index,
required this.mediaAssetLoader,
});
final String mediaAssetId;
final int index;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
@override
Widget build(BuildContext context) {
@ -1422,57 +1488,203 @@ final class _ImageMediaPage extends StatelessWidget {
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),
],
child: FutureBuilder<MediaAsset?>(
future: mediaAssetLoader(mediaAssetId),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final asset = snapshot.data;
if (asset == null || asset.kind != MediaKind.image) {
return _MissingMediaPlaceholder(
icon: Icons.broken_image_outlined,
title: 'Image indisponible',
subtitle: mediaAssetId,
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
_fileFromLocalUri(asset.localUri),
key: ValueKey('exercise-image-${asset.metadata.id}'),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return _MissingMediaPlaceholder(
icon: Icons.broken_image_outlined,
title: 'Image introuvable',
subtitle: asset.metadata.id,
);
},
),
);
},
),
);
}
}
final class _VideoMediaPanel extends StatelessWidget {
const _VideoMediaPanel({required this.videoMediaId});
const _VideoMediaPanel({
required this.videoMediaId,
required this.mediaAssetLoader,
this.videoMediaBuilder,
});
final String videoMediaId;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@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),
],
),
child: FutureBuilder<MediaAsset?>(
future: mediaAssetLoader(videoMediaId),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 260,
child: Center(child: CircularProgressIndicator()),
);
}
final asset = snapshot.data;
if (asset == null || asset.kind != MediaKind.video) {
return SizedBox(
height: 260,
child: _MissingMediaPlaceholder(
icon: Icons.videocam_off_outlined,
title: 'Vidéo indisponible',
subtitle: videoMediaId,
),
);
}
final builder = videoMediaBuilder;
if (builder != null) {
return builder(context, asset);
}
return _VideoPlayerSurface(asset: asset);
},
),
);
}
}
final class _VideoPlayerSurface extends StatefulWidget {
const _VideoPlayerSurface({required this.asset});
final MediaAsset asset;
@override
State<_VideoPlayerSurface> createState() => _VideoPlayerSurfaceState();
}
final class _VideoPlayerSurfaceState extends State<_VideoPlayerSurface> {
late final VideoPlayerController _controller;
late final Future<void> _initialize;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.file(
_fileFromLocalUri(widget.asset.localUri),
);
_initialize = _controller.initialize().then((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _initialize,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 260,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError || !_controller.value.isInitialized) {
return SizedBox(
height: 260,
child: _MissingMediaPlaceholder(
icon: Icons.videocam_off_outlined,
title: 'Vidéo introuvable',
subtitle: widget.asset.metadata.id,
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
),
const SizedBox(height: 12),
IconButton.filled(
tooltip: _controller.value.isPlaying ? 'Pause' : 'Lecture',
onPressed: () async {
if (_controller.value.isPlaying) {
await _controller.pause();
} else {
await _controller.play();
}
if (mounted) setState(() {});
},
icon: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
),
),
],
);
},
);
}
}
final class _MissingMediaPlaceholder extends StatelessWidget {
const _MissingMediaPlaceholder({
required this.icon,
required this.title,
required this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
File _fileFromLocalUri(String localUri) {
final uri = Uri.tryParse(localUri);
if (uri != null && uri.hasScheme) {
return File.fromUri(uri);
}
return File(localUri);
}
final class _WorkoutPlanSetTile extends StatelessWidget {
const _WorkoutPlanSetTile({
required this.position,

View File

@ -13,6 +13,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.activeUseCases,
required this.closeUseCase,
required this.historyUseCases,
this.mediaUseCases,
super.key,
});
@ -21,6 +22,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final MediaUseCases? mediaUseCases;
@override
State<WorkoutTemplateListScreen> createState() =>
@ -141,6 +143,7 @@ final class _WorkoutTemplateListScreenState
closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
mediaUseCases: widget.mediaUseCases,
),
),
);