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; return asset;
} }
Future<MediaAsset?> findById(String id) {
return mediaRepository.findById(id);
}
Future<Exercise> attachToExercise({ Future<Exercise> attachToExercise({
required String exerciseId, required String exerciseId,
required String mediaAssetId, required String mediaAssetId,

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
@ -10,6 +12,8 @@ import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished } enum WorkoutExecutionMode { active, rest, paused, finished }
typedef VideoMediaBuilder = Widget Function(BuildContext context, MediaAsset asset);
final class WorkoutExecutionScreen extends StatefulWidget { final class WorkoutExecutionScreen extends StatefulWidget {
const WorkoutExecutionScreen({ const WorkoutExecutionScreen({
required this.initialSession, required this.initialSession,
@ -17,6 +21,9 @@ final class WorkoutExecutionScreen extends StatefulWidget {
required this.closeUseCase, required this.closeUseCase,
required this.historyUseCases, required this.historyUseCases,
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
this.mediaUseCases,
this.mediaAssetLoader,
this.videoMediaBuilder,
super.key, super.key,
}); });
@ -25,6 +32,9 @@ final class WorkoutExecutionScreen extends StatefulWidget {
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases; final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases;
final MediaUseCases? mediaUseCases;
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@override @override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState(); State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
@ -36,6 +46,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
late WorkoutExecutionMode _mode; late WorkoutExecutionMode _mode;
Timer? _ticker; Timer? _ticker;
Timer? _restTicker; Timer? _restTicker;
Timer? _scoreStopwatchTicker;
WorkoutHistory? _completedHistory; WorkoutHistory? _completedHistory;
String? _activeRestStateId; String? _activeRestStateId;
ActiveScoreStopwatchState? _scoreStopwatch; ActiveScoreStopwatchState? _scoreStopwatch;
@ -73,6 +84,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
void dispose() { void dispose() {
_ticker?.cancel(); _ticker?.cancel();
_restTicker?.cancel(); _restTicker?.cancel();
_scoreStopwatchTicker?.cancel();
_scoreController.dispose(); _scoreController.dispose();
super.dispose(); super.dispose();
} }
@ -339,10 +351,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
restRemainingSeconds: _mode == WorkoutExecutionMode.rest restRemainingSeconds: _mode == WorkoutExecutionMode.rest
? _remainingRestSeconds ? _remainingRestSeconds
: null, : 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 { int get _scoreStopwatchElapsedMs {
final manual = _manualScoreTimeMs; final manual = _manualScoreTimeMs;
if (manual != null) { if (manual != null) {
@ -359,6 +381,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!_exercise.stopwatchScoreEnabled) { if (!_exercise.stopwatchScoreEnabled) {
_scoreStopwatch = null; _scoreStopwatch = null;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
_refreshScoreStopwatchTicker();
return; return;
} }
final state = await widget.activeUseCases.findScoreStopwatch( final state = await widget.activeUseCases.findScoreStopwatch(
@ -369,6 +392,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
); );
if (!mounted) return; if (!mounted) return;
setState(() => _scoreStopwatch = state); setState(() => _scoreStopwatch = state);
_refreshScoreStopwatchTicker();
} }
Future<void> _startScoreStopwatch() async { Future<void> _startScoreStopwatch() async {
@ -383,6 +407,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = state; _scoreStopwatch = state;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
}); });
_refreshScoreStopwatchTicker();
} }
Future<ActiveScoreStopwatchState?> _stopScoreStopwatch() async { Future<ActiveScoreStopwatchState?> _stopScoreStopwatch() async {
@ -396,6 +421,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
); );
if (!mounted) return stopped; if (!mounted) return stopped;
setState(() => _scoreStopwatch = stopped); setState(() => _scoreStopwatch = stopped);
_refreshScoreStopwatchTicker();
return stopped; return stopped;
} }
@ -411,6 +437,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = state; _scoreStopwatch = state;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
}); });
_refreshScoreStopwatchTicker();
} }
Future<void> _resetScoreStopwatch() async { Future<void> _resetScoreStopwatch() async {
@ -445,6 +472,23 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = null; _scoreStopwatch = null;
_manualScoreTimeMs = 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 { Future<void> _editScoreStopwatchTime() async {
@ -575,6 +619,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_scoreStopwatch = null; _scoreStopwatch = null;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
}); });
_refreshScoreStopwatchTicker();
} }
if (!skipped && if (!skipped &&
_exercise.stopwatchScoreEnabled && _exercise.stopwatchScoreEnabled &&
@ -767,6 +812,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
_activeRestStateId = null; _activeRestStateId = null;
_scoreStopwatch = null; _scoreStopwatch = null;
_manualScoreTimeMs = null; _manualScoreTimeMs = null;
_refreshScoreStopwatchTicker();
_seriesStartedAt = DateTime.now().toUtc(); _seriesStartedAt = DateTime.now().toUtc();
_session = await widget.activeUseCases.updateProgress( _session = await widget.activeUseCases.updateProgress(
sessionId: _session.metadata.id, sessionId: _session.metadata.id,
@ -836,6 +882,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases, historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases, 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 { final class _ExerciseMediaSheet extends StatefulWidget {
const _ExerciseMediaSheet({ const _ExerciseMediaSheet({
required this.exercise, required this.exercise,
required this.mediaAssetLoader,
this.videoMediaBuilder,
this.restRemainingSeconds, this.restRemainingSeconds,
}); });
final ExecutionExercise exercise; final ExecutionExercise exercise;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
final int? restRemainingSeconds; final int? restRemainingSeconds;
@override @override
@ -1346,9 +1399,14 @@ final class _ExerciseMediaSheetState extends State<_ExerciseMediaSheet> {
pageController: _pageController, pageController: _pageController,
currentIndex: _imageIndex, currentIndex: _imageIndex,
onPageChanged: (index) => setState(() => _imageIndex = index), onPageChanged: (index) => setState(() => _imageIndex = index),
mediaAssetLoader: widget.mediaAssetLoader,
) )
else if (hasVideo) 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.pageController,
required this.currentIndex, required this.currentIndex,
required this.onPageChanged, required this.onPageChanged,
required this.mediaAssetLoader,
}); });
final List<String> imageMediaIds; final List<String> imageMediaIds;
final PageController pageController; final PageController pageController;
final int currentIndex; final int currentIndex;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1396,6 +1456,7 @@ final class _ImageMediaGallery extends StatelessWidget {
return _ImageMediaPage( return _ImageMediaPage(
mediaAssetId: imageMediaIds[index], mediaAssetId: imageMediaIds[index],
index: index, index: index,
mediaAssetLoader: mediaAssetLoader,
); );
}, },
), ),
@ -1407,10 +1468,15 @@ final class _ImageMediaGallery extends StatelessWidget {
} }
final class _ImageMediaPage 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 String mediaAssetId;
final int index; final int index;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1422,57 +1488,203 @@ final class _ImageMediaPage extends StatelessWidget {
border: Border.all(color: tokens.border), border: Border.all(color: tokens.border),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
), ),
child: Column( child: FutureBuilder<MediaAsset?>(
mainAxisAlignment: MainAxisAlignment.center, future: mediaAssetLoader(mediaAssetId),
children: [ builder: (context, snapshot) {
Icon( if (snapshot.connectionState != ConnectionState.done) {
Icons.image_outlined, return const Center(child: CircularProgressIndicator());
size: 64, }
color: Theme.of(context).colorScheme.primary, final asset = snapshot.data;
), if (asset == null || asset.kind != MediaKind.image) {
const SizedBox(height: 16), return _MissingMediaPlaceholder(
Text( icon: Icons.broken_image_outlined,
'Image ${index + 1}', title: 'Image indisponible',
style: Theme.of(context).textTheme.titleMedium, subtitle: mediaAssetId,
), );
const SizedBox(height: 4), }
Text(mediaAssetId, style: Theme.of(context).textTheme.bodySmall), 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 { final class _VideoMediaPanel extends StatelessWidget {
const _VideoMediaPanel({required this.videoMediaId}); const _VideoMediaPanel({
required this.videoMediaId,
required this.mediaAssetLoader,
this.videoMediaBuilder,
});
final String videoMediaId; final String videoMediaId;
final Future<MediaAsset?> Function(String id) mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CourtBlazerAccentPanel( return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SizedBox( child: FutureBuilder<MediaAsset?>(
height: 260, future: mediaAssetLoader(videoMediaId),
child: Column( builder: (context, snapshot) {
mainAxisAlignment: MainAxisAlignment.center, if (snapshot.connectionState != ConnectionState.done) {
children: [ return const SizedBox(
Icon( height: 260,
Icons.play_circle_outline, child: Center(child: CircularProgressIndicator()),
size: 64, );
color: Theme.of(context).colorScheme.primary, }
), final asset = snapshot.data;
const SizedBox(height: 16), if (asset == null || asset.kind != MediaKind.video) {
Text('Vidéo', style: Theme.of(context).textTheme.titleMedium), return SizedBox(
const SizedBox(height: 4), height: 260,
Text(videoMediaId, style: Theme.of(context).textTheme.bodySmall), 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 { final class _WorkoutPlanSetTile extends StatelessWidget {
const _WorkoutPlanSetTile({ const _WorkoutPlanSetTile({
required this.position, required this.position,

View File

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

View File

@ -177,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -344,6 +352,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -829,6 +845,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,6 +17,7 @@ dependencies:
path: ^1.9.1 path: ^1.9.1
path_provider: ^2.1.5 path_provider: ^2.1.5
sqlite3_flutter_libs: ^0.6.0+eol sqlite3_flutter_libs: ^0.6.0+eol
video_player: ^2.11.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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', ( testWidgets('le bouton médias ouvre la sheet si médias présents', (
tester, tester,
) async { ) 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 clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession( final session = ActiveWorkoutSession(
metadata: _metadata('session-1'), metadata: _metadata('session-1'),
@ -170,34 +177,74 @@ void main() {
), ),
); );
await tester.pumpWidget( await tester.runAsync(() async {
MaterialApp( await tester.pumpWidget(
home: WorkoutExecutionScreen( MaterialApp(
initialSession: session, home: WorkoutExecutionScreen(
activeUseCases: _activeUseCases( initialSession: session,
_FakeActiveSessionRepository(), activeUseCases: _activeUseCases(
clock, _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 lexercice'), findsOneWidget); expect(find.byTooltip('Voir les médias de lexercice'), findsOneWidget);
await tester.tap(find.text('Voir médias')); await tester.tap(find.text('Voir médias'));
await tester.pumpAndSettle(); await tester.runAsync(() async {
await tester.pumpAndSettle();
});
expect(find.text('Médias de lexercice'), findsOneWidget); expect(find.text('Médias de lexercice'), findsOneWidget);
expect(find.text('Images'), findsOneWidget); expect(find.text('Images'), findsOneWidget);
expect(find.text('Vidéo'), findsOneWidget); expect(find.text('Vidéo'), findsOneWidget);
expect(find.text('1/1'), 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 { 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 clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession( final session = ActiveWorkoutSession(
metadata: _metadata('session-1'), metadata: _metadata('session-1'),
@ -214,31 +261,45 @@ void main() {
), ),
); );
await tester.pumpWidget( await tester.runAsync(() async {
MaterialApp( await tester.pumpWidget(
home: WorkoutExecutionScreen( MaterialApp(
initialSession: session, home: WorkoutExecutionScreen(
activeUseCases: _activeUseCases( initialSession: session,
_FakeActiveSessionRepository(), activeUseCases: _activeUseCases(
clock, _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.tap(find.text('Voir médias'));
await tester.pumpAndSettle(); await tester.runAsync(() async {
await tester.pumpAndSettle();
});
expect(find.text('1/3'), findsOneWidget); expect(find.text('1/3'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(-400, 0)); 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('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 sarrête avec la bonne durée', ( testWidgets('le chrono score démarre et sarrête avec la bonne durée', (
@ -282,6 +343,16 @@ void main() {
await tester.tap(find.text('Démarrer')); await tester.tap(find.text('Démarrer'));
await tester.pump(); 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); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 1, 200);
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
@ -843,6 +914,16 @@ Finder _textFieldWithLabel(String label) {
); );
} }
Future<File> _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( ActiveWorkoutSessionUseCases _activeUseCases(
_FakeActiveSessionRepository repository, _FakeActiveSessionRepository repository,
_FakeClock clock, _FakeClock clock,