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

@ -19,9 +19,11 @@ part 'app_database.g.dart';
ExerciseSteps,
MediaAssets,
OnlineAccountSessions,
PendingShareActions,
ProgramExercises,
Programs,
RemoteResourceMappings,
ShareInboxItems,
SyncMetadataEntries,
WorkoutHistories,
WorkoutHistorySetResults,
@ -44,7 +46,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 11;
int get schemaVersion => 12;
@override
MigrationStrategy get migration {
@ -92,6 +94,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 11) {
await _migrateToSchema11(migrator);
}
if (from < 12) {
await _migrateToSchema12(migrator);
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -140,6 +145,14 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
'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(
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
'ON workout_template_programs (workout_template_id)',
@ -341,4 +354,9 @@ extension on AppDatabase {
await migrator.createTable(syncMetadataEntries);
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

View File

@ -395,7 +395,40 @@ final class DriftLocalSyncChangeRepository
);
return true;
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:
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:
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 {
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) {
final values = _metadataValues(program.metadata);
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) {
final metadata = item.payload['metadata'];
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(
Map<String, Object?> payload,
String key,
@ -2965,6 +3368,67 @@ OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (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) {
domain.ActiveWorkoutStatus.running => 'running',
domain.ActiveWorkoutStatus.paused => 'paused',

View File

@ -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 {
@override
String get tableName => 'media_assets';

View File

@ -1,3 +1,4 @@
export 'auth_api.dart';
export 'http_api_client.dart';
export 'share_api.dart';
export 'sync_api.dart';

View 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));
}