import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/remote_command.dart'; import '../models/venue.dart'; import '../utils/app_config.dart'; class ApiException implements Exception { final int statusCode; /// Machine-readable reason from the server, e.g. `invalid_registration_code`. final String? code; final String message; const ApiException(this.statusCode, this.message, {this.code}); @override String toString() => 'HTTP $statusCode${code == null ? '' : ' ($code)'}: $message'; } /// The device token was revoked or is invalid. class UnauthorizedException extends ApiException { const UnauthorizedException({super.code, String? message}) : super(401, message ?? 'Unauthorized'); } class RateLimitedException extends ApiException { final Duration retryAfter; const RateLimitedException(this.retryAfter) : super(429, 'Rate limited'); } class DeviceCredentials { final String deviceId; final String token; const DeviceCredentials({required this.deviceId, required this.token}); } class KoolTapApiClient { final http.Client _client; final String baseUrl; KoolTapApiClient({http.Client? client, this.baseUrl = kRemoteServerUrl}) : _client = client ?? http.Client(); static const _timeout = Duration(seconds: 15); static const _defaultRetryAfter = Duration(seconds: 60); Future> getVenues() async { final response = await _client .get(Uri.parse('$baseUrl/venues/')) .timeout(_timeout); final data = _decode(response) as List; return [ for (final venue in data) Venue.fromJson(venue as Map), ]; } Future registerDevice({ required String venueId, required String deviceName, required String registrationCode, }) async { final data = await _post('/devices/register', { 'venueId': venueId, 'name': deviceName, 'registrationCode': registrationCode, }) as Map; return DeviceCredentials( deviceId: data['deviceId'] as String, token: data['token'] as String, ); } Future sync({ required String token, required SyncRequest request, }) async { final data = await _post( '/devices/${request.deviceId}/sync', request.toJson(), token: token, ); return SyncResponse.fromJson(data as Map? ?? {}); } Future ackCommand({ required String token, required String deviceId, required CommandAck ack, }) async { await _post( '/devices/$deviceId/commands/${ack.commandId}/ack', ack.toJson(), token: token, ); } Future _post( String path, Map body, { String? token, }) async { final response = await _client .post( Uri.parse('$baseUrl$path'), headers: { 'Content-Type': 'application/json', if (token != null) 'Authorization': 'Bearer $token', }, body: jsonEncode(body), ) .timeout(_timeout); return _decode(response, authenticated: token != null); } /// A 401 only means the device token is no longer valid when the request /// used one; while pairing it means the registration code was rejected. Object? _decode(http.Response response, {bool authenticated = false}) { final status = response.statusCode; if (status == 401 && authenticated) { final error = _error(response); throw UnauthorizedException(code: error.code, message: error.message); } if (status == 429) { final seconds = int.tryParse(response.headers['retry-after'] ?? ''); throw RateLimitedException( seconds == null ? _defaultRetryAfter : Duration(seconds: seconds), ); } if (status < 200 || status >= 300) { final error = _error(response); throw ApiException(status, error.message ?? '', code: error.code); } if (response.body.isEmpty) return null; return jsonDecode(response.body); } /// The server reports errors as `{"error": {"code": ..., "message": ...}}`; /// anything else (e.g. a proxy's HTML error page) falls back to the reason /// phrase. ({String? code, String? message}) _error(http.Response response) { final fallback = response.reasonPhrase; try { final error = (jsonDecode(response.body) as Map)['error']; if (error is! Map) { return (code: null, message: fallback); } return ( code: error['code'] as String?, message: error['message'] as String? ?? fallback, ); } on FormatException { return (code: null, message: fallback); } on TypeError { return (code: null, message: fallback); } } }