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:
2026-07-19 21:54:45 +02:00
parent 715fdd4335
commit 010e055404
12 changed files with 3445 additions and 1 deletions

View File

@ -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(),
);
}

View File

@ -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 {

View File

@ -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,