From 3605b3d99a8723afd5f1da9fd28f3a0bb9a2c169 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 22:28:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(execution):=20carrousel=20de=20m=C3=A9d?= =?UTF-8?q?ias=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, From 1c5f2d50eebc4e591576ee94b8be05a519cbe3d5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 22:28:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20les=20?= =?UTF-8?q?tickets=20apr=C3=A8s=20le=20correctif=20m=C3=A9dias/chrono?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clôture les tickets #41, #42, #43, ajoute le cadrage des tickets #44/#45/#46. Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/41/carnet.md | 6 ++++ .ideai/tickets/41/issue.md | 16 +++++++++ .ideai/tickets/42/carnet.md | 6 ++++ .ideai/tickets/42/issue.md | 16 +++++++++ .ideai/tickets/43/carnet.md | 6 ++++ .ideai/tickets/43/issue.md | 16 +++++++++ .ideai/tickets/44/carnet.md | 6 ++++ .ideai/tickets/44/issue.md | 16 +++++++++ .ideai/tickets/45/carnet.md | 6 ++++ .ideai/tickets/45/issue.md | 16 +++++++++ .ideai/tickets/46/carnet.md | 6 ++++ .ideai/tickets/46/issue.md | 16 +++++++++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 72 +++++++++++++++++++++++++++++++++++++ 14 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 .ideai/tickets/41/carnet.md create mode 100644 .ideai/tickets/41/issue.md create mode 100644 .ideai/tickets/42/carnet.md create mode 100644 .ideai/tickets/42/issue.md create mode 100644 .ideai/tickets/43/carnet.md create mode 100644 .ideai/tickets/43/issue.md create mode 100644 .ideai/tickets/44/carnet.md create mode 100644 .ideai/tickets/44/issue.md create mode 100644 .ideai/tickets/45/carnet.md create mode 100644 .ideai/tickets/45/issue.md create mode 100644 .ideai/tickets/46/carnet.md create mode 100644 .ideai/tickets/46/issue.md diff --git a/.ideai/tickets/41/carnet.md b/.ideai/tickets/41/carnet.md new file mode 100644 index 0000000..6ce13fa --- /dev/null +++ b/.ideai/tickets/41/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#41" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784379589805 +--- diff --git a/.ideai/tickets/41/issue.md b/.ideai/tickets/41/issue.md new file mode 100644 index 0000000..9729d22 --- /dev/null +++ b/.ideai/tickets/41/issue.md @@ -0,0 +1,16 @@ +--- +id: "d92c2af3-1981-4782-9aba-460a45b37d56" +number: 41 +title: "[bug] images non affichée dans le caroussel" +status: "inProgress" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784378925702 +updatedAt: 1784379589805 +version: 5 +--- +Dans le carroussel des exercices dans les séances, les images ne s'affichent pas vraiment, je n'ai qu'une icone avec le titre image 1, image 2 etc... \ No newline at end of file diff --git a/.ideai/tickets/42/carnet.md b/.ideai/tickets/42/carnet.md new file mode 100644 index 0000000..9f81018 --- /dev/null +++ b/.ideai/tickets/42/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#42" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784379590525 +--- diff --git a/.ideai/tickets/42/issue.md b/.ideai/tickets/42/issue.md new file mode 100644 index 0000000..f059b32 --- /dev/null +++ b/.ideai/tickets/42/issue.md @@ -0,0 +1,16 @@ +--- +id: "a788671c-fbc4-4c3d-855e-bb44c120ab54" +number: 42 +title: "[bug] vidéo non affichée dans le caroussel" +status: "inProgress" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784378998844 +updatedAt: 1784379590525 +version: 5 +--- +Dans les exerccies des séances, je ne peux pas lancer la vidéo. Je n'ai qu'un icone Play avec marqué Vidéo mais rien ne se passe si je selectionne \ No newline at end of file diff --git a/.ideai/tickets/43/carnet.md b/.ideai/tickets/43/carnet.md new file mode 100644 index 0000000..1da6087 --- /dev/null +++ b/.ideai/tickets/43/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#43" +version: 5 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784379591252 +--- diff --git a/.ideai/tickets/43/issue.md b/.ideai/tickets/43/issue.md new file mode 100644 index 0000000..adf3341 --- /dev/null +++ b/.ideai/tickets/43/issue.md @@ -0,0 +1,16 @@ +--- +id: "33546028-43ab-467c-90af-f2ff3aeb920f" +number: 43 +title: "[Bug] chrono qui n'affiche que les secondes" +status: "inProgress" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784379078126 +updatedAt: 1784379591252 +version: 5 +--- +Pour le chrono score, quand je le lance j'ai d'abord 0.9 sec qui s'affiche, puis uniquement les secondes qui s'incrémentent \ No newline at end of file diff --git a/.ideai/tickets/44/carnet.md b/.ideai/tickets/44/carnet.md new file mode 100644 index 0000000..9003dc2 --- /dev/null +++ b/.ideai/tickets/44/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#44" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784379295672 +--- diff --git a/.ideai/tickets/44/issue.md b/.ideai/tickets/44/issue.md new file mode 100644 index 0000000..99bafc3 --- /dev/null +++ b/.ideai/tickets/44/issue.md @@ -0,0 +1,16 @@ +--- +id: "c63e88a9-5868-4bd0-a71b-ebc4e7e6552a" +number: 44 +title: "[Bug] Je veux pouvoir mettre un score par défaut à 0." +status: "open" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784379259225 +updatedAt: 1784379295672 +version: 4 +--- +Je veux pouvoir renseigner un score par défaut à 0 (je ne parle pas du nombre de répétition qui doit lui rester comme actuellement). \ No newline at end of file diff --git a/.ideai/tickets/45/carnet.md b/.ideai/tickets/45/carnet.md new file mode 100644 index 0000000..d92b242 --- /dev/null +++ b/.ideai/tickets/45/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#45" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784379490098 +--- diff --git a/.ideai/tickets/45/issue.md b/.ideai/tickets/45/issue.md new file mode 100644 index 0000000..2c05818 --- /dev/null +++ b/.ideai/tickets/45/issue.md @@ -0,0 +1,16 @@ +--- +id: "2f075be4-bcb8-4fdd-89fc-2f897e97bde4" +number: 45 +title: "[Bug] Affichage de répétition en séance à 0" +status: "open" +priority: "medium" +sprint: "abc4f969-b169-45f7-988c-daeeab762201" +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784379414910 +updatedAt: 1784379490098 +version: 4 +--- +Quand je suis en séance sur un exercice qui demande un nombre de répétition, l'affichage m'affiche 0 alors que le nombre de répétition est set à une valeur > 0 dans l'exercice. Il devrait être à la même valeur \ No newline at end of file diff --git a/.ideai/tickets/46/carnet.md b/.ideai/tickets/46/carnet.md new file mode 100644 index 0000000..b069455 --- /dev/null +++ b/.ideai/tickets/46/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#46" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784380502263 +--- diff --git a/.ideai/tickets/46/issue.md b/.ideai/tickets/46/issue.md new file mode 100644 index 0000000..a5505fd --- /dev/null +++ b/.ideai/tickets/46/issue.md @@ -0,0 +1,16 @@ +--- +id: "dda499ac-7197-4321-bd2b-a303510b1bbe" +number: 46 +title: "Créer un application headless serveur pour l'application" +status: "open" +priority: "low" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784379695135 +updatedAt: 1784380502263 +version: 4 +--- +J'aimerais que l'application puisse proposer des services online aussi. Un peu a la manière de Heavy avec la possibilité de partager ses séances etc. A la manière de Heavy, j'aimerais que l'on puisse se créer un compte et s'y connecter, synchroniser son historique de seances, ses séances, ses programmes ainsi que ses exercices. Ce ticket est la partie serveur/web API, il faudra que ça soit un serveur headless linux qui tourne dans un container docker. Il faudra que je puisse y acceder via un reverse proxy qui tourne sur une machine différente de celle du serveur headless mais dans le même réseau local. J'y accederais via une adresse https. le container se lancera ensuite à partir d'un docker-compose.yaml dans lequel tu laissera les champs libre de tout ce qui sera necessaire de configurer (adresse ip du reverse proxy, de la machine sur laquelle tourne le docker si necessaire etc.). Pour la création du serveur, créé un subdirectory spécialement pour cette partie serveur. Dans le dossier serveur à lma racine, je veux un script pour pouvoir push le container sur le gitea qui est déjà lié au projet, je veux aussi le docker-compose.yaml demandé. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index d1b41b9..f501ae3 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 41 + "nextNumber": 47 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index b436093..49cc85b 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -460,6 +460,78 @@ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], "updatedAt": 1784362686414 + }, + { + "issueRef": "#41", + "path": "41", + "title": "[bug] images non affichée dans le caroussel", + "status": "inProgress", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784379589805 + }, + { + "issueRef": "#42", + "path": "42", + "title": "[bug] vidéo non affichée dans le caroussel", + "status": "inProgress", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784379590525 + }, + { + "issueRef": "#43", + "path": "43", + "title": "[Bug] chrono qui n'affiche que les secondes", + "status": "inProgress", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784379591252 + }, + { + "issueRef": "#44", + "path": "44", + "title": "[Bug] Je veux pouvoir mettre un score par défaut à 0.", + "status": "open", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784379295672 + }, + { + "issueRef": "#45", + "path": "45", + "title": "[Bug] Affichage de répétition en séance à 0", + "status": "open", + "priority": "medium", + "sprint": "abc4f969-b169-45f7-988c-daeeab762201", + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784379490098 + }, + { + "issueRef": "#46", + "path": "46", + "title": "Créer un application headless serveur pour l'application", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784380502263 } ] } \ No newline at end of file