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;
|
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||||
SyncUseCases get syncUseCases;
|
SyncUseCases get syncUseCases;
|
||||||
|
ShareUseCases get shareUseCases;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppBootstrap implements AppDependencies {
|
final class AppBootstrap implements AppDependencies {
|
||||||
@ -29,6 +30,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
required this.closeWorkoutSessionUseCase,
|
required this.closeWorkoutSessionUseCase,
|
||||||
required this.workoutHistoryUseCases,
|
required this.workoutHistoryUseCases,
|
||||||
required this.syncUseCases,
|
required this.syncUseCases,
|
||||||
|
required this.shareUseCases,
|
||||||
required this.syncGateway,
|
required this.syncGateway,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,6 +55,8 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||||
@override
|
@override
|
||||||
final SyncUseCases syncUseCases;
|
final SyncUseCases syncUseCases;
|
||||||
|
@override
|
||||||
|
final ShareUseCases shareUseCases;
|
||||||
final SyncGateway syncGateway;
|
final SyncGateway syncGateway;
|
||||||
|
|
||||||
static Future<AppBootstrap> create() async {
|
static Future<AppBootstrap> create() async {
|
||||||
@ -64,6 +68,10 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final syncMetadataRepository = DriftSyncMetadataRepository(database);
|
final syncMetadataRepository = DriftSyncMetadataRepository(database);
|
||||||
final mappingRepository = DriftRemoteResourceMappingRepository(database);
|
final mappingRepository = DriftRemoteResourceMappingRepository(database);
|
||||||
final localSyncChangeRepository = DriftLocalSyncChangeRepository(database);
|
final localSyncChangeRepository = DriftLocalSyncChangeRepository(database);
|
||||||
|
final shareInboxRepository = DriftShareInboxRepository(database);
|
||||||
|
final pendingShareActionRepository = DriftPendingShareActionRepository(
|
||||||
|
database,
|
||||||
|
);
|
||||||
final programRepository = DriftProgramRepository(database);
|
final programRepository = DriftProgramRepository(database);
|
||||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||||
@ -77,6 +85,9 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final remoteSyncApi = HttpRemoteSyncApi(
|
final remoteSyncApi = HttpRemoteSyncApi(
|
||||||
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||||
);
|
);
|
||||||
|
final remoteShareApi = HttpRemoteShareApi(
|
||||||
|
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
|
||||||
|
);
|
||||||
|
|
||||||
return AppBootstrap._(
|
return AppBootstrap._(
|
||||||
database: database,
|
database: database,
|
||||||
@ -150,6 +161,17 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
clock: clock,
|
clock: clock,
|
||||||
deviceId: originDeviceId,
|
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(),
|
syncGateway: const NoOpSyncGateway(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -259,6 +259,69 @@ abstract interface class LocalSyncChangeRepository {
|
|||||||
Future<bool> applyRemoteItem(RemoteSyncedItem item);
|
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();
|
const Object _portsUnchanged = Object();
|
||||||
|
|
||||||
abstract interface class ExerciseRepository {
|
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 {
|
final class ExerciseUseCases {
|
||||||
const ExerciseUseCases({
|
const ExerciseUseCases({
|
||||||
required this.repository,
|
required this.repository,
|
||||||
@ -2642,6 +2893,85 @@ String _syncKey(SyncResourceType resourceType, String clientId) {
|
|||||||
return '${resourceType.name}:$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) {
|
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
'stopwatch' => ScoreInputMode.stopwatch,
|
'stopwatch' => ScoreInputMode.stopwatch,
|
||||||
|
|||||||
@ -16,6 +16,14 @@ enum SetResultStatus { completed, skipped }
|
|||||||
|
|
||||||
enum ActiveScoreStopwatchStatus { running, stopped }
|
enum ActiveScoreStopwatchStatus { running, stopped }
|
||||||
|
|
||||||
|
enum ShareResourceType { program, workoutTemplate }
|
||||||
|
|
||||||
|
enum ShareInboxStatus { pending, accepted, declined, revoked }
|
||||||
|
|
||||||
|
enum PendingShareActionType { send, accept, decline, revoke }
|
||||||
|
|
||||||
|
enum PendingShareActionStatus { pending, succeeded, failed }
|
||||||
|
|
||||||
enum ActiveExerciseStepProgressStatus {
|
enum ActiveExerciseStepProgressStatus {
|
||||||
notStarted,
|
notStarted,
|
||||||
waitingManual,
|
waitingManual,
|
||||||
@ -154,6 +162,115 @@ final class UserAccountSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class ShareInboxItem {
|
||||||
|
ShareInboxItem({
|
||||||
|
required String shareId,
|
||||||
|
required String senderUserId,
|
||||||
|
required this.resourceType,
|
||||||
|
required String payloadJson,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
this.respondedAt,
|
||||||
|
}) : shareId = _nonBlank(shareId, 'Share id'),
|
||||||
|
senderUserId = _nonBlank(senderUserId, 'Sender user id'),
|
||||||
|
payloadJson = _nonBlank(payloadJson, 'Share payload JSON');
|
||||||
|
|
||||||
|
final String shareId;
|
||||||
|
final String senderUserId;
|
||||||
|
final ShareResourceType resourceType;
|
||||||
|
final String payloadJson;
|
||||||
|
final ShareInboxStatus status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? respondedAt;
|
||||||
|
|
||||||
|
ShareInboxItem copyWith({
|
||||||
|
String? senderUserId,
|
||||||
|
ShareResourceType? resourceType,
|
||||||
|
String? payloadJson,
|
||||||
|
ShareInboxStatus? status,
|
||||||
|
DateTime? createdAt,
|
||||||
|
Object? respondedAt = _unchanged,
|
||||||
|
}) {
|
||||||
|
return ShareInboxItem(
|
||||||
|
shareId: shareId,
|
||||||
|
senderUserId: senderUserId ?? this.senderUserId,
|
||||||
|
resourceType: resourceType ?? this.resourceType,
|
||||||
|
payloadJson: payloadJson ?? this.payloadJson,
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
respondedAt: respondedAt == _unchanged
|
||||||
|
? this.respondedAt
|
||||||
|
: respondedAt as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PendingShareAction {
|
||||||
|
PendingShareAction({
|
||||||
|
required String id,
|
||||||
|
required this.actionType,
|
||||||
|
this.shareId,
|
||||||
|
this.resourceType,
|
||||||
|
this.payloadJson,
|
||||||
|
this.recipientEmailsJson,
|
||||||
|
required this.createdAt,
|
||||||
|
this.lastAttemptAt,
|
||||||
|
this.attemptCount = 0,
|
||||||
|
this.status = PendingShareActionStatus.pending,
|
||||||
|
}) : id = _nonBlank(id, 'Pending share action id') {
|
||||||
|
_requireNonNegative(attemptCount, 'Share action attempt count');
|
||||||
|
if (shareId != null) {
|
||||||
|
_nonBlank(shareId, 'Share id');
|
||||||
|
}
|
||||||
|
if (payloadJson != null) {
|
||||||
|
_nonBlank(payloadJson, 'Share payload JSON');
|
||||||
|
}
|
||||||
|
if (recipientEmailsJson != null) {
|
||||||
|
_nonBlank(recipientEmailsJson, 'Recipient emails JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final PendingShareActionType actionType;
|
||||||
|
final String? shareId;
|
||||||
|
final ShareResourceType? resourceType;
|
||||||
|
final String? payloadJson;
|
||||||
|
final String? recipientEmailsJson;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? lastAttemptAt;
|
||||||
|
final int attemptCount;
|
||||||
|
final PendingShareActionStatus status;
|
||||||
|
|
||||||
|
PendingShareAction copyWith({
|
||||||
|
Object? shareId = _unchanged,
|
||||||
|
ShareResourceType? resourceType,
|
||||||
|
Object? payloadJson = _unchanged,
|
||||||
|
Object? recipientEmailsJson = _unchanged,
|
||||||
|
Object? lastAttemptAt = _unchanged,
|
||||||
|
int? attemptCount,
|
||||||
|
PendingShareActionStatus? status,
|
||||||
|
}) {
|
||||||
|
return PendingShareAction(
|
||||||
|
id: id,
|
||||||
|
actionType: actionType,
|
||||||
|
shareId: shareId == _unchanged ? this.shareId : shareId as String?,
|
||||||
|
resourceType: resourceType ?? this.resourceType,
|
||||||
|
payloadJson: payloadJson == _unchanged
|
||||||
|
? this.payloadJson
|
||||||
|
: payloadJson as String?,
|
||||||
|
recipientEmailsJson: recipientEmailsJson == _unchanged
|
||||||
|
? this.recipientEmailsJson
|
||||||
|
: recipientEmailsJson as String?,
|
||||||
|
createdAt: createdAt,
|
||||||
|
lastAttemptAt: lastAttemptAt == _unchanged
|
||||||
|
? this.lastAttemptAt
|
||||||
|
: lastAttemptAt as DateTime?,
|
||||||
|
attemptCount: attemptCount ?? this.attemptCount,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class MediaAsset {
|
final class MediaAsset {
|
||||||
const MediaAsset({
|
const MediaAsset({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
|
|||||||
@ -19,9 +19,11 @@ part 'app_database.g.dart';
|
|||||||
ExerciseSteps,
|
ExerciseSteps,
|
||||||
MediaAssets,
|
MediaAssets,
|
||||||
OnlineAccountSessions,
|
OnlineAccountSessions,
|
||||||
|
PendingShareActions,
|
||||||
ProgramExercises,
|
ProgramExercises,
|
||||||
Programs,
|
Programs,
|
||||||
RemoteResourceMappings,
|
RemoteResourceMappings,
|
||||||
|
ShareInboxItems,
|
||||||
SyncMetadataEntries,
|
SyncMetadataEntries,
|
||||||
WorkoutHistories,
|
WorkoutHistories,
|
||||||
WorkoutHistorySetResults,
|
WorkoutHistorySetResults,
|
||||||
@ -44,7 +46,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 11;
|
int get schemaVersion => 12;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@ -92,6 +94,9 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 11) {
|
if (from < 11) {
|
||||||
await _migrateToSchema11(migrator);
|
await _migrateToSchema11(migrator);
|
||||||
}
|
}
|
||||||
|
if (from < 12) {
|
||||||
|
await _migrateToSchema12(migrator);
|
||||||
|
}
|
||||||
await _createIndexes();
|
await _createIndexes();
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
@ -140,6 +145,14 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
|
'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
|
||||||
'ON remote_resource_mappings (resource_type, client_id)',
|
'ON remote_resource_mappings (resource_type, client_id)',
|
||||||
);
|
);
|
||||||
|
await customStatement(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_share_inbox_items_created_at '
|
||||||
|
'ON share_inbox_items (created_at)',
|
||||||
|
);
|
||||||
|
await customStatement(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_pending_share_actions_status '
|
||||||
|
'ON pending_share_actions (status, created_at)',
|
||||||
|
);
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
|
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
|
||||||
'ON workout_template_programs (workout_template_id)',
|
'ON workout_template_programs (workout_template_id)',
|
||||||
@ -341,4 +354,9 @@ extension on AppDatabase {
|
|||||||
await migrator.createTable(syncMetadataEntries);
|
await migrator.createTable(syncMetadataEntries);
|
||||||
await migrator.createTable(remoteResourceMappings);
|
await migrator.createTable(remoteResourceMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateToSchema12(Migrator migrator) async {
|
||||||
|
await migrator.createTable(shareInboxItems);
|
||||||
|
await migrator.createTable(pendingShareActions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -395,7 +395,40 @@ final class DriftLocalSyncChangeRepository
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
case SyncResourceType.program:
|
case SyncResourceType.program:
|
||||||
|
final program = _programFromPayload(item);
|
||||||
|
await database.transaction(() async {
|
||||||
|
await database
|
||||||
|
.into(database.programs)
|
||||||
|
.insertOnConflictUpdate(_programCompanion(program));
|
||||||
|
for (final exercise in program.exercises) {
|
||||||
|
await database
|
||||||
|
.into(database.programExercises)
|
||||||
|
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
case SyncResourceType.workoutTemplate:
|
case SyncResourceType.workoutTemplate:
|
||||||
|
final template = _workoutTemplateFromPayload(item);
|
||||||
|
await database.transaction(() async {
|
||||||
|
await database
|
||||||
|
.into(database.workoutTemplates)
|
||||||
|
.insertOnConflictUpdate(_workoutTemplateCompanion(template));
|
||||||
|
for (final program in template.programs) {
|
||||||
|
await database
|
||||||
|
.into(database.workoutTemplatePrograms)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
_workoutTemplateProgramCompanion(program),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final override in template.overrides) {
|
||||||
|
await database
|
||||||
|
.into(database.workoutTemplateExerciseOverrides)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
_workoutTemplateExerciseOverrideCompanion(override),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
case SyncResourceType.workoutHistory:
|
case SyncResourceType.workoutHistory:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -483,6 +516,132 @@ final class DriftLocalSyncChangeRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class DriftShareInboxRepository implements ShareInboxRepository {
|
||||||
|
const DriftShareInboxRepository(this.database);
|
||||||
|
|
||||||
|
final db.AppDatabase database;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<domain.ShareInboxItem>> listAll() async {
|
||||||
|
final rows = await (database.select(
|
||||||
|
database.shareInboxItems,
|
||||||
|
)..orderBy([(table) => OrderingTerm.desc(table.createdAt)])).get();
|
||||||
|
return rows.map(_shareInboxItemFromRow).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<domain.ShareInboxItem?> findByShareId(String shareId) async {
|
||||||
|
final row = await (database.select(
|
||||||
|
database.shareInboxItems,
|
||||||
|
)..where((table) => table.shareId.equals(shareId))).getSingleOrNull();
|
||||||
|
return row == null ? null : _shareInboxItemFromRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsert(domain.ShareInboxItem item) async {
|
||||||
|
await database
|
||||||
|
.into(database.shareInboxItems)
|
||||||
|
.insertOnConflictUpdate(_shareInboxItemCompanion(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertAll(List<domain.ShareInboxItem> items) async {
|
||||||
|
await database.batch((batch) {
|
||||||
|
batch.insertAllOnConflictUpdate(
|
||||||
|
database.shareInboxItems,
|
||||||
|
items.map(_shareInboxItemCompanion).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markStatus(
|
||||||
|
String shareId,
|
||||||
|
domain.ShareInboxStatus status,
|
||||||
|
DateTime respondedAt,
|
||||||
|
) async {
|
||||||
|
await (database.update(
|
||||||
|
database.shareInboxItems,
|
||||||
|
)..where((table) => table.shareId.equals(shareId))).write(
|
||||||
|
db.ShareInboxItemsCompanion(
|
||||||
|
status: Value<String>(_shareInboxStatusToDb(status)),
|
||||||
|
respondedAt: Value<DateTime?>(respondedAt.toUtc()),
|
||||||
|
updatedAt: Value<DateTime>(respondedAt.toUtc()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DriftPendingShareActionRepository
|
||||||
|
implements PendingShareActionRepository {
|
||||||
|
const DriftPendingShareActionRepository(this.database);
|
||||||
|
|
||||||
|
final db.AppDatabase database;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<domain.PendingShareAction>> listPending() async {
|
||||||
|
final rows =
|
||||||
|
await (database.select(database.pendingShareActions)
|
||||||
|
..where(
|
||||||
|
(table) => table.status.isIn([
|
||||||
|
_pendingShareActionStatusToDb(
|
||||||
|
domain.PendingShareActionStatus.pending,
|
||||||
|
),
|
||||||
|
_pendingShareActionStatusToDb(
|
||||||
|
domain.PendingShareActionStatus.failed,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
..orderBy([(table) => OrderingTerm.asc(table.createdAt)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_pendingShareActionFromRow).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> add(domain.PendingShareAction action) async {
|
||||||
|
await database
|
||||||
|
.into(database.pendingShareActions)
|
||||||
|
.insertOnConflictUpdate(_pendingShareActionCompanion(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markSucceeded(String id, DateTime attemptedAt) async {
|
||||||
|
await _markActionAttempt(
|
||||||
|
id,
|
||||||
|
attemptedAt,
|
||||||
|
domain.PendingShareActionStatus.succeeded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markFailed(String id, DateTime attemptedAt) async {
|
||||||
|
await _markActionAttempt(
|
||||||
|
id,
|
||||||
|
attemptedAt,
|
||||||
|
domain.PendingShareActionStatus.failed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markActionAttempt(
|
||||||
|
String id,
|
||||||
|
DateTime attemptedAt,
|
||||||
|
domain.PendingShareActionStatus status,
|
||||||
|
) async {
|
||||||
|
final row = await (database.select(
|
||||||
|
database.pendingShareActions,
|
||||||
|
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||||
|
await (database.update(
|
||||||
|
database.pendingShareActions,
|
||||||
|
)..where((table) => table.id.equals(id))).write(
|
||||||
|
db.PendingShareActionsCompanion(
|
||||||
|
lastAttemptAt: Value<DateTime?>(attemptedAt.toUtc()),
|
||||||
|
attemptCount: Value<int>((row?.attemptCount ?? 0) + 1),
|
||||||
|
status: Value<String>(_pendingShareActionStatusToDb(status)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class DriftProgramRepository implements ProgramRepository {
|
final class DriftProgramRepository implements ProgramRepository {
|
||||||
const DriftProgramRepository(this.database);
|
const DriftProgramRepository(this.database);
|
||||||
|
|
||||||
@ -1846,6 +2005,74 @@ domain.UserAccountSession _userAccountSessionFromRow(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.ShareInboxItemsCompanion _shareInboxItemCompanion(
|
||||||
|
domain.ShareInboxItem item,
|
||||||
|
) {
|
||||||
|
final updatedAt = item.respondedAt ?? item.createdAt;
|
||||||
|
return db.ShareInboxItemsCompanion.insert(
|
||||||
|
shareId: item.shareId,
|
||||||
|
senderUserId: item.senderUserId,
|
||||||
|
resourceType: _shareResourceTypeToDb(item.resourceType),
|
||||||
|
payloadJson: item.payloadJson,
|
||||||
|
status: _shareInboxStatusToDb(item.status),
|
||||||
|
createdAt: item.createdAt.toUtc(),
|
||||||
|
updatedAt: updatedAt.toUtc(),
|
||||||
|
respondedAt: Value<DateTime?>(_utcOrNull(item.respondedAt)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.ShareInboxItem _shareInboxItemFromRow(db.ShareInboxItem row) {
|
||||||
|
return domain.ShareInboxItem(
|
||||||
|
shareId: row.shareId,
|
||||||
|
senderUserId: row.senderUserId,
|
||||||
|
resourceType: _shareResourceTypeFromDb(row.resourceType),
|
||||||
|
payloadJson: row.payloadJson,
|
||||||
|
status: _shareInboxStatusFromDb(row.status),
|
||||||
|
createdAt: _utc(row.createdAt),
|
||||||
|
respondedAt: _utcOrNull(row.respondedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PendingShareActionsCompanion _pendingShareActionCompanion(
|
||||||
|
domain.PendingShareAction action,
|
||||||
|
) {
|
||||||
|
return db.PendingShareActionsCompanion.insert(
|
||||||
|
id: action.id,
|
||||||
|
actionType: _pendingShareActionTypeToDb(action.actionType),
|
||||||
|
shareId: Value<String?>(action.shareId),
|
||||||
|
resourceType: Value<String?>(
|
||||||
|
action.resourceType == null
|
||||||
|
? null
|
||||||
|
: _shareResourceTypeToDb(action.resourceType!),
|
||||||
|
),
|
||||||
|
payloadJson: Value<String?>(action.payloadJson),
|
||||||
|
recipientEmailsJson: Value<String?>(action.recipientEmailsJson),
|
||||||
|
createdAt: action.createdAt.toUtc(),
|
||||||
|
lastAttemptAt: Value<DateTime?>(_utcOrNull(action.lastAttemptAt)),
|
||||||
|
attemptCount: action.attemptCount,
|
||||||
|
status: _pendingShareActionStatusToDb(action.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.PendingShareAction _pendingShareActionFromRow(
|
||||||
|
db.PendingShareAction row,
|
||||||
|
) {
|
||||||
|
return domain.PendingShareAction(
|
||||||
|
id: row.id,
|
||||||
|
actionType: _pendingShareActionTypeFromDb(row.actionType),
|
||||||
|
shareId: row.shareId,
|
||||||
|
resourceType: row.resourceType == null
|
||||||
|
? null
|
||||||
|
: _shareResourceTypeFromDb(row.resourceType!),
|
||||||
|
payloadJson: row.payloadJson,
|
||||||
|
recipientEmailsJson: row.recipientEmailsJson,
|
||||||
|
createdAt: _utc(row.createdAt),
|
||||||
|
lastAttemptAt: _utcOrNull(row.lastAttemptAt),
|
||||||
|
attemptCount: row.attemptCount,
|
||||||
|
status: _pendingShareActionStatusFromDb(row.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
db.ProgramsCompanion _programCompanion(domain.Program program) {
|
db.ProgramsCompanion _programCompanion(domain.Program program) {
|
||||||
final values = _metadataValues(program.metadata);
|
final values = _metadataValues(program.metadata);
|
||||||
return db.ProgramsCompanion(
|
return db.ProgramsCompanion(
|
||||||
@ -2825,6 +3052,35 @@ domain.MediaAsset _mediaAssetFromPayload(RemoteSyncedItem item) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
domain.Program _programFromPayload(RemoteSyncedItem item) {
|
||||||
|
final payload = item.payload;
|
||||||
|
final metadata = _metadataFromPayload(item);
|
||||||
|
return domain.Program(
|
||||||
|
metadata: metadata,
|
||||||
|
name: _stringFromPayload(payload, 'name', item.clientId),
|
||||||
|
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
|
||||||
|
exercises: _programExercisesFromPayload(payload['exercises'], metadata),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.WorkoutTemplate _workoutTemplateFromPayload(RemoteSyncedItem item) {
|
||||||
|
final payload = item.payload;
|
||||||
|
final metadata = _metadataFromPayload(item);
|
||||||
|
return domain.WorkoutTemplate(
|
||||||
|
metadata: metadata,
|
||||||
|
name: _stringFromPayload(payload, 'name', item.clientId),
|
||||||
|
lastStartedAt: _dateTimeFromPayload(payload['lastStartedAt']),
|
||||||
|
programs: _workoutTemplateProgramsFromPayload(
|
||||||
|
payload['programs'],
|
||||||
|
metadata,
|
||||||
|
),
|
||||||
|
overrides: _workoutTemplateOverridesFromPayload(
|
||||||
|
payload['overrides'],
|
||||||
|
metadata,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
|
domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
|
||||||
final metadata = item.payload['metadata'];
|
final metadata = item.payload['metadata'];
|
||||||
final map = metadata is Map ? Map<String, Object?>.from(metadata) : null;
|
final map = metadata is Map ? Map<String, Object?>.from(metadata) : null;
|
||||||
@ -2842,6 +3098,153 @@ domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<domain.ProgramExercise> _programExercisesFromPayload(
|
||||||
|
Object? value,
|
||||||
|
domain.EntityMetadata parentMetadata,
|
||||||
|
) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((entry) {
|
||||||
|
final map = Map<String, Object?>.from(entry);
|
||||||
|
final id = _stringFromPayload(map, 'id', 'program-exercise');
|
||||||
|
return domain.ProgramExercise(
|
||||||
|
metadata: _childMetadataFromPayload(map, id, parentMetadata),
|
||||||
|
programId: parentMetadata.id,
|
||||||
|
sourceExerciseId: null,
|
||||||
|
position: map['position'] as int? ?? 0,
|
||||||
|
exerciseNameSnapshot: _stringFromPayload(
|
||||||
|
map,
|
||||||
|
'exerciseNameSnapshot',
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
exerciseDescriptionSnapshot:
|
||||||
|
map['exerciseDescriptionSnapshot'] as String?,
|
||||||
|
exerciseImageMediaIdSnapshot:
|
||||||
|
map['exerciseImageMediaIdSnapshot'] as String?,
|
||||||
|
exerciseImageMediaIdsSnapshot: _stringListFromPayload(
|
||||||
|
map['exerciseImageMediaIdsSnapshot'] ??
|
||||||
|
map['imageMediaIdsSnapshot'],
|
||||||
|
),
|
||||||
|
exerciseVideoMediaIdSnapshot:
|
||||||
|
map['exerciseVideoMediaIdSnapshot'] as String?,
|
||||||
|
exerciseStepsSnapshot: _stepsFromPayload(
|
||||||
|
map['exerciseStepsSnapshot'],
|
||||||
|
),
|
||||||
|
exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
|
||||||
|
availableTimeSnapshot: map['availableTimeSnapshot'] == true,
|
||||||
|
availableRepsSnapshot: map['availableRepsSnapshot'] == true,
|
||||||
|
availableScoreSnapshot: map['availableScoreSnapshot'] == true,
|
||||||
|
scoreInputModeSnapshot: _scoreInputModeFromDb(
|
||||||
|
map['scoreInputModeSnapshot'] as String? ?? 'manual',
|
||||||
|
),
|
||||||
|
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?,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<domain.WorkoutTemplateProgram> _workoutTemplateProgramsFromPayload(
|
||||||
|
Object? value,
|
||||||
|
domain.EntityMetadata parentMetadata,
|
||||||
|
) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((entry) {
|
||||||
|
final map = Map<String, Object?>.from(entry);
|
||||||
|
final id = _stringFromPayload(map, 'id', 'template-program');
|
||||||
|
return domain.WorkoutTemplateProgram(
|
||||||
|
metadata: _childMetadataFromPayload(map, id, parentMetadata),
|
||||||
|
workoutTemplateId: parentMetadata.id,
|
||||||
|
sourceProgramId: null,
|
||||||
|
position: map['position'] as int? ?? 0,
|
||||||
|
programNameSnapshot: _stringFromPayload(
|
||||||
|
map,
|
||||||
|
'programNameSnapshot',
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
defaultRestSecondsSnapshot:
|
||||||
|
map['defaultRestSecondsSnapshot'] as int? ?? 0,
|
||||||
|
programSnapshotJson:
|
||||||
|
map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<domain.WorkoutTemplateExerciseOverride>
|
||||||
|
_workoutTemplateOverridesFromPayload(
|
||||||
|
Object? value,
|
||||||
|
domain.EntityMetadata parentMetadata,
|
||||||
|
) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((entry) {
|
||||||
|
final map = Map<String, Object?>.from(entry);
|
||||||
|
final id = _stringFromPayload(map, 'id', 'template-override');
|
||||||
|
return domain.WorkoutTemplateExerciseOverride(
|
||||||
|
metadata: _childMetadataFromPayload(map, id, parentMetadata),
|
||||||
|
workoutTemplateProgramId: _stringFromPayload(
|
||||||
|
map,
|
||||||
|
'workoutTemplateProgramId',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
snapshotProgramExerciseId: _stringFromPayload(
|
||||||
|
map,
|
||||||
|
'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?,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.EntityMetadata _childMetadataFromPayload(
|
||||||
|
Map<String, Object?> payload,
|
||||||
|
String id,
|
||||||
|
domain.EntityMetadata parentMetadata,
|
||||||
|
) {
|
||||||
|
final metadata = payload['metadata'];
|
||||||
|
final map = metadata is Map ? Map<String, Object?>.from(metadata) : null;
|
||||||
|
return domain.EntityMetadata(
|
||||||
|
id: id,
|
||||||
|
createdAt:
|
||||||
|
_dateTimeFromPayload(map?['createdAt']) ?? parentMetadata.createdAt,
|
||||||
|
updatedAt:
|
||||||
|
_dateTimeFromPayload(map?['updatedAt']) ?? parentMetadata.updatedAt,
|
||||||
|
deletedAt: _dateTimeFromPayload(map?['deletedAt']),
|
||||||
|
schemaVersion:
|
||||||
|
map?['schemaVersion'] as int? ?? parentMetadata.schemaVersion,
|
||||||
|
syncState: parentMetadata.syncState,
|
||||||
|
localRevision: map?['localRevision'] as int? ?? 0,
|
||||||
|
originDeviceId:
|
||||||
|
map?['originDeviceId'] as String? ?? parentMetadata.originDeviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _stringFromPayload(
|
String _stringFromPayload(
|
||||||
Map<String, Object?> payload,
|
Map<String, Object?> payload,
|
||||||
String key,
|
String key,
|
||||||
@ -2965,6 +3368,67 @@ OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) {
|
|||||||
_ => throw domain.DomainException('Unknown sync status: $value'),
|
_ => throw domain.DomainException('Unknown sync status: $value'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
String _shareResourceTypeToDb(domain.ShareResourceType type) => switch (type) {
|
||||||
|
domain.ShareResourceType.program => 'program',
|
||||||
|
domain.ShareResourceType.workoutTemplate => 'workoutTemplate',
|
||||||
|
};
|
||||||
|
|
||||||
|
domain.ShareResourceType _shareResourceTypeFromDb(String value) =>
|
||||||
|
switch (value) {
|
||||||
|
'program' => domain.ShareResourceType.program,
|
||||||
|
'workoutTemplate' => domain.ShareResourceType.workoutTemplate,
|
||||||
|
_ => throw domain.DomainException('Unknown share resource type: $value'),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _shareInboxStatusToDb(domain.ShareInboxStatus status) =>
|
||||||
|
switch (status) {
|
||||||
|
domain.ShareInboxStatus.pending => 'pending',
|
||||||
|
domain.ShareInboxStatus.accepted => 'accepted',
|
||||||
|
domain.ShareInboxStatus.declined => 'declined',
|
||||||
|
domain.ShareInboxStatus.revoked => 'revoked',
|
||||||
|
};
|
||||||
|
|
||||||
|
domain.ShareInboxStatus _shareInboxStatusFromDb(String value) =>
|
||||||
|
switch (value) {
|
||||||
|
'pending' => domain.ShareInboxStatus.pending,
|
||||||
|
'accepted' => domain.ShareInboxStatus.accepted,
|
||||||
|
'declined' => domain.ShareInboxStatus.declined,
|
||||||
|
'revoked' => domain.ShareInboxStatus.revoked,
|
||||||
|
_ => throw domain.DomainException('Unknown share inbox status: $value'),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _pendingShareActionTypeToDb(domain.PendingShareActionType type) =>
|
||||||
|
switch (type) {
|
||||||
|
domain.PendingShareActionType.send => 'send',
|
||||||
|
domain.PendingShareActionType.accept => 'accept',
|
||||||
|
domain.PendingShareActionType.decline => 'decline',
|
||||||
|
domain.PendingShareActionType.revoke => 'revoke',
|
||||||
|
};
|
||||||
|
|
||||||
|
domain.PendingShareActionType _pendingShareActionTypeFromDb(String value) =>
|
||||||
|
switch (value) {
|
||||||
|
'send' => domain.PendingShareActionType.send,
|
||||||
|
'accept' => domain.PendingShareActionType.accept,
|
||||||
|
'decline' => domain.PendingShareActionType.decline,
|
||||||
|
'revoke' => domain.PendingShareActionType.revoke,
|
||||||
|
_ => throw domain.DomainException('Unknown share action type: $value'),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _pendingShareActionStatusToDb(domain.PendingShareActionStatus status) =>
|
||||||
|
switch (status) {
|
||||||
|
domain.PendingShareActionStatus.pending => 'pending',
|
||||||
|
domain.PendingShareActionStatus.succeeded => 'succeeded',
|
||||||
|
domain.PendingShareActionStatus.failed => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
domain.PendingShareActionStatus _pendingShareActionStatusFromDb(String value) =>
|
||||||
|
switch (value) {
|
||||||
|
'pending' => domain.PendingShareActionStatus.pending,
|
||||||
|
'succeeded' => domain.PendingShareActionStatus.succeeded,
|
||||||
|
'failed' => domain.PendingShareActionStatus.failed,
|
||||||
|
_ => throw domain.DomainException('Unknown share action status: $value'),
|
||||||
|
};
|
||||||
|
|
||||||
String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
|
String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
|
||||||
domain.ActiveWorkoutStatus.running => 'running',
|
domain.ActiveWorkoutStatus.running => 'running',
|
||||||
domain.ActiveWorkoutStatus.paused => 'paused',
|
domain.ActiveWorkoutStatus.paused => 'paused',
|
||||||
|
|||||||
@ -92,6 +92,57 @@ class RemoteResourceMappings extends Table {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ShareInboxItems extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'share_inbox_items';
|
||||||
|
|
||||||
|
TextColumn get shareId => text()();
|
||||||
|
TextColumn get senderUserId => text().withLength(min: 1)();
|
||||||
|
TextColumn get resourceType => text()();
|
||||||
|
TextColumn get payloadJson => text().withLength(min: 1)();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
DateTimeColumn get respondedAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {shareId};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get customConstraints => [
|
||||||
|
"CHECK (resource_type IN ('program', 'workoutTemplate'))",
|
||||||
|
"CHECK (status IN ('pending', 'accepted', 'declined', 'revoked'))",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingShareActions extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'pending_share_actions';
|
||||||
|
|
||||||
|
TextColumn get id => text()();
|
||||||
|
TextColumn get actionType => text()();
|
||||||
|
TextColumn get shareId => text().nullable()();
|
||||||
|
TextColumn get resourceType => text().nullable()();
|
||||||
|
TextColumn get payloadJson => text().nullable()();
|
||||||
|
TextColumn get recipientEmailsJson => text().nullable()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
||||||
|
IntColumn get attemptCount =>
|
||||||
|
integer().customConstraint('NOT NULL CHECK (attempt_count >= 0)')();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get customConstraints => [
|
||||||
|
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
|
||||||
|
"CHECK (resource_type IS NULL OR resource_type IN "
|
||||||
|
"('program', 'workoutTemplate'))",
|
||||||
|
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
class MediaAssets extends SyncableTable {
|
class MediaAssets extends SyncableTable {
|
||||||
@override
|
@override
|
||||||
String get tableName => 'media_assets';
|
String get tableName => 'media_assets';
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export 'auth_api.dart';
|
export 'auth_api.dart';
|
||||||
export 'http_api_client.dart';
|
export 'http_api_client.dart';
|
||||||
|
export 'share_api.dart';
|
||||||
export 'sync_api.dart';
|
export 'sync_api.dart';
|
||||||
|
|||||||
188
lib/infrastructure/remote/share_api.dart
Normal file
188
lib/infrastructure/remote/share_api.dart
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../application/application.dart';
|
||||||
|
import '../../domain/domain.dart';
|
||||||
|
import 'http_api_client.dart';
|
||||||
|
|
||||||
|
final class HttpRemoteShareApi implements RemoteShareApi {
|
||||||
|
const HttpRemoteShareApi(this.client);
|
||||||
|
|
||||||
|
final HttpApiClient client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteShareSendResult> sendShare({
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required Map<String, Object?> payload,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
final response = await client.postJson(
|
||||||
|
'/shares',
|
||||||
|
bearerToken: token,
|
||||||
|
body: {
|
||||||
|
'resourceType': _shareResourceTypeToWire(resourceType),
|
||||||
|
'payload': payload,
|
||||||
|
'recipientEmails': recipientEmails,
|
||||||
|
},
|
||||||
|
expectedStatuses: const {201},
|
||||||
|
);
|
||||||
|
return RemoteShareSendResult(
|
||||||
|
shareId: _requiredString(response, 'shareId'),
|
||||||
|
recipientUserIds: _stringList(response, 'recipientUserIds'),
|
||||||
|
unresolvedEmails: _stringList(response, 'unresolvedEmails'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> fetchInbox(String token) async {
|
||||||
|
final response = await client.getJson('/shares/inbox', bearerToken: token);
|
||||||
|
return _list(
|
||||||
|
response,
|
||||||
|
'items',
|
||||||
|
).map((item) => _inboxItemFromJson(_map(item))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||||
|
final response = await client.postJson(
|
||||||
|
'/shares/$shareId/accept',
|
||||||
|
bearerToken: token,
|
||||||
|
);
|
||||||
|
return _syncedItemFromJson(_map(response['createdResource']));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> declineShare(String shareId, String token) {
|
||||||
|
return client.postEmpty('/shares/$shareId/decline', bearerToken: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revokeShare(String shareId, String token) {
|
||||||
|
return client.postEmpty('/shares/$shareId/revoke', bearerToken: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareInboxItem _inboxItemFromJson(Map<String, Object?> json) {
|
||||||
|
return ShareInboxItem(
|
||||||
|
shareId: _requiredString(json, 'shareId'),
|
||||||
|
senderUserId: _requiredString(json, 'senderUserId'),
|
||||||
|
resourceType: _shareResourceTypeFromWire(
|
||||||
|
_requiredString(json, 'resourceType'),
|
||||||
|
),
|
||||||
|
payloadJson: _jsonObjectString(json['payload']),
|
||||||
|
status: _shareInboxStatusFromWire(_requiredString(json, 'status')),
|
||||||
|
createdAt: _requiredDateTime(json, 'createdAt'),
|
||||||
|
respondedAt: _optionalDateTime(json, 'respondedAt'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
||||||
|
return RemoteSyncedItem(
|
||||||
|
resourceType: _syncResourceTypeFromWire(
|
||||||
|
_requiredString(json, 'resourceType'),
|
||||||
|
),
|
||||||
|
clientId: _requiredString(json, 'clientId'),
|
||||||
|
serverId: _requiredString(json, 'serverId'),
|
||||||
|
schemaVersion: _requiredInt(json, 'schemaVersion'),
|
||||||
|
clientUpdatedAt: _requiredDateTime(json, 'clientUpdatedAt'),
|
||||||
|
serverUpdatedAt: _requiredDateTime(json, 'serverUpdatedAt'),
|
||||||
|
deletedAt: _optionalDateTime(json, 'deletedAt'),
|
||||||
|
payload: _map(json['payload']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shareResourceTypeToWire(ShareResourceType type) => switch (type) {
|
||||||
|
ShareResourceType.program => 'program',
|
||||||
|
ShareResourceType.workoutTemplate => 'workoutTemplate',
|
||||||
|
};
|
||||||
|
|
||||||
|
ShareResourceType _shareResourceTypeFromWire(String value) => switch (value) {
|
||||||
|
'program' => ShareResourceType.program,
|
||||||
|
'workoutTemplate' => ShareResourceType.workoutTemplate,
|
||||||
|
_ => throw RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'Unknown share resource type: $value',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
ShareInboxStatus _shareInboxStatusFromWire(String value) => switch (value) {
|
||||||
|
'pending' => ShareInboxStatus.pending,
|
||||||
|
'accepted' => ShareInboxStatus.accepted,
|
||||||
|
'declined' => ShareInboxStatus.declined,
|
||||||
|
'revoked' => ShareInboxStatus.revoked,
|
||||||
|
_ => throw RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'Unknown share status: $value',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
SyncResourceType _syncResourceTypeFromWire(String value) => switch (value) {
|
||||||
|
'exercise' => SyncResourceType.exercise,
|
||||||
|
'program' => SyncResourceType.program,
|
||||||
|
'workoutTemplate' => SyncResourceType.workoutTemplate,
|
||||||
|
'workoutHistory' => SyncResourceType.workoutHistory,
|
||||||
|
'mediaAsset' => SyncResourceType.mediaAsset,
|
||||||
|
_ => throw RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'Unknown sync resource type: $value',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Object?> _list(Map<String, Object?> json, String key) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
return value.cast<Object?>();
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _stringList(Map<String, Object?> json, String key) {
|
||||||
|
return _list(json, key).whereType<String>().toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _map(Object? value) {
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, Object?>.from(value);
|
||||||
|
}
|
||||||
|
throw const RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'Expected JSON object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredString(Map<String, Object?> json, String key) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'$key is missing from share response.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _requiredInt(Map<String, Object?> json, String key) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw RemoteAuthException(
|
||||||
|
RemoteAuthFailure.unknown,
|
||||||
|
'$key is missing from share response.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _requiredDateTime(Map<String, Object?> json, String key) {
|
||||||
|
return DateTime.parse(_requiredString(json, key)).toUtc();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
|
||||||
|
final value = json[key];
|
||||||
|
return value is String && value.trim().isNotEmpty
|
||||||
|
? DateTime.parse(value).toUtc()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _jsonObjectString(Object? value) {
|
||||||
|
return jsonEncode(_map(value));
|
||||||
|
}
|
||||||
@ -1361,6 +1361,118 @@ void main() {
|
|||||||
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ShareUseCases sendShare returns neutral result without token',
|
||||||
|
() async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi();
|
||||||
|
final pendingRepository = _FakePendingShareActionRepository();
|
||||||
|
final programRepository = _FakeProgramRepository()
|
||||||
|
..programs.add(_shareableProgram());
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await _shareUseCase(
|
||||||
|
tokenStore: _FakeAuthTokenStore(),
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
pendingActionRepository: pendingRepository,
|
||||||
|
programRepository: programRepository,
|
||||||
|
).sendShare(
|
||||||
|
resourceType: ShareResourceType.program,
|
||||||
|
localResourceId: 'program-1',
|
||||||
|
recipientEmails: const ['friend@example.com'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, ShareSendStatus.notConnected);
|
||||||
|
expect(remoteShareApi.sendCalls, 0);
|
||||||
|
expect(pendingRepository.actions, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('ShareUseCases sendShare queues action when network fails', () async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi()
|
||||||
|
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
||||||
|
final pendingRepository = _FakePendingShareActionRepository();
|
||||||
|
final programRepository = _FakeProgramRepository()
|
||||||
|
..programs.add(_shareableProgram());
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await _shareUseCase(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
pendingActionRepository: pendingRepository,
|
||||||
|
programRepository: programRepository,
|
||||||
|
).sendShare(
|
||||||
|
resourceType: ShareResourceType.program,
|
||||||
|
localResourceId: 'program-1',
|
||||||
|
recipientEmails: const ['friend@example.com'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, ShareSendStatus.queued);
|
||||||
|
expect(
|
||||||
|
pendingRepository.actions.single.actionType,
|
||||||
|
PendingShareActionType.send,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pendingRepository.actions.single.resourceType,
|
||||||
|
ShareResourceType.program,
|
||||||
|
);
|
||||||
|
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ShareUseCases acceptShare imports returned resource locally', () async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi()
|
||||||
|
..acceptResult = _remoteSharedProgramItem();
|
||||||
|
final localChanges = _FakeLocalSyncChangeRepository();
|
||||||
|
final inboxRepository = _FakeShareInboxRepository()
|
||||||
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending));
|
||||||
|
|
||||||
|
await _shareUseCase(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
localChanges: localChanges,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
).acceptShare('share-1');
|
||||||
|
|
||||||
|
expect(localChanges.appliedItems.single.clientId, 'shared-program-1');
|
||||||
|
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ShareUseCases refreshInbox updates the local cache', () async {
|
||||||
|
final remoteShareApi = _FakeRemoteShareApi()
|
||||||
|
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
||||||
|
final inboxRepository = _FakeShareInboxRepository();
|
||||||
|
|
||||||
|
final items = await _shareUseCase(
|
||||||
|
remoteShareApi: remoteShareApi,
|
||||||
|
inboxRepository: inboxRepository,
|
||||||
|
).refreshInbox();
|
||||||
|
|
||||||
|
expect(items.single.shareId, 'share-1');
|
||||||
|
expect(inboxRepository.items.single.senderUserId, 'sender-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ShareUseCases decline and revoke update local statuses', () async {
|
||||||
|
final inboxRepository = _FakeShareInboxRepository()
|
||||||
|
..items.add(_shareInboxItem(status: ShareInboxStatus.pending))
|
||||||
|
..items.add(
|
||||||
|
_shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending),
|
||||||
|
);
|
||||||
|
final useCase = _shareUseCase(inboxRepository: inboxRepository);
|
||||||
|
|
||||||
|
await useCase.declineShare('share-1');
|
||||||
|
await useCase.revokeShare('share-2');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
inboxRepository.items
|
||||||
|
.singleWhere((item) => item.shareId == 'share-1')
|
||||||
|
.status,
|
||||||
|
ShareInboxStatus.declined,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
inboxRepository.items
|
||||||
|
.singleWhere((item) => item.shareId == 'share-2')
|
||||||
|
.status,
|
||||||
|
ShareInboxStatus.revoked,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('score result enforces manual xor stopwatch values', () {
|
test('score result enforces manual xor stopwatch values', () {
|
||||||
expect(
|
expect(
|
||||||
() => ActiveSetResult(
|
() => ActiveSetResult(
|
||||||
@ -1734,6 +1846,211 @@ RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ShareUseCases _shareUseCase({
|
||||||
|
_FakeAuthTokenStore? tokenStore,
|
||||||
|
_FakeRemoteShareApi? remoteShareApi,
|
||||||
|
_FakeShareInboxRepository? inboxRepository,
|
||||||
|
_FakePendingShareActionRepository? pendingActionRepository,
|
||||||
|
_FakeLocalSyncChangeRepository? localChanges,
|
||||||
|
_FakeProgramRepository? programRepository,
|
||||||
|
_FakeWorkoutTemplateRepository? templateRepository,
|
||||||
|
}) {
|
||||||
|
return ShareUseCases(
|
||||||
|
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
||||||
|
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
|
||||||
|
inboxRepository: inboxRepository ?? _FakeShareInboxRepository(),
|
||||||
|
pendingActionRepository:
|
||||||
|
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||||||
|
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
||||||
|
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||||
|
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
|
Exception? exception;
|
||||||
|
RemoteSyncedItem? acceptResult;
|
||||||
|
List<ShareInboxItem> inboxItems = const [];
|
||||||
|
var sendCalls = 0;
|
||||||
|
var acceptCalls = 0;
|
||||||
|
var declineCalls = 0;
|
||||||
|
var revokeCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||||
|
acceptCalls += 1;
|
||||||
|
final error = exception;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return acceptResult ?? _remoteSharedProgramItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> declineShare(String shareId, String token) async {
|
||||||
|
declineCalls += 1;
|
||||||
|
final error = exception;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> fetchInbox(String token) async {
|
||||||
|
final error = exception;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return inboxItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revokeShare(String shareId, String token) async {
|
||||||
|
revokeCalls += 1;
|
||||||
|
final error = exception;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteShareSendResult> sendShare({
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required Map<String, Object?> payload,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
sendCalls += 1;
|
||||||
|
final error = exception;
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return const RemoteShareSendResult(
|
||||||
|
shareId: 'share-1',
|
||||||
|
recipientUserIds: ['server-user-2'],
|
||||||
|
unresolvedEmails: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||||
|
final items = <ShareInboxItem>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
||||||
|
return items.where((item) => item.shareId == shareId).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> listAll() async => List.of(items);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markStatus(
|
||||||
|
String shareId,
|
||||||
|
ShareInboxStatus status,
|
||||||
|
DateTime respondedAt,
|
||||||
|
) async {
|
||||||
|
final index = items.indexWhere((item) => item.shareId == shareId);
|
||||||
|
if (index == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items[index] = items[index].copyWith(
|
||||||
|
status: status,
|
||||||
|
respondedAt: respondedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsert(ShareInboxItem item) async {
|
||||||
|
items.removeWhere((existing) => existing.shareId == item.shareId);
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertAll(List<ShareInboxItem> items) async {
|
||||||
|
for (final item in items) {
|
||||||
|
await upsert(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakePendingShareActionRepository
|
||||||
|
implements PendingShareActionRepository {
|
||||||
|
final actions = <PendingShareAction>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> add(PendingShareAction action) async {
|
||||||
|
actions.add(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PendingShareAction>> listPending() async {
|
||||||
|
return actions
|
||||||
|
.where((action) => action.status != PendingShareActionStatus.succeeded)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markFailed(String id, DateTime attemptedAt) async {
|
||||||
|
_mark(id, attemptedAt, PendingShareActionStatus.failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markSucceeded(String id, DateTime attemptedAt) async {
|
||||||
|
_mark(id, attemptedAt, PendingShareActionStatus.succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _mark(String id, DateTime attemptedAt, PendingShareActionStatus status) {
|
||||||
|
final index = actions.indexWhere((action) => action.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final action = actions[index];
|
||||||
|
actions[index] = action.copyWith(
|
||||||
|
lastAttemptAt: attemptedAt,
|
||||||
|
attemptCount: action.attemptCount + 1,
|
||||||
|
status: status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Program _shareableProgram() {
|
||||||
|
return Program(
|
||||||
|
metadata: _metadata('program-1'),
|
||||||
|
name: 'Programme',
|
||||||
|
defaultRestSeconds: 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareInboxItem _shareInboxItem({
|
||||||
|
String shareId = 'share-1',
|
||||||
|
required ShareInboxStatus status,
|
||||||
|
}) {
|
||||||
|
return ShareInboxItem(
|
||||||
|
shareId: shareId,
|
||||||
|
senderUserId: 'sender-1',
|
||||||
|
resourceType: ShareResourceType.program,
|
||||||
|
payloadJson: '{"name":"Programme partagé"}',
|
||||||
|
status: status,
|
||||||
|
createdAt: DateTime.utc(2026, 7, 17, 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteSyncedItem _remoteSharedProgramItem() {
|
||||||
|
return RemoteSyncedItem(
|
||||||
|
resourceType: SyncResourceType.program,
|
||||||
|
clientId: 'shared-program-1',
|
||||||
|
serverId: 'server-program-1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
||||||
|
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
||||||
|
deletedAt: null,
|
||||||
|
payload: const {'id': 'shared-program-1', 'name': 'Programme partagé'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ExerciseUseCases _exerciseUseCase(
|
ExerciseUseCases _exerciseUseCase(
|
||||||
_FakeExerciseRepository repository, {
|
_FakeExerciseRepository repository, {
|
||||||
_FakeProgramRepository? programRepository,
|
_FakeProgramRepository? programRepository,
|
||||||
|
|||||||
@ -146,6 +146,17 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
deviceId: 'device-1',
|
deviceId: 'device-1',
|
||||||
),
|
),
|
||||||
|
shareUseCases = ShareUseCases(
|
||||||
|
tokenStore: _FakeAuthTokenStore(),
|
||||||
|
remoteShareApi: _FakeRemoteShareApi(),
|
||||||
|
inboxRepository: _FakeShareInboxRepository(),
|
||||||
|
pendingActionRepository: _FakePendingShareActionRepository(),
|
||||||
|
localChanges: _FakeLocalSyncChangeRepository(),
|
||||||
|
programRepository: _FakeProgramRepository(),
|
||||||
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
ids: _FakeIds(),
|
||||||
|
),
|
||||||
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
||||||
sessionRepository: activeRepository,
|
sessionRepository: activeRepository,
|
||||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||||
@ -207,6 +218,9 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
@override
|
@override
|
||||||
final SyncUseCases syncUseCases;
|
final SyncUseCases syncUseCases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ShareUseCases shareUseCases;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final ExerciseUseCases exerciseUseCases;
|
final ExerciseUseCases exerciseUseCases;
|
||||||
|
|
||||||
@ -390,6 +404,72 @@ final class _FakeLocalSyncChangeRepository
|
|||||||
) async {}
|
) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||||
|
@override
|
||||||
|
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||||
|
throw const RemoteAuthException(RemoteAuthFailure.network);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> declineShare(String shareId, String token) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revokeShare(String shareId, String token) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RemoteShareSendResult> sendShare({
|
||||||
|
required ShareResourceType resourceType,
|
||||||
|
required Map<String, Object?> payload,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
required String token,
|
||||||
|
}) async {
|
||||||
|
return const RemoteShareSendResult(
|
||||||
|
shareId: 'share-1',
|
||||||
|
recipientUserIds: [],
|
||||||
|
unresolvedEmails: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||||
|
@override
|
||||||
|
Future<ShareInboxItem?> findByShareId(String shareId) async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> listAll() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markStatus(
|
||||||
|
String shareId,
|
||||||
|
ShareInboxStatus status,
|
||||||
|
DateTime respondedAt,
|
||||||
|
) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsert(ShareInboxItem item) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertAll(List<ShareInboxItem> items) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakePendingShareActionRepository
|
||||||
|
implements PendingShareActionRepository {
|
||||||
|
@override
|
||||||
|
Future<void> add(PendingShareAction action) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PendingShareAction>> listPending() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markFailed(String id, DateTime attemptedAt) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
|
||||||
|
}
|
||||||
|
|
||||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||||
ActiveWorkoutSession? session;
|
ActiveWorkoutSession? session;
|
||||||
final restStates = <ActiveRestState>[];
|
final restStates = <ActiveRestState>[];
|
||||||
|
|||||||
Reference in New Issue
Block a user