From b6f048ad6425bdfab59a1efd3159c3e70417f1d7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 20 Jul 2026 09:39:40 +0200 Subject: [PATCH] fix(online): validation client online offline-first (ticket #70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/app_bootstrap.dart | 1 + lib/application/use_cases.dart | 278 +++++++++++++++++- lib/presentation/profile_screen.dart | 20 ++ lib/presentation/share_screen.dart | 5 + test/application/use_cases_test.dart | 88 +++++- test/presentation/home_screen_test.dart | 1 + test/presentation/profile_screen_test.dart | 2 + test/presentation/program_screen_test.dart | 1 + .../presentation/share_inbox_screen_test.dart | 86 +++++- .../workout_template_screen_test.dart | 1 + 10 files changed, 470 insertions(+), 13 deletions(-) diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index ea2417f..490b872 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -171,6 +171,7 @@ final class AppBootstrap implements AppDependencies { templateRepository: templateRepository, clock: clock, ids: ids, + originDeviceId: originDeviceId, ), syncGateway: const NoOpSyncGateway(), ); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 4c7d116..f703c33 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -250,6 +250,7 @@ final class ShareUseCases { required this.templateRepository, required this.clock, required this.ids, + required this.originDeviceId, }); final AuthTokenStore tokenStore; @@ -261,6 +262,7 @@ final class ShareUseCases { final WorkoutTemplateRepository templateRepository; final Clock clock; final IdGenerator ids; + final String originDeviceId; Future sendShare({ required ShareResourceType resourceType, @@ -322,8 +324,10 @@ final class ShareUseCases { } Future acceptShare(String shareId) async { + final cachedItem = await inboxRepository.findByShareId(shareId); final token = await tokenStore.readToken(); if (token == null) { + await _acceptCachedShareAndQueueAck(shareId, cachedItem); return; } try { @@ -335,12 +339,7 @@ final class ShareUseCases { clock.now(), ); } catch (_) { - await pendingActionRepository.add( - _pendingShareAction( - actionType: PendingShareActionType.accept, - shareId: shareId, - ), - ); + await _acceptCachedShareAndQueueAck(shareId, cachedItem); } } @@ -488,6 +487,257 @@ final class ShareUseCases { return; } } + + Future _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 _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 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 _programExerciseCopiesFromSharePayload( + Object? value, + String programId, + DateTime now, + ) { + if (value is! List) { + return const []; + } + final exercises = []; + for (var index = 0; index < value.length; index++) { + final raw = value[index]; + if (raw is! Map) { + continue; + } + final map = Map.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 payload, + DateTime now, + ) { + final templateId = ids.newId(); + final programIdBySharedId = {}; + 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 _workoutTemplateProgramCopiesFromSharePayload( + Object? value, + String templateId, + Map programIdBySharedId, + DateTime now, + ) { + if (value is! List) { + return const []; + } + final programs = []; + for (var index = 0; index < value.length; index++) { + final raw = value[index]; + if (raw is! Map) { + continue; + } + final map = Map.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 + _workoutTemplateOverrideCopiesFromSharePayload( + Object? value, + Map programIdBySharedId, + DateTime now, + ) { + if (value is! List) { + return const []; + } + final overrides = []; + for (final raw in value) { + if (raw is! Map) { + continue; + } + final map = Map.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 { @@ -2965,6 +3215,22 @@ Map _jsonObject(String? json) { return const {}; } +String _stringFromSharePayload( + Map payload, + String key, + String fallback, +) { + final value = payload[key]; + return value is String && value.trim().isNotEmpty ? value : fallback; +} + +List _stringListFromSharePayload(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); +} + List _jsonStringList(String? json) { if (json == null) { return const []; diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index 49d71b0..9c3ddc3 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../application/application.dart'; @@ -64,6 +66,7 @@ final class _ProfileScreenState extends State { builder: (context) => LoginScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, + shareUseCases: widget.shareUseCases, ), ), ); @@ -83,6 +86,7 @@ final class _ProfileScreenState extends State { builder: (context) => RegisterScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, + shareUseCases: widget.shareUseCases, ), ), ); @@ -420,11 +424,13 @@ final class LoginScreen extends StatefulWidget { const LoginScreen({ required this.authUseCases, required this.syncUseCases, + this.shareUseCases, super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; + final ShareUseCases? shareUseCases; @override State createState() => _LoginScreenState(); @@ -486,6 +492,7 @@ final class _LoginScreenState extends State { builder: (context) => RegisterScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, + shareUseCases: widget.shareUseCases, ), ), ); @@ -515,6 +522,11 @@ final class _LoginScreenState extends State { email: _emailController.text, password: _passwordController.text, ); + unawaited(widget.syncUseCases.synchronize(manual: false)); + final shareUseCases = widget.shareUseCases; + if (shareUseCases != null) { + unawaited(shareUseCases.processPendingShareActions()); + } if (!mounted) return; Navigator.of(context).pop(true); } on RemoteAuthException catch (error) { @@ -537,11 +549,13 @@ final class RegisterScreen extends StatefulWidget { const RegisterScreen({ required this.authUseCases, required this.syncUseCases, + this.shareUseCases, super.key, }); final AuthUseCases authUseCases; final SyncUseCases syncUseCases; + final ShareUseCases? shareUseCases; @override State createState() => _RegisterScreenState(); @@ -628,6 +642,7 @@ final class _RegisterScreenState extends State { builder: (context) => LoginScreen( authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, + shareUseCases: widget.shareUseCases, ), ), ); @@ -660,6 +675,11 @@ final class _RegisterScreenState extends State { password: _passwordController.text, displayName: displayName.isEmpty ? null : displayName, ); + unawaited(widget.syncUseCases.synchronize(manual: false)); + final shareUseCases = widget.shareUseCases; + if (shareUseCases != null) { + unawaited(shareUseCases.processPendingShareActions()); + } if (!mounted) return; Navigator.of(context).pop(true); } on RemoteAuthException catch (error) { diff --git a/lib/presentation/share_screen.dart b/lib/presentation/share_screen.dart index 6b1d7ea..b3a3169 100644 --- a/lib/presentation/share_screen.dart +++ b/lib/presentation/share_screen.dart @@ -118,6 +118,7 @@ final class _ShareFormScreenState extends State { MaterialPageRoute( builder: (context) => ShareAccountRequiredScreen( resourceType: widget.resourceType, + shareUseCases: widget.shareUseCases, authUseCases: widget.authUseCases, syncUseCases: widget.syncUseCases, ), @@ -137,12 +138,14 @@ final class _ShareFormScreenState extends State { final class ShareAccountRequiredScreen extends StatelessWidget { const ShareAccountRequiredScreen({ required this.resourceType, + this.shareUseCases, this.authUseCases, this.syncUseCases, super.key, }); final ShareResourceType resourceType; + final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; @@ -179,6 +182,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget { builder: (context) => LoginScreen( authUseCases: authUseCases!, syncUseCases: syncUseCases!, + shareUseCases: shareUseCases, ), ), ) @@ -193,6 +197,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget { builder: (context) => RegisterScreen( authUseCases: authUseCases!, syncUseCases: syncUseCases!, + shareUseCases: shareUseCases, ), ), ) diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index aaf241b..7c25970 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1434,6 +1434,50 @@ void main() { 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 { final remoteShareApi = _FakeRemoteShareApi() ..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)]; @@ -1866,6 +1910,7 @@ ShareUseCases _shareUseCase({ templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), + originDeviceId: 'device-1', ); } @@ -2027,17 +2072,58 @@ Program _shareableProgram() { ShareInboxItem _shareInboxItem({ String shareId = 'share-1', required ShareInboxStatus status, + String payloadJson = '{"name":"Programme partagé"}', }) { return ShareInboxItem( shareId: shareId, senderUserId: 'sender-1', resourceType: ShareResourceType.program, - payloadJson: '{"name":"Programme partagé"}', + payloadJson: payloadJson, status: status, createdAt: DateTime.utc(2026, 7, 17, 12), ); } +Map _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() { return RemoteSyncedItem( resourceType: SyncResourceType.program, diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 1da6a49..24b9ec0 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -156,6 +156,7 @@ final class _FakeBootstrap implements AppDependencies { templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), + originDeviceId: 'device-1', ), activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeRepository, diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index e781c86..a439324 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -93,6 +93,7 @@ void main() { expect(find.text('alex@example.com'), findsOneWidget); expect(find.text('Synchronisation'), findsOneWidget); expect(find.text('Partages reçus'), findsOneWidget); + expect(harness.syncRemote.pullCalls, 1); }); testWidgets('la déconnexion repasse au profil déconnecté', (tester) async { @@ -273,6 +274,7 @@ final class _AuthHarness { templateRepository: templateRepository, clock: const _FakeClock(), ids: _FakeIds(), + originDeviceId: 'device-1', ); } diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index 04f54a9..717c852 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -604,6 +604,7 @@ ShareUseCases _shareUseCases({ templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), + originDeviceId: 'device-1', ); } diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart index 6d68f95..bfbb3cc 100644 --- a/test/presentation/share_inbox_screen_test.dart +++ b/test/presentation/share_inbox_screen_test.dart @@ -70,22 +70,78 @@ void main() { 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({ + _FakeAuthTokenStore? tokenStore, required _FakeRemoteShareApi remoteShareApi, required _FakeShareInboxRepository inboxRepository, + _FakePendingShareActionRepository? pendingActionRepository, + _FakeProgramRepository? programRepository, }) { return ShareUseCases( - tokenStore: _FakeAuthTokenStore('token-1'), + tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'), remoteShareApi: remoteShareApi, inboxRepository: inboxRepository, - pendingActionRepository: _FakePendingShareActionRepository(), + pendingActionRepository: + pendingActionRepository ?? _FakePendingShareActionRepository(), localChanges: _FakeLocalSyncChangeRepository(), - programRepository: _FakeProgramRepository(), + programRepository: programRepository ?? _FakeProgramRepository(), templateRepository: _FakeWorkoutTemplateRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), + originDeviceId: 'device-1', ); } @@ -99,7 +155,17 @@ ShareInboxItem _shareItem({ ShareResourceType.program => { 'name': name, 'exercises': const [ - {'setsCount': 3}, + { + 'exerciseNameSnapshot': 'Sprint', + 'setsCount': 3, + 'availableTimeSnapshot': false, + 'availableRepsSnapshot': true, + 'availableScoreSnapshot': false, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 10, + }, ], }, ShareResourceType.workoutTemplate => { @@ -267,8 +333,12 @@ final class _FakeShareInboxRepository implements ShareInboxRepository { final class _FakePendingShareActionRepository implements PendingShareActionRepository { + final actions = []; + @override - Future add(PendingShareAction action) async {} + Future add(PendingShareAction action) async { + actions.add(action); + } @override Future> listPending() async => const []; @@ -296,6 +366,8 @@ final class _FakeLocalSyncChangeRepository } final class _FakeProgramRepository implements ProgramRepository { + final saved = []; + @override Future findById(String id) async => null; @@ -306,7 +378,9 @@ final class _FakeProgramRepository implements ProgramRepository { Future replaceExercises(Program program, DateTime deletedAt) async {} @override - Future save(Program program) async {} + Future save(Program program) async { + saved.add(program); + } @override Future saveExercise(ProgramExercise exercise) async {} diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 3d9027c..8c00744 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -537,6 +537,7 @@ ShareUseCases _shareUseCases({ templateRepository: templateRepository, clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), + originDeviceId: 'device-1', ); }