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:
6
.ideai/tickets/41/carnet.md
Normal file
6
.ideai/tickets/41/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#41"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784379589805
|
||||
---
|
||||
16
.ideai/tickets/41/issue.md
Normal file
16
.ideai/tickets/41/issue.md
Normal 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...
|
||||
6
.ideai/tickets/42/carnet.md
Normal file
6
.ideai/tickets/42/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#42"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784379590525
|
||||
---
|
||||
16
.ideai/tickets/42/issue.md
Normal file
16
.ideai/tickets/42/issue.md
Normal 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
|
||||
6
.ideai/tickets/43/carnet.md
Normal file
6
.ideai/tickets/43/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#43"
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784379591252
|
||||
---
|
||||
16
.ideai/tickets/43/issue.md
Normal file
16
.ideai/tickets/43/issue.md
Normal 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
|
||||
6
.ideai/tickets/44/carnet.md
Normal file
6
.ideai/tickets/44/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#44"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784379295672
|
||||
---
|
||||
16
.ideai/tickets/44/issue.md
Normal file
16
.ideai/tickets/44/issue.md
Normal 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).
|
||||
6
.ideai/tickets/45/carnet.md
Normal file
6
.ideai/tickets/45/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#45"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784379490098
|
||||
---
|
||||
16
.ideai/tickets/45/issue.md
Normal file
16
.ideai/tickets/45/issue.md
Normal 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
|
||||
6
.ideai/tickets/46/carnet.md
Normal file
6
.ideai/tickets/46/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#46"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784380502263
|
||||
---
|
||||
16
.ideai/tickets/46/issue.md
Normal file
16
.ideai/tickets/46/issue.md
Normal 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é.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 41
|
||||
"nextNumber": 47
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,55 +1488,201 @@ 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,
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
child: FutureBuilder<MediaAsset?>(
|
||||
future: mediaAssetLoader(videoMediaId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const 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: 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 {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
56
pubspec.lock
56
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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,6 +177,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
@ -181,23 +189,62 @@ void main() {
|
||||
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}')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(find.byTooltip('Voir les médias de l’exercice'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Voir médias'));
|
||||
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,6 +261,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
@ -225,20 +273,33 @@ void main() {
|
||||
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
mediaAssetLoader: (id) async => MediaAsset(
|
||||
metadata: _metadata(id),
|
||||
kind: MediaKind.image,
|
||||
localUri: imageFile.uri.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await tester.tap(find.text('Voir médias'));
|
||||
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.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<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,
|
||||
|
||||
Reference in New Issue
Block a user