merge(main): partage sortant et boîte de réception (ticket #69)

Fusionne feature/online-client-69-sharing-ui — analyze propre (mêmes
infos préexistantes), 132/132 tests verts, build APK debug validé.
Dernier ticket de développement du chantier "Ajouter les features
serveur au client" (#63) — il ne reste que la QA finale (#70).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:23:28 +02:00
21 changed files with 1755 additions and 32 deletions

View File

@ -1,6 +1,6 @@
--- ---
issueRef: "#68" issueRef: "#68"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784488359218 updatedAt: 1784491589721
--- ---

View File

@ -2,15 +2,15 @@
id: "ac634023-e7ba-4588-9c7c-9f93c12d9aad" id: "ac634023-e7ba-4588-9c7c-9f93c12d9aad"
number: 68 number: 68
title: "[DevFrontend] Statut de synchronisation discret et action manuelle" title: "[DevFrontend] Statut de synchronisation discret et action manuelle"
status: "open" status: "qa"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#63","kind":"relatesTo"},{"target":"#65","kind":"dependsOn"},{"target":"#67","kind":"dependsOn"}] links: [{"target":"#63","kind":"relatesTo"},{"target":"#65","kind":"dependsOn"},{"target":"#67","kind":"dependsOn"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784488359218 createdAt: 1784488359218
updatedAt: 1784488359218 updatedAt: 1784491589721
version: 1 version: 2
--- ---
Afficher la section Synchronisation dans Profil : en cours, à jour, jamais faite, retry automatique, hors ligne. Ajouter `Synchroniser maintenant`. Ne jamais afficher de snackbar/dialog d'échec réseau hors contexte Profil. Optionnel : sous-titre discret de l'entrée Profil sur l'accueil. Afficher la section Synchronisation dans Profil : en cours, à jour, jamais faite, retry automatique, hors ligne. Ajouter `Synchroniser maintenant`. Ne jamais afficher de snackbar/dialog d'échec réseau hors contexte Profil. Optionnel : sous-titre discret de l'entrée Profil sur l'accueil.

View File

@ -0,0 +1,6 @@
---
issueRef: "#72"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784493074891
---

View File

@ -0,0 +1,16 @@
---
id: "b388e21b-5262-4b3e-b075-5654d2a478f2"
number: 72
title: "[Bug] les étapes des exercices ne sont pas affichés pendant la séance"
status: "open"
priority: "critical"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784492460868
updatedAt: 1784493074891
version: 3
---
Pendant la séance, il n'y a aucune étape d'exercice affiché. J'ia un exercice divisé en 3 étapes avec des temps par étapes ou des répétitions, et quand je suis a cet exercices pendant ma séance, je n'ai pas les étapes d'affichées à l'écran. Je ne sais donc pas ce que je dois faire, ni combien de temps ou combien de fois. Je pense qu'il faudrait au moins le titre de chaque étape avec le nombre de répétitions ou le chrono qui défile et un bouton pour start/stop les chronos des étapes si les étapes possèdent un temps.

View File

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

View File

@ -0,0 +1,16 @@
---
id: "5ebceb98-b5d4-4a7d-8d4e-ab88c73a4c76"
number: 73
title: "Pouvoir enchainer les chronos des étapes"
status: "open"
priority: "medium"
sprint: null
links: [{"target":"#72","kind":"relatesTo"}]
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784493103354
updatedAt: 1784493253775
version: 4
---
Dans le cas ou deux étapes à chronos s'enchainent, j'aimerais qu'une option proposant d'enchainer le start des chrono lorsque le rpécédent arrive à 0 soit proposé. Cette option serait disponible à la création de l'exercice, mais pourrait aussi être override dans le programme ainsi que dans la séance

View File

@ -0,0 +1,6 @@
---
issueRef: "#74"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784493327494
---

View File

@ -0,0 +1,61 @@
---
id: "c18d9e4a-f6b4-4f6e-bfb5-b77c958f5534"
number: 74
title: "[Bug] Serveur non lancable"
status: "open"
priority: "critical"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784493302766
updatedAt: 1784493327494
version: 3
---
J'ai cette erreur quand j'essaie de build le serveur:
docker compose up --build
[+] Building 5.2s (15/20)
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 534B 0.0s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 835B 0.0s
=> [internal] load metadata for docker.io/library/debian:bookworm-slim 0.7s
=> [internal] load metadata for docker.io/library/dart:stable 0.7s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 93B 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 2.76kB 0.0s
=> [build 1/6] FROM docker.io/library/dart:stable@sha256:13140e26d84f4fda57cea31942222112aeb2eec10e5e6874c1c0f70 0.0s
=> => resolve docker.io/library/dart:stable@sha256:13140e26d84f4fda57cea31942222112aeb2eec10e5e6874c1c0f70beed18 0.0s
=> [runtime 1/8] FROM docker.io/library/debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a04 0.0s
=> => resolve docker.io/library/debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acb 0.0s
=> CACHED [runtime 2/8] RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates 0.0s
=> CACHED [runtime 3/8] WORKDIR /app 0.0s
=> CACHED [build 2/6] WORKDIR /app 0.0s
=> CACHED [build 3/6] COPY pubspec.* ./ 0.0s
=> CACHED [build 4/6] RUN dart pub get 0.0s
=> CACHED [build 5/6] COPY . . 0.0s
=> ERROR [build 6/6] RUN dart compile exe bin/server.dart -o build/server && dart compile exe bin/migrate.da 4.3s
------
> [build 6/6] RUN dart compile exe bin/server.dart -o build/server && dart compile exe bin/migrate.dart -o build/migrate:
4.278 Error: AOT compilation failed
4.278 PathNotFoundException: Cannot open file, path = '/app/build/server' (OS Error: No such file or directory, errno = 2)
------
[+] up 0/1
⠙ Image server-api Building 5.3s
Dockerfile:9
--------------------
8 | COPY . .
9 | >>> RUN dart compile exe bin/server.dart -o build/server \
10 | >>> && dart compile exe bin/migrate.dart -o build/migrate
11 |
--------------------
failed to solve: process "/bin/sh -c dart compile exe bin/server.dart -o build/server && dart compile exe bin/migrate.dart -o build/migrate" did not complete successfully: exit code: 254

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 72 "nextNumber": 75
} }

