// lib/core/api/api_client.dart import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../storage/token_storage.dart'; class ApiClient { static const Duration _timeout = Duration(seconds: 30); /// POST request /// /// - `url` should be a fully qualified endpoint (ApiEndpoints.*) /// - `body` is the JSON object to send (Map) /// - when `requiresAuth == true` token & username are added to the request body Future> post( String url, { Map? body, bool requiresAuth = true, }) async { final headers = { 'Content-Type': 'application/json', }; final Map finalBody = await _buildAuthBody(body, requiresAuth); late http.Response response; try { response = await http .post( Uri.parse(url), headers: headers, body: jsonEncode(finalBody), ) .timeout(_timeout); } catch (e) { if (kDebugMode) debugPrint('ApiClient.post network error: $e'); throw Exception('Network error: $e'); } return _handleResponse(url, response, finalBody); } /// GET request /// /// - If requiresAuth==true, token & username will be attached as query parameters. /// - `params` will be appended as query parameters. Future> get( String url, { Map? params, bool requiresAuth = true, }) async { // build final query params including auth if needed final Map finalParams = {}; if (requiresAuth) { final token = await TokenStorage.getToken(); final username = await TokenStorage.getUsername(); if (token == null || username == null) { throw Exception('Authentication required'); } finalParams['token'] = token; finalParams['username'] = username; } if (params != null) finalParams.addAll(params); final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString()))); late http.Response response; try { response = await http.get(uri).timeout(_timeout); } catch (e) { if (kDebugMode) debugPrint('ApiClient.get network error: $e'); throw Exception('Network error: $e'); } return _handleResponse(url, response, finalParams); } /// Build request body and attach token + username if required Future> _buildAuthBody(Map? body, bool requiresAuth) async { final Map finalBody = {}; if (requiresAuth) { final token = await TokenStorage.getToken(); final username = await TokenStorage.getUsername(); if (token == null || username == null) { throw Exception('Authentication required'); } finalBody['token'] = token; finalBody['username'] = username; } if (body != null) finalBody.addAll(body); return finalBody; } /// Centralized response handling and error parsing Map _handleResponse(String url, http.Response response, Map requestBody) { dynamic decoded; try { decoded = jsonDecode(response.body); } catch (e) { decoded = response.body; } if (kDebugMode) { debugPrint('API -> $url'); debugPrint('Status: ${response.statusCode}'); debugPrint('Request body: ${jsonEncode(requestBody)}'); debugPrint('Response body: ${response.body}'); } if (response.statusCode >= 200 && response.statusCode < 300) { if (decoded is Map) return decoded; return {'data': decoded}; } // Build human-friendly message from common server patterns String errorMessage = 'Request failed (status ${response.statusCode})'; if (decoded is Map) { // 1) If there's an explicit top-level 'message' (string), prefer it if (decoded.containsKey('message') && decoded['message'] is String) { errorMessage = decoded['message'] as String; } // 2) If 'errors' exists and is a map, collect inner messages else if (decoded.containsKey('errors')) { final errs = decoded['errors']; final messages = []; if (errs is String) { messages.add(errs); } else if (errs is List) { for (final e in errs) messages.add(e.toString()); } else if (errs is Map) { // collect first-level messages (prefer the text, not the key) errs.forEach((k, v) { if (v is List && v.isNotEmpty) { messages.add(v.first.toString()); } else if (v is String) { messages.add(v); } else { messages.add(v.toString()); } }); } else { messages.add(errs.toString()); } if (messages.isNotEmpty) { errorMessage = messages.join(' | '); } } // 3) If '__all__' present (DRF default), show it else if (decoded.containsKey('__all__')) { final all = decoded['__all__']; if (all is List) { errorMessage = all.join(' | '); } else { errorMessage = all.toString(); } } // 4) fallback - join map values' messages (prefer strings inside lists) else { final messages = []; decoded.forEach((k, v) { if (v is List && v.isNotEmpty) { messages.add(v.first.toString()); } else { messages.add(v.toString()); } }); errorMessage = messages.isNotEmpty ? messages.join(' | ') : decoded.toString(); } } else if (decoded is String) { errorMessage = decoded; } throw Exception(errorMessage); } }