feat(online): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)

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:
2026-07-19 21:41:18 +02:00
parent b643988986
commit c0dbce275b
16 changed files with 3913 additions and 3 deletions

View File

@ -68,12 +68,55 @@ final class HttpApiClient {
}
}
Uri _resolve(String path) {
Future<Map<String, Object?>> getJson(
String path, {
Map<String, String?> queryParameters = const {},
String? bearerToken,
Set<int> expectedStatuses = const {200},
}) async {
final response = await _send(
() => client.get(
_resolve(path, queryParameters: queryParameters),
headers: _headers(bearerToken),
),
);
if (!expectedStatuses.contains(response.statusCode)) {
throw _exceptionForStatus(response.statusCode, response.body);
}
if (response.body.trim().isEmpty) {
return const {};
}
final Object? decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException catch (error) {
throw RemoteAuthException(RemoteAuthFailure.unknown, error.message);
}
if (decoded is Map) {
return Map<String, Object?>.from(decoded);
}
throw const RemoteAuthException(
RemoteAuthFailure.unknown,
'Unexpected JSON response.',
);
}
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
final normalized = path.startsWith('/') ? path.substring(1) : path;
final base = baseUrl.toString().endsWith('/')
? baseUrl
: Uri.parse('${baseUrl.toString()}/');
return base.resolve(normalized);
final resolved = base.resolve(normalized);
final cleanQuery = {
for (final entry in queryParameters.entries)
if (entry.value != null) entry.key: entry.value!,
};
if (cleanQuery.isEmpty) {
return resolved;
}
return resolved.replace(
queryParameters: {...resolved.queryParameters, ...cleanQuery},
);
}
Map<String, String> _headers(String? bearerToken) => {

View File

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

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