View File

@ -763,13 +763,13 @@
"issueRef": "#68", "issueRef": "#68",
"path": "68", "path": "68",
"title": "[DevFrontend] Statut de synchronisation discret et action manuelle", "title": "[DevFrontend] Statut de synchronisation discret et action manuelle",
"status": "open", "status": "qa",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784488359218 "updatedAt": 1784491589721
}, },
{ {
"issueRef": "#69", "issueRef": "#69",
@ -806,6 +806,42 @@
"57695b92-24d0-4876-837c-76116e70a6ae" "57695b92-24d0-4876-837c-76116e70a6ae"
], ],
"updatedAt": 1784490850142 "updatedAt": 1784490850142
},
{
"issueRef": "#72",
"path": "72",
"title": "[Bug] les étapes des exercices ne sont pas affichés pendant la séance",
"status": "open",
"priority": "critical",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784493074891
},
{
"issueRef": "#73",
"path": "73",
"title": "Pouvoir enchainer les chronos des étapes",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784493253775
},
{
"issueRef": "#74",
"path": "74",
"title": "[Bug] Serveur non lancable",
"status": "open",
"priority": "critical",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784493327494
} }
] ]
} }

View File

@ -138,6 +138,9 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
builder: (context) => ProgramListScreen( builder: (context) => ProgramListScreen(
programUseCases: widget.bootstrap.programUseCases, programUseCases: widget.bootstrap.programUseCases,
exerciseUseCases: widget.bootstrap.exerciseUseCases, exerciseUseCases: widget.bootstrap.exerciseUseCases,
shareUseCases: widget.bootstrap.shareUseCases,
authUseCases: widget.bootstrap.authUseCases,
syncUseCases: widget.bootstrap.syncUseCases,
), ),
), ),
), ),
@ -157,6 +160,9 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases, historyUseCases: widget.bootstrap.workoutHistoryUseCases,
mediaUseCases: widget.bootstrap.mediaUseCases, mediaUseCases: widget.bootstrap.mediaUseCases,
shareUseCases: widget.bootstrap.shareUseCases,
authUseCases: widget.bootstrap.authUseCases,
syncUseCases: widget.bootstrap.syncUseCases,
), ),
), ),
), ),
@ -205,6 +211,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
builder: (context) => ProfileScreen( builder: (context) => ProfileScreen(
authUseCases: widget.bootstrap.authUseCases, authUseCases: widget.bootstrap.authUseCases,
syncUseCases: widget.bootstrap.syncUseCases, syncUseCases: widget.bootstrap.syncUseCases,
shareUseCases: widget.bootstrap.shareUseCases,
), ),
), ),
), ),

View File

@ -5,6 +5,8 @@ export 'history_screen.dart';
export 'home_screen.dart'; export 'home_screen.dart';
export 'profile_screen.dart'; export 'profile_screen.dart';
export 'program_screen.dart'; export 'program_screen.dart';
export 'share_inbox_screen.dart';
export 'share_screen.dart';
export 'theme.dart'; export 'theme.dart';
export 'workout_execution_screen.dart'; export 'workout_execution_screen.dart';
export 'workout_template_screen.dart'; export 'workout_template_screen.dart';

View File

