|
|
|
|
|
// 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<User>(
|
|
|
|
|
|
/// url: '/users/me',
|
|
|
|
|
|
/// shouldKeepToken: true,
|
|
|
|
|
|
/// fromJson: (j) => User.fromJson(j as Map<String, dynamic>),
|
|
|
|
|
|
/// );
|
|
|
|
|
|
/// 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<dynamic>)? _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<String, CancelToken> _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<String, dynamic>? headers,
|
|
|
|
|
|
Duration connectTimeout = const Duration(seconds: 30),
|
|
|
|
|
|
Duration receiveTimeout = const Duration(seconds: 30),
|
|
|
|
|
|
bool Function(Response<dynamic>)? isSuccess,
|
|
|
|
|
|
List<Interceptor> interceptors = const [],
|
|
|
|
|
|
}) {
|
|
|
|
|
|
_globalIsSuccess = isSuccess;
|
|
|
|
|
|
_isInitialized = false; // guard against partially-constructed state
|
|
|
|
|
|
|
|
|
|
|
|
_dio = Dio(
|
|
|
|
|
|
BaseOptions(
|
|
|
|
|
|
baseUrl: baseUrl,
|
|
|
|
|
|
connectTimeout: connectTimeout,
|
|
|
|
|
|
receiveTimeout: receiveTimeout,
|
|
|
|
|
|
headers: <String, dynamic>{
|
|
|
|
|
|
'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<void> 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 <token>`, 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<T>] on success or [ApiFailure<T>] on any error.
|
|
|
|
|
|
/// **Never throws** (except [ApiClientNotInitializedException] if
|
|
|
|
|
|
/// [initialize] was never called — this is a programming error).
|
|
|
|
|
|
static Future<ApiResponse<T>> get<T>({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
bool shouldKeepToken = false,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<ApiResponse<T>> post<T>({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
dynamic body,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
bool shouldKeepToken = false,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<ApiResponse<T>> put<T>({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
dynamic body,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
bool shouldKeepToken = false,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<ApiResponse<T>> patch<T>({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
dynamic body,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
bool shouldKeepToken = false,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<ApiResponse<T>> delete<T>({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
dynamic body,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
bool shouldKeepToken = false,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<ApiResponse<T>> _call<T>({
|
|
|
|
|
|
required HttpMethod method,
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
dynamic body,
|
|
|
|
|
|
Map<String, dynamic>? queryParams,
|
|
|
|
|
|
Map<String, dynamic>? headers,
|
|
|
|
|
|
required bool shouldKeepToken,
|
|
|
|
|
|
bool Function(Response<dynamic>)? 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<T>(
|
|
|
|
|
|
message:
|
|
|
|
|
|
'No internet connection. Please check your network settings.',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Connectivity check failed — proceed optimistically.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 3. Build headers + optional Bearer token ─────────────────────────────
|
|
|
|
|
|
final mergedHeaders = <String, dynamic>{...?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<dynamic> 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<T>(
|
|
|
|
|
|
code: response.statusCode,
|
|
|
|
|
|
message: _extractErrorMessage(response.data) ??
|
|
|
|
|
|
'Request failed with status ${response.statusCode}',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 7. Parse ────────────────────────────────────────────────────────
|
|
|
|
|
|
try {
|
|
|
|
|
|
return ApiSuccess<T>(fromJson(response.data));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return ApiFailure<T>(
|
|
|
|
|
|
code: response.statusCode,
|
|
|
|
|
|
message: 'Response parsing failed: $e',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
return _mapDioException<T>(e);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return ApiFailure<T>(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<T> _mapDioException<T>(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<T>(
|
|
|
|
|
|
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<T>(
|
|
|
|
|
|
code: code,
|
|
|
|
|
|
message: 'Connection timed out. Please try again.',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
case DioExceptionType.connectionError:
|
|
|
|
|
|
return ApiFailure<T>(
|
|
|
|
|
|
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<T>(
|
|
|
|
|
|
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<String, dynamic>) 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<void> fetchProfile() async { │
|
|
|
|
|
|
// │ final result = await ApiClient.get<User>( │
|
|
|
|
|
|
// │ url: '/users/me', // relative → Dio prepends baseUrl │
|
|
|
|
|
|
// │ shouldKeepToken: true, // adds Authorization: Bearer <token> │
|
|
|
|
|
|
// │ // 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<String, dynamic>), │
|
|
|
|
|
|
// │ ); │
|
|
|
|
|
|
// │ │
|
|
|
|
|
|
// │ 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<void> close() { │
|
|
|
|
|
|
// │ ApiClient.cancel(); // safe — client stays usable for new calls │
|
|
|
|
|
|
// │ return super.close(); │
|
|
|
|
|
|
// │ } │
|
|
|
|
|
|
// └──────────────────────────────────────────────────────────────────────────┘
|