diff --git a/.ideai/tickets/68/carnet.md b/.ideai/tickets/68/carnet.md index 6d59d99..c7bb9d2 100644 --- a/.ideai/tickets/68/carnet.md +++ b/.ideai/tickets/68/carnet.md @@ -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 --- diff --git a/.ideai/tickets/68/issue.md b/.ideai/tickets/68/issue.md index 7c30873..a00c120 100644 --- a/.ideai/tickets/68/issue.md +++ b/.ideai/tickets/68/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/72/carnet.md b/.ideai/tickets/72/carnet.md new file mode 100644 index 0000000..005235c --- /dev/null +++ b/.ideai/tickets/72/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#72" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1784493074891 +--- diff --git a/.ideai/tickets/72/issue.md b/.ideai/tickets/72/issue.md new file mode 100644 index 0000000..32ccb53 --- /dev/null +++ b/.ideai/tickets/72/issue.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/73/carnet.md b/.ideai/tickets/73/carnet.md new file mode 100644 index 0000000..0877a8b --- /dev/null +++ b/.ideai/tickets/73/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#73" +version: 4 +updatedBy: {"kind":"user"} +updatedAt: 1784493253775 +--- diff --git a/.ideai/tickets/73/issue.md b/.ideai/tickets/73/issue.md new file mode 100644 index 0000000..7aa61a7 --- /dev/null +++ b/.ideai/tickets/73/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/74/carnet.md b/.ideai/tickets/74/carnet.md new file mode 100644 index 0000000..e99b7b7 --- /dev/null +++ b/.ideai/tickets/74/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#74" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1784493327494 +--- diff --git a/.ideai/tickets/74/issue.md b/.ideai/tickets/74/issue.md new file mode 100644 index 0000000..b6cfba9 --- /dev/null +++ b/.ideai/tickets/74/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 531c6bf..1ca5d8b 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 72 + "nextNumber": 75 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 0107f7d..0b30752 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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 } ] } \ No newline at end of file diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 3d30a18..8d59f53 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -138,6 +138,9 @@ final class _HomeScreenState extends State 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 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 with RouteAware { builder: (context) => ProfileScreen( authUseCases: widget.bootstrap.authUseCases, syncUseCases: widget.bootstrap.syncUseCases, + shareUseCases: widget.bootstrap.shareUseCases, ), ), ), diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index aab8ecf..c3c32da 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -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'; diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index 1248311..49d71b0 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_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 createState() => _ProfileScreenState(); @@ -122,7 +125,10 @@ final class _ProfileScreenState extends State { 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 { } } -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}); diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 61d3bad..3ea6326 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -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 createState() => _ProgramListScreenState(); @@ -101,6 +108,9 @@ final class _ProgramListScreenState extends State { 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 { 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 { } } + Future _shareProgram() async { + final program = widget.program; + final shareUseCases = widget.shareUseCases; + if (program == null || shareUseCases == null) return; + await Navigator.of(context).push( + 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) { diff --git a/lib/presentation/share_inbox_screen.dart b/lib/presentation/share_inbox_screen.dart new file mode 100644 index 0000000..c5a8cac --- /dev/null +++ b/lib/presentation/share_inbox_screen.dart @@ -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 createState() => _ShareInboxScreenState(); +} + +final class _ShareInboxScreenState extends State { + late Future> _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>( + future: _items, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final items = snapshot.data ?? const []; + 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 _reload() async { + setState(() { + _items = widget.shareUseCases.refreshInbox(); + }); + await _items; + } + + Future _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 _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; + 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 payload) { + final exercises = payload['exercises'] as List? ?? const []; + final sets = exercises.fold(0, (total, raw) { + final exercise = raw as Map; + return total + (exercise['setsCount'] as int? ?? 0); + }); + return '${exercises.length} exercice${exercises.length > 1 ? 's' : ''} · ' + '$sets série${sets > 1 ? 's' : ''}'; +} + +String _templateDetail(Map payload) { + final programs = payload['programs'] as List? ?? const []; + var exerciseCount = 0; + for (final raw in programs) { + final program = raw as Map; + final snapshotJson = program['programSnapshotJson'] as String?; + if (snapshotJson == null) continue; + final snapshot = jsonDecode(snapshotJson) as Map; + exerciseCount += + (snapshot['exercises'] as List? ?? 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.', + }; +} diff --git a/lib/presentation/share_screen.dart b/lib/presentation/share_screen.dart new file mode 100644 index 0000000..6b1d7ea --- /dev/null +++ b/lib/presentation/share_screen.dart @@ -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 createState() => _ShareFormScreenState(); +} + +final class _ShareFormScreenState extends State { + final _formKey = GlobalKey(); + 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 _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( + 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 _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', + }; +} diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 05f480f..7d15f7f 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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 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 _shareTemplate() async { + final template = widget.template; + final shareUseCases = widget.shareUseCases; + if (template == null || shareUseCases == null) return; + await Navigator.of(context).push( + 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( diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index 6ddc148..e781c86 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -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 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 declineShare(String shareId, String token) async {} + + @override + Future> fetchInbox(String token) async => const []; + + @override + Future revokeShare(String shareId, String token) async {} + + @override + Future sendShare({ + required ShareResourceType resourceType, + required Map payload, + required List recipientEmails, + required String token, + }) async { + return const RemoteShareSendResult( + shareId: 'share-1', + recipientUserIds: ['recipient-1'], + unresolvedEmails: [], + ); + } +} + +final class _FakeShareInboxRepository implements ShareInboxRepository { + final items = []; + + @override + Future findByShareId(String shareId) async { + return items.cast().firstWhere( + (item) => item?.shareId == shareId, + orElse: () => null, + ); + } + + @override + Future> listAll() async => List.of(items); + + @override + Future 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 upsert(ShareInboxItem item) async { + items.removeWhere((existing) => existing.shareId == item.shareId); + items.add(item); + } + + @override + Future upsertAll(List items) async { + for (final item in items) { + await upsert(item); + } + } +} + +final class _FakePendingShareActionRepository + implements PendingShareActionRepository { + final actions = []; + + @override + Future add(PendingShareAction action) async { + actions.add(action); + } + + @override + Future> listPending() async => actions; + + @override + Future markFailed(String id, DateTime attemptedAt) async {} + + @override + Future markSucceeded(String id, DateTime attemptedAt) async {} +} + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index b5a58ea..47f5e3e 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -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 = []; @@ -458,3 +592,119 @@ final class _FakeProgramRepository implements ProgramRepository { @override Future saveExercise(ProgramExercise exercise) async {} } + +final class _FakeAuthTokenStore implements AuthTokenStore { + _FakeAuthTokenStore([this.token]); + + String? token; + + @override + Future clearToken() async { + token = null; + } + + @override + Future readToken() async => token; + + @override + Future saveToken(String token, DateTime expiresAt) async { + this.token = token; + } +} + +final class _FakeRemoteShareApi implements RemoteShareApi { + var sendCalls = 0; + ShareResourceType? lastResourceType; + List lastRecipientEmails = const []; + + @override + Future 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 declineShare(String shareId, String token) async {} + + @override + Future> fetchInbox(String token) async => const []; + + @override + Future revokeShare(String shareId, String token) async {} + + @override + Future sendShare({ + required ShareResourceType resourceType, + required Map payload, + required List 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 findByShareId(String shareId) async => null; + + @override + Future> listAll() async => const []; + + @override + Future markStatus( + String shareId, + ShareInboxStatus status, + DateTime respondedAt, + ) async {} + + @override + Future upsert(ShareInboxItem item) async {} + + @override + Future upsertAll(List items) async {} +} + +final class _FakePendingShareActionRepository + implements PendingShareActionRepository { + @override + Future add(PendingShareAction action) async {} + + @override + Future> listPending() async => const []; + + @override + Future markFailed(String id, DateTime attemptedAt) async {} + + @override + Future markSucceeded(String id, DateTime attemptedAt) async {} +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + @override + Future applyRemoteItem(RemoteSyncedItem item) async => true; + + @override + Future> listPendingChanges() async => const []; + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async {} +} diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart new file mode 100644 index 0000000..6d68f95 --- /dev/null +++ b/test/presentation/share_inbox_screen_test.dart @@ -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 clearToken() async { + token = null; + } + + @override + Future readToken() async => token; + + @override + Future saveToken(String token, DateTime expiresAt) async { + this.token = token; + } +} + +final class _FakeRemoteShareApi implements RemoteShareApi { + List inboxItems = const []; + var acceptCalls = 0; + var declineCalls = 0; + + @override + Future 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 declineShare(String shareId, String token) async { + declineCalls += 1; + _mark(shareId, ShareInboxStatus.declined); + } + + @override + Future> fetchInbox(String token) async => inboxItems; + + @override + Future revokeShare(String shareId, String token) async { + _mark(shareId, ShareInboxStatus.revoked); + } + + @override + Future sendShare({ + required ShareResourceType resourceType, + required Map payload, + required List 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 = []; + + @override + Future findByShareId(String shareId) async { + return items.cast().firstWhere( + (item) => item?.shareId == shareId, + orElse: () => null, + ); + } + + @override + Future> listAll() async => List.of(items); + + @override + Future 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 upsert(ShareInboxItem item) async { + items.removeWhere((existing) => existing.shareId == item.shareId); + items.add(item); + } + + @override + Future upsertAll(List items) async { + for (final item in items) { + await upsert(item); + } + } +} + +final class _FakePendingShareActionRepository + implements PendingShareActionRepository { + @override + Future add(PendingShareAction action) async {} + + @override + Future> listPending() async => const []; + + @override + Future markFailed(String id, DateTime attemptedAt) async {} + + @override + Future markSucceeded(String id, DateTime attemptedAt) async {} +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + @override + Future applyRemoteItem(RemoteSyncedItem item) async => true; + + @override + Future> listPendingChanges() async => const []; + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async {} +} + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 8f1b72f..3d9027c 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -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 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 clearToken() async { + token = null; + } + + @override + Future readToken() async => token; + + @override + Future saveToken(String token, DateTime expiresAt) async { + this.token = token; + } +} + +final class _FakeRemoteShareApi implements RemoteShareApi { + ShareResourceType? lastResourceType; + List lastRecipientEmails = const []; + + @override + Future 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 declineShare(String shareId, String token) async {} + + @override + Future> fetchInbox(String token) async => const []; + + @override + Future revokeShare(String shareId, String token) async {} + + @override + Future sendShare({ + required ShareResourceType resourceType, + required Map payload, + required List 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 findByShareId(String shareId) async => null; + + @override + Future> listAll() async => const []; + + @override + Future markStatus( + String shareId, + ShareInboxStatus status, + DateTime respondedAt, + ) async {} + + @override + Future upsert(ShareInboxItem item) async {} + + @override + Future upsertAll(List items) async {} +} + +final class _FakePendingShareActionRepository + implements PendingShareActionRepository { + @override + Future add(PendingShareAction action) async {} + + @override + Future> listPending() async => const []; + + @override + Future markFailed(String id, DateTime attemptedAt) async {} + + @override + Future markSucceeded(String id, DateTime attemptedAt) async {} +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + @override + Future applyRemoteItem(RemoteSyncedItem item) async => true; + + @override + Future> listPendingChanges() async => const []; + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async {} +}