Ajoute la synchronisation client incrémentale LWW vers l'API serveur (application/use_cases.dart: SyncUseCases, ports.dart, migration Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de ressources, infrastructure/remote/sync_api.dart) et l'écran de profil avec entrée sur l'accueil (presentation/profile_screen.dart, home_screen.dart, presentation.dart). Développés dans le même worktree partagé par DevBackend et DevFrontend ; commit combiné car test/presentation/home_screen_test.dart mélange authentiquement les deux tickets (le test de l'entrée Profil et la mise à jour du fake de bootstrap requise par le nouveau getter syncUseCases sur AppDependencies). Corrige au passage une couleur `crimson` inexistante dans le thème (remplacée par colorScheme.error) et une signature de paramètres positionnels/nommés incohérente sur un fake de test. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 119/119 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
171
lib/infrastructure/remote/sync_api.dart
Normal file
171
lib/infrastructure/remote/sync_api.dart
Normal file
@ -0,0 +1,171 @@
|
||||
import '../../application/application.dart';
|
||||
import 'http_api_client.dart';
|
||||
|
||||
final class HttpRemoteSyncApi implements RemoteSyncApi {
|
||||
const HttpRemoteSyncApi(this.client);
|
||||
|
||||
final HttpApiClient client;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await client.postJson(
|
||||
'/sync/push',
|
||||
bearerToken: token,
|
||||
body: {
|
||||
'deviceId': deviceId,
|
||||
'items': items.map(_pushItemToJson).toList(),
|
||||
},
|
||||
);
|
||||
return RemoteSyncPushResult(
|
||||
serverCursor: _optionalString(response, 'serverCursor'),
|
||||
results: (_list(
|
||||
response,
|
||||
'results',
|
||||
)).map((item) => _pushItemResultFromJson(_map(item))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
}) async {
|
||||
final response = await client.getJson(
|
||||
'/sync/pull',
|
||||
bearerToken: token,
|
||||
queryParameters: {'since': since},
|
||||
);
|
||||
return RemoteSyncPullResult(
|
||||
serverCursor: _optionalString(response, 'serverCursor'),
|
||||
items: (_list(
|
||||
response,
|
||||
'items',
|
||||
)).map((item) => _syncedItemFromJson(_map(item))).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _pushItemToJson(RemoteSyncPushItem item) => {
|
||||
'resourceType': _resourceTypeToWire(item.resourceType),
|
||||
'clientId': item.clientId,
|
||||
'schemaVersion': item.schemaVersion,
|
||||
'clientUpdatedAt': item.clientUpdatedAt.toUtc().toIso8601String(),
|
||||
'deletedAt': item.deletedAt?.toUtc().toIso8601String(),
|
||||
'payload': item.payload,
|
||||
};
|
||||
|
||||
RemoteSyncPushItemResult _pushItemResultFromJson(Map<String, Object?> json) {
|
||||
return RemoteSyncPushItemResult(
|
||||
resourceType: _resourceTypeFromWire(
|
||||
_requiredString(json, 'resourceType'),
|
||||
),
|
||||
clientId: _requiredString(json, 'clientId'),
|
||||
serverId: _optionalString(json, 'serverId'),
|
||||
status: _pushStatusFromWire(_requiredString(json, 'status')),
|
||||
serverUpdatedAt: _optionalDateTime(json, 'serverUpdatedAt'),
|
||||
errorMessage: _optionalString(json, 'message'),
|
||||
);
|
||||
}
|
||||
|
||||
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: _resourceTypeFromWire(
|
||||
_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 _resourceTypeToWire(SyncResourceType type) => switch (type) {
|
||||
SyncResourceType.exercise => 'exercise',
|
||||
SyncResourceType.program => 'program',
|
||||
SyncResourceType.workoutTemplate => 'workoutTemplate',
|
||||
SyncResourceType.workoutHistory => 'workoutHistory',
|
||||
SyncResourceType.mediaAsset => 'mediaAsset',
|
||||
};
|
||||
|
||||
SyncResourceType _resourceTypeFromWire(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',
|
||||
),
|
||||
};
|
||||
|
||||
RemoteSyncPushStatus _pushStatusFromWire(String value) => switch (value) {
|
||||
'accepted' => RemoteSyncPushStatus.accepted,
|
||||
'ignoredOlder' => RemoteSyncPushStatus.ignoredOlder,
|
||||
'error' => RemoteSyncPushStatus.error,
|
||||
_ => throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unknown sync push status: $value',
|
||||
),
|
||||
};
|
||||
|
||||
List<Object?> _list(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is List) {
|
||||
return value.cast<Object?>();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
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 sync response.',
|
||||
);
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
return value is String && value.trim().isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
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 sync 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 = _optionalString(json, key);
|
||||
return value == null ? null : DateTime.parse(value).toUtc();
|
||||
}
|
||||
Reference in New Issue
Block a user