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>
168 lines
5.0 KiB
Dart
168 lines
5.0 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../../application/application.dart';
|
|
|
|
final class HttpApiClient {
|
|
HttpApiClient({
|
|
required this.baseUrl,
|
|
http.Client? client,
|
|
this.timeout = const Duration(seconds: 10),
|
|
}) : client = client ?? http.Client();
|
|
|
|
static const defaultBaseUrl = String.fromEnvironment(
|
|
'GAMETIME_API_BASE_URL',
|
|
defaultValue: 'http://localhost:8080',
|
|
);
|
|
|
|
final Uri baseUrl;
|
|
final http.Client client;
|
|
final Duration timeout;
|
|
|
|
Future<Map<String, Object?>> postJson(
|
|
String path, {
|
|
Map<String, Object?>? body,
|
|
String? bearerToken,
|
|
Set<int> expectedStatuses = const {200, 201},
|
|
}) async {
|
|
final response = await _send(
|
|
() => client.post(
|
|
_resolve(path),
|
|
headers: _headers(bearerToken),
|
|
body: body == null ? null : jsonEncode(body),
|
|
),
|
|
);
|
|
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.',
|
|
);
|
|
}
|
|
|
|
Future<void> postEmpty(
|
|
String path, {
|
|
String? bearerToken,
|
|
Set<int> expectedStatuses = const {204},
|
|
}) async {
|
|
final response = await _send(
|
|
() => client.post(_resolve(path), headers: _headers(bearerToken)),
|
|
);
|
|
if (!expectedStatuses.contains(response.statusCode)) {
|
|
throw _exceptionForStatus(response.statusCode, response.body);
|
|
}
|
|
}
|
|
|
|
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()}/');
|
|
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) => {
|
|
'accept': 'application/json',
|
|
'content-type': 'application/json',
|
|
if (bearerToken != null) 'authorization': 'Bearer $bearerToken',
|
|
};
|
|
|
|
Future<http.Response> _send(Future<http.Response> Function() send) async {
|
|
try {
|
|
return await send().timeout(timeout);
|
|
} on TimeoutException {
|
|
throw const RemoteAuthException(RemoteAuthFailure.network, 'Timeout.');
|
|
} on http.ClientException catch (error) {
|
|
throw RemoteAuthException(RemoteAuthFailure.network, error.message);
|
|
} on FormatException catch (error) {
|
|
throw RemoteAuthException(RemoteAuthFailure.unknown, error.message);
|
|
}
|
|
}
|
|
|
|
RemoteAuthException _exceptionForStatus(int statusCode, String body) {
|
|
final message = _messageFromBody(body);
|
|
return switch (statusCode) {
|
|
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
|
|
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
|
|
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
|
|
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
|
|
};
|
|
}
|
|
|
|
String? _messageFromBody(String body) {
|
|
if (body.trim().isEmpty) {
|
|
return null;
|
|
}
|
|
try {
|
|
final decoded = jsonDecode(body);
|
|
if (decoded is Map && decoded['error'] is String) {
|
|
return decoded['error'] as String;
|
|
}
|
|
if (decoded is Map && decoded['message'] is String) {
|
|
return decoded['message'] as String;
|
|
}
|
|
} on FormatException {
|
|
return body;
|
|
}
|
|
return body;
|
|
}
|
|
}
|