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:
@ -171,6 +171,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
syncGateway: const NoOpSyncGateway(),
|
||||
);
|
||||
|
||||
@ -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 [];
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -604,6 +604,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -537,6 +537,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user