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'); } /// This app is older than the backoffice accepts; it has to be updated /// before it can sync again. The token stays valid. class UpdateRequiredException extends ApiException { const UpdateRequiredException({super.code, String? message}) : super(426, message ?? 'App update required'); } 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; /// Sent with every request so the backoffice can refuse an app that is too /// old (`426 app_update_required`). final String? appVersion; KoolTapApiClient({ http.Client? client, this.baseUrl = kRemoteServerUrl, this.appVersion, }) : _client = client ?? http.Client(); Map get _versionHeader => {'X-App-Version': ?appVersion}; static const _timeout = Duration(seconds: 15); static const _defaultRetryAfter = Duration(seconds: 60); Future> getVenues() async { final response = await _client .get(Uri.parse('$baseUrl/venues/'), headers: _versionHeader) .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, ); } /// Uploads a picture taken on this tablet; the server crops and stores it /// and returns its id, which a `product_updated` event then links. Future uploadImage({ required String token, required String deviceId, required List bytes, required String filename, }) async { final request = http.MultipartRequest( 'POST', Uri.parse('$baseUrl/devices/$deviceId/images'), ) ..headers.addAll(_versionHeader) ..headers['Authorization'] = 'Bearer $token' ..files.add( http.MultipartFile.fromBytes('file', bytes, filename: filename), ); // Uploading a photo over a slow connection takes longer than a sync final response = await http.Response.fromStream( await _client.send(request).timeout(_timeout * 4), ); final data = _decode(response, authenticated: true) as Map; return data['imageId'] as String; } /// A product picture (public: pictures never change at their address). Future> downloadImage(String imageId) async { final response = await _client .get(Uri.parse('$baseUrl/images/$imageId'), headers: _versionHeader) .timeout(_timeout * 2); if (response.statusCode != 200) { throw ApiException(response.statusCode, 'Image $imageId not available'); } return response.bodyBytes; } Future _post( String path, Map body, { String? token, }) async { final response = await _client .post( Uri.parse('$baseUrl$path'), headers: { 'Content-Type': 'application/json', ..._versionHeader, 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 == 426) { final error = _error(response); throw UpdateRequiredException(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); } } }