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:
@ -15,6 +15,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -37,6 +38,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -69,6 +71,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -111,6 +114,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -138,6 +142,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -164,6 +169,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -190,6 +196,7 @@ void main() {
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -204,6 +211,26 @@ void main() {
|
||||
|
||||
expect(find.textContaining('Dernière synchro :'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('partages reçus ouvre la boîte de réception', (tester) async {
|
||||
final harness = _AuthHarness.connected();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Partages reçus'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Aucun partage reçu'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
final class _AuthHarness {
|
||||
@ -214,7 +241,12 @@ final class _AuthHarness {
|
||||
syncRemote = _FakeRemoteSyncApi(),
|
||||
syncMetadata = _FakeSyncMetadataRepository(),
|
||||
syncMappings = _FakeRemoteResourceMappingRepository(),
|
||||
syncChanges = _FakeLocalSyncChangeRepository() {
|
||||
syncChanges = _FakeLocalSyncChangeRepository(),
|
||||
shareRemote = _FakeRemoteShareApi(),
|
||||
shareInbox = _FakeShareInboxRepository(),
|
||||
pendingShares = _FakePendingShareActionRepository(),
|
||||
programRepository = _FakeProgramRepository(),
|
||||
templateRepository = _FakeWorkoutTemplateRepository() {
|
||||
useCases = AuthUseCases(
|
||||
tokenStore: tokenStore,
|
||||
accountRepository: accountRepository,
|
||||
@ -231,6 +263,17 @@ final class _AuthHarness {
|
||||
clock: const _FakeClock(),
|
||||
deviceId: 'device-1',
|
||||
);
|
||||
shareUseCases = ShareUseCases(
|
||||
tokenStore: tokenStore,
|
||||
remoteShareApi: shareRemote,
|
||||
inboxRepository: shareInbox,
|
||||
pendingActionRepository: pendingShares,
|
||||
localChanges: syncChanges,
|
||||
programRepository: programRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: const _FakeClock(),
|
||||
ids: _FakeIds(),
|
||||
);
|
||||
}
|
||||
|
||||
factory _AuthHarness.connected() {
|
||||
@ -255,8 +298,14 @@ final class _AuthHarness {
|
||||
final _FakeSyncMetadataRepository syncMetadata;
|
||||
final _FakeRemoteResourceMappingRepository syncMappings;
|
||||
final _FakeLocalSyncChangeRepository syncChanges;
|
||||
final _FakeRemoteShareApi shareRemote;
|
||||
final _FakeShareInboxRepository shareInbox;
|
||||
final _FakePendingShareActionRepository pendingShares;
|
||||
final _FakeProgramRepository programRepository;
|
||||
final _FakeWorkoutTemplateRepository templateRepository;
|
||||
late final AuthUseCases useCases;
|
||||
late final SyncUseCases syncUseCases;
|
||||
late final ShareUseCases shareUseCases;
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
@ -429,3 +478,144 @@ final class _FakeLocalSyncChangeRepository
|
||||
DateTime syncedAt,
|
||||
) async {}
|
||||
}
|
||||
|
||||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
@override
|
||||
Future<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);
|
||||
},
|
||||
);
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('partager une séance ouvre le formulaire de partage', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||||
_workoutTemplate(),
|
||||
]);
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutTemplateFormScreen(
|
||||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
programUseCases: ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
shareUseCases: _shareUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
remoteShareApi: remoteShareApi,
|
||||
),
|
||||
template: templateRepository.templates.single,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Partager'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Partager la séance'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Emails destinataires'),
|
||||
'coach@example.com',
|
||||
);
|
||||
await tester.tap(find.text('Envoyer le partage'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(remoteShareApi.lastResourceType, ShareResourceType.workoutTemplate);
|
||||
expect(remoteShareApi.lastRecipientEmails, ['coach@example.com']);
|
||||
});
|
||||
}
|
||||
|
||||
WorkoutTemplate _workoutTemplate() {
|
||||
@ -466,3 +521,135 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<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