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

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