Files
GameTime/lib/infrastructure/remote/http_api_client.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

186 lines
5.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
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 _configuredBaseUrl = String.fromEnvironment(
'GAMETIME_API_BASE_URL',
defaultValue: '',
);
static String get defaultBaseUrl =>
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
static String defaultBaseUrlFor({
required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl,
}) {
final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) {
return configured;
}
if (isAndroid) {
return 'http://10.0.2.2:8080';
}
return '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.server, 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;
}
}