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:
@ -14,6 +14,7 @@ abstract interface class AppDependencies {
|
||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
SyncUseCases get syncUseCases;
|
||||
ShareUseCases get shareUseCases;
|
||||
}
|
||||
|
||||
final class AppBootstrap implements AppDependencies {
|
||||
@ -29,6 +30,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.closeWorkoutSessionUseCase,
|
||||
required this.workoutHistoryUseCases,
|
||||
required this.syncUseCases,
|
||||
required this.shareUseCases,
|
||||
required this.syncGateway,
|
||||
});
|
||||
|
||||
@ -53,6 +55,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||
@override
|
||||
final SyncUseCases syncUseCases;
|
||||
@override
|
||||
final ShareUseCases shareUseCases;
|
||||
final SyncGateway syncGateway;
|
||||
|
||||
static Future<AppBootstrap> create() async {
|
||||
@ -64,6 +68,10 @@ final class AppBootstrap implements AppDependencies {
|
||||
final syncMetadataRepository = DriftSyncMetadataRepository(database);
|
||||
final mappingRepository = DriftRemoteResourceMappingRepository(database);
|
||||
final localSyncChangeRepository = DriftLocalSyncChangeRepository(database);
|
||||
final shareInboxRepository = DriftShareInboxRepository(database);
|
||||
final pendingShareActionRepository = DriftPendingShareActionRepository(
|
||||
database,
|
||||
);
|
||||
final programRepository = DriftProgramRepository(database);
|
||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||
@ -77,6 +85,9 @@ final class AppBootstrap implements AppDependencies {
|
||||
final remoteSyncApi = HttpRemoteSyncApi(
|
||||
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||
);
|
||||
final remoteShareApi = HttpRemoteShareApi(
|
||||
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||
);
|
||||
|
||||
return AppBootstrap._(
|
||||
database: database,
|
||||
@ -150,6 +161,17 @@ final class AppBootstrap implements AppDependencies {
|
||||
clock: clock,
|
||||
deviceId: originDeviceId,
|
||||
),
|
||||
shareUseCases: ShareUseCases(
|
||||
tokenStore: const SecureStorageAuthTokenStore(),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: shareInboxRepository,
|
||||
pendingActionRepository: pendingShareActionRepository,
|
||||
localChanges: localSyncChangeRepository,
|
||||
programRepository: programRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
syncGateway: const NoOpSyncGateway(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -259,6 +259,69 @@ abstract interface class LocalSyncChangeRepository {
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item);
|
||||
}
|
||||
|
||||
final class RemoteShareSendResult {
|
||||
const RemoteShareSendResult({
|
||||
required this.shareId,
|
||||
required this.recipientUserIds,
|
||||
required this.unresolvedEmails,
|
||||
});
|
||||
|
||||
final String shareId;
|
||||
final List<String> recipientUserIds;
|
||||
final List<String> unresolvedEmails;
|
||||
}
|
||||
|
||||
enum ShareSendStatus { sent, notConnected, queued }
|
||||
|
||||
final class ShareSendResult {
|
||||
const ShareSendResult({
|
||||
required this.status,
|
||||
this.shareId,
|
||||
this.recipientUserIds = const [],
|
||||
this.unresolvedEmails = const [],
|
||||
this.pendingActionId,
|
||||
});
|
||||
|
||||
final ShareSendStatus status;
|
||||
final String? shareId;
|
||||
final List<String> recipientUserIds;
|
||||
final List<String> unresolvedEmails;
|
||||
final String? pendingActionId;
|
||||
}
|
||||
|
||||
abstract interface class RemoteShareApi {
|
||||
Future<RemoteShareSendResult> sendShare({
|
||||
required ShareResourceType resourceType,
|
||||
required Map<String, Object?> payload,
|
||||
required List<String> recipientEmails,
|
||||
required String token,
|
||||
});
|
||||
|
||||
Future<List<ShareInboxItem>> fetchInbox(String token);
|
||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token);
|
||||
Future<void> declineShare(String shareId, String token);
|
||||
Future<void> revokeShare(String shareId, String token);
|
||||
}
|
||||
|
||||
abstract interface class ShareInboxRepository {
|
||||
Future<List<ShareInboxItem>> listAll();
|
||||
Future<ShareInboxItem?> findByShareId(String shareId);
|
||||
Future<void> upsert(ShareInboxItem item);
|
||||
Future<void> upsertAll(List<ShareInboxItem> items);
|
||||
Future<void> markStatus(
|
||||
String shareId,
|
||||
ShareInboxStatus status,
|
||||
DateTime respondedAt,
|
||||
);
|
||||
}
|
||||
|
||||
abstract interface class PendingShareActionRepository {
|
||||
Future<List<PendingShareAction>> listPending();
|
||||
Future<void> add(PendingShareAction action);
|
||||
Future<void> markSucceeded(String id, DateTime attemptedAt);
|
||||
Future<void> markFailed(String id, DateTime attemptedAt);
|
||||
}
|
||||
|
||||
const Object _portsUnchanged = Object();
|
||||
|
||||
abstract interface class ExerciseRepository {
|
||||
|
||||
@ -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