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

Fusionne feature/online-client-70-qa — pub get OK, dart format
appliqué (aucun changement), analyze propre (mêmes infos
préexistantes), 137/137 tests verts, build APK debug validé. Fix
d'acceptation de partage hors-ligne relu manuellement et confirmé.
Clôture le chantier "Ajouter les features serveur au client" (#63,
tickets #64 à #70).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 09:40:01 +02:00
13 changed files with 479 additions and 22 deletions

View File

@ -1,6 +1,6 @@
---
issueRef: "#72"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784493074891
version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784527867008
---

View File

@ -2,15 +2,15 @@
id: "b388e21b-5262-4b3e-b075-5654d2a478f2"
number: 72
title: "[Bug] les étapes des exercices ne sont pas affichés pendant la séance"
status: "open"
status: "qa"
priority: "critical"
sprint: null
links: []
agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784492460868
updatedAt: 1784493074891
version: 3
updatedAt: 1784527867008
version: 4
---
Pendant la séance, il n'y a aucune étape d'exercice affiché. J'ia un exercice divisé en 3 étapes avec des temps par étapes ou des répétitions, et quand je suis a cet exercices pendant ma séance, je n'ai pas les étapes d'affichées à l'écran. Je ne sais donc pas ce que je dois faire, ni combien de temps ou combien de fois. Je pense qu'il faudrait au moins le titre de chaque étape avec le nombre de répétitions ou le chrono qui défile et un bouton pour start/stop les chronos des étapes si les étapes possèdent un temps.

View File

@ -811,13 +811,13 @@
"issueRef": "#72",
"path": "72",
"title": "[Bug] les étapes des exercices ne sont pas affichés pendant la séance",
"status": "open",
"status": "qa",
"priority": "critical",
"sprint": null,
"assignedAgentIds": [
"57695b92-24d0-4876-837c-76116e70a6ae"
],
"updatedAt": 1784493074891
"updatedAt": 1784527867008
},
{
"issueRef": "#73",

View File

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

View File

@ -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<ShareSendResult> sendShare({
required ShareResourceType resourceType,
@ -322,8 +324,10 @@ final class ShareUseCases {
}
Future<void> 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<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 {
@ -2965,6 +3215,22 @@ Map<String, Object?> _jsonObject(String? json) {
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) {
if (json == null) {
return const [];

View File

@ -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<ProfileScreen> {
builder: (context) => LoginScreen(
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
),
),
);
@ -83,6 +86,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
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<LoginScreen> createState() => _LoginScreenState();
@ -486,6 +492,7 @@ final class _LoginScreenState extends State<LoginScreen> {
builder: (context) => RegisterScreen(
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
),
),
);
@ -515,6 +522,11 @@ final class _LoginScreenState extends State<LoginScreen> {
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<RegisterScreen> createState() => _RegisterScreenState();
@ -628,6 +642,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
builder: (context) => LoginScreen(
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
shareUseCases: widget.shareUseCases,
),
),
);
@ -660,6 +675,11 @@ final class _RegisterScreenState extends State<RegisterScreen> {
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) {

View File

@ -118,6 +118,7 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
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<ShareFormScreen> {
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,
),
),
)

View File

@ -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<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() {
return RemoteSyncedItem(
resourceType: SyncResourceType.program,

View File

@ -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,

View File

@ -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',
);
}

View File

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

View File

@ -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 = <PendingShareAction>[];
@override
Future<void> add(PendingShareAction action) async {}
Future<void> add(PendingShareAction action) async {
actions.add(action);
}
@override
Future<List<PendingShareAction>> listPending() async => const [];
@ -296,6 +366,8 @@ final class _FakeLocalSyncChangeRepository
}
final class _FakeProgramRepository implements ProgramRepository {
final saved = <Program>[];
@override
Future<Program?> findById(String id) async => null;
@ -306,7 +378,9 @@ final class _FakeProgramRepository implements ProgramRepository {
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
Future<void> save(Program program) async {
saved.add(program);
}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}

View File

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