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:
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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});
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
271
lib/presentation/share_inbox_screen.dart
Normal file
271
lib/presentation/share_inbox_screen.dart
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../application/application.dart';
|
||||||
|
import '../domain/domain.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
final class ShareInboxScreen extends StatefulWidget {
|
||||||
|
const ShareInboxScreen({required this.shareUseCases, super.key});
|
||||||
|
|
||||||
|
final ShareUseCases shareUseCases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareInboxScreen> createState() => _ShareInboxScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||||
|
late Future<List<ShareInboxItem>> _items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_items = widget.shareUseCases.refreshInbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Partages reçus')),
|
||||||
|
body: FutureBuilder<List<ShareInboxItem>>(
|
||||||
|
future: _items,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final items = snapshot.data ?? const <ShareInboxItem>[];
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const _EmptyInbox();
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _reload,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _ShareInboxTile(
|
||||||
|
item: item,
|
||||||
|
onAccept: item.status == ShareInboxStatus.pending
|
||||||
|
? () => _accept(item)
|
||||||
|
: null,
|
||||||
|
onDecline: item.status == ShareInboxStatus.pending
|
||||||
|
? () => _decline(item)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemCount: items.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reload() async {
|
||||||
|
setState(() {
|
||||||
|
_items = widget.shareUseCases.refreshInbox();
|
||||||
|
});
|
||||||
|
await _items;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept(ShareInboxItem item) async {
|
||||||
|
await widget.shareUseCases.acceptShare(item.shareId);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _reload();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(_acceptedMessage(item.resourceType))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decline(ShareInboxItem item) async {
|
||||||
|
await widget.shareUseCases.declineShare(item.shareId);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _reload();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Partage refusé.')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _ShareInboxTile extends StatelessWidget {
|
||||||
|
const _ShareInboxTile({
|
||||||
|
required this.item,
|
||||||
|
required this.onAccept,
|
||||||
|
required this.onDecline,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ShareInboxItem item;
|
||||||
|
final VoidCallback? onAccept;
|
||||||
|
final VoidCallback? onDecline;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final summary = _summaryFromPayload(item);
|
||||||
|
return CourtBlazerAccentPanel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
item.resourceType == ShareResourceType.program
|
||||||
|
? Icons.list_alt
|
||||||
|
: Icons.event_note,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_resourceTitle(item.resourceType),
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
summary.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Envoyé par ${item.senderUserId}'),
|
||||||
|
if (summary.detail.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(summary.detail),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: Text(_statusLabel(item.status)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (item.status == ShareInboxStatus.pending) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onDecline,
|
||||||
|
child: const Text('Refuser'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: onAccept,
|
||||||
|
child: const Text('Accepter'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _EmptyInbox extends StatelessWidget {
|
||||||
|
const _EmptyInbox();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Aucun partage reçu', textAlign: TextAlign.center),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Les programmes et séances qu'on t'envoie apparaîtront ici.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _InboxSummary {
|
||||||
|
const _InboxSummary({required this.name, required this.detail});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
|
||||||
|
try {
|
||||||
|
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
|
||||||
|
final name = payload['name'] as String? ?? 'Partage GameTime';
|
||||||
|
return switch (item.resourceType) {
|
||||||
|
ShareResourceType.program => _InboxSummary(
|
||||||
|
name: name,
|
||||||
|
detail: _programDetail(payload),
|
||||||
|
),
|
||||||
|
ShareResourceType.workoutTemplate => _InboxSummary(
|
||||||
|
name: name,
|
||||||
|
detail: _templateDetail(payload),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} on Object {
|
||||||
|
return const _InboxSummary(name: 'Partage GameTime', detail: '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _programDetail(Map<String, dynamic> payload) {
|
||||||
|
final exercises = payload['exercises'] as List<dynamic>? ?? const [];
|
||||||
|
final sets = exercises.fold<int>(0, (total, raw) {
|
||||||
|
final exercise = raw as Map<String, dynamic>;
|
||||||
|
return total + (exercise['setsCount'] as int? ?? 0);
|
||||||
|
});
|
||||||
|
return '${exercises.length} exercice${exercises.length > 1 ? 's' : ''} · '
|
||||||
|
'$sets série${sets > 1 ? 's' : ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _templateDetail(Map<String, dynamic> payload) {
|
||||||
|
final programs = payload['programs'] as List<dynamic>? ?? const [];
|
||||||
|
var exerciseCount = 0;
|
||||||
|
for (final raw in programs) {
|
||||||
|
final program = raw as Map<String, dynamic>;
|
||||||
|
final snapshotJson = program['programSnapshotJson'] as String?;
|
||||||
|
if (snapshotJson == null) continue;
|
||||||
|
final snapshot = jsonDecode(snapshotJson) as Map<String, dynamic>;
|
||||||
|
exerciseCount +=
|
||||||
|
(snapshot['exercises'] as List<dynamic>? ?? const []).length;
|
||||||
|
}
|
||||||
|
return '${programs.length} programme${programs.length > 1 ? 's' : ''} · '
|
||||||
|
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resourceTitle(ShareResourceType type) {
|
||||||
|
return switch (type) {
|
||||||
|
ShareResourceType.program => 'Programme',
|
||||||
|
ShareResourceType.workoutTemplate => 'Séance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(ShareInboxStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
ShareInboxStatus.pending => 'À traiter',
|
||||||
|
ShareInboxStatus.accepted => 'Accepté',
|
||||||
|
ShareInboxStatus.declined => 'Refusé',
|
||||||
|
ShareInboxStatus.revoked => 'Retiré',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _acceptedMessage(ShareResourceType type) {
|
||||||
|
return switch (type) {
|
||||||
|
ShareResourceType.program => 'Programme ajouté.',
|
||||||
|
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
|
||||||
|
};
|
||||||
|
}
|
||||||
260
lib/presentation/share_screen.dart
Normal file
260
lib/presentation/share_screen.dart
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../application/application.dart';
|
||||||
|
import '../domain/domain.dart';
|
||||||
|
import 'profile_screen.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
final class ShareFormScreen extends StatefulWidget {
|
||||||
|
const ShareFormScreen({
|
||||||
|
required this.shareUseCases,
|
||||||
|
required this.resourceType,
|
||||||
|
required this.localResourceId,
|
||||||
|
required this.resourceName,
|
||||||
|
required this.resourceSummary,
|
||||||
|
this.authUseCases,
|
||||||
|
this.syncUseCases,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ShareUseCases shareUseCases;
|
||||||
|
final AuthUseCases? authUseCases;
|
||||||
|
final SyncUseCases? syncUseCases;
|
||||||
|
final ShareResourceType resourceType;
|
||||||
|
final String localResourceId;
|
||||||
|
final String resourceName;
|
||||||
|
final String resourceSummary;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareFormScreen> createState() => _ShareFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _ShareFormScreenState extends State<ShareFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailsController = TextEditingController();
|
||||||
|
var _sending = false;
|
||||||
|
String? _message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(_shareTitle(widget.resourceType))),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
CourtBlazerAccentPanel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_resourceTitle(widget.resourceType)} à partager',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(widget.resourceName),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(widget.resourceSummary),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _emailsController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Emails destinataires',
|
||||||
|
helperText: 'Sépare plusieurs emails par une virgule.',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
validator: _emailsValidator,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_message != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(_message!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _sending ? null : _send,
|
||||||
|
icon: _sending
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.ios_share),
|
||||||
|
label: const Text('Envoyer le partage'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _send() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() {
|
||||||
|
_sending = true;
|
||||||
|
_message = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await widget.shareUseCases.sendShare(
|
||||||
|
resourceType: widget.resourceType,
|
||||||
|
localResourceId: widget.localResourceId,
|
||||||
|
recipientEmails: _parseEmails(_emailsController.text),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_sending = false;
|
||||||
|
_message = _shareResultMessage(result);
|
||||||
|
});
|
||||||
|
if (result.status == ShareSendStatus.notConnected) {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ShareAccountRequiredScreen(
|
||||||
|
resourceType: widget.resourceType,
|
||||||
|
authUseCases: widget.authUseCases,
|
||||||
|
syncUseCases: widget.syncUseCases,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on DomainException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_sending = false;
|
||||||
|
_message = 'Partage indisponible pour le moment.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||||
|
const ShareAccountRequiredScreen({
|
||||||
|
required this.resourceType,
|
||||||
|
this.authUseCases,
|
||||||
|
this.syncUseCases,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ShareResourceType resourceType;
|
||||||
|
final AuthUseCases? authUseCases;
|
||||||
|
final SyncUseCases? syncUseCases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final targetLabel = switch (resourceType) {
|
||||||
|
ShareResourceType.program => 'ce programme',
|
||||||
|
ShareResourceType.workoutTemplate => 'cette séance',
|
||||||
|
};
|
||||||
|
final canOpenAuth = authUseCases != null && syncUseCases != null;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Compte requis pour partager')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
CourtBlazerAccentPanel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Compte requis pour partager',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Connecte-toi pour envoyer $targetLabel à un autre compte '
|
||||||
|
'GameTime. Le reste de l’app reste utilisable sans compte.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: canOpenAuth
|
||||||
|
? () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => LoginScreen(
|
||||||
|
authUseCases: authUseCases!,
|
||||||
|
syncUseCases: syncUseCases!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: const Text('Se connecter'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: canOpenAuth
|
||||||
|
? () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RegisterScreen(
|
||||||
|
authUseCases: authUseCases!,
|
||||||
|
syncUseCases: syncUseCases!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: const Text('Créer un compte'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _emailsValidator(String? value) {
|
||||||
|
final emails = _parseEmails(value ?? '');
|
||||||
|
if (emails.isEmpty) {
|
||||||
|
return "Saisis l'email du destinataire.";
|
||||||
|
}
|
||||||
|
final invalid = emails.any(
|
||||||
|
(email) => !RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email),
|
||||||
|
);
|
||||||
|
if (invalid) {
|
||||||
|
return "Saisis l'email du destinataire.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseEmails(String value) {
|
||||||
|
return value
|
||||||
|
.split(RegExp(r'[,;\n]'))
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.where((email) => email.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shareResultMessage(ShareSendResult result) {
|
||||||
|
return switch (result.status) {
|
||||||
|
ShareSendStatus.notConnected => 'Compte requis pour partager.',
|
||||||
|
ShareSendStatus.queued => 'Partage enregistré. Envoi dès que possible.',
|
||||||
|
ShareSendStatus.sent when result.unresolvedEmails.isNotEmpty =>
|
||||||
|
'Partage envoyé. Non trouvés : ${result.unresolvedEmails.join(', ')}.',
|
||||||
|
ShareSendStatus.sent => 'Partage envoyé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shareTitle(ShareResourceType type) {
|
||||||
|
return switch (type) {
|
||||||
|
ShareResourceType.program => 'Partager le programme',
|
||||||
|
ShareResourceType.workoutTemplate => 'Partager la séance',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resourceTitle(ShareResourceType type) {
|
||||||
|
return switch (type) {
|
||||||
|
ShareResourceType.program => 'Programme',
|
||||||
|
ShareResourceType.workoutTemplate => 'Séance',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../application/application.dart';
|
import '../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(
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
}
|
||||||
|
|||||||
337
test/presentation/share_inbox_screen_test.dart
Normal file
337
test/presentation/share_inbox_screen_test.dart
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gametime/application/application.dart';
|
||||||
|
import 'package:gametime/domain/domain.dart';
|
||||||
|
import 'package:gametime/presentation/share_inbox_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('la boîte de réception affiche les partages et les actions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi()
|
||||||
|
..inboxItems = [
|
||||||
|
_shareItem(
|
||||||
|
shareId: 'share-program',
|
||||||
|
resourceType: ShareResourceType.program,
|
||||||
|
name: 'Programme tirs',
|
||||||
|
status: ShareInboxStatus.pending,
|
||||||
|
),
|
||||||
|
_shareItem(
|
||||||
|
shareId: 'share-template',
|
||||||
|
resourceType: ShareResourceType.workoutTemplate,
|
||||||
|
name: 'Séance match',
|
||||||
|
status: ShareInboxStatus.pending,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final inboxRepository = _FakeShareInboxRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ShareInboxScreen(
|
||||||
|
shareUseCases: _shareUseCases(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Programme tirs'), findsOneWidget);
|
||||||
|
expect(find.text('Séance match'), findsOneWidget);
|
||||||
|
expect(find.text('Accepter'), findsNWidgets(2));
|
||||||
|
expect(find.text('Refuser'), findsNWidgets(2));
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Accepter').first);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(remoteShareApi.acceptCalls, 1);
|
||||||
|
expect(
|
||||||
|
inboxRepository.items
|
||||||
|
.firstWhere((item) => item.shareId == 'share-program')
|
||||||
|
.status,
|
||||||
|
ShareInboxStatus.accepted,
|
||||||
|
);
|
||||||
|
expect(find.text('Programme ajouté.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(OutlinedButton, 'Refuser').first);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(remoteShareApi.declineCalls, 1);
|
||||||
|
expect(
|
||||||
|
inboxRepository.items
|
||||||
|
.firstWhere((item) => item.shareId == 'share-template')
|
||||||
|
.status,
|
||||||
|
ShareInboxStatus.declined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareUseCases _shareUseCases({
|
||||||
|
required _FakeRemoteShareApi remoteShareApi,
|
||||||
|
required _FakeShareInboxRepository inboxRepository,
|
||||||
|
}) {
|
||||||
|
return ShareUseCases(
|
||||||
|
tokenStore: _FakeAuthTokenStore('token-1'),
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
pendingActionRepository: _FakePendingShareActionRepository(),
|
||||||
|
localChanges: _FakeLocalSyncChangeRepository(),
|
||||||
|
programRepository: _FakeProgramRepository(),
|
||||||
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareInboxItem _shareItem({
|
||||||
|
required String shareId,
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required String name,
|
||||||
|
required ShareInboxStatus status,
|
||||||
|
}) {
|
||||||
|
final payload = switch (resourceType) {
|
||||||
|
ShareResourceType.program => {
|
||||||
|
'name': name,
|
||||||
|
'exercises': const [
|
||||||
|
{'setsCount': 3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ShareResourceType.workoutTemplate => {
|
||||||
|
'name': name,
|
||||||
|
'programs': [
|
||||||
|
{
|
||||||
|
'programSnapshotJson': jsonEncode({
|
||||||
|
'exercises': const [
|
||||||
|
{'id': 'exercise-1'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ShareInboxItem(
|
||||||
|
shareId: shareId,
|
||||||
|
senderUserId: 'Alex',
|
||||||
|
resourceType: resourceType,
|
||||||
|
payloadJson: jsonEncode(payload),
|
||||||
|
status: status,
|
||||||
|
createdAt: DateTime.utc(2026, 7, 17),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeClock implements Clock {
|
||||||
|
const _FakeClock(this.value);
|
||||||
|
|
||||||
|
final DateTime value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DateTime now() => value;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeIds implements IdGenerator {
|
||||||
|
var _next = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String newId() {
|
||||||
|
_next += 1;
|
||||||
|
return 'id-$_next';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeAuthTokenStore implements AuthTokenStore {
|
||||||
|
_FakeAuthTokenStore(this.token);
|
||||||
|
|
||||||
|
String? token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearToken() async {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readToken() async => token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveToken(String token, DateTime expiresAt) async {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
|
List<ShareInboxItem> inboxItems = const [];
|
||||||
|
var acceptCalls = 0;
|
||||||
|
var declineCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||||
|
acceptCalls += 1;
|
||||||
|
_mark(shareId, ShareInboxStatus.accepted);
|
||||||
|
return RemoteSyncedItem(
|
||||||
|
resourceType: SyncResourceType.program,
|
||||||
|
clientId: 'shared-program-1',
|
||||||
|
serverId: 'server-program-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'name': 'Programme tirs'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> declineShare(String shareId, String token) async {
|
||||||
|
declineCalls += 1;
|
||||||
|
_mark(shareId, ShareInboxStatus.declined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> fetchInbox(String token) async => inboxItems;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revokeShare(String shareId, String token) async {
|
||||||
|
_mark(shareId, ShareInboxStatus.revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteShareSendResult> sendShare({
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required Map<String, Object?> payload,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
return const RemoteShareSendResult(
|
||||||
|
shareId: 'share-1',
|
||||||
|
recipientUserIds: ['recipient-1'],
|
||||||
|
unresolvedEmails: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _mark(String shareId, ShareInboxStatus status) {
|
||||||
|
inboxItems = [
|
||||||
|
for (final item in inboxItems)
|
||||||
|
if (item.shareId == shareId)
|
||||||
|
item.copyWith(status: status, respondedAt: DateTime.utc(2026, 7, 17))
|
||||||
|
else
|
||||||
|
item,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||||
|
final items = <ShareInboxItem>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
||||||
|
return items.cast<ShareInboxItem?>().firstWhere(
|
||||||
|
(item) => item?.shareId == shareId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> listAll() async => List.of(items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markStatus(
|
||||||
|
String shareId,
|
||||||
|
ShareInboxStatus status,
|
||||||
|
DateTime respondedAt,
|
||||||
|
) async {
|
||||||
|
final index = items.indexWhere((item) => item.shareId == shareId);
|
||||||
|
if (index == -1) return;
|
||||||
|
items[index] = items[index].copyWith(
|
||||||
|
status: status,
|
||||||
|
respondedAt: respondedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsert(ShareInboxItem item) async {
|
||||||
|
items.removeWhere((existing) => existing.shareId == item.shareId);
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertAll(List<ShareInboxItem> items) async {
|
||||||
|
for (final item in items) {
|
||||||
|
await upsert(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakePendingShareActionRepository
|
||||||
|
implements PendingShareActionRepository {
|
||||||
|
@override
|
||||||
|
Future<void> add(PendingShareAction action) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PendingShareAction>> listPending() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markFailed(String id, DateTime attemptedAt) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeLocalSyncChangeRepository
|
||||||
|
implements LocalSyncChangeRepository {
|
||||||
|
@override
|
||||||
|
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markChangesSynced(
|
||||||
|
List<String> changeLogIds,
|
||||||
|
DateTime syncedAt,
|
||||||
|
) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeProgramRepository implements ProgramRepository {
|
||||||
|
@override
|
||||||
|
Future<Program?> findById(String id) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Program>> listActive() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(Program program) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeWorkoutTemplateRepository
|
||||||
|
implements WorkoutTemplateRepository {
|
||||||
|
@override
|
||||||
|
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceComposition(
|
||||||
|
WorkoutTemplate template,
|
||||||
|
DateTime deletedAt,
|
||||||
|
) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> save(WorkoutTemplate template) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||||||
|
}
|
||||||
@ -184,6 +184,61 @@ void main() {
|
|||||||
expect(templateRepository.saved.single.metadata.deletedAt, isNotNull);
|
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 {}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user