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

Ajoute l'écran de partage sortant (presentation/share_screen.dart) et
la boîte de réception des partages (presentation/share_inbox_screen.dart),
avec les points d'entrée sur profile_screen.dart, home_screen.dart,
program_screen.dart et workout_template_screen.dart. flutter analyze
propre (mêmes infos préexistantes), 132/132 tests verts, build APK
debug validé. Dernier ticket de développement du chantier "Ajouter
les features serveur au client" (#63) — il ne reste que la QA finale
(#70).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:23:14 +02:00
parent eee4348b52
commit d16a017743
11 changed files with 1598 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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