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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user