@ -2,17 +2,20 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'share_inbox_screen.dart';
import 'theme.dart'; import 'theme.dart';
final class ProfileScreen extends StatefulWidget { final class ProfileScreen extends StatefulWidget {
const ProfileScreen({ const ProfileScreen({
required this.authUseCases, required this.authUseCases,
required this.syncUseCases, required this.syncUseCases,
required this.shareUseCases,
super.key, super.key,
}); });
final AuthUseCases authUseCases; final AuthUseCases authUseCases;
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
final ShareUseCases shareUseCases;
@override @override
State<ProfileScreen> createState() => _ProfileScreenState(); State<ProfileScreen> createState() => _ProfileScreenState();
@ -122,7 +125,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
void _openReceivedShares(BuildContext context) { void _openReceivedShares(BuildContext context) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ReceivedSharesScreen()), MaterialPageRoute(
builder: (context) =>
ShareInboxScreen(shareUseCases: widget.shareUseCases),
),
); );
} }
@ -672,26 +678,6 @@ final class _RegisterScreenState extends State<RegisterScreen> {
} }
} }
final class ReceivedSharesScreen extends StatelessWidget {
const ReceivedSharesScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Partages reçus')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
"Les programmes et séances qu'on t'envoie apparaîtront ici.",
textAlign: TextAlign.center,
),
),
),
);
}
}
final class _AuthFormScaffold extends StatelessWidget { final class _AuthFormScaffold extends StatelessWidget {
const _AuthFormScaffold({required this.child}); const _AuthFormScaffold({required this.child});

View File

@ -5,16 +5,23 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'exercise_library_screen.dart'; import 'exercise_library_screen.dart';
import 'share_screen.dart';
final class ProgramListScreen extends StatefulWidget { final class ProgramListScreen extends StatefulWidget {
const ProgramListScreen({ const ProgramListScreen({
required this.programUseCases, required this.programUseCases,
required this.exerciseUseCases, required this.exerciseUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
super.key, super.key,
}); });
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases; final ExerciseUseCases exerciseUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override @override
State<ProgramListScreen> createState() => _ProgramListScreenState(); State<ProgramListScreen> createState() => _ProgramListScreenState();
@ -101,6 +108,9 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
builder: (context) => ProgramFormScreen( builder: (context) => ProgramFormScreen(
programUseCases: widget.programUseCases, programUseCases: widget.programUseCases,
exerciseUseCases: widget.exerciseUseCases, exerciseUseCases: widget.exerciseUseCases,
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
program: program, program: program,
), ),
), ),
@ -161,12 +171,18 @@ final class ProgramFormScreen extends StatefulWidget {
const ProgramFormScreen({ const ProgramFormScreen({
required this.programUseCases, required this.programUseCases,
required this.exerciseUseCases, required this.exerciseUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.program, this.program,
super.key, super.key,
}); });
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final ExerciseUseCases exerciseUseCases; final ExerciseUseCases exerciseUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final Program? program; final Program? program;
@override @override
@ -216,6 +232,14 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
title: Text( title: Text(
_isEditing ? 'Modifier le programme' : 'Créer un programme', _isEditing ? 'Modifier le programme' : 'Créer un programme',
), ),
actions: [
if (_isEditing && widget.shareUseCases != null)
IconButton(
tooltip: 'Partager',
icon: const Icon(Icons.ios_share),
onPressed: _shareProgram,
),
],
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16), minimum: const EdgeInsets.all(16),
@ -395,6 +419,25 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
} }
} }
Future<void> _shareProgram() async {
final program = widget.program;
final shareUseCases = widget.shareUseCases;
if (program == null || shareUseCases == null) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareFormScreen(
shareUseCases: shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
resourceType: ShareResourceType.program,
localResourceId: program.metadata.id,
resourceName: program.name,
resourceSummary: _programSummary(program),
),
),
);
}
String? _nonNegativeIntValidator(String? value) { String? _nonNegativeIntValidator(String? value) {
final number = int.tryParse(value?.trim() ?? ''); final number = int.tryParse(value?.trim() ?? '');
if (number == null || number < 0) { if (number == null || number < 0) {

View File

@ -0,0 +1,271 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.dart';
final class ShareInboxScreen extends StatefulWidget {
const ShareInboxScreen({required this.shareUseCases, super.key});
final ShareUseCases shareUseCases;
@override
State<ShareInboxScreen> createState() => _ShareInboxScreenState();
}
final class _ShareInboxScreenState extends State<ShareInboxScreen> {
late Future<List<ShareInboxItem>> _items;
@override
void initState() {
super.initState();
_items = widget.shareUseCases.refreshInbox();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Partages reçus')),
body: FutureBuilder<List<ShareInboxItem>>(
future: _items,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final items = snapshot.data ?? const <ShareInboxItem>[];
if (items.isEmpty) {
return const _EmptyInbox();
}
return RefreshIndicator(
onRefresh: _reload,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final item = items[index];
return _ShareInboxTile(
item: item,
onAccept: item.status == ShareInboxStatus.pending
? () => _accept(item)
: null,
onDecline: item.status == ShareInboxStatus.pending
? () => _decline(item)
: null,
);
},
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemCount: items.length,
),
);
},
),
);
}
Future<void> _reload() async {
setState(() {
_items = widget.shareUseCases.refreshInbox();
});
await _items;
}
Future<void> _accept(ShareInboxItem item) async {
await widget.shareUseCases.acceptShare(item.shareId);
if (!mounted) return;
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_acceptedMessage(item.resourceType))),
);
}
Future<void> _decline(ShareInboxItem item) async {
await widget.shareUseCases.declineShare(item.shareId);
if (!mounted) return;
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Partage refusé.')));
}
}
final class _ShareInboxTile extends StatelessWidget {
const _ShareInboxTile({
required this.item,
required this.onAccept,
required this.onDecline,
});
final ShareInboxItem item;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final summary = _summaryFromPayload(item);
return CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
item.resourceType == ShareResourceType.program
? Icons.list_alt
: Icons.event_note,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_resourceTitle(item.resourceType),
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Text(
summary.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text('Envoyé par ${item.senderUserId}'),
if (summary.detail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(summary.detail),
],
],
),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_statusLabel(item.status)),
),
],
),
if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
child: const Text('Refuser'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: onAccept,
child: const Text('Accepter'),
),
),
],
),
],
],
),
);
}
}
final class _EmptyInbox extends StatelessWidget {
const _EmptyInbox();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Aucun partage reçu', textAlign: TextAlign.center),
SizedBox(height: 8),
Text(
"Les programmes et séances qu'on t'envoie apparaîtront ici.",
textAlign: TextAlign.center,
),
],
),
),
);
}
}
final class _InboxSummary {
const _InboxSummary({required this.name, required this.detail});
final String name;
final String detail;
}
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
try {
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
final name = payload['name'] as String? ?? 'Partage GameTime';
return switch (item.resourceType) {
ShareResourceType.program => _InboxSummary(
name: name,
detail: _programDetail(payload),
),
ShareResourceType.workoutTemplate => _InboxSummary(
name: name,
detail: _templateDetail(payload),
),
};
} on Object {
return const _InboxSummary(name: 'Partage GameTime', detail: '');
}
}
String _programDetail(Map<String, dynamic> payload) {
final exercises = payload['exercises'] as List<dynamic>? ?? const [];
final sets = exercises.fold<int>(0, (total, raw) {
final exercise = raw as Map<String, dynamic>;
return total + (exercise['setsCount'] as int? ?? 0);
});
return '${exercises.length} exercice${exercises.length > 1 ? 's' : ''} · '
'$sets série${sets > 1 ? 's' : ''}';
}
String _templateDetail(Map<String, dynamic> payload) {
final programs = payload['programs'] as List<dynamic>? ?? const [];
var exerciseCount = 0;
for (final raw in programs) {
final program = raw as Map<String, dynamic>;
final snapshotJson = program['programSnapshotJson'] as String?;
if (snapshotJson == null) continue;
final snapshot = jsonDecode(snapshotJson) as Map<String, dynamic>;
exerciseCount +=
(snapshot['exercises'] as List<dynamic>? ?? const []).length;
}
return '${programs.length} programme${programs.length > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _resourceTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme',
ShareResourceType.workoutTemplate => 'Séance',
};
}
String _statusLabel(ShareInboxStatus status) {
return switch (status) {
ShareInboxStatus.pending => 'À traiter',
ShareInboxStatus.accepted => 'Accepté',
ShareInboxStatus.declined => 'Refusé',
ShareInboxStatus.revoked => 'Retiré',
};
}
String _acceptedMessage(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme ajouté.',
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
};
}

