fix(online): validation client online offline-first (ticket #70)

Corrige l'acceptation de partage hors-ligne (acceptShare,
_acceptCachedShareAndQueueAck, _importSharedPayload dans
application/use_cases.dart) : crée bien une copie locale indépendante
avec de nouveaux IDs avant de mettre l'accusé serveur en file
d'attente. Ajuste app_bootstrap.dart, profile_screen.dart et
share_screen.dart en conséquence. flutter pub get OK, dart format
appliqué (aucun changement), analyze propre (mêmes infos
préexistantes), 137/137 tests verts, build APK debug validé. Dernier
ticket du chantier "Ajouter les features serveur au client" (#63,
tickets #64 à #70 tous terminés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 09:39:40 +02:00
parent c00f61ef06
commit b6f048ad64
10 changed files with 470 additions and 13 deletions

View File

@ -171,6 +171,7 @@ final class AppBootstrap implements AppDependencies {
templateRepository: templateRepository, templateRepository: templateRepository,
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId,
), ),
syncGateway: const NoOpSyncGateway(), syncGateway: const NoOpSyncGateway(),
); );

View File

@ -250,6 +250,7 @@ final class ShareUseCases {
required this.templateRepository, required this.templateRepository,
required this.clock, required this.clock,
required this.ids, required this.ids,
required this.originDeviceId,
}); });
final AuthTokenStore tokenStore; final AuthTokenStore tokenStore;
@ -261,6 +262,7 @@ final class ShareUseCases {
final WorkoutTemplateRepository templateRepository; final WorkoutTemplateRepository templateRepository;
final Clock clock; final Clock clock;
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId;
Future<ShareSendResult> sendShare({ Future<ShareSendResult> sendShare({
required ShareResourceType resourceType, required ShareResourceType resourceType,
@ -322,8 +324,10 @@ final class ShareUseCases {
} }
Future<void> acceptShare(String shareId) async { Future<void> acceptShare(String shareId) async {
final cachedItem = await inboxRepository.findByShareId(shareId);
final token = await tokenStore.readToken(); final token = await tokenStore.readToken();
if (token == null) { if (token == null) {
await _acceptCachedShareAndQueueAck(shareId, cachedItem);
return; return;
} }
try { try {
@ -335,12 +339,7 @@ final class ShareUseCases {
clock.now(), clock.now(),
); );
} catch (_) { } catch (_) {
await pendingActionRepository.add( await _acceptCachedShareAndQueueAck(shareId, cachedItem);
_pendingShareAction(
actionType: PendingShareActionType.accept,
shareId: shareId,
),
);
} }
} }
@ -488,6 +487,257 @@ final class ShareUseCases {
return; return;
} }
} }
Future<void> _acceptCachedShareAndQueueAck(
String shareId,
ShareInboxItem? item,
) async {
if (item != null && item.status == ShareInboxStatus.pending) {
await _importSharedPayload(item);
await inboxRepository.markStatus(
shareId,
ShareInboxStatus.accepted,
clock.now(),
);
}
await pendingActionRepository.add(
_pendingShareAction(
actionType: PendingShareActionType.accept,
shareId: shareId,
),
);
}
Future<void> _importSharedPayload(ShareInboxItem item) async {
final payload = _jsonObject(item.payloadJson);
final now = clock.now();
switch (item.resourceType) {
case ShareResourceType.program:
await programRepository.save(
_programCopyFromSharePayload(payload, now),
);
return;
case ShareResourceType.workoutTemplate:
await templateRepository.save(
_workoutTemplateCopyFromSharePayload(payload, now),
);
return;
}
}
Program _programCopyFromSharePayload(
Map<String, Object?> payload,
DateTime now,
) {
final programId = ids.newId();
final programMetadata = EntityMetadata(
id: programId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
return Program(
metadata: programMetadata,
name: _stringFromSharePayload(payload, 'name', 'Programme partagé'),
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
exercises: _programExerciseCopiesFromSharePayload(
payload['exercises'],
programId,
now,
),
);
}
List<ProgramExercise> _programExerciseCopiesFromSharePayload(
Object? value,
String programId,
DateTime now,
) {
if (value is! List) {
return const [];
}
final exercises = <ProgramExercise>[];
for (var index = 0; index < value.length; index++) {
final raw = value[index];
if (raw is! Map) {
continue;
}
final map = Map<String, Object?>.from(raw);
exercises.add(
ProgramExercise(
metadata: EntityMetadata(
id: ids.newId(),
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
programId: programId,
sourceExerciseId: null,
position: map['position'] as int? ?? index,
exerciseNameSnapshot: _stringFromSharePayload(
map,
'exerciseNameSnapshot',
'Exercice partagé',
),
exerciseDescriptionSnapshot:
map['exerciseDescriptionSnapshot'] as String?,
exerciseImageMediaIdSnapshot:
map['exerciseImageMediaIdSnapshot'] as String?,
exerciseImageMediaIdsSnapshot: _stringListFromSharePayload(
map['exerciseImageMediaIdsSnapshot'] ??
map['imageMediaIdsSnapshot'],
),
exerciseVideoMediaIdSnapshot:
map['exerciseVideoMediaIdSnapshot'] as String?,
exerciseStepsSnapshot: _exerciseStepsFromSnapshot(
map['exerciseStepsSnapshot'],
),
exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
availableTimeSnapshot: map['availableTimeSnapshot'] == true,
availableRepsSnapshot: map['availableRepsSnapshot'] == true,
availableScoreSnapshot: map['availableScoreSnapshot'] == true,
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
map['scoreInputModeSnapshot'],
),
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
setsCount: map['setsCount'] as int? ?? 1,
timeEnabled: map['timeEnabled'] == true,
repsEnabled: map['repsEnabled'] == true,
scoreEnabled: map['scoreEnabled'] == true,
targetTimeSeconds: map['targetTimeSeconds'] as int?,
targetReps: map['targetReps'] as int?,
targetScore: (map['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs: map['targetScoreTimeMs'] as int?,
restSecondsOverride: map['restSecondsOverride'] as int?,
),
);
}
return exercises;
}
WorkoutTemplate _workoutTemplateCopyFromSharePayload(
Map<String, Object?> payload,
DateTime now,
) {
final templateId = ids.newId();
final programIdBySharedId = <String, String>{};
final programs = _workoutTemplateProgramCopiesFromSharePayload(
payload['programs'],
templateId,
programIdBySharedId,
now,
);
return WorkoutTemplate(
metadata: EntityMetadata(
id: templateId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
name: _stringFromSharePayload(payload, 'name', 'Séance partagée'),
lastStartedAt: null,
programs: programs,
overrides: _workoutTemplateOverrideCopiesFromSharePayload(
payload['overrides'],
programIdBySharedId,
now,
),
);
}
List<WorkoutTemplateProgram> _workoutTemplateProgramCopiesFromSharePayload(
Object? value,
String templateId,
Map<String, String> programIdBySharedId,
DateTime now,
) {
if (value is! List) {
return const [];
}
final programs = <WorkoutTemplateProgram>[];
for (var index = 0; index < value.length; index++) {
final raw = value[index];
if (raw is! Map) {
continue;
}
final map = Map<String, Object?>.from(raw);
final copiedId = ids.newId();
final sharedId = map['id'] as String?;
if (sharedId != null) {
programIdBySharedId[sharedId] = copiedId;
}
programs.add(
WorkoutTemplateProgram(
metadata: EntityMetadata(
id: copiedId,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
workoutTemplateId: templateId,
sourceProgramId: null,
position: map['position'] as int? ?? index,
programNameSnapshot: _stringFromSharePayload(
map,
'programNameSnapshot',
'Programme partagé',
),
defaultRestSecondsSnapshot:
map['defaultRestSecondsSnapshot'] as int? ?? 0,
programSnapshotJson:
map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
),
);
}
return programs;
}
List<WorkoutTemplateExerciseOverride>
_workoutTemplateOverrideCopiesFromSharePayload(
Object? value,
Map<String, String> programIdBySharedId,
DateTime now,
) {
if (value is! List) {
return const [];
}
final overrides = <WorkoutTemplateExerciseOverride>[];
for (final raw in value) {
if (raw is! Map) {
continue;
}
final map = Map<String, Object?>.from(raw);
final sharedProgramId = map['workoutTemplateProgramId'] as String?;
final copiedProgramId = programIdBySharedId[sharedProgramId];
if (copiedProgramId == null) {
continue;
}
final snapshotProgramExerciseId = map['snapshotProgramExerciseId'];
if (snapshotProgramExerciseId is! String ||
snapshotProgramExerciseId.trim().isEmpty) {
continue;
}
overrides.add(
WorkoutTemplateExerciseOverride(
metadata: EntityMetadata(
id: ids.newId(),
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
),
workoutTemplateProgramId: copiedProgramId,
snapshotProgramExerciseId: snapshotProgramExerciseId,
setsCountOverride: map['setsCountOverride'] as int?,
targetTimeSecondsOverride: map['targetTimeSecondsOverride'] as int?,
targetRepsOverride: map['targetRepsOverride'] as int?,
targetScoreOverride: (map['targetScoreOverride'] as num?)?.toDouble(),
targetScoreTimeMsOverride: map['targetScoreTimeMsOverride'] as int?,
),
);
}
return overrides;
}
} }
final class ExerciseUseCases { final class ExerciseUseCases {
@ -2965,6 +3215,22 @@ Map<String, Object?> _jsonObject(String? json) {
return const {}; return const {};
} }
String _stringFromSharePayload(
Map<String, Object?> payload,
String key,
String fallback,
) {
final value = payload[key];
return value is String && value.trim().isNotEmpty ? value : fallback;
}
List<String> _stringListFromSharePayload(Object? value) {
if (value is! List) {
return const [];
}
return value.whereType<String>().toList(growable: false);
}
List<String> _jsonStringList(String? json) { List<String> _jsonStringList(String? json) {
if (json == null) { if (json == null) {
return const []; return const [];

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
@ -64,6 +66,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
builder: (context) => LoginScreen( builder: (context) => LoginScreen(
authUseCases: widget.authUseCases, authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
), ),
), ),
); );
@ -83,6 +86,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
builder: (context) => RegisterScreen( builder: (context) => RegisterScreen(
authUseCases: widget.authUseCases, authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
), ),
), ),
); );
@ -420,11 +424,13 @@ final class LoginScreen extends StatefulWidget {
const LoginScreen({ const LoginScreen({
required this.authUseCases, required this.authUseCases,
required this.syncUseCases, required this.syncUseCases,
this.shareUseCases,
super.key, super.key,
}); });
final AuthUseCases authUseCases; final AuthUseCases authUseCases;
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
final ShareUseCases? shareUseCases;
@override @override
State<LoginScreen> createState() => _LoginScreenState(); State<LoginScreen> createState() => _LoginScreenState();
@ -486,6 +492,7 @@ final class _LoginScreenState extends State<LoginScreen> {
builder: (context) => RegisterScreen( builder: (context) => RegisterScreen(
authUseCases: widget.authUseCases, authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
), ),
), ),
); );
@ -515,6 +522,11 @@ final class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text, email: _emailController.text,
password: _passwordController.text, password: _passwordController.text,
); );
unawaited(widget.syncUseCases.synchronize(manual: false));
final shareUseCases = widget.shareUseCases;
if (shareUseCases != null) {
unawaited(shareUseCases.processPendingShareActions());
}
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
} on RemoteAuthException catch (error) { } on RemoteAuthException catch (error) {
@ -537,11 +549,13 @@ final class RegisterScreen extends StatefulWidget {
const RegisterScreen({ const RegisterScreen({
required this.authUseCases, required this.authUseCases,
required this.syncUseCases, required this.syncUseCases,
this.shareUseCases,
super.key, super.key,
}); });
final AuthUseCases authUseCases; final AuthUseCases authUseCases;
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
final ShareUseCases? shareUseCases;
@override @override
State<RegisterScreen> createState() => _RegisterScreenState(); State<RegisterScreen> createState() => _RegisterScreenState();
@ -628,6 +642,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
builder: (context) => LoginScreen( builder: (context) => LoginScreen(
authUseCases: widget.authUseCases, authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
), ),
), ),
); );
@ -660,6 +675,11 @@ final class _RegisterScreenState extends State<RegisterScreen> {
password: _passwordController.text, password: _passwordController.text,
displayName: displayName.isEmpty ? null : displayName, displayName: displayName.isEmpty ? null : displayName,
); );
unawaited(widget.syncUseCases.synchronize(manual: false));
final shareUseCases = widget.shareUseCases;
if (shareUseCases != null) {
unawaited(shareUseCases.processPendingShareActions());
}
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
} on RemoteAuthException catch (error) { } on RemoteAuthException catch (error) {

View File

@ -118,6 +118,7 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ShareAccountRequiredScreen( builder: (context) => ShareAccountRequiredScreen(
resourceType: widget.resourceType, resourceType: widget.resourceType,
shareUseCases: widget.shareUseCases,
authUseCases: widget.authUseCases, authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases, syncUseCases: widget.syncUseCases,
), ),
@ -137,12 +138,14 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
final class ShareAccountRequiredScreen extends StatelessWidget { final class ShareAccountRequiredScreen extends StatelessWidget {
const ShareAccountRequiredScreen({ const ShareAccountRequiredScreen({
required this.resourceType, required this.resourceType,
this.shareUseCases,
this.authUseCases, this.authUseCases,
this.syncUseCases, this.syncUseCases,
super.key, super.key,
}); });
final ShareResourceType resourceType; final ShareResourceType resourceType;
final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases; final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases; final SyncUseCases? syncUseCases;
@ -179,6 +182,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
builder: (context) => LoginScreen( builder: (context) => LoginScreen(
authUseCases: authUseCases!, authUseCases: authUseCases!,
syncUseCases: syncUseCases!, syncUseCases: syncUseCases!,
shareUseCases: shareUseCases,
), ),
), ),
) )
@ -193,6 +197,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
builder: (context) => RegisterScreen( builder: (context) => RegisterScreen(
authUseCases: authUseCases!, authUseCases: authUseCases!,
syncUseCases: syncUseCases!, syncUseCases: syncUseCases!,
shareUseCases: shareUseCases,
), ),
), ),
) )

