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:
2026-07-20 09:39:40 +02:00
parent c00f61ef06
commit b6f048ad64
10 changed files with 470 additions and 13 deletions

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 [];