The pubspec pinned Flutter 3.44.5 while the toolchain had moved to
3.44.9, so `flutter pub upgrade` failed version solving outright and no
dependency had been upgradable for a while. Bumping the pin unblocks
resolution (CI reads the same field via flutter-version-file).
Notable bumps: dio 5.9.2 -> 5.11.0, firebase_core 4.10 -> 4.13 (plus
crashlytics/analytics/performance), app_links 7.2.1, flutter_markdown_plus
1.0.12, intl, path_provider, webview_flutter, flutter_cache_manager.
dio 5.11 adds DioExceptionType.transformTimeout, which broke the
exhaustive switch in BaseApiClient._handleError. Grouped it with the
other timeout types and covered all four mappings with tests, which
were previously untested.
Raised the json_annotation floor to ^4.12.0 to silence a
json_serializable build warning; the resolved version was already 4.12.0.
flutter_secure_storage stays on v9 deliberately. v10.0.0 shipped in 5.0.0
and was reverted in 5.0.1 (ba28e09) while chasing the #484 startup freeze
on low-end Android 14, and upstream #1084 (unrecoverable NPE in
initialize() on Android 13) is still open. v9 pins win32 ^5, so
device_info_plus, package_info_plus and wakelock_plus are held at their
current majors too - their only breaking change was a win32 6.0.0 bump
that affects Windows only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
8.1 KiB
Dart
262 lines
8.1 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
|
|
|
/// Common type aliases to reduce repetition
|
|
typedef JsonMap = Map<String, dynamic>;
|
|
typedef JsonList = List<dynamic>;
|
|
typedef QueryParams = Map<String, dynamic>;
|
|
typedef ApiHeaders = Map<String, String>;
|
|
|
|
/// Custom API exceptions for better error handling
|
|
class ApiException implements Exception {
|
|
final String message;
|
|
final int? statusCode;
|
|
final DioExceptionType? type;
|
|
|
|
const ApiException(this.message, [this.statusCode, this.type]);
|
|
|
|
@override
|
|
String toString() => 'ApiException: $message';
|
|
}
|
|
|
|
class NetworkException extends ApiException {
|
|
const NetworkException(String message)
|
|
: super(message, null, DioExceptionType.connectionError);
|
|
}
|
|
|
|
class TimeoutException extends ApiException {
|
|
const TimeoutException(String message)
|
|
: super(message, null, DioExceptionType.connectionTimeout);
|
|
}
|
|
|
|
class ServerException extends ApiException {
|
|
const ServerException(String message, int statusCode)
|
|
: super(message, statusCode, DioExceptionType.badResponse);
|
|
}
|
|
|
|
class NotFoundException extends ApiException {
|
|
const NotFoundException(String message)
|
|
: super(message, 404, DioExceptionType.badResponse);
|
|
}
|
|
|
|
class UnauthorizedException extends ApiException {
|
|
const UnauthorizedException(String message)
|
|
: super(message, 401, DioExceptionType.badResponse);
|
|
}
|
|
|
|
/// Base API client to eliminate code duplication across API services
|
|
abstract class BaseApiClient {
|
|
final Dio _dio;
|
|
final String baseUrl;
|
|
|
|
const BaseApiClient(this._dio, this.baseUrl);
|
|
|
|
/// Builds the full URL, handling both relative endpoints and full URLs
|
|
String _buildUrl(String endpoint) {
|
|
// If endpoint is already a full URL (starts with http:// or https://), use it as-is
|
|
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
|
|
return endpoint;
|
|
}
|
|
// Otherwise, concatenate with base URL
|
|
return '$baseUrl$endpoint';
|
|
}
|
|
|
|
/// Generic GET request with common error handling
|
|
Future<T> get<T>(
|
|
String endpoint, {
|
|
QueryParams? queryParameters,
|
|
ApiHeaders? headers,
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
final url = _buildUrl(endpoint);
|
|
final response = await _dio.get(
|
|
url,
|
|
queryParameters: queryParameters,
|
|
options: Options(headers: headers),
|
|
cancelToken: cancelToken,
|
|
);
|
|
return response.data as T;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
/// Generic POST request with common error handling
|
|
Future<T> post<T>(
|
|
String endpoint, {
|
|
dynamic data,
|
|
QueryParams? queryParameters,
|
|
ApiHeaders? headers,
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
final url = _buildUrl(endpoint);
|
|
final response = await _dio.post(
|
|
url,
|
|
data: data,
|
|
queryParameters: queryParameters,
|
|
options: Options(headers: headers),
|
|
cancelToken: cancelToken,
|
|
);
|
|
return response.data as T;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
/// Generic PUT request with common error handling
|
|
Future<T> put<T>(
|
|
String endpoint, {
|
|
dynamic data,
|
|
QueryParams? queryParameters,
|
|
ApiHeaders? headers,
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
final url = _buildUrl(endpoint);
|
|
final response = await _dio.put(
|
|
url,
|
|
data: data,
|
|
queryParameters: queryParameters,
|
|
options: Options(headers: headers),
|
|
cancelToken: cancelToken,
|
|
);
|
|
return response.data as T;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
/// Generic DELETE request with common error handling
|
|
Future<T> delete<T>(
|
|
String endpoint, {
|
|
QueryParams? queryParameters,
|
|
ApiHeaders? headers,
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
try {
|
|
final url = _buildUrl(endpoint);
|
|
final response = await _dio.delete(
|
|
url,
|
|
queryParameters: queryParameters,
|
|
options: Options(headers: headers),
|
|
cancelToken: cancelToken,
|
|
);
|
|
return response.data as T;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
/// Check if response indicates success (useful for status-only operations)
|
|
bool isSuccessResponse(Response response) {
|
|
return response.statusCode != null &&
|
|
response.statusCode! >= 200 &&
|
|
response.statusCode! < 300;
|
|
}
|
|
|
|
/// Converts DioException to appropriate ApiException subtype
|
|
ApiException _handleError(DioException error) {
|
|
final exception = switch (error.type) {
|
|
DioExceptionType.connectionTimeout ||
|
|
DioExceptionType.sendTimeout ||
|
|
DioExceptionType.receiveTimeout ||
|
|
DioExceptionType.transformTimeout =>
|
|
TimeoutException(_getTimeoutMessage(error.type)),
|
|
DioExceptionType.connectionError => NetworkException(
|
|
'No internet connection. Please check your network.',
|
|
),
|
|
DioExceptionType.badResponse => _handleHttpError(error),
|
|
DioExceptionType.cancel => ApiException('Request was cancelled'),
|
|
DioExceptionType.badCertificate =>
|
|
ApiException('Security certificate error'),
|
|
DioExceptionType.unknown =>
|
|
ApiException(error.message ?? 'An unexpected error occurred'),
|
|
};
|
|
|
|
// Record server errors and unexpected failures to Crashlytics.
|
|
// Skip timeouts, network errors (normal offline conditions), 401s, and cancellations.
|
|
if (exception is ServerException ||
|
|
error.type == DioExceptionType.unknown ||
|
|
error.type == DioExceptionType.badCertificate) {
|
|
try {
|
|
FirebaseCrashlytics.instance.recordError(
|
|
exception,
|
|
error.stackTrace,
|
|
reason: '${error.requestOptions.method} ${error.requestOptions.uri}',
|
|
);
|
|
} catch (_) {
|
|
// Firebase may not be initialized (e.g., in tests).
|
|
}
|
|
}
|
|
|
|
return exception;
|
|
}
|
|
|
|
/// Gets specific timeout message based on timeout type
|
|
String _getTimeoutMessage(DioExceptionType type) {
|
|
switch (type) {
|
|
case DioExceptionType.connectionTimeout:
|
|
return 'Connection timeout. Please try again.';
|
|
case DioExceptionType.sendTimeout:
|
|
return 'Upload timeout. Please try again.';
|
|
case DioExceptionType.receiveTimeout:
|
|
return 'Server response timeout. Please try again.';
|
|
case DioExceptionType.transformTimeout:
|
|
return 'Response processing timeout. Please try again.';
|
|
default:
|
|
return 'Request timeout. Please try again.';
|
|
}
|
|
}
|
|
|
|
/// Handles HTTP status code errors with specific exception types
|
|
ApiException _handleHttpError(DioException error) {
|
|
final statusCode = error.response?.statusCode;
|
|
final responseData = error.response?.data;
|
|
|
|
// Extract error message from server response
|
|
String? serverMessage;
|
|
if (responseData is JsonMap) {
|
|
serverMessage =
|
|
responseData['message'] as String? ??
|
|
responseData['error'] as String? ??
|
|
responseData['error_description'] as String?;
|
|
}
|
|
|
|
switch (statusCode) {
|
|
case 400:
|
|
return ApiException(serverMessage ?? 'Invalid request data');
|
|
case 401:
|
|
return UnauthorizedException('Please log in to continue');
|
|
case 403:
|
|
return ApiException('Access denied. Insufficient permissions');
|
|
case 404:
|
|
return NotFoundException(serverMessage ?? 'Resource not found');
|
|
case 409:
|
|
return ApiException('Conflict with current state. Please refresh');
|
|
case 422:
|
|
return ApiException(serverMessage ?? 'Invalid data provided');
|
|
case 429:
|
|
return ApiException(
|
|
'Rate limit exceeded. Please wait before trying again',
|
|
);
|
|
case 500:
|
|
return ServerException('Server error. Please try again later', 500);
|
|
case 502:
|
|
return ServerException('Service temporarily unavailable', 502);
|
|
case 503:
|
|
return ServerException(
|
|
'Service maintenance. Please try again later',
|
|
503,
|
|
);
|
|
case 504:
|
|
return ServerException('Server timeout. Please try again', 504);
|
|
default:
|
|
return ServerException(
|
|
serverMessage ?? 'HTTP Error $statusCode occurred',
|
|
statusCode ?? 500,
|
|
);
|
|
}
|
|
}
|
|
}
|