View File

@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'profile_screen.dart';
import 'theme.dart';
final class ShareFormScreen extends StatefulWidget {
const ShareFormScreen({
required this.shareUseCases,
required this.resourceType,
required this.localResourceId,
required this.resourceName,
required this.resourceSummary,
this.authUseCases,
this.syncUseCases,
super.key,
});
final ShareUseCases shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final ShareResourceType resourceType;
final String localResourceId;
final String resourceName;
final String resourceSummary;
@override
State<ShareFormScreen> createState() => _ShareFormScreenState();
}
final class _ShareFormScreenState extends State<ShareFormScreen> {
final _formKey = GlobalKey<FormState>();
final _emailsController = TextEditingController();
var _sending = false;
String? _message;
@override
void dispose() {
_emailsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_shareTitle(widget.resourceType))),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${_resourceTitle(widget.resourceType)} à partager',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(widget.resourceName),
const SizedBox(height: 4),
Text(widget.resourceSummary),
const SizedBox(height: 16),
Form(
key: _formKey,
child: TextFormField(
controller: _emailsController,
decoration: const InputDecoration(
labelText: 'Emails destinataires',
helperText: 'Sépare plusieurs emails par une virgule.',
),
keyboardType: TextInputType.emailAddress,
validator: _emailsValidator,
),
),
if (_message != null) ...[
const SizedBox(height: 12),
Text(_message!),
],
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _sending ? null : _send,
icon: _sending
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.ios_share),
label: const Text('Envoyer le partage'),
),
],
),
),
],
),
);
}
Future<void> _send() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_sending = true;
_message = null;
});
try {
final result = await widget.shareUseCases.sendShare(
resourceType: widget.resourceType,
localResourceId: widget.localResourceId,
recipientEmails: _parseEmails(_emailsController.text),
);
if (!mounted) return;
setState(() {
_sending = false;
_message = _shareResultMessage(result);
});
if (result.status == ShareSendStatus.notConnected) {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareAccountRequiredScreen(
resourceType: widget.resourceType,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
),
),
);
}
} on DomainException {
if (!mounted) return;
setState(() {
_sending = false;
_message = 'Partage indisponible pour le moment.';
});
}
}
}
final class ShareAccountRequiredScreen extends StatelessWidget {
const ShareAccountRequiredScreen({
required this.resourceType,
this.authUseCases,
this.syncUseCases,
super.key,
});
final ShareResourceType resourceType;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override
Widget build(BuildContext context) {
final targetLabel = switch (resourceType) {
ShareResourceType.program => 'ce programme',
ShareResourceType.workoutTemplate => 'cette séance',
};
final canOpenAuth = authUseCases != null && syncUseCases != null;
return Scaffold(
appBar: AppBar(title: const Text('Compte requis pour partager')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Compte requis pour partager',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Connecte-toi pour envoyer $targetLabel à un autre compte '
'GameTime. Le reste de lapp reste utilisable sans compte.',
),
const SizedBox(height: 16),
FilledButton(
onPressed: canOpenAuth
? () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => LoginScreen(
authUseCases: authUseCases!,
syncUseCases: syncUseCases!,
),
),
)
: null,
child: const Text('Se connecter'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: canOpenAuth
? () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => RegisterScreen(
authUseCases: authUseCases!,
syncUseCases: syncUseCases!,
),
),
)
: null,
child: const Text('Créer un compte'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
),
],
),
);
}
}
String? _emailsValidator(String? value) {
final emails = _parseEmails(value ?? '');
if (emails.isEmpty) {
return "Saisis l'email du destinataire.";
}
final invalid = emails.any(
(email) => !RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email),
);
if (invalid) {
return "Saisis l'email du destinataire.";
}
return null;
}
List<String> _parseEmails(String value) {
return value
.split(RegExp(r'[,;\n]'))
.map((email) => email.trim())
.where((email) => email.isNotEmpty)
.toList(growable: false);
}
String _shareResultMessage(ShareSendResult result) {
return switch (result.status) {
ShareSendStatus.notConnected => 'Compte requis pour partager.',
ShareSendStatus.queued => 'Partage enregistré. Envoi dès que possible.',
ShareSendStatus.sent when result.unresolvedEmails.isNotEmpty =>
'Partage envoyé. Non trouvés : ${result.unresolvedEmails.join(', ')}.',
ShareSendStatus.sent => 'Partage envoyé.',
};
}
String _shareTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Partager le programme',
ShareResourceType.workoutTemplate => 'Partager la séance',
};
}
String _resourceTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme',
ShareResourceType.workoutTemplate => 'Séance',
};
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'share_screen.dart';
import 'workout_execution_screen.dart'; import 'workout_execution_screen.dart';
final class WorkoutTemplateListScreen extends StatefulWidget { final class WorkoutTemplateListScreen extends StatefulWidget {
@ -15,6 +16,9 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.historyUseCases, required this.historyUseCases,
this.mediaUseCases, this.mediaUseCases,
this.stepUseCases, this.stepUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
super.key, super.key,
}); });
@ -25,6 +29,9 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases; final WorkoutHistoryUseCases historyUseCases;
final MediaUseCases? mediaUseCases; final MediaUseCases? mediaUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
@override @override
State<WorkoutTemplateListScreen> createState() => State<WorkoutTemplateListScreen> createState() =>
@ -109,6 +116,9 @@ final class _WorkoutTemplateListScreenState
builder: (context) => WorkoutTemplateFormScreen( builder: (context) => WorkoutTemplateFormScreen(
workoutTemplateUseCases: widget.workoutTemplateUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases,
programUseCases: widget.programUseCases, programUseCases: widget.programUseCases,
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
template: template, template: template,
), ),
), ),
@ -193,12 +203,18 @@ final class WorkoutTemplateFormScreen extends StatefulWidget {
const WorkoutTemplateFormScreen({ const WorkoutTemplateFormScreen({
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
required this.programUseCases, required this.programUseCases,
this.shareUseCases,
this.authUseCases,
this.syncUseCases,
this.template, this.template,
super.key, super.key,
}); });
final WorkoutTemplateUseCases workoutTemplateUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases;
final WorkoutTemplate? template; final WorkoutTemplate? template;
@override @override
@ -241,6 +257,14 @@ final class _WorkoutTemplateFormScreenState
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'), title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
actions: [
if (_isEditing && widget.shareUseCases != null)
IconButton(
tooltip: 'Partager',
icon: const Icon(Icons.ios_share),
onPressed: _shareTemplate,
),
],
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16), minimum: const EdgeInsets.all(16),
@ -390,6 +414,25 @@ final class _WorkoutTemplateFormScreenState
} }
} }
Future<void> _shareTemplate() async {
final template = widget.template;
final shareUseCases = widget.shareUseCases;
if (template == null || shareUseCases == null) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => ShareFormScreen(
shareUseCases: shareUseCases,
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
resourceType: ShareResourceType.workoutTemplate,
localResourceId: template.metadata.id,
resourceName: template.name,
resourceSummary: _templateSummary(template),
),
),
);
}
void _showSnackBar(String message) { void _showSnackBar(String message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(

View File

@ -15,6 +15,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -37,6 +38,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -69,6 +71,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -111,6 +114,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -138,6 +142,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -164,6 +169,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -190,6 +196,7 @@ void main() {
home: ProfileScreen( home: ProfileScreen(
authUseCases: harness.useCases, authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases, syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
), ),
), ),
); );
@ -204,6 +211,26 @@ void main() {
expect(find.textContaining('Dernière synchro :'), findsOneWidget); expect(find.textContaining('Dernière synchro :'), findsOneWidget);
}); });
testWidgets('partages reçus ouvre la boîte de réception', (tester) async {
final harness = _AuthHarness.connected();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
),
),
);
await tester.pump();
await tester.tap(find.text('Partages reçus'));
await tester.pumpAndSettle();
expect(find.text('Aucun partage reçu'), findsOneWidget);
});
} }
final class _AuthHarness { final class _AuthHarness {
@ -214,7 +241,12 @@ final class _AuthHarness {
syncRemote = _FakeRemoteSyncApi(), syncRemote = _FakeRemoteSyncApi(),
syncMetadata = _FakeSyncMetadataRepository(), syncMetadata = _FakeSyncMetadataRepository(),
syncMappings = _FakeRemoteResourceMappingRepository(), syncMappings = _FakeRemoteResourceMappingRepository(),
syncChanges = _FakeLocalSyncChangeRepository() { syncChanges = _FakeLocalSyncChangeRepository(),
shareRemote = _FakeRemoteShareApi(),
shareInbox = _FakeShareInboxRepository(),
pendingShares = _FakePendingShareActionRepository(),
programRepository = _FakeProgramRepository(),
templateRepository = _FakeWorkoutTemplateRepository() {
useCases = AuthUseCases( useCases = AuthUseCases(
tokenStore: tokenStore, tokenStore: tokenStore,
accountRepository: accountRepository, accountRepository: accountRepository,
@ -231,6 +263,17 @@ final class _AuthHarness {
clock: const _FakeClock(), clock: const _FakeClock(),
deviceId: 'device-1', deviceId: 'device-1',
); );
shareUseCases = ShareUseCases(
tokenStore: tokenStore,
remoteShareApi: shareRemote,
inboxRepository: shareInbox,
pendingActionRepository: pendingShares,
localChanges: syncChanges,
programRepository: programRepository,
templateRepository: templateRepository,
clock: const _FakeClock(),
ids: _FakeIds(),
);
} }
factory _AuthHarness.connected() { factory _AuthHarness.connected() {
@ -255,8 +298,14 @@ final class _AuthHarness {
final _FakeSyncMetadataRepository syncMetadata; final _FakeSyncMetadataRepository syncMetadata;
final _FakeRemoteResourceMappingRepository syncMappings; final _FakeRemoteResourceMappingRepository syncMappings;
final _FakeLocalSyncChangeRepository syncChanges; final _FakeLocalSyncChangeRepository syncChanges;
final _FakeRemoteShareApi shareRemote;
final _FakeShareInboxRepository shareInbox;
final _FakePendingShareActionRepository pendingShares;
final _FakeProgramRepository programRepository;
final _FakeWorkoutTemplateRepository templateRepository;
late final AuthUseCases useCases; late final AuthUseCases useCases;
late final SyncUseCases syncUseCases; late final SyncUseCases syncUseCases;
late final ShareUseCases shareUseCases;
} }
final class _FakeClock implements Clock { final class _FakeClock implements Clock {
@ -429,3 +478,144 @@ final class _FakeLocalSyncChangeRepository
DateTime syncedAt, DateTime syncedAt,
) async {} ) async {}
} }
final class _FakeRemoteShareApi implements RemoteShareApi {
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
@override
Future<void> revokeShare(String shareId, String token) async {}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> recipientEmails,
required String token,
}) async {
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
final items = <ShareInboxItem>[];
@override
Future<ShareInboxItem?> findByShareId(String shareId) async {
return items.cast<ShareInboxItem?>().firstWhere(
(item) => item?.shareId == shareId,
orElse: () => null,
);
}
@override
Future<List<ShareInboxItem>> listAll() async => List.of(items);
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {
final index = items.indexWhere((item) => item.shareId == shareId);
if (index == -1) return;
items[index] = items[index].copyWith(
status: status,
respondedAt: respondedAt,
);
}
@override
Future<void> upsert(ShareInboxItem item) async {
items.removeWhere((existing) => existing.shareId == item.shareId);
items.add(item);
}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {
for (final item in items) {
await upsert(item);
}
}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
final actions = <PendingShareAction>[];
@override
Future<void> add(PendingShareAction action) async {
actions.add(action);
}
@override
Future<List<PendingShareAction>> listPending() async => actions;
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}

View File

@ -304,6 +304,121 @@ void main() {
expect(programRepository.saved.single.metadata.deletedAt, isNotNull); expect(programRepository.saved.single.metadata.deletedAt, isNotNull);
}, },
); );
testWidgets('partager un programme envoie le partage avec un email valide', (
tester,
) async {
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Programme tirs',
defaultRestSeconds: 45,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository();
final remoteShareApi = _FakeRemoteShareApi();
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
shareUseCases: _shareUseCases(
programRepository: programRepository,
remoteShareApi: remoteShareApi,
),
program: programRepository.programs.single,
),
),
);
await tester.tap(find.byTooltip('Partager'));
await tester.pumpAndSettle();
expect(find.text('Partager le programme'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextFormField, 'Emails destinataires'),
'joueur@example.com',
);
await tester.tap(find.text('Envoyer le partage'));
await tester.pump();
await tester.pump();
expect(remoteShareApi.sendCalls, 1);
expect(remoteShareApi.lastResourceType, ShareResourceType.program);
expect(remoteShareApi.lastRecipientEmails, ['joueur@example.com']);
expect(find.text('Partage envoyé.'), findsOneWidget);
});
testWidgets('partager sans compte affiche lécran compte requis', (
tester,
) async {
final programRepository = _FakeProgramRepository()
..programs.add(
Program(
metadata: _metadata('program-1'),
name: 'Programme tirs',
defaultRestSeconds: 45,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
shareUseCases: _shareUseCases(
tokenStore: _FakeAuthTokenStore(),
programRepository: programRepository,
),
program: programRepository.programs.single,
),
),
);
await tester.tap(find.byTooltip('Partager'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Emails destinataires'),
'joueur@example.com',
);
await tester.tap(find.text('Envoyer le partage'));
await tester.pumpAndSettle();
expect(find.text('Compte requis pour partager'), findsWidgets);
expect(find.text('Annuler'), findsOneWidget);
await tester.tap(find.text('Annuler'));
await tester.pumpAndSettle();
expect(find.text('Partager le programme'), findsOneWidget);
});
} }
ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) { ExerciseUseCases _exerciseUseCases(_FakeExerciseRepository repository) {
@ -331,6 +446,25 @@ ProgramUseCases _programUseCases(
); );
} }
ShareUseCases _shareUseCases({
_FakeAuthTokenStore? tokenStore,
_FakeRemoteShareApi? remoteShareApi,
_FakeProgramRepository? programRepository,
_FakeWorkoutTemplateRepository? templateRepository,
}) {
return ShareUseCases(
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
inboxRepository: _FakeShareInboxRepository(),
pendingActionRepository: _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: programRepository ?? _FakeProgramRepository(),
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
);
}
final class _FakeWorkoutTemplateRepository final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository { implements WorkoutTemplateRepository {
final templates = <WorkoutTemplate>[]; final templates = <WorkoutTemplate>[];
@ -458,3 +592,119 @@ final class _FakeProgramRepository implements ProgramRepository {
@override @override
Future<void> saveExercise(ProgramExercise exercise) async {} Future<void> saveExercise(ProgramExercise exercise) async {}
} }
final class _FakeAuthTokenStore implements AuthTokenStore {
_FakeAuthTokenStore([this.token]);
String? token;
@override
Future<void> clearToken() async {
token = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
}
}
final class _FakeRemoteShareApi implements RemoteShareApi {
var sendCalls = 0;
ShareResourceType? lastResourceType;
List<String> lastRecipientEmails = const [];
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'program-remote',
serverId: 'program-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
@override
Future<void> revokeShare(String shareId, String token) async {}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> recipientEmails,
required String token,
}) async {
sendCalls += 1;
lastResourceType = resourceType;
lastRecipientEmails = recipientEmails;
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
@override
Future<ShareInboxItem?> findByShareId(String shareId) async => null;
@override
Future<List<ShareInboxItem>> listAll() async => const [];
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {}
@override
Future<void> upsert(ShareInboxItem item) async {}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
@override
Future<void> add(PendingShareAction action) async {}
@override
Future<List<PendingShareAction>> listPending() async => const [];
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}