View File

@ -1434,6 +1434,50 @@ void main() {
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
}); });
test(
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
() async {
final remoteShareApi = _FakeRemoteShareApi();
final pendingRepository = _FakePendingShareActionRepository();
final programRepository = _FakeProgramRepository();
final inboxRepository = _FakeShareInboxRepository()
..items.add(
_shareInboxItem(
status: ShareInboxStatus.pending,
payloadJson: jsonEncode(_sharedProgramPayload()),
),
);
await _shareUseCase(
tokenStore: _FakeAuthTokenStore(),
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
pendingActionRepository: pendingRepository,
programRepository: programRepository,
).acceptShare('share-1');
expect(remoteShareApi.acceptCalls, 0);
expect(
pendingRepository.actions.single.actionType,
PendingShareActionType.accept,
);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
final imported = programRepository.programs.single;
expect(imported.metadata.id, isNot('program-from-sender'));
expect(imported.metadata.originDeviceId, 'device-1');
expect(imported.name, 'Programme partagé');
expect(
imported.exercises.single.metadata.id,
isNot('exercise-from-sender'),
);
expect(imported.exercises.single.sourceExerciseId, isNull);
expect(
imported.exercises.single.exerciseStepsSnapshot.single.name,
'Sprint',
);
},
);
test('ShareUseCases refreshInbox updates the local cache', () async { test('ShareUseCases refreshInbox updates the local cache', () async {
final remoteShareApi = _FakeRemoteShareApi() final remoteShareApi = _FakeRemoteShareApi()
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)]; ..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
@ -1866,6 +1910,7 @@ ShareUseCases _shareUseCase({
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
); );
} }
@ -2027,17 +2072,58 @@ Program _shareableProgram() {
ShareInboxItem _shareInboxItem({ ShareInboxItem _shareInboxItem({
String shareId = 'share-1', String shareId = 'share-1',
required ShareInboxStatus status, required ShareInboxStatus status,
String payloadJson = '{"name":"Programme partagé"}',
}) { }) {
return ShareInboxItem( return ShareInboxItem(
shareId: shareId, shareId: shareId,
senderUserId: 'sender-1', senderUserId: 'sender-1',
resourceType: ShareResourceType.program, resourceType: ShareResourceType.program,
payloadJson: '{"name":"Programme partagé"}', payloadJson: payloadJson,
status: status, status: status,
createdAt: DateTime.utc(2026, 7, 17, 12), createdAt: DateTime.utc(2026, 7, 17, 12),
); );
} }
Map<String, Object?> _sharedProgramPayload() {
return {
'id': 'program-from-sender',
'name': 'Programme partagé',
'defaultRestSeconds': 45,
'exercises': [
{
'id': 'exercise-from-sender',
'sourceExerciseId': 'source-exercise-from-sender',
'position': 0,
'exerciseNameSnapshot': 'Sprint',
'exerciseStepsSnapshot': [
{
'id': 'step-from-sender',
'position': 0,
'name': 'Sprint',
'type': 'time',
'defaultTargetValue': 20,
'hasScore': false,
'scoreInputMode': 'manual',
'scoreLabel': null,
'scoreUnit': null,
'defaultTargetScore': null,
'defaultTargetScoreTimeMs': null,
},
],
'availableTimeSnapshot': true,
'availableRepsSnapshot': false,
'availableScoreSnapshot': false,
'scoreInputModeSnapshot': 'manual',
'setsCount': 2,
'timeEnabled': true,
'repsEnabled': false,
'scoreEnabled': false,
'targetTimeSeconds': 20,
},
],
};
}
RemoteSyncedItem _remoteSharedProgramItem() { RemoteSyncedItem _remoteSharedProgramItem() {
return RemoteSyncedItem( return RemoteSyncedItem(
resourceType: SyncResourceType.program, resourceType: SyncResourceType.program,

View File

@ -156,6 +156,7 @@ final class _FakeBootstrap implements AppDependencies {
templateRepository: _FakeWorkoutTemplateRepository(), templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
), ),
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository, sessionRepository: activeRepository,

View File

@ -93,6 +93,7 @@ void main() {
expect(find.text('alex@example.com'), findsOneWidget); expect(find.text('alex@example.com'), findsOneWidget);
expect(find.text('Synchronisation'), findsOneWidget); expect(find.text('Synchronisation'), findsOneWidget);
expect(find.text('Partages reçus'), findsOneWidget); expect(find.text('Partages reçus'), findsOneWidget);
expect(harness.syncRemote.pullCalls, 1);
}); });
testWidgets('la déconnexion repasse au profil déconnecté', (tester) async { testWidgets('la déconnexion repasse au profil déconnecté', (tester) async {
@ -273,6 +274,7 @@ final class _AuthHarness {
templateRepository: templateRepository, templateRepository: templateRepository,
clock: const _FakeClock(), clock: const _FakeClock(),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
); );
} }

View File

@ -604,6 +604,7 @@ ShareUseCases _shareUseCases({
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
); );
} }

