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 {} +}