import 'dart:convert'; import 'package:http/http.dart' as http; typedef TokenProvider = String? Function(); typedef UnauthorizedHandler = Future Function(); class ApiService { String _baseUrl; final http.Client _client; final Duration timeout; TokenProvider? _getToken; UnauthorizedHandler? _onUnauthorized; ApiService({ required String baseUrl, http.Client? client, this.timeout = const Duration(seconds: 30), }) : _baseUrl = baseUrl, _client = client ?? http.Client(); String get baseUrl => _baseUrl; void setBaseUrl(String url) { _baseUrl = url; } void setTokenProvider(TokenProvider provider) { _getToken = provider; } void setUnauthorizedHandler(UnauthorizedHandler handler) { _onUnauthorized = handler; } void dispose() { _client.close(); } Map _buildHeaders(Map? extra) { final token = _getToken?.call(); final headers = {'accept': 'application/json', ...?extra}; if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } return headers; } Future get(String endpoint, {Map? headers}) async { final response = await _client .get( Uri.parse('$baseUrl$endpoint'), headers: _buildHeaders(headers), ) .timeout(timeout); return _processResponse(response); } Future post( String endpoint, dynamic data, { Map? headers, }) async { final hasBody = data != null; final response = await _client .post( Uri.parse('$baseUrl$endpoint'), headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), body: hasBody ? jsonEncode(data) : null, ) .timeout(timeout); return _processResponse(response); } Future postForm(String endpoint, Map data) async { final response = await _client .post( Uri.parse('$baseUrl$endpoint'), headers: _buildHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', 'accept': 'application/json', }), body: data, // http package handles form-encoding for Map ) .timeout(timeout); return _processResponse(response); } Future put( String endpoint, dynamic data, { Map? headers, }) async { final hasBody = data != null; final response = await _client .put( Uri.parse('$baseUrl$endpoint'), headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers), body: hasBody ? jsonEncode(data) : null, ) .timeout(timeout); return _processResponse(response); } Future delete( String endpoint, { Map? headers, }) async { final response = await _client .delete( Uri.parse('$baseUrl$endpoint'), headers: _buildHeaders(headers), ) .timeout(timeout); return _processResponse(response); } Map _jsonHeaders(Map? extra) { return {'Content-Type': 'application/json', if (extra != null) ...extra}; } Future _processResponse(http.Response res) async { final body = _decodeBody(res); if (res.statusCode >= 200 && res.statusCode < 300) { return body; } if (res.statusCode == 401 && _onUnauthorized != null) { await _onUnauthorized!(); } final message = _extractErrorMessage(body); throw ApiException( statusCode: res.statusCode, message: message, body: body, ); } dynamic _decodeBody(http.Response res) { if (res.body.isEmpty) return null; final contentType = res.headers['content-type'] ?? ''; final shouldTryJson = contentType.contains('application/json') || contentType.contains('+json') || res.body.trimLeft().startsWith('{') || res.body.trimLeft().startsWith('['); if (!shouldTryJson) return res.body; try { return jsonDecode(res.body); } catch (_) { // Avoid turning a server-side error body into a client-side crash. return res.body; } } String _extractErrorMessage(dynamic body) { if (body == null) return 'No response body'; if (body is String) return body; if (body is Map) { for (final key in ['message', 'error', 'detail', 'msg']) { final val = body[key]; if (val is String && val.trim().isNotEmpty) return val; } return body.toString(); } if (body is List) { final parts = body .map((e) => e is Map ? _extractErrorMessage(Map.from(e)) : e.toString()) .where((e) => e.trim().isNotEmpty) .toList(); if (parts.isNotEmpty) return parts.join('; '); } return body.toString(); } } class ApiException implements Exception { final int statusCode; final String message; final dynamic body; ApiException({ required this.statusCode, required this.message, this.body, }); @override String toString() => 'API error $statusCode: $message'; }