merge(main): correctifs carrousel de médias et chrono du score (tickets #41, #42, #43)

Fusionne feature/bugsprint-medias-chrono — analyze propre, 77/77
tests verts (plus aucun hang), pub get résolu avec video_player 2.13.0,
build APK validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:29:02 +02:00
22 changed files with 635 additions and 65 deletions

View File

@ -0,0 +1,6 @@
---
issueRef: "#41"
version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784379589805
---

View File

@ -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...

View File

@ -0,0 +1,6 @@
---
issueRef: "#42"
version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784379590525
---

View File

@ -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

View File

@ -0,0 +1,6 @@
---
issueRef: "#43"
version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784379591252
---

View File

@ -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

View File

@ -0,0 +1,6 @@
---
issueRef: "#44"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784379295672
---

View File

@ -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).

View File

@ -0,0 +1,6 @@
---
issueRef: "#45"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784379490098
---

View File

@ -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

View File

@ -0,0 +1,6 @@
---
issueRef: "#46"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784380502263
---

View File

@ -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é.

View File

@ -1,3 +1,3 @@
{
"nextNumber": 41
"nextNumber": 47
}

View File

@ -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
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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:

View File

@ -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 lexercice'), 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 lexercice'), 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 sarrê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<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(
_FakeActiveSessionRepository repository,
_FakeClock clock,