// ignore_for_file: avoid_catches_without_on_clauses import 'dart:developer'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'api_response.dart'; import 'http_method.dart'; import 'token_manager.dart'; // ─── Custom exception ────────────────────────────────────────────────────── /// Thrown **synchronously** when any [ApiClient] call method is invoked before /// [ApiClient.initialize] has been called. /// /// This is an exception (not a silent [ApiFailure]) because it represents a /// **programming error** — the developer forgot to call [ApiClient.initialize] /// in `main()`. It should crash loudly in development so it is never shipped. final class ApiClientNotInitializedException implements Exception { const ApiClientNotInitializedException(); @override String toString() => 'ApiClientNotInitializedException: ' 'ApiClient.initialize() must be called before making any API calls. ' 'Add `ApiClient.initialize(baseUrl: …)` to your main() function.'; } // ─── ApiClient ───────────────────────────────────────────────────────────── /// A thin, zero-boilerplate HTTP wrapper around [Dio] for Flutter apps. /// /// ### Quick start /// ```dart /// // 1. Once in main(): /// ApiClient.initialize(baseUrl: 'https://api.example.com'); /// /// // 2. Make a call: /// final result = await ApiClient.get( /// url: '/users/me', /// shouldKeepToken: true, /// fromJson: (j) => User.fromJson(j as Map), /// ); /// switch (result) { /// case ApiSuccess(:final data): print(data.name); /// case ApiFailure(:final message): print('Error: $message'); /// } /// /// // 3. Cancel all in-flight requests (e.g. from a BLoC's close()): /// ApiClient.cancel(); /// ``` /// /// --- /// ### Design decisions /// /// **Named methods vs. a single `call(HttpMethod, …)`** /// Separate named methods (`get`, `post`, `put`, `patch`, `delete`) are /// preferred because: /// 1. Call sites are instantly readable — `ApiClient.post(url: …)` vs /// `ApiClient.call(HttpMethod.post, url: …)`. /// 2. Each method can omit inapplicable parameters (e.g. no `body` on `get`). /// 3. IDE auto-complete surfaces the right signature without guessing. /// All named methods delegate to the private [_call] dispatcher so there is /// zero logic duplication. /// /// **URL resolution** /// If [url] already starts with `http://` or `https://`, Dio uses it **as-is** /// (absolute URL) — the configured [BaseOptions.baseUrl] is _not_ prepended. /// For relative paths, Dio concatenates `baseUrl + url` automatically. /// This matches browser / fetch / axios semantics and prevents accidentally /// double-prepending the base when the caller passes a full URL for a /// third-party endpoint. class ApiClient { ApiClient._(); static Dio? _dio; static bool _isInitialized = false; static bool Function(Response)? _globalIsSuccess; /// Shared cancel token for all in-flight requests. /// /// After [cancel] is invoked, a fresh [CancelToken] is created immediately /// so subsequent calls are never pre-cancelled — the client stays usable. /// /// ### Extension point for per-request cancellation /// To layer per-request cancellation on in the future, accept an optional /// `String? requestTag` in every call method and maintain a /// `static final Map _taggedTokens = {}` registry /// inside [_call]. No structural rewrite of the existing code is needed. static CancelToken _cancelToken = CancelToken(); // ── Initialization ──────────────────────────────────────────────────────── /// Configure the global [Dio] instance. **Call exactly once at app startup, /// before any API call is made.** /// /// Calling [initialize] again replaces the existing instance (useful in /// tests that need different base URLs or interceptors). /// /// Parameters: /// - [baseUrl] — prepended to every relative URL. /// - [headers] — merged on top of the default `Content-Type` / /// `Accept` headers for every request. /// - [connectTimeout] — how long to wait for a connection; default 30 s. /// - [receiveTimeout] — how long to wait for a full response; default 30 s. /// - [isSuccess] — global predicate that decides whether a [Response] /// is successful. Falls back to HTTP 200–299 when /// `null`. /// - [interceptors] — extra [Interceptor]s (logging, auth-refresh, etc.) /// added to the [Dio] instance. static void initialize({ required String baseUrl, Map? headers, Duration connectTimeout = const Duration(seconds: 30), Duration receiveTimeout = const Duration(seconds: 30), bool Function(Response)? isSuccess, List interceptors = const [], }) { _globalIsSuccess = isSuccess; _isInitialized = false; // guard against partially-constructed state _dio = Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: connectTimeout, receiveTimeout: receiveTimeout, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', ...?headers, }, ), ); if (interceptors.isNotEmpty) { _dio!.interceptors.addAll(interceptors); } _isInitialized = true; } // ── Cancellation ───────────────────────────────────────────────────────── /// Cancel **all** currently in-flight requests that were made through /// [ApiClient]. /// /// A fresh [CancelToken] is created immediately afterward so new requests /// are never affected — the client remains fully usable after cancellation. /// /// Cancelled requests resolve as [ApiFailure] with `code: -1` and the /// provided [reason] as their [ApiFailure.message]. /// /// Typical usage — inside a BLoC's `close()`: /// ```dart /// @override /// Future close() { /// ApiClient.cancel(); /// return super.close(); /// } /// ``` static void cancel({String reason = 'Request was cancelled'}) { _cancelToken.cancel(reason); // Regenerate immediately so subsequent requests are never pre-cancelled. _cancelToken = CancelToken(); } // ── Public call methods ─────────────────────────────────────────────────── /// Perform an HTTP **GET** request. /// /// - [url] — absolute (`https://…`) or relative path resolved /// against [baseUrl]. /// - [queryParams] — appended as URL query parameters. /// - [headers] — per-call headers merged on top of defaults. /// - [shouldKeepToken] — when `true`, the stored [TokenManager] token is /// attached as `Authorization: Bearer `, and any /// `Authorization` header returned in the response is /// persisted back to [TokenManager]. /// - [isSuccess] — per-call predicate that overrides the global default /// set in [initialize] for this single call. /// - [fromJson] — converts the successful response body to [T]. /// /// Returns [ApiSuccess] on success or [ApiFailure] on any error. /// **Never throws** (except [ApiClientNotInitializedException] if /// [initialize] was never called — this is a programming error). static Future> get({ required String url, Map? queryParams, Map? headers, bool shouldKeepToken = false, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) => _call( method: HttpMethod.get, url: url, queryParams: queryParams, headers: headers, shouldKeepToken: shouldKeepToken, isSuccess: isSuccess, fromJson: fromJson, ); /// Perform an HTTP **POST** request. /// /// - [body] — request body; Dio serialises `Map` / `List` to JSON /// automatically. Pass a [FormData] for multipart uploads. static Future> post({ required String url, dynamic body, Map? queryParams, Map? headers, bool shouldKeepToken = false, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) => _call( method: HttpMethod.post, url: url, body: body, queryParams: queryParams, headers: headers, shouldKeepToken: shouldKeepToken, isSuccess: isSuccess, fromJson: fromJson, ); /// Perform an HTTP **PUT** request. /// /// - [body] — request body (serialised to JSON by Dio). static Future> put({ required String url, dynamic body, Map? queryParams, Map? headers, bool shouldKeepToken = false, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) => _call( method: HttpMethod.put, url: url, body: body, queryParams: queryParams, headers: headers, shouldKeepToken: shouldKeepToken, isSuccess: isSuccess, fromJson: fromJson, ); /// Perform an HTTP **PATCH** request. /// /// - [body] — request body (serialised to JSON by Dio). static Future> patch({ required String url, dynamic body, Map? queryParams, Map? headers, bool shouldKeepToken = false, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) => _call( method: HttpMethod.patch, url: url, body: body, queryParams: queryParams, headers: headers, shouldKeepToken: shouldKeepToken, isSuccess: isSuccess, fromJson: fromJson, ); /// Perform an HTTP **DELETE** request. /// /// - [body] — optional request body (serialised to JSON by Dio). static Future> delete({ required String url, dynamic body, Map? queryParams, Map? headers, bool shouldKeepToken = false, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) => _call( method: HttpMethod.delete, url: url, body: body, queryParams: queryParams, headers: headers, shouldKeepToken: shouldKeepToken, isSuccess: isSuccess, fromJson: fromJson, ); // ── Core dispatcher ─────────────────────────────────────────────────────── /// Unified internal dispatcher — every public method delegates here. /// /// Execution pipeline (in order): /// 1. **Guard** — throw [ApiClientNotInitializedException] if [initialize] /// was never called. /// 2. **Connectivity** — return [ApiFailure] immediately if offline. /// 3. **Token injection** — if [shouldKeepToken] is `true` and /// [TokenManager.hasToken], add `Authorization: Bearer …` to headers. /// 4. **Execute** — fire the Dio call with the shared [_cancelToken]. /// 5. **Token extraction** — if [shouldKeepToken] is `true` and the response /// contains an `Authorization` header, persist the new token. /// 6. **Success predicate** — per-call → global → HTTP 200–299 fallback. /// 7. **Parse** — call [fromJson] → wrap in [ApiSuccess]. /// 8. **Error mapping** — catch [DioException] and any other exception and /// return an appropriate [ApiFailure]. static Future> _call({ required HttpMethod method, required String url, dynamic body, Map? queryParams, Map? headers, required bool shouldKeepToken, bool Function(Response)? isSuccess, required T Function(dynamic json) fromJson, }) async { // ── 1. Guard ───────────────────────────────────────────────────────────── if (!_isInitialized || _dio == null) { throw const ApiClientNotInitializedException(); } // ── 2. Connectivity check ──────────────────────────────────────────────── // We wrap this in its own try/catch: if the connectivity plugin itself // throws (e.g. on simulator/unknown platform), we proceed and let the // actual network error surface naturally rather than blocking the call. try { final results = await Connectivity().checkConnectivity(); final isOffline = results.isEmpty || results.every((r) => r == ConnectivityResult.none); if (isOffline) { return ApiFailure( message: 'No internet connection. Please check your network settings.', ); } } catch (_) { // Connectivity check failed — proceed optimistically. } // ── 3. Build headers + optional Bearer token ───────────────────────────── final mergedHeaders = {...?headers}; if (shouldKeepToken && TokenManager.hasToken) { mergedHeaders['Authorization'] = 'Bearer ${TokenManager.getToken()}'; } final options = Options( headers: mergedHeaders.isNotEmpty ? mergedHeaders : null, ); // ── 4. Execute Dio call ────────────────────────────────────────────────── try { late final Response response; switch (method) { case HttpMethod.get: response = await _dio!.get( url, queryParameters: queryParams, options: options, cancelToken: _cancelToken, ); case HttpMethod.post: response = await _dio!.post( url, data: body, queryParameters: queryParams, options: options, cancelToken: _cancelToken, ); case HttpMethod.put: response = await _dio!.put( url, data: body, queryParameters: queryParams, options: options, cancelToken: _cancelToken, ); case HttpMethod.patch: response = await _dio!.patch( url, data: body, queryParameters: queryParams, options: options, cancelToken: _cancelToken, ); case HttpMethod.delete: response = await _dio!.delete( url, data: body, queryParameters: queryParams, options: options, cancelToken: _cancelToken, ); } log("+++++++++++++++++++++++++++"); log("$body"); log("+++++++++++++++++++++++++++"); log("$response"); log("+++++++++++++++++++++++++++"); // ── 5. Token extraction from response ─────────────────────────────── if (shouldKeepToken) { // Dio lowercases header names so check both casings for safety. final raw = response.headers.value('authorization') ?? response.headers.value('Authorization'); if (raw != null && raw.isNotEmpty) { final token = raw.startsWith('Bearer ') ? raw.substring(7) : raw; TokenManager.setToken(token); } } // ── 6. Success predicate ──────────────────────────────────────────── // Priority order: per-call → global → HTTP 200–299 fallback. final succeeded = isSuccess != null ? isSuccess(response) : _globalIsSuccess != null ? _globalIsSuccess!(response) : (response.statusCode != null && response.statusCode! >= 200 && response.statusCode! < 300); if (!succeeded) { return ApiFailure( code: response.statusCode, message: _extractErrorMessage(response.data) ?? 'Request failed with status ${response.statusCode}', ); } // ── 7. Parse ──────────────────────────────────────────────────────── try { return ApiSuccess(fromJson(response.data)); } catch (e) { return ApiFailure( code: response.statusCode, message: 'Response parsing failed: $e', ); } } on DioException catch (e) { return _mapDioException(e); } catch (e) { return ApiFailure(message: 'Unexpected error: $e'); } } // ── Helpers ─────────────────────────────────────────────────────────────── /// Map a [DioException] to the most descriptive [ApiFailure] available. /// /// Cancellations are identified by `code: -1` so callers can distinguish /// them from real HTTP failures without string-matching on [ApiFailure.message]. static ApiFailure _mapDioException(DioException e) { // Cancelled request — distinct code so BLoCs can decide not to show // an error dialog when the user navigated away. if (e.type == DioExceptionType.cancel) { return ApiFailure( code: -1, message: e.message ?? 'Request was cancelled', ); } final code = e.response?.statusCode; switch (e.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: return ApiFailure( code: code, message: 'Connection timed out. Please try again.', ); case DioExceptionType.connectionError: return ApiFailure( code: code, message: 'Unable to reach the server. Check your connection.', ); default: // Try to pull a message out of the error response body first. final serverMsg = e.response != null ? _extractErrorMessage(e.response!.data) : null; return ApiFailure( code: code, message: serverMsg ?? e.message ?? 'An unknown error occurred.', ); } } /// Attempt to pull a human-readable error string from a response body. /// /// Handles the three most common API error patterns: /// ```json /// { "message": "…" } /// { "error": "…" } /// { "error": { "message": "…" } } /// ``` static String? _extractErrorMessage(dynamic data) { if (data is! Map) return null; if (data['message'] is String) return data['message'] as String; if (data['error'] is String) return data['error'] as String; if (data['error'] is Map) { final inner = data['error'] as Map; if (inner['message'] is String) return inner['message'] as String; } return null; } } // ─── Usage example ───────────────────────────────────────────────────────── // // ┌── main.dart ─────────────────────────────────────────────────────────────┐ // │ │ // │ import 'package:your_app/core/network/api_client.dart'; │ // │ import 'package:dio/dio.dart'; │ // │ import 'package:flutter/material.dart'; │ // │ │ // │ void main() { │ // │ // ① Configure once before runApp(). │ // │ ApiClient.initialize( │ // │ baseUrl: 'https://api.example.com', │ // │ connectTimeout: const Duration(seconds: 15), │ // │ receiveTimeout: const Duration(seconds: 15), │ // │ headers: {'X-App-Version': '1.0.0'}, │ // │ // Global rule: treat only status 200 as success. │ // │ isSuccess: (response) => response.statusCode == 200, │ // │ interceptors: [LogInterceptor(requestBody: true)], │ // │ ); │ // │ runApp(const MyApp()); │ // │ } │ // └──────────────────────────────────────────────────────────────────────────┘ // // ┌── GET with custom isSuccess predicate + shouldKeepToken ─────────────────┐ // │ │ // │ Future fetchProfile() async { │ // │ final result = await ApiClient.get( │ // │ url: '/users/me', // relative → Dio prepends baseUrl │ // │ shouldKeepToken: true, // adds Authorization: Bearer │ // │ // Per-call override: accept both 200 and 201 for this endpoint. │ // │ isSuccess: (r) => r.statusCode == 200 || r.statusCode == 201, │ // │ fromJson: (j) => User.fromJson(j as Map), │ // │ ); │ // │ │ // │ switch (result) { │ // │ case ApiSuccess(:final data): │ // │ print('Welcome, ${data.name}'); │ // │ case ApiFailure(:final code, :final message): │ // │ print('Error $code: $message'); │ // │ } │ // │ } │ // └──────────────────────────────────────────────────────────────────────────┘ // // ┌── Cancel all in-flight requests (e.g. from a BLoC's close()) ────────────┐ // │ │ // │ @override │ // │ Future close() { │ // │ ApiClient.cancel(); // safe — client stays usable for new calls │ // │ return super.close(); │ // │ } │ // └──────────────────────────────────────────────────────────────────────────┘