View File

@ -0,0 +1,337 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/share_inbox_screen.dart';
void main() {
testWidgets('la boîte de réception affiche les partages et les actions', (
tester,
) async {
final remoteShareApi = _FakeRemoteShareApi()
..inboxItems = [
_shareItem(
shareId: 'share-program',
resourceType: ShareResourceType.program,
name: 'Programme tirs',
status: ShareInboxStatus.pending,
),
_shareItem(
shareId: 'share-template',
resourceType: ShareResourceType.workoutTemplate,
name: 'Séance match',
status: ShareInboxStatus.pending,
),
];
final inboxRepository = _FakeShareInboxRepository();
await tester.pumpWidget(
MaterialApp(
home: ShareInboxScreen(
shareUseCases: _shareUseCases(
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Programme tirs'), findsOneWidget);
expect(find.text('Séance match'), findsOneWidget);
expect(find.text('Accepter'), findsNWidgets(2));
expect(find.text('Refuser'), findsNWidgets(2));
await tester.tap(find.widgetWithText(FilledButton, 'Accepter').first);
await tester.pump();
await tester.pump();
expect(remoteShareApi.acceptCalls, 1);
expect(
inboxRepository.items
.firstWhere((item) => item.shareId == 'share-program')
.status,
ShareInboxStatus.accepted,
);
expect(find.text('Programme ajouté.'), findsOneWidget);
await tester.tap(find.widgetWithText(OutlinedButton, 'Refuser').first);
await tester.pump();
await tester.pump();
expect(remoteShareApi.declineCalls, 1);
expect(
inboxRepository.items
.firstWhere((item) => item.shareId == 'share-template')
.status,
ShareInboxStatus.declined,
);
});
}
ShareUseCases _shareUseCases({
required _FakeRemoteShareApi remoteShareApi,
required _FakeShareInboxRepository inboxRepository,
}) {
return ShareUseCases(
tokenStore: _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
pendingActionRepository: _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: _FakeProgramRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
);
}
ShareInboxItem _shareItem({
required String shareId,
required ShareResourceType resourceType,
required String name,
required ShareInboxStatus status,
}) {
final payload = switch (resourceType) {
ShareResourceType.program => {
'name': name,
'exercises': const [
{'setsCount': 3},
],
},
ShareResourceType.workoutTemplate => {
'name': name,
'programs': [
{
'programSnapshotJson': jsonEncode({
'exercises': const [
{'id': 'exercise-1'},
],
}),
},
],
},
};
return ShareInboxItem(
shareId: shareId,
senderUserId: 'Alex',
resourceType: resourceType,
payloadJson: jsonEncode(payload),
status: status,
createdAt: DateTime.utc(2026, 7, 17),
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeAuthTokenStore implements AuthTokenStore {
_FakeAuthTokenStore(this.token);
String? token;
@override
Future<void> clearToken() async {
token = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
}
}
final class _FakeRemoteShareApi implements RemoteShareApi {
List<ShareInboxItem> inboxItems = const [];
var acceptCalls = 0;
var declineCalls = 0;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
acceptCalls += 1;
_mark(shareId, ShareInboxStatus.accepted);
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'shared-program-1',
serverId: 'server-program-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme tirs'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {
declineCalls += 1;
_mark(shareId, ShareInboxStatus.declined);
}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => inboxItems;
@override
Future<void> revokeShare(String shareId, String token) async {
_mark(shareId, ShareInboxStatus.revoked);
}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> recipientEmails,
required String token,
}) async {
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
void _mark(String shareId, ShareInboxStatus status) {
inboxItems = [
for (final item in inboxItems)
if (item.shareId == shareId)
item.copyWith(status: status, respondedAt: DateTime.utc(2026, 7, 17))
else
item,
];
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
final items = <ShareInboxItem>[];
@override
Future<ShareInboxItem?> findByShareId(String shareId) async {
return items.cast<ShareInboxItem?>().firstWhere(
(item) => item?.shareId == shareId,
orElse: () => null,
);
}
@override
Future<List<ShareInboxItem>> listAll() async => List.of(items);
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {
final index = items.indexWhere((item) => item.shareId == shareId);
if (index == -1) return;
items[index] = items[index].copyWith(
status: status,
respondedAt: respondedAt,
);
}
@override
Future<void> upsert(ShareInboxItem item) async {
items.removeWhere((existing) => existing.shareId == item.shareId);
items.add(item);
}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {
for (final item in items) {
await upsert(item);
}
}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
@override
Future<void> add(PendingShareAction action) async {}
@override
Future<List<PendingShareAction>> listPending() async => const [];
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}

View File

@ -184,6 +184,61 @@ void main() {
expect(templateRepository.saved.single.metadata.deletedAt, isNotNull); expect(templateRepository.saved.single.metadata.deletedAt, isNotNull);
}, },
); );
testWidgets('partager une séance ouvre le formulaire de partage', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final programRepository = _FakeProgramRepository();
final remoteShareApi = _FakeRemoteShareApi();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
shareUseCases: _shareUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
remoteShareApi: remoteShareApi,
),
template: templateRepository.templates.single,
),
),
);
await tester.tap(find.byTooltip('Partager'));
await tester.pumpAndSettle();
expect(find.text('Partager la séance'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextFormField, 'Emails destinataires'),
'coach@example.com',
);
await tester.tap(find.text('Envoyer le partage'));
await tester.pump();
await tester.pump();
expect(remoteShareApi.lastResourceType, ShareResourceType.workoutTemplate);
expect(remoteShareApi.lastRecipientEmails, ['coach@example.com']);
});
} }
WorkoutTemplate _workoutTemplate() { WorkoutTemplate _workoutTemplate() {
@ -466,3 +521,135 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override @override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {} Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
} }
ShareUseCases _shareUseCases({
required _FakeWorkoutTemplateRepository templateRepository,
required _FakeProgramRepository programRepository,
_FakeRemoteShareApi? remoteShareApi,
}) {
return ShareUseCases(
tokenStore: _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
inboxRepository: _FakeShareInboxRepository(),
pendingActionRepository: _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: programRepository,
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
);
}
final class _FakeAuthTokenStore implements AuthTokenStore {
_FakeAuthTokenStore([this.token]);
String? token;
@override
Future<void> clearToken() async {
token = null;
}
@override
Future<String?> readToken() async => token;
@override
Future<void> saveToken(String token, DateTime expiresAt) async {
this.token = token;
}
}
final class _FakeRemoteShareApi implements RemoteShareApi {
ShareResourceType? lastResourceType;
List<String> lastRecipientEmails = const [];
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
return RemoteSyncedItem(
resourceType: SyncResourceType.workoutTemplate,
clientId: 'template-remote',
serverId: 'template-server',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Séance partagée'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
@override
Future<void> revokeShare(String shareId, String token) async {}
@override
Future<RemoteShareSendResult> sendShare({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> recipientEmails,
required String token,
}) async {
lastResourceType = resourceType;
lastRecipientEmails = recipientEmails;
return const RemoteShareSendResult(
shareId: 'share-1',
recipientUserIds: ['recipient-1'],
unresolvedEmails: [],
);
}
}
final class _FakeShareInboxRepository implements ShareInboxRepository {
@override
Future<ShareInboxItem?> findByShareId(String shareId) async => null;
@override
Future<List<ShareInboxItem>> listAll() async => const [];
@override
Future<void> markStatus(
String shareId,
ShareInboxStatus status,
DateTime respondedAt,
) async {}
@override
Future<void> upsert(ShareInboxItem item) async {}
@override
Future<void> upsertAll(List<ShareInboxItem> items) async {}
}
final class _FakePendingShareActionRepository
implements PendingShareActionRepository {
@override
Future<void> add(PendingShareAction action) async {}
@override
Future<List<PendingShareAction>> listPending() async => const [];
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}