feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+175
View File
@@ -0,0 +1,175 @@
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<List<Venue>> getVenues() async {
final response = await _client
.get(Uri.parse('$baseUrl/venues/'))
.timeout(_timeout);
final data = _decode(response) as List<dynamic>;
return [
for (final venue in data) Venue.fromJson(venue as Map<String, dynamic>),
];
}
Future<DeviceCredentials> 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<String, dynamic>;
return DeviceCredentials(
deviceId: data['deviceId'] as String,
token: data['token'] as String,
);
}
Future<SyncResponse> 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<String, dynamic>? ?? {});
}
Future<void> ackCommand({
required String token,
required String deviceId,
required CommandAck ack,
}) async {
await _post(
'/devices/$deviceId/commands/${ack.commandId}/ack',
ack.toJson(),
token: token,
);
}
Future<Object?> _post(
String path,
Map<String, dynamic> 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<String, dynamic>)['error'];
if (error is! Map<String, dynamic>) {
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);
}
}
}