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:
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#68"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
||||
updatedAt: 1784488359218
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||
updatedAt: 1784491589721
|
||||
---
|
||||
|
||||
@ -2,15 +2,15 @@
|
||||
id: "ac634023-e7ba-4588-9c7c-9f93c12d9aad"
|
||||
number: 68
|
||||
title: "[DevFrontend] Statut de synchronisation discret et action manuelle"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: [{"target":"#63","kind":"relatesTo"},{"target":"#65","kind":"dependsOn"},{"target":"#67","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
|
||||
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
|
||||
updatedAt: 1784488359218
|
||||
version: 1
|
||||
updatedAt: 1784491589721
|
||||
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.
|
||||
6
.ideai/tickets/72/carnet.md
Normal file
6
.ideai/tickets/72/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#72"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784493074891
|
||||
---
|
||||
16
.ideai/tickets/72/issue.md
Normal file
16
.ideai/tickets/72/issue.md
Normal 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.
|
||||
6
.ideai/tickets/73/carnet.md
Normal file
6
.ideai/tickets/73/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#73"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784493253775
|
||||
---
|
||||
16
.ideai/tickets/73/issue.md
Normal file
16
.ideai/tickets/73/issue.md
Normal 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
|
||||
6
.ideai/tickets/74/carnet.md
Normal file
6
.ideai/tickets/74/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#74"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784493327494
|
||||
---
|
||||
61
.ideai/tickets/74/issue.md
Normal file
61
.ideai/tickets/74/issue.md
Normal 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
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 72
|
||||
"nextNumber": 75
|
||||
}
|
||||
@ -763,13 +763,13 @@
|
||||
"issueRef": "#68",
|
||||
"path": "68",
|
||||
"title": "[DevFrontend] Statut de synchronisation discret et action manuelle",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
|
||||
],
|
||||
"updatedAt": 1784488359218
|
||||
"updatedAt": 1784491589721
|
||||
},
|
||||
{
|
||||
"issueRef": "#69",
|
||||
@ -806,6 +806,42 @@
|
||||
"57695b92-24d0-4876-837c-76116e70a6ae"
|
||||
],
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -138,6 +138,9 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
builder: (context) => ProgramListScreen(
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
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,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
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(
|
||||
authUseCases: widget.bootstrap.authUseCases,
|
||||
syncUseCases: widget.bootstrap.syncUseCases,
|
||||
shareUseCases: widget.bootstrap.shareUseCases,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -5,6 +5,8 @@ export 'history_screen.dart';
|
||||
export 'home_screen.dart';
|
||||
export 'profile_screen.dart';
|
||||
export 'program_screen.dart';
|
||||
export 'share_inbox_screen.dart';
|
||||
export 'share_screen.dart';
|
||||
export 'theme.dart';
|
||||
export 'workout_execution_screen.dart';
|
||||
export 'workout_template_screen.dart';
|
||||
|
||||
@ -2,17 +2,20 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'share_inbox_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
final class ProfileScreen extends StatefulWidget {
|
||||
const ProfileScreen({
|
||||
required this.authUseCases,
|
||||
required this.syncUseCases,
|
||||
required this.shareUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AuthUseCases authUseCases;
|
||||
final SyncUseCases syncUseCases;
|
||||
final ShareUseCases shareUseCases;
|
||||
|
||||
@override
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
@ -122,7 +125,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
|
||||
void _openReceivedShares(BuildContext context) {
|
||||
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 {
|
||||
const _AuthFormScaffold({required this.child});
|
||||
|
||||
|
||||
@ -5,16 +5,23 @@ import 'package:flutter/material.dart';
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'exercise_library_screen.dart';
|
||||
import 'share_screen.dart';
|
||||
|
||||
final class ProgramListScreen extends StatefulWidget {
|
||||
const ProgramListScreen({
|
||||
required this.programUseCases,
|
||||
required this.exerciseUseCases,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProgramUseCases programUseCases;
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
|
||||
@override
|
||||
State<ProgramListScreen> createState() => _ProgramListScreenState();
|
||||
@ -101,6 +108,9 @@ final class _ProgramListScreenState extends State<ProgramListScreen> {
|
||||
builder: (context) => ProgramFormScreen(
|
||||
programUseCases: widget.programUseCases,
|
||||
exerciseUseCases: widget.exerciseUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
program: program,
|
||||
),
|
||||
),
|
||||
@ -161,12 +171,18 @@ final class ProgramFormScreen extends StatefulWidget {
|
||||
const ProgramFormScreen({
|
||||
required this.programUseCases,
|
||||
required this.exerciseUseCases,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
this.program,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProgramUseCases programUseCases;
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
final Program? program;
|
||||
|
||||
@override
|
||||
@ -216,6 +232,14 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
title: Text(
|
||||
_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(
|
||||
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) {
|
||||
final number = int.tryParse(value?.trim() ?? '');
|
||||
if (number == null || number < 0) {
|
||||
|
||||
271
lib/presentation/share_inbox_screen.dart
Normal file
271
lib/presentation/share_inbox_screen.dart
Normal 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.',
|
||||
};
|
||||
}
|
||||
260
lib/presentation/share_screen.dart
Normal file
260
lib/presentation/share_screen.dart
Normal 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 l’app 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',
|
||||
};
|
||||
}
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'share_screen.dart';
|
||||
import 'workout_execution_screen.dart';
|
||||
|
||||
final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
@ -15,6 +16,9 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
required this.historyUseCases,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@ -25,6 +29,9 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
|
||||
@override
|
||||
State<WorkoutTemplateListScreen> createState() =>
|
||||
@ -109,6 +116,9 @@ final class _WorkoutTemplateListScreenState
|
||||
builder: (context) => WorkoutTemplateFormScreen(
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
programUseCases: widget.programUseCases,
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
template: template,
|
||||
),
|
||||
),
|
||||
@ -193,12 +203,18 @@ final class WorkoutTemplateFormScreen extends StatefulWidget {
|
||||
const WorkoutTemplateFormScreen({
|
||||
required this.workoutTemplateUseCases,
|
||||
required this.programUseCases,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
this.template,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ProgramUseCases programUseCases;
|
||||
final ShareUseCases? shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
final WorkoutTemplate? template;
|
||||
|
||||
@override
|
||||
@ -241,6 +257,14 @@ final class _WorkoutTemplateFormScreenState
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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(
|
||||
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) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
|
||||
@ -15,6 +15,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -37,6 +38,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -69,6 +71,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -111,6 +114,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -138,6 +142,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -164,6 +169,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -190,6 +196,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -204,6 +211,26 @@ void main() {
|
||||
|
||||
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 {
|
||||
@ -214,7 +241,12 @@ final class _AuthHarness {
|
||||
syncRemote = _FakeRemoteSyncApi(),
|
||||
syncMetadata = _FakeSyncMetadataRepository(),
|
||||
syncMappings = _FakeRemoteResourceMappingRepository(),
|
||||
syncChanges = _FakeLocalSyncChangeRepository() {
|
||||
syncChanges = _FakeLocalSyncChangeRepository(),
|
||||
shareRemote = _FakeRemoteShareApi(),
|
||||
shareInbox = _FakeShareInboxRepository(),
|
||||
pendingShares = _FakePendingShareActionRepository(),
|
||||
programRepository = _FakeProgramRepository(),
|
||||
templateRepository = _FakeWorkoutTemplateRepository() {
|
||||
useCases = AuthUseCases(
|
||||
tokenStore: tokenStore,
|
||||
accountRepository: accountRepository,
|
||||
@ -231,6 +263,17 @@ final class _AuthHarness {
|
||||
clock: const _FakeClock(),
|
||||
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() {
|
||||
@ -255,8 +298,14 @@ final class _AuthHarness {
|
||||
final _FakeSyncMetadataRepository syncMetadata;
|
||||
final _FakeRemoteResourceMappingRepository syncMappings;
|
||||
final _FakeLocalSyncChangeRepository syncChanges;
|
||||
final _FakeRemoteShareApi shareRemote;
|
||||
final _FakeShareInboxRepository shareInbox;
|
||||
final _FakePendingShareActionRepository pendingShares;
|
||||
final _FakeProgramRepository programRepository;
|
||||
final _FakeWorkoutTemplateRepository templateRepository;
|
||||
late final AuthUseCases useCases;
|
||||
late final SyncUseCases syncUseCases;
|
||||
late final ShareUseCases shareUseCases;
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
@ -429,3 +478,144 @@ final class _FakeLocalSyncChangeRepository
|
||||
DateTime syncedAt,
|
||||
) 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 {}
|
||||
}
|
||||
|
||||
@ -304,6 +304,121 @@ void main() {
|
||||
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) {
|
||||
@ -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
|
||||
implements WorkoutTemplateRepository {
|
||||
final templates = <WorkoutTemplate>[];
|
||||
@ -458,3 +592,119 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
@override
|
||||
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 {}
|
||||
}
|
||||
|
||||
337
test/presentation/share_inbox_screen_test.dart
Normal file
337
test/presentation/share_inbox_screen_test.dart
Normal 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 {}
|
||||
}
|
||||
@ -184,6 +184,61 @@ void main() {
|
||||
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() {
|
||||
@ -466,3 +521,135 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user