From 3605b3d99a8723afd5f1da9fd28f3a0bb9a2c169 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 22:28:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(execution):=20carrousel=20de=20m=C3=A9dias?= =?UTF-8?q?=20et=20chrono=20du=20score=20(tickets=20#41,=20#42,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/use_cases.dart | 4 + lib/presentation/history_screen.dart | 6 + lib/presentation/home_screen.dart | 3 + .../workout_execution_screen.dart | 282 +++++++++++++++--- lib/presentation/workout_template_screen.dart | 3 + pubspec.lock | 56 ++++ pubspec.yaml | 1 + .../workout_execution_screen_test.dart | 139 +++++++-- 8 files changed, 430 insertions(+), 64 deletions(-) diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index da54905..e019459 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -350,6 +350,10 @@ final class MediaUseCases { return asset; } + Future findById(String id) { + return mediaRepository.findById(id); + } + Future attachToExercise({ required String exerciseId, required String mediaAssetId, diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 5d53fca..eb37453 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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 createState() => _HistoryListScreenState(); @@ -93,6 +95,7 @@ final class _HistoryListScreenState extends State { 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, ), ), ); diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index e9426ea..96f1167 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -154,6 +154,7 @@ final class _HomeScreenState extends State 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 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 with RouteAware { closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, historyUseCases: widget.bootstrap.workoutHistoryUseCases, workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, + mediaUseCases: widget.bootstrap.mediaUseCases, ), ), ); diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 4548e6b..bcef767 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -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 Function(String id)? mediaAssetLoader; + final VideoMediaBuilder? videoMediaBuilder; @override State createState() => _WorkoutExecutionScreenState(); @@ -36,6 +46,7 @@ final class _WorkoutExecutionScreenState extends State { late WorkoutExecutionMode _mode; Timer? _ticker; Timer? _restTicker; + Timer? _scoreStopwatchTicker; WorkoutHistory? _completedHistory; String? _activeRestStateId; ActiveScoreStopwatchState? _scoreStopwatch; @@ -73,6 +84,7 @@ final class _WorkoutExecutionScreenState extends State { void dispose() { _ticker?.cancel(); _restTicker?.cancel(); + _scoreStopwatchTicker?.cancel(); _scoreController.dispose(); super.dispose(); } @@ -339,10 +351,20 @@ final class _WorkoutExecutionScreenState extends State { restRemainingSeconds: _mode == WorkoutExecutionMode.rest ? _remainingRestSeconds : null, + mediaAssetLoader: _loadMediaAsset, + videoMediaBuilder: widget.videoMediaBuilder, ), ); } + Future _loadMediaAsset(String id) { + final loader = widget.mediaAssetLoader; + if (loader != null) return loader(id); + final mediaUseCases = widget.mediaUseCases; + if (mediaUseCases == null) return Future.value(); + return mediaUseCases.findById(id); + } + int get _scoreStopwatchElapsedMs { final manual = _manualScoreTimeMs; if (manual != null) { @@ -359,6 +381,7 @@ final class _WorkoutExecutionScreenState extends State { if (!_exercise.stopwatchScoreEnabled) { _scoreStopwatch = null; _manualScoreTimeMs = null; + _refreshScoreStopwatchTicker(); return; } final state = await widget.activeUseCases.findScoreStopwatch( @@ -369,6 +392,7 @@ final class _WorkoutExecutionScreenState extends State { ); if (!mounted) return; setState(() => _scoreStopwatch = state); + _refreshScoreStopwatchTicker(); } Future _startScoreStopwatch() async { @@ -383,6 +407,7 @@ final class _WorkoutExecutionScreenState extends State { _scoreStopwatch = state; _manualScoreTimeMs = null; }); + _refreshScoreStopwatchTicker(); } Future _stopScoreStopwatch() async { @@ -396,6 +421,7 @@ final class _WorkoutExecutionScreenState extends State { ); if (!mounted) return stopped; setState(() => _scoreStopwatch = stopped); + _refreshScoreStopwatchTicker(); return stopped; } @@ -411,6 +437,7 @@ final class _WorkoutExecutionScreenState extends State { _scoreStopwatch = state; _manualScoreTimeMs = null; }); + _refreshScoreStopwatchTicker(); } Future _resetScoreStopwatch() async { @@ -445,6 +472,23 @@ final class _WorkoutExecutionScreenState extends State { _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 _editScoreStopwatchTime() async { @@ -575,6 +619,7 @@ final class _WorkoutExecutionScreenState extends State { _scoreStopwatch = null; _manualScoreTimeMs = null; }); + _refreshScoreStopwatchTicker(); } if (!skipped && _exercise.stopwatchScoreEnabled && @@ -767,6 +812,7 @@ final class _WorkoutExecutionScreenState extends State { _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 { 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 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 imageMediaIds; final PageController pageController; final int currentIndex; final ValueChanged onPageChanged; + final Future 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 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( + 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 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( + 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 _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( + 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, diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 0718165..d1bb840 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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 createState() => @@ -141,6 +143,7 @@ final class _WorkoutTemplateListScreenState closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, + mediaUseCases: widget.mediaUseCases, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 67bbe32..6684c65 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -344,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -829,6 +845,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "20ef311160e2ced020a62f38e4cc399a1bf289e722fc5fe37c9d7b18e44c9e8d" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "5121de08444d00363efdda630a69ad4d27a208a9f7586482d4909a44a1cdbc63" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: c238f5f0a26845cd0bcc2956049b63065a4d3c40ddfc22c3414cd170bef7fff9 + url: "https://pub.dev" + source: hosted + version: "2.11.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.dev" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index be4a5b8..39ce96c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.5 sqlite3_flutter_libs: ^0.6.0+eol + video_player: ^2.11.1 dev_dependencies: flutter_test: diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 3d91586..352fcc3 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -153,6 +154,12 @@ void main() { testWidgets('le bouton médias ouvre la sheet si médias présents', ( tester, ) async { + final imageFile = (await tester.runAsync(_createTestImageFile))!; + addTearDown(() async { + await tester.runAsync(() async { + await imageFile.parent.delete(recursive: true); + }); + }); final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), @@ -170,34 +177,74 @@ void main() { ), ); - await tester.pumpWidget( - MaterialApp( - home: WorkoutExecutionScreen( - initialSession: session, - activeUseCases: _activeUseCases( - _FakeActiveSessionRepository(), - clock, + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases( + _FakeActiveSessionRepository(), + clock, + ), + closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + mediaAssetLoader: (id) async { + if (id == 'image-1') { + return MediaAsset( + metadata: _metadata(id), + kind: MediaKind.image, + localUri: imageFile.uri.toString(), + ); + } + if (id == 'video-1') { + return MediaAsset( + metadata: _metadata(id), + kind: MediaKind.video, + localUri: imageFile.uri.toString(), + ); + } + return null; + }, + videoMediaBuilder: (context, asset) => SizedBox( + height: 260, + child: Center(child: Text('Lecteur vidéo ${asset.metadata.id}')), + ), ), - closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), - historyUseCases: _historyUseCases(clock), - workoutTemplateUseCases: _workoutTemplateUseCases(), ), - ), - ); + ); + }); expect(find.byTooltip('Voir les médias de l’exercice'), findsOneWidget); await tester.tap(find.text('Voir médias')); - await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.pumpAndSettle(); + }); expect(find.text('Médias de l’exercice'), findsOneWidget); expect(find.text('Images'), findsOneWidget); expect(find.text('Vidéo'), findsOneWidget); expect(find.text('1/1'), findsOneWidget); - expect(find.text('Image 1'), findsOneWidget); + expect( + find.byKey(const ValueKey('exercise-image-image-1')), + findsOneWidget, + ); + expect(find.text('Image 1'), findsNothing); + + await tester.tap(find.text('Vidéo')); + await tester.pumpAndSettle(); + + expect(find.text('Lecteur vidéo video-1'), findsOneWidget); }); testWidgets('la galerie médias navigue entre les images', (tester) async { + final imageFile = (await tester.runAsync(_createTestImageFile))!; + addTearDown(() async { + await tester.runAsync(() async { + await imageFile.parent.delete(recursive: true); + }); + }); final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), @@ -214,31 +261,45 @@ void main() { ), ); - await tester.pumpWidget( - MaterialApp( - home: WorkoutExecutionScreen( - initialSession: session, - activeUseCases: _activeUseCases( - _FakeActiveSessionRepository(), - clock, + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: WorkoutExecutionScreen( + initialSession: session, + activeUseCases: _activeUseCases( + _FakeActiveSessionRepository(), + clock, + ), + closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), + mediaAssetLoader: (id) async => MediaAsset( + metadata: _metadata(id), + kind: MediaKind.image, + localUri: imageFile.uri.toString(), + ), ), - closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), - historyUseCases: _historyUseCases(clock), - workoutTemplateUseCases: _workoutTemplateUseCases(), ), - ), - ); + ); + }); await tester.tap(find.text('Voir médias')); - await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.pumpAndSettle(); + }); expect(find.text('1/3'), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(-400, 0)); - await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.pumpAndSettle(); + }); expect(find.text('2/3'), findsOneWidget); - expect(find.text('Image 2'), findsOneWidget); + expect( + find.byKey(const ValueKey('exercise-image-image-2')), + findsOneWidget, + ); }); testWidgets('le chrono score démarre et s’arrête avec la bonne durée', ( @@ -282,6 +343,16 @@ void main() { await tester.tap(find.text('Démarrer')); await tester.pump(); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 100); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('00:00.1'), findsOneWidget); + + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 0, 900); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('00:00.9'), findsOneWidget); + clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200); await tester.pump(const Duration(seconds: 1)); @@ -843,6 +914,16 @@ Finder _textFieldWithLabel(String label) { ); } +Future _createTestImageFile() async { + final directory = await Directory.systemTemp.createTemp('gametime-media-'); + final file = File('${directory.path}/image.png'); + const transparentPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ' + 'AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + await file.writeAsBytes(base64Decode(transparentPng)); + return file; +} + ActiveWorkoutSessionUseCases _activeUseCases( _FakeActiveSessionRepository repository, _FakeClock clock,