feat(online): partage client - use cases, inbox cache et import local (ticket #66)
Ajoute l'adapter API distant de partage (infrastructure/remote/share_api.dart) et les use cases associés (application/use_cases.dart), avec cache d'inbox et import local des ressources partagées acceptées. Étend le modèle Drift (migration schemaVersion 11→12) et les entités du domaine en conséquence. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 124/124 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -235,6 +235,257 @@ final class SyncUseCases {
|
||||
}
|
||||
}
|
||||
|
||||
final class ShareUseCases {
|
||||
const ShareUseCases({
|
||||
required this.tokenStore,
|
||||
required this.remoteShareApi,
|
||||
required this.inboxRepository,
|
||||
required this.pendingActionRepository,
|
||||
required this.localChanges,
|
||||
required this.programRepository,
|
||||
required this.templateRepository,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
final AuthTokenStore tokenStore;
|
||||
final RemoteShareApi remoteShareApi;
|
||||
final ShareInboxRepository inboxRepository;
|
||||
final PendingShareActionRepository pendingActionRepository;
|
||||
final LocalSyncChangeRepository localChanges;
|
||||
final ProgramRepository programRepository;
|
||||
final WorkoutTemplateRepository templateRepository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
|
||||
Future<ShareSendResult> sendShare({
|
||||
required ShareResourceType resourceType,
|
||||
required String localResourceId,
|
||||
required List<String> recipientEmails,
|
||||
}) async {
|
||||
final emails = recipientEmails
|
||||
.map((email) => email.trim())
|
||||
.where((email) => email.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (emails.isEmpty) {
|
||||
throw const DomainException('At least one recipient email is required.');
|
||||
}
|
||||
final payload = await _sharePayload(resourceType, localResourceId);
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return const ShareSendResult(status: ShareSendStatus.notConnected);
|
||||
}
|
||||
try {
|
||||
final result = await remoteShareApi.sendShare(
|
||||
resourceType: resourceType,
|
||||
payload: payload,
|
||||
recipientEmails: emails,
|
||||
token: token,
|
||||
);
|
||||
return ShareSendResult(
|
||||
status: ShareSendStatus.sent,
|
||||
shareId: result.shareId,
|
||||
recipientUserIds: result.recipientUserIds,
|
||||
unresolvedEmails: result.unresolvedEmails,
|
||||
);
|
||||
} catch (_) {
|
||||
final action = _pendingShareAction(
|
||||
actionType: PendingShareActionType.send,
|
||||
resourceType: resourceType,
|
||||
payload: payload,
|
||||
recipientEmails: emails,
|
||||
);
|
||||
await pendingActionRepository.add(action);
|
||||
return ShareSendResult(
|
||||
status: ShareSendStatus.queued,
|
||||
pendingActionId: action.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ShareInboxItem>> refreshInbox() async {
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return inboxRepository.listAll();
|
||||
}
|
||||
try {
|
||||
final items = await remoteShareApi.fetchInbox(token);
|
||||
await inboxRepository.upsertAll(items);
|
||||
return inboxRepository.listAll();
|
||||
} catch (_) {
|
||||
return inboxRepository.listAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> acceptShare(String shareId) async {
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final createdResource = await remoteShareApi.acceptShare(shareId, token);
|
||||
await localChanges.applyRemoteItem(createdResource);
|
||||
await inboxRepository.markStatus(
|
||||
shareId,
|
||||
ShareInboxStatus.accepted,
|
||||
clock.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
await pendingActionRepository.add(
|
||||
_pendingShareAction(
|
||||
actionType: PendingShareActionType.accept,
|
||||
shareId: shareId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> declineShare(String shareId) async {
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await remoteShareApi.declineShare(shareId, token);
|
||||
await inboxRepository.markStatus(
|
||||
shareId,
|
||||
ShareInboxStatus.declined,
|
||||
clock.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
await pendingActionRepository.add(
|
||||
_pendingShareAction(
|
||||
actionType: PendingShareActionType.decline,
|
||||
shareId: shareId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> revokeShare(String shareId) async {
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await remoteShareApi.revokeShare(shareId, token);
|
||||
await inboxRepository.markStatus(
|
||||
shareId,
|
||||
ShareInboxStatus.revoked,
|
||||
clock.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
await pendingActionRepository.add(
|
||||
_pendingShareAction(
|
||||
actionType: PendingShareActionType.revoke,
|
||||
shareId: shareId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> processPendingShareActions() async {
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
final actions = await pendingActionRepository.listPending();
|
||||
for (final action in actions) {
|
||||
final attemptedAt = clock.now();
|
||||
try {
|
||||
await _processPendingShareAction(action, token);
|
||||
await pendingActionRepository.markSucceeded(action.id, attemptedAt);
|
||||
} catch (_) {
|
||||
await pendingActionRepository.markFailed(action.id, attemptedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _sharePayload(
|
||||
ShareResourceType resourceType,
|
||||
String localResourceId,
|
||||
) async {
|
||||
switch (resourceType) {
|
||||
case ShareResourceType.program:
|
||||
final program = await programRepository.findById(localResourceId);
|
||||
if (program == null) {
|
||||
throw const DomainException('Program not found.');
|
||||
}
|
||||
return _programSharePayload(program);
|
||||
case ShareResourceType.workoutTemplate:
|
||||
final template = await templateRepository.findById(localResourceId);
|
||||
if (template == null) {
|
||||
throw const DomainException('Workout template not found.');
|
||||
}
|
||||
return _workoutTemplateSharePayload(template);
|
||||
}
|
||||
}
|
||||
|
||||
PendingShareAction _pendingShareAction({
|
||||
required PendingShareActionType actionType,
|
||||
String? shareId,
|
||||
ShareResourceType? resourceType,
|
||||
Map<String, Object?>? payload,
|
||||
List<String>? recipientEmails,
|
||||
}) {
|
||||
return PendingShareAction(
|
||||
id: ids.newId(),
|
||||
actionType: actionType,
|
||||
shareId: shareId,
|
||||
resourceType: resourceType,
|
||||
payloadJson: payload == null ? null : jsonEncode(payload),
|
||||
recipientEmailsJson: recipientEmails == null
|
||||
? null
|
||||
: jsonEncode(recipientEmails),
|
||||
createdAt: clock.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processPendingShareAction(
|
||||
PendingShareAction action,
|
||||
String token,
|
||||
) async {
|
||||
switch (action.actionType) {
|
||||
case PendingShareActionType.send:
|
||||
await remoteShareApi.sendShare(
|
||||
resourceType: action.resourceType!,
|
||||
payload: _jsonObject(action.payloadJson),
|
||||
recipientEmails: _jsonStringList(action.recipientEmailsJson),
|
||||
token: token,
|
||||
);
|
||||
return;
|
||||
case PendingShareActionType.accept:
|
||||
final created = await remoteShareApi.acceptShare(
|
||||
action.shareId!,
|
||||
token,
|
||||
);
|
||||
await localChanges.applyRemoteItem(created);
|
||||
await inboxRepository.markStatus(
|
||||
action.shareId!,
|
||||
ShareInboxStatus.accepted,
|
||||
clock.now(),
|
||||
);
|
||||
return;
|
||||
case PendingShareActionType.decline:
|
||||
await remoteShareApi.declineShare(action.shareId!, token);
|
||||
await inboxRepository.markStatus(
|
||||
action.shareId!,
|
||||
ShareInboxStatus.declined,
|
||||
clock.now(),
|
||||
);
|
||||
return;
|
||||
case PendingShareActionType.revoke:
|
||||
await remoteShareApi.revokeShare(action.shareId!, token);
|
||||
await inboxRepository.markStatus(
|
||||
action.shareId!,
|
||||
ShareInboxStatus.revoked,
|
||||
clock.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ExerciseUseCases {
|
||||
const ExerciseUseCases({
|
||||
required this.repository,
|
||||
@ -2642,6 +2893,85 @@ String _syncKey(SyncResourceType resourceType, String clientId) {
|
||||
return '${resourceType.name}:$clientId';
|
||||
}
|
||||
|
||||
Map<String, Object?> _programSharePayload(Program program) => {
|
||||
'metadata': _metadataSharePayload(program.metadata),
|
||||
'id': program.metadata.id,
|
||||
'name': program.name,
|
||||
'defaultRestSeconds': program.defaultRestSeconds,
|
||||
'exercises': program.exercises
|
||||
.map((exercise) => exercise.toSnapshotJson())
|
||||
.toList(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _workoutTemplateSharePayload(WorkoutTemplate template) => {
|
||||
'metadata': _metadataSharePayload(template.metadata),
|
||||
'id': template.metadata.id,
|
||||
'name': template.name,
|
||||
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
|
||||
'programs': template.programs
|
||||
.map(
|
||||
(program) => {
|
||||
'id': program.metadata.id,
|
||||
'sourceProgramId': program.sourceProgramId,
|
||||
'position': program.position,
|
||||
'programNameSnapshot': program.programNameSnapshot,
|
||||
'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot,
|
||||
'programSnapshotJson': program.programSnapshotJson,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'overrides': template.overrides
|
||||
.map(
|
||||
(override) => {
|
||||
'id': override.metadata.id,
|
||||
'workoutTemplateProgramId': override.workoutTemplateProgramId,
|
||||
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
|
||||
'setsCountOverride': override.setsCountOverride,
|
||||
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
|
||||
'targetRepsOverride': override.targetRepsOverride,
|
||||
'targetScoreOverride': override.targetScoreOverride,
|
||||
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _metadataSharePayload(EntityMetadata metadata) => {
|
||||
'id': metadata.id,
|
||||
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
|
||||
'updatedAt': metadata.updatedAt.toUtc().toIso8601String(),
|
||||
'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(),
|
||||
'schemaVersion': metadata.schemaVersion,
|
||||
'syncState': metadata.syncState.name,
|
||||
'localRevision': metadata.localRevision,
|
||||
'originDeviceId': metadata.originDeviceId,
|
||||
'futureOwnerProfileId': metadata.futureOwnerProfileId,
|
||||
'lastSyncedAt': metadata.lastSyncedAt?.toUtc().toIso8601String(),
|
||||
'remoteRevision': metadata.remoteRevision,
|
||||
};
|
||||
|
||||
Map<String, Object?> _jsonObject(String? json) {
|
||||
if (json == null) {
|
||||
return const {};
|
||||
}
|
||||
final decoded = jsonDecode(json);
|
||||
if (decoded is Map) {
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
List<String> _jsonStringList(String? json) {
|
||||
if (json == null) {
|
||||
return const [];
|
||||
}
|
||||
final decoded = jsonDecode(json);
|
||||
if (decoded is List) {
|
||||
return decoded.whereType<String>().toList(growable: false);
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
|
||||
return switch (value) {
|
||||
'stopwatch' => ScoreInputMode.stopwatch,
|
||||
|
||||
Reference in New Issue
Block a user