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,
|
templateRepository: templateRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
ids: ids,
|
ids: ids,
|
||||||
|
originDeviceId: originDeviceId,
|
||||||
),
|
),
|
||||||
syncGateway: const NoOpSyncGateway(),
|
syncGateway: const NoOpSyncGateway(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user