View File

@ -70,22 +70,78 @@ void main() {
ShareInboxStatus.declined, ShareInboxStatus.declined,
); );
}); });
testWidgets(
'accepter un partage en cache hors connexion importe une copie locale',
(tester) async {
final remoteShareApi = _FakeRemoteShareApi();
final inboxRepository = _FakeShareInboxRepository()
..items.add(
_shareItem(
shareId: 'share-program',
resourceType: ShareResourceType.program,
name: 'Programme tirs',
status: ShareInboxStatus.pending,
),
);
final programRepository = _FakeProgramRepository();
final pendingRepository = _FakePendingShareActionRepository();
await tester.pumpWidget(
MaterialApp(
home: ShareInboxScreen(
shareUseCases: _shareUseCases(
tokenStore: _FakeAuthTokenStore(null),
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
pendingActionRepository: pendingRepository,
programRepository: programRepository,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Accepter'));
await tester.pump();
await tester.pump();
expect(remoteShareApi.acceptCalls, 0);
expect(programRepository.saved.single.name, 'Programme tirs');
expect(
programRepository.saved.single.metadata.id,
isNot('share-program'),
);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
expect(
pendingRepository.actions.single.actionType,
PendingShareActionType.accept,
);
expect(find.text('Programme ajouté.'), findsOneWidget);
},
);
} }
ShareUseCases _shareUseCases({ ShareUseCases _shareUseCases({
_FakeAuthTokenStore? tokenStore,
required _FakeRemoteShareApi remoteShareApi, required _FakeRemoteShareApi remoteShareApi,
required _FakeShareInboxRepository inboxRepository, required _FakeShareInboxRepository inboxRepository,
_FakePendingShareActionRepository? pendingActionRepository,
_FakeProgramRepository? programRepository,
}) { }) {
return ShareUseCases( return ShareUseCases(
tokenStore: _FakeAuthTokenStore('token-1'), tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi, remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository, inboxRepository: inboxRepository,
pendingActionRepository: _FakePendingShareActionRepository(), pendingActionRepository:
pendingActionRepository ?? _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(), localChanges: _FakeLocalSyncChangeRepository(),
programRepository: _FakeProgramRepository(), programRepository: programRepository ?? _FakeProgramRepository(),
templateRepository: _FakeWorkoutTemplateRepository(), templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
); );
} }
@ -99,7 +155,17 @@ ShareInboxItem _shareItem({
ShareResourceType.program => { ShareResourceType.program => {
'name': name, 'name': name,
'exercises': const [ 'exercises': const [
{'setsCount': 3}, {
'exerciseNameSnapshot': 'Sprint',
'setsCount': 3,
'availableTimeSnapshot': false,
'availableRepsSnapshot': true,
'availableScoreSnapshot': false,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': 10,
},
], ],
}, },
ShareResourceType.workoutTemplate => { ShareResourceType.workoutTemplate => {
@ -267,8 +333,12 @@ final class _FakeShareInboxRepository implements ShareInboxRepository {
final class _FakePendingShareActionRepository final class _FakePendingShareActionRepository
implements PendingShareActionRepository { implements PendingShareActionRepository {
final actions = <PendingShareAction>[];
@override @override
Future<void> add(PendingShareAction action) async {} Future<void> add(PendingShareAction action) async {
actions.add(action);
}
@override @override
Future<List<PendingShareAction>> listPending() async => const []; Future<List<PendingShareAction>> listPending() async => const [];
@ -296,6 +366,8 @@ final class _FakeLocalSyncChangeRepository
} }
final class _FakeProgramRepository implements ProgramRepository { final class _FakeProgramRepository implements ProgramRepository {
final saved = <Program>[];
@override @override
Future<Program?> findById(String id) async => null; Future<Program?> findById(String id) async => null;
@ -306,7 +378,9 @@ final class _FakeProgramRepository implements ProgramRepository {
Future<void> replaceExercises(Program program, DateTime deletedAt) async {} Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override @override
Future<void> save(Program program) async {} Future<void> save(Program program) async {
saved.add(program);
}
@override @override
Future<void> saveExercise(ProgramExercise exercise) async {} Future<void> saveExercise(ProgramExercise exercise) async {}

View File

@ -537,6 +537,7 @@ ShareUseCases _shareUseCases({
templateRepository: templateRepository, templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1',
); );
} }