chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View File

@@ -0,0 +1,397 @@
import 'package:flutter/foundation.dart';
/// Standardized API error representation
/// Provides consistent error information across all API operations
@immutable
class ApiError implements Exception {
const ApiError._({
required this.type,
required this.message,
this.endpoint,
this.method,
this.statusCode,
this.details,
this.fieldErrors = const {},
this.originalError,
this.technical,
this.retryAfter,
this.timeoutDuration,
});
// Factory constructors for different error types
const ApiError.network({
required String message,
String? endpoint,
String? method,
dynamic originalError,
String? technical,
}) : this._(
type: ApiErrorType.network,
message: message,
endpoint: endpoint,
method: method,
originalError: originalError,
technical: technical,
);
const ApiError.timeout({
required String message,
String? endpoint,
String? method,
Duration? timeoutDuration,
}) : this._(
type: ApiErrorType.timeout,
message: message,
endpoint: endpoint,
method: method,
timeoutDuration: timeoutDuration,
);
const ApiError.authentication({
required String message,
String? endpoint,
String? method,
int? statusCode,
}) : this._(
type: ApiErrorType.authentication,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode,
);
const ApiError.authorization({
required String message,
String? endpoint,
String? method,
int? statusCode,
}) : this._(
type: ApiErrorType.authorization,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode,
);
const ApiError.validation({
required String message,
String? endpoint,
String? method,
Map<String, List<String>> fieldErrors = const {},
ParsedErrorResponse? details,
}) : this._(
type: ApiErrorType.validation,
message: message,
endpoint: endpoint,
method: method,
statusCode: 422,
fieldErrors: fieldErrors,
details: details,
);
const ApiError.badRequest({
required String message,
String? endpoint,
String? method,
ParsedErrorResponse? details,
}) : this._(
type: ApiErrorType.badRequest,
message: message,
endpoint: endpoint,
method: method,
statusCode: 400,
details: details,
);
const ApiError.notFound({
required String message,
String? endpoint,
String? method,
int? statusCode,
}) : this._(
type: ApiErrorType.notFound,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode ?? 404,
);
const ApiError.server({
required String message,
String? endpoint,
String? method,
int? statusCode,
ParsedErrorResponse? details,
}) : this._(
type: ApiErrorType.server,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode,
details: details,
);
const ApiError.rateLimit({
required String message,
String? endpoint,
String? method,
int? statusCode,
Duration? retryAfter,
}) : this._(
type: ApiErrorType.rateLimit,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode ?? 429,
retryAfter: retryAfter,
);
const ApiError.cancelled({
required String message,
String? endpoint,
String? method,
}) : this._(
type: ApiErrorType.cancelled,
message: message,
endpoint: endpoint,
method: method,
);
const ApiError.security({
required String message,
String? endpoint,
String? method,
}) : this._(
type: ApiErrorType.security,
message: message,
endpoint: endpoint,
method: method,
);
const ApiError.unknown({
required String message,
String? endpoint,
String? method,
dynamic originalError,
String? technical,
}) : this._(
type: ApiErrorType.unknown,
message: message,
endpoint: endpoint,
method: method,
originalError: originalError,
technical: technical,
);
const ApiError.client({
required String message,
String? endpoint,
String? method,
int? statusCode,
ParsedErrorResponse? details,
}) : this._(
type: ApiErrorType.badRequest,
message: message,
endpoint: endpoint,
method: method,
statusCode: statusCode,
details: details,
);
final ApiErrorType type;
final String message;
final String? endpoint;
final String? method;
final int? statusCode;
final ParsedErrorResponse? details;
final Map<String, List<String>> fieldErrors;
final dynamic originalError;
final String? technical;
final Duration? retryAfter;
final Duration? timeoutDuration;
/// Check if this error has field-specific validation errors
bool get hasFieldErrors => fieldErrors.isNotEmpty;
/// Check if this error is retryable
bool get isRetryable {
switch (type) {
case ApiErrorType.network:
case ApiErrorType.timeout:
case ApiErrorType.server:
case ApiErrorType.rateLimit:
return true;
case ApiErrorType.authentication:
case ApiErrorType.authorization:
case ApiErrorType.validation:
case ApiErrorType.badRequest:
case ApiErrorType.notFound:
case ApiErrorType.cancelled:
case ApiErrorType.security:
case ApiErrorType.unknown:
return false;
}
}
/// Get all field error messages as a flattened list
List<String> get allFieldErrorMessages {
final messages = <String>[];
for (final entry in fieldErrors.entries) {
final field = entry.key;
final errors = entry.value;
for (final error in errors) {
messages.add('$field: $error');
}
}
return messages;
}
/// Get first field error message for quick display
String? get firstFieldError {
if (fieldErrors.isEmpty) return null;
final firstEntry = fieldErrors.entries.first;
final field = firstEntry.key;
final firstError = firstEntry.value.first;
return '$field: $firstError';
}
/// Create a copy with updated fields
ApiError copyWith({
ApiErrorType? type,
String? message,
String? endpoint,
String? method,
int? statusCode,
ParsedErrorResponse? details,
Map<String, List<String>>? fieldErrors,
dynamic originalError,
String? technical,
Duration? retryAfter,
Duration? timeoutDuration,
}) {
return ApiError._(
type: type ?? this.type,
message: message ?? this.message,
endpoint: endpoint ?? this.endpoint,
method: method ?? this.method,
statusCode: statusCode ?? this.statusCode,
details: details ?? this.details,
fieldErrors: fieldErrors ?? this.fieldErrors,
originalError: originalError ?? this.originalError,
technical: technical ?? this.technical,
retryAfter: retryAfter ?? this.retryAfter,
timeoutDuration: timeoutDuration ?? this.timeoutDuration,
);
}
/// Convert to map for logging and debugging
Map<String, dynamic> toMap() {
return {
'type': type.name,
'message': message,
'endpoint': endpoint,
'method': method,
'statusCode': statusCode,
'fieldErrors': fieldErrors,
'technical': technical,
'retryAfter': retryAfter?.inSeconds,
'timeoutDuration': timeoutDuration?.inSeconds,
'isRetryable': isRetryable,
'hasFieldErrors': hasFieldErrors,
};
}
@override
String toString() {
final buffer = StringBuffer();
buffer.write('ApiError(');
buffer.write('type: ${type.name}, ');
buffer.write('message: $message');
if (endpoint != null) {
buffer.write(', endpoint: $endpoint');
}
if (method != null) {
buffer.write(', method: $method');
}
if (statusCode != null) {
buffer.write(', statusCode: $statusCode');
}
if (hasFieldErrors) {
buffer.write(', fieldErrors: ${fieldErrors.length}');
}
buffer.write(')');
return buffer.toString();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ApiError &&
other.type == type &&
other.message == message &&
other.endpoint == endpoint &&
other.method == method &&
other.statusCode == statusCode &&
mapEquals(other.fieldErrors, fieldErrors);
}
@override
int get hashCode {
return Object.hash(
type,
message,
endpoint,
method,
statusCode,
fieldErrors,
);
}
}
/// Types of API errors for categorization and handling
enum ApiErrorType {
network, // Connection issues, DNS resolution, etc.
timeout, // Request timeout (send, receive, connection)
authentication, // 401, invalid credentials, expired tokens
authorization, // 403, insufficient permissions
validation, // 422, field validation errors
badRequest, // 400, malformed request
notFound, // 404, resource not found
server, // 5xx server errors
rateLimit, // 429, too many requests
cancelled, // Request was cancelled
security, // Certificate, SSL/TLS issues
unknown, // Unexpected or unhandled errors
}
/// Parsed error response from API
/// Contains structured error information from server responses
class ParsedErrorResponse {
const ParsedErrorResponse({
this.message,
this.code,
this.errors = const [],
this.fieldErrors = const {},
this.metadata = const {},
});
final String? message;
final String? code;
final List<String> errors;
final Map<String, List<String>> fieldErrors;
final Map<String, dynamic> metadata;
bool get hasFieldErrors => fieldErrors.isNotEmpty;
bool get hasGeneralErrors => errors.isNotEmpty;
@override
String toString() {
return 'ParsedErrorResponse(message: $message, errors: ${errors.length}, fieldErrors: ${fieldErrors.length})';
}
}

View File

@@ -0,0 +1,408 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_error.dart';
import 'error_parser.dart';
/// Comprehensive API error handler with structured error parsing
/// Handles all types of API errors and converts them to standardized format
class ApiErrorHandler {
static final ApiErrorHandler _instance = ApiErrorHandler._internal();
factory ApiErrorHandler() => _instance;
ApiErrorHandler._internal();
final ErrorParser _errorParser = ErrorParser();
/// Transform any exception into standardized ApiError
ApiError transformError(
dynamic error, {
String? endpoint,
String? method,
Map<String, dynamic>? requestData,
}) {
try {
if (error is DioException) {
return _handleDioException(error, endpoint: endpoint, method: method);
} else if (error is ApiError) {
return error;
} else {
return ApiError.unknown(
message: 'An unexpected error occurred',
originalError: error,
technical: error.toString(),
);
}
} catch (e) {
// Fallback error if transformation itself fails
debugPrint('ApiErrorHandler: Error transforming exception: $e');
return ApiError.unknown(
message: 'A system error occurred',
originalError: error,
technical: 'Error transformation failed: $e',
);
}
}
/// Handle DioException with detailed error parsing
ApiError _handleDioException(
DioException dioError, {
String? endpoint,
String? method,
}) {
final statusCode = dioError.response?.statusCode;
final responseData = dioError.response?.data;
final requestPath = endpoint ?? dioError.requestOptions.path;
final httpMethod = method ?? dioError.requestOptions.method;
// Log error details for debugging
_logErrorDetails(dioError, requestPath, httpMethod);
switch (dioError.type) {
case DioExceptionType.connectionTimeout:
return ApiError.timeout(
message: 'Connection timeout - please check your internet connection',
endpoint: requestPath,
method: httpMethod,
timeoutDuration: dioError.requestOptions.connectTimeout,
);
case DioExceptionType.sendTimeout:
return ApiError.timeout(
message: 'Request send timeout - the upload took too long',
endpoint: requestPath,
method: httpMethod,
timeoutDuration: dioError.requestOptions.sendTimeout,
);
case DioExceptionType.receiveTimeout:
return ApiError.timeout(
message: 'Response timeout - the server took too long to respond',
endpoint: requestPath,
method: httpMethod,
timeoutDuration: dioError.requestOptions.receiveTimeout,
);
case DioExceptionType.badCertificate:
return ApiError.security(
message:
'Security certificate error - unable to verify server identity',
endpoint: requestPath,
method: httpMethod,
);
case DioExceptionType.connectionError:
return ApiError.network(
message:
'Network connection error - please check your internet connection',
endpoint: requestPath,
method: httpMethod,
originalError: dioError,
);
case DioExceptionType.cancel:
return ApiError.cancelled(
message: 'Request was cancelled',
endpoint: requestPath,
method: httpMethod,
);
case DioExceptionType.badResponse:
return _handleBadResponse(
dioError,
requestPath,
httpMethod,
statusCode,
responseData,
);
case DioExceptionType.unknown:
return ApiError.unknown(
message: 'An unexpected network error occurred',
endpoint: requestPath,
method: httpMethod,
originalError: dioError,
technical: dioError.message,
);
}
}
/// Handle bad response errors with detailed status code analysis
ApiError _handleBadResponse(
DioException dioError,
String requestPath,
String httpMethod,
int? statusCode,
dynamic responseData,
) {
if (statusCode == null) {
return ApiError.server(
message: 'Invalid server response',
endpoint: requestPath,
method: httpMethod,
statusCode: null,
);
}
switch (statusCode) {
case 400:
return _handleBadRequest(
dioError,
requestPath,
httpMethod,
responseData,
);
case 401:
return ApiError.authentication(
message: 'Authentication failed - please sign in again',
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
);
case 403:
return ApiError.authorization(
message: 'Access denied - you don\'t have permission for this action',
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
);
case 404:
return ApiError.notFound(
message: 'The requested resource was not found',
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
);
case 422:
return _handleValidationError(
dioError,
requestPath,
httpMethod,
responseData,
);
case 429:
return ApiError.rateLimit(
message: 'Too many requests - please wait before trying again',
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
retryAfter: _extractRetryAfter(dioError.response?.headers),
);
default:
if (statusCode >= 500) {
return _handleServerError(
dioError,
requestPath,
httpMethod,
statusCode,
responseData,
);
} else {
return ApiError.client(
message: 'Client error occurred',
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
details: _errorParser.parseErrorResponse(responseData),
);
}
}
}
/// Handle 400 Bad Request with detailed parsing
ApiError _handleBadRequest(
DioException dioError,
String requestPath,
String httpMethod,
dynamic responseData,
) {
final parsedError = _errorParser.parseErrorResponse(responseData);
return ApiError.badRequest(
message:
parsedError.message ?? 'Invalid request - please check your input',
endpoint: requestPath,
method: httpMethod,
details: parsedError,
);
}
/// Handle 422 Validation Error with field-specific parsing
ApiError _handleValidationError(
DioException dioError,
String requestPath,
String httpMethod,
dynamic responseData,
) {
final parsedError = _errorParser.parseValidationError(responseData);
return ApiError.validation(
message: 'Validation failed - please check your input',
endpoint: requestPath,
method: httpMethod,
fieldErrors: parsedError.fieldErrors,
details: parsedError,
);
}
/// Handle server errors (5xx)
ApiError _handleServerError(
DioException dioError,
String requestPath,
String httpMethod,
int statusCode,
dynamic responseData,
) {
final parsedError = _errorParser.parseErrorResponse(responseData);
String message;
switch (statusCode) {
case 500:
message = 'Internal server error - please try again later';
break;
case 502:
message = 'Bad gateway - the server is temporarily unavailable';
break;
case 503:
message = 'Service unavailable - the server is temporarily down';
break;
case 504:
message = 'Gateway timeout - the server took too long to respond';
break;
default:
message = 'Server error occurred - please try again later';
}
return ApiError.server(
message: message,
endpoint: requestPath,
method: httpMethod,
statusCode: statusCode,
details: parsedError,
);
}
/// Extract retry-after header for rate limiting
Duration? _extractRetryAfter(Headers? headers) {
if (headers == null) return null;
final retryAfterHeader =
headers.value('retry-after') ??
headers.value('Retry-After') ??
headers.value('X-RateLimit-Reset-After');
if (retryAfterHeader != null) {
final seconds = int.tryParse(retryAfterHeader);
if (seconds != null) {
return Duration(seconds: seconds);
}
}
return null;
}
/// Log error details for debugging and monitoring
void _logErrorDetails(
DioException dioError,
String requestPath,
String httpMethod,
) {
if (kDebugMode) {
debugPrint('🔴 API Error Details:');
debugPrint(' Method: ${httpMethod.toUpperCase()}');
debugPrint(' Endpoint: $requestPath');
debugPrint(' Type: ${dioError.type}');
debugPrint(' Status: ${dioError.response?.statusCode}');
if (dioError.response?.data != null) {
debugPrint(' Response: ${dioError.response?.data}');
}
if (dioError.requestOptions.data != null) {
debugPrint(' Request Data: ${dioError.requestOptions.data}');
}
debugPrint(' Error: ${dioError.message}');
}
// In production, you would send this to your error tracking service
// FirebaseCrashlytics.instance.recordError(dioError, stackTrace);
// Sentry.captureException(dioError);
}
/// Check if error is retryable
bool isRetryable(ApiError error) {
switch (error.type) {
case ApiErrorType.timeout:
case ApiErrorType.network:
case ApiErrorType.server:
return true;
case ApiErrorType.rateLimit:
return true; // Can retry after waiting
case ApiErrorType.authentication:
return false; // Need new token
case ApiErrorType.authorization:
case ApiErrorType.notFound:
case ApiErrorType.validation:
case ApiErrorType.badRequest:
return false; // Client errors aren't retryable
case ApiErrorType.cancelled:
case ApiErrorType.security:
case ApiErrorType.unknown:
return false;
}
}
/// Get suggested retry delay for retryable errors
Duration? getRetryDelay(ApiError error) {
if (!isRetryable(error)) return null;
switch (error.type) {
case ApiErrorType.rateLimit:
return error.retryAfter ?? const Duration(minutes: 1);
case ApiErrorType.timeout:
return const Duration(seconds: 5);
case ApiErrorType.network:
return const Duration(seconds: 3);
case ApiErrorType.server:
return const Duration(seconds: 10);
default:
return const Duration(seconds: 5);
}
}
/// Get user-friendly error message with actionable advice
String getUserMessage(ApiError error) {
final baseMessage = error.message;
// Add actionable advice based on error type
switch (error.type) {
case ApiErrorType.network:
return '$baseMessage\n\nPlease check your internet connection and try again.';
case ApiErrorType.timeout:
return '$baseMessage\n\nThis might be due to a slow connection. Try again in a moment.';
case ApiErrorType.authentication:
return '$baseMessage\n\nPlease sign in again to continue.';
case ApiErrorType.authorization:
return '$baseMessage\n\nContact support if you believe this is an error.';
case ApiErrorType.validation:
return '$baseMessage\n\nPlease correct the highlighted fields and try again.';
case ApiErrorType.rateLimit:
final delay = error.retryAfter;
if (delay != null) {
final minutes = delay.inMinutes;
final seconds = delay.inSeconds % 60;
return '$baseMessage\n\nPlease wait ${minutes > 0 ? '${minutes}m ' : ''}${seconds}s before trying again.';
}
return '$baseMessage\n\nPlease wait a moment before trying again.';
case ApiErrorType.server:
return '$baseMessage\n\nOur servers are experiencing issues. Please try again in a few minutes.';
default:
return baseMessage;
}
}
}

View File

@@ -0,0 +1,239 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_error_handler.dart';
import 'api_error.dart';
/// Dio interceptor for automatic error handling and transformation
/// Converts all HTTP errors into standardized ApiError format
class ApiErrorInterceptor extends Interceptor {
final ApiErrorHandler _errorHandler = ApiErrorHandler();
final bool logErrors;
final bool throwApiErrors;
ApiErrorInterceptor({this.logErrors = true, this.throwApiErrors = true});
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
try {
// Transform the error into our standardized format
final apiError = _errorHandler.transformError(
err,
endpoint: err.requestOptions.path,
method: err.requestOptions.method,
);
if (logErrors) {
_logApiError(apiError, err);
}
if (throwApiErrors) {
// Replace the DioException with our ApiError
final enhancedError = DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: apiError,
message: apiError.message,
);
handler.reject(enhancedError);
} else {
// Store the ApiError in the response extra data
if (err.response != null) {
err.response!.extra['apiError'] = apiError;
}
handler.next(err);
}
} catch (e) {
// Fallback if error transformation fails
debugPrint('ApiErrorInterceptor: Failed to transform error: $e');
handler.next(err);
}
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Check for errors in successful responses (some APIs return errors with 200 status)
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
final data = response.data as Map<String, dynamic>;
// Check for error indicators in successful responses
if (_isErrorResponse(data)) {
final apiError = _errorHandler.transformError(
data,
endpoint: response.requestOptions.path,
method: response.requestOptions.method,
);
if (logErrors) {
debugPrint('🟡 API Error in successful response: $apiError');
}
// Store the error for later handling
response.extra['apiError'] = apiError;
}
}
handler.next(response);
}
/// Check if a successful response actually contains an error
bool _isErrorResponse(Map<String, dynamic> data) {
// Common error indicators in successful responses
const errorIndicators = [
'error',
'errors',
'error_message',
'errorMessage',
'success',
];
for (final indicator in errorIndicators) {
if (data.containsKey(indicator)) {
final value = data[indicator];
// Check for explicit error indicators
if (indicator == 'success' && value == false) {
return true;
}
// Check for error messages or arrays
if (indicator != 'success' && value != null) {
if (value is String && value.isNotEmpty) {
return true;
} else if (value is List && value.isNotEmpty) {
return true;
} else if (value is Map && value.isNotEmpty) {
return true;
}
}
}
}
return false;
}
/// Log API error with structured information
void _logApiError(ApiError apiError, DioException originalError) {
if (!kDebugMode) return;
final typeIcon = _getErrorTypeIcon(apiError.type);
debugPrint('$typeIcon API Error [${apiError.type.name.toUpperCase()}]');
debugPrint(' Method: ${apiError.method?.toUpperCase() ?? 'UNKNOWN'}');
debugPrint(' Endpoint: ${apiError.endpoint ?? 'unknown'}');
debugPrint(' Status: ${apiError.statusCode ?? 'N/A'}');
debugPrint(' Message: ${apiError.message}');
if (apiError.hasFieldErrors) {
debugPrint(' Field Errors:');
for (final entry in apiError.fieldErrors.entries) {
final field = entry.key;
final errors = entry.value;
debugPrint(' $field: ${errors.join(', ')}');
}
}
if (apiError.technical != null) {
debugPrint(' Technical: ${apiError.technical}');
}
if (apiError.retryAfter != null) {
debugPrint(' Retry After: ${apiError.retryAfter!.inSeconds}s');
}
// Log original error type for debugging
debugPrint(' Original Type: ${originalError.type}');
// Log request details if available
final requestData = originalError.requestOptions.data;
if (requestData != null && requestData.toString().length < 500) {
debugPrint(' Request: $requestData');
}
// Log response data if available and not too large
final responseData = originalError.response?.data;
if (responseData != null && responseData.toString().length < 1000) {
debugPrint(' Response: $responseData');
}
}
/// Get emoji icon for error type
String _getErrorTypeIcon(ApiErrorType type) {
switch (type) {
case ApiErrorType.network:
return '🌐';
case ApiErrorType.timeout:
return '⏱️';
case ApiErrorType.authentication:
return '🔐';
case ApiErrorType.authorization:
return '🚫';
case ApiErrorType.validation:
return '✏️';
case ApiErrorType.badRequest:
return '';
case ApiErrorType.notFound:
return '🔍';
case ApiErrorType.server:
return '🔥';
case ApiErrorType.rateLimit:
return '🐌';
case ApiErrorType.cancelled:
return '🛑';
case ApiErrorType.security:
return '🔒';
case ApiErrorType.unknown:
return '';
}
}
/// Extract ApiError from DioException if available
static ApiError? extractApiError(DioException error) {
return error.error is ApiError ? error.error as ApiError : null;
}
/// Extract ApiError from Response if available
static ApiError? extractApiErrorFromResponse(Response response) {
return response.extra['apiError'] as ApiError?;
}
/// Check if DioException contains an ApiError
static bool hasApiError(DioException error) {
return extractApiError(error) != null;
}
/// Get user-friendly message from DioException
static String getUserMessage(DioException error) {
final apiError = extractApiError(error);
if (apiError != null) {
return ApiErrorHandler().getUserMessage(apiError);
}
// Fallback to basic DioException handling
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'Connection timeout - please check your internet connection';
case DioExceptionType.connectionError:
return 'Network connection error - please check your internet connection';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode == 401) {
return 'Authentication failed - please sign in again';
} else if (statusCode == 403) {
return 'Access denied - you don\'t have permission for this action';
} else if (statusCode == 404) {
return 'The requested resource was not found';
} else if (statusCode != null && statusCode >= 500) {
return 'Server error occurred - please try again later';
}
return 'An error occurred with your request';
case DioExceptionType.cancel:
return 'Request was cancelled';
case DioExceptionType.badCertificate:
return 'Security certificate error - unable to verify server identity';
case DioExceptionType.unknown:
return 'An unexpected error occurred - please try again';
}
}
}

View File

@@ -0,0 +1,467 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'api_error.dart';
import 'api_error_handler.dart';
import 'api_error_interceptor.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/theme/theme_extensions.dart';
/// Enhanced error service with comprehensive error handling capabilities
/// Provides unified error management across the application
class EnhancedErrorService {
static final EnhancedErrorService _instance =
EnhancedErrorService._internal();
factory EnhancedErrorService() => _instance;
EnhancedErrorService._internal();
final ApiErrorHandler _errorHandler = ApiErrorHandler();
/// Transform any error into ApiError format
ApiError transformError(
dynamic error, {
String? endpoint,
String? method,
Map<String, dynamic>? requestData,
}) {
return _errorHandler.transformError(
error,
endpoint: endpoint,
method: method,
requestData: requestData,
);
}
/// Get user-friendly error message
String getUserMessage(dynamic error) {
if (error is ApiError) {
return _errorHandler.getUserMessage(error);
} else if (error is DioException) {
return ApiErrorInterceptor.getUserMessage(error);
} else {
return _getGenericErrorMessage(error);
}
}
/// Get technical error details for debugging
String getTechnicalDetails(dynamic error) {
if (error is ApiError) {
return error.technical ?? error.toString();
} else if (error is DioException) {
final apiError = ApiErrorInterceptor.extractApiError(error);
if (apiError != null) {
return apiError.technical ?? apiError.toString();
}
return '${error.type}: ${error.message}';
} else {
return error.toString();
}
}
/// Check if error is retryable
bool isRetryable(dynamic error) {
if (error is ApiError) {
return _errorHandler.isRetryable(error);
} else if (error is DioException) {
final apiError = ApiErrorInterceptor.extractApiError(error);
if (apiError != null) {
return _errorHandler.isRetryable(apiError);
}
return _isDioErrorRetryable(error);
}
return false;
}
/// Get suggested retry delay
Duration? getRetryDelay(dynamic error) {
if (error is ApiError) {
return _errorHandler.getRetryDelay(error);
} else if (error is DioException) {
final apiError = ApiErrorInterceptor.extractApiError(error);
if (apiError != null) {
return _errorHandler.getRetryDelay(apiError);
}
return _getDioRetryDelay(error);
}
return null;
}
/// Show error snackbar with appropriate styling and actions
void showErrorSnackbar(
BuildContext context,
dynamic error, {
VoidCallback? onRetry,
Duration? duration,
bool showTechnicalDetails = false,
}) {
final message = getUserMessage(error);
final isRetryableError = isRetryable(error);
final retryDelay = getRetryDelay(error);
final snackBar = SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getErrorIcon(error),
color: AppTheme.neutral50,
size: IconSize.md,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
message,
style: const TextStyle(color: AppTheme.neutral50),
),
),
],
),
if (showTechnicalDetails) ...[
const SizedBox(height: Spacing.sm),
Text(
getTechnicalDetails(error),
style: TextStyle(
color: AppTheme.neutral50.withValues(alpha: Alpha.strong),
fontSize: AppTypography.labelMedium,
),
),
],
],
),
backgroundColor: _getErrorColor(error),
duration: duration ?? _getSnackbarDuration(error),
action: isRetryableError && onRetry != null
? SnackBarAction(
label: retryDelay != null && retryDelay.inSeconds > 5
? 'Retry (${retryDelay.inSeconds}s)'
: 'Retry',
textColor: AppTheme.neutral50,
onPressed: onRetry,
)
: null,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
/// Show error dialog with detailed information and recovery options
Future<void> showErrorDialog(
BuildContext context,
dynamic error, {
String? title,
VoidCallback? onRetry,
VoidCallback? onDismiss,
bool showTechnicalDetails = false,
}) async {
final message = getUserMessage(error);
final technicalDetails = getTechnicalDetails(error);
final isRetryableError = isRetryable(error);
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return AlertDialog(
title: Row(
children: [
Icon(_getErrorIcon(error), color: _getErrorColor(error)),
const SizedBox(width: Spacing.sm),
Expanded(child: Text(title ?? _getErrorTitle(error))),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (showTechnicalDetails) ...[
const SizedBox(height: Spacing.md),
const Text(
'Technical Details:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: Spacing.xs),
Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: AppTheme.neutral100,
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
),
child: Text(
technicalDetails,
style: const TextStyle(
fontFamily: AppTypography.monospaceFontFamily,
fontSize: AppTypography.labelMedium,
),
),
),
],
],
),
actions: [
if (isRetryableError && onRetry != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
child: const Text('Retry'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onDismiss?.call();
},
child: const Text('OK'),
),
],
);
},
);
}
/// Build error widget for displaying in UI
Widget buildErrorWidget(
dynamic error, {
VoidCallback? onRetry,
bool showTechnicalDetails = false,
EdgeInsets? padding,
}) {
final message = getUserMessage(error);
final technicalDetails = getTechnicalDetails(error);
final isRetryableError = isRetryable(error);
return Container(
padding: padding ?? const EdgeInsets.all(Spacing.md),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getErrorIcon(error),
size: IconSize.xxl,
color: _getErrorColor(error),
),
const SizedBox(height: Spacing.md),
Text(
_getErrorTitle(error),
style: const TextStyle(
fontSize: AppTypography.headlineSmall,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: Spacing.sm),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.neutral600),
),
if (showTechnicalDetails) ...[
const SizedBox(height: Spacing.md),
Container(
padding: const EdgeInsets.all(Spacing.xs),
decoration: BoxDecoration(
color: AppTheme.neutral100,
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Text(
technicalDetails,
style: const TextStyle(
fontFamily: AppTypography.monospaceFontFamily,
fontSize: AppTypography.labelMedium,
),
),
),
],
if (isRetryableError && onRetry != null) ...[
const SizedBox(height: Spacing.md),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
],
),
);
}
/// Log error with structured information
void logError(
dynamic error, {
String? context,
Map<String, dynamic>? additionalData,
StackTrace? stackTrace,
}) {
if (kDebugMode) {
final timestamp = DateTime.now().toIso8601String();
debugPrint('🔴 ERROR [$timestamp] ${context ?? 'Unknown Context'}');
debugPrint(' Message: ${getUserMessage(error)}');
debugPrint(' Technical: ${getTechnicalDetails(error)}');
if (additionalData != null && additionalData.isNotEmpty) {
debugPrint(' Additional Data: $additionalData');
}
if (stackTrace != null) {
debugPrint(' Stack Trace: $stackTrace');
}
}
// In production, send to error tracking service
// FirebaseCrashlytics.instance.recordError(error, stackTrace, context: context);
// Sentry.captureException(error, stackTrace: stackTrace);
}
// Private helper methods
String _getGenericErrorMessage(dynamic error) {
if (error is Exception) {
return 'An error occurred: ${error.toString()}';
}
return 'An unexpected error occurred';
}
bool _isDioErrorRetryable(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionError:
return true;
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
return statusCode != null && statusCode >= 500;
default:
return false;
}
}
Duration? _getDioRetryDelay(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const Duration(seconds: 5);
case DioExceptionType.connectionError:
return const Duration(seconds: 3);
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode != null && statusCode >= 500) {
return const Duration(seconds: 10);
}
break;
default:
break;
}
return null;
}
IconData _getErrorIcon(dynamic error) {
if (error is ApiError) {
switch (error.type) {
case ApiErrorType.network:
return Icons.wifi_off;
case ApiErrorType.timeout:
return Icons.timer_off;
case ApiErrorType.authentication:
return Icons.lock;
case ApiErrorType.authorization:
return Icons.block;
case ApiErrorType.validation:
return Icons.edit_off;
case ApiErrorType.badRequest:
return Icons.error_outline;
case ApiErrorType.notFound:
return Icons.search_off;
case ApiErrorType.server:
return Icons.dns;
case ApiErrorType.rateLimit:
return Icons.speed;
case ApiErrorType.cancelled:
return Icons.cancel;
case ApiErrorType.security:
return Icons.security;
case ApiErrorType.unknown:
return Icons.help_outline;
}
}
return Icons.error_outline;
}
Color _getErrorColor(dynamic error) {
if (error is ApiError) {
switch (error.type) {
case ApiErrorType.network:
case ApiErrorType.timeout:
return AppTheme.warning;
case ApiErrorType.authentication:
case ApiErrorType.authorization:
return AppTheme.error;
case ApiErrorType.validation:
case ApiErrorType.badRequest:
return AppTheme.warning;
case ApiErrorType.server:
return AppTheme.error;
case ApiErrorType.rateLimit:
return AppTheme.info;
default:
return AppTheme.error;
}
}
return AppTheme.error;
}
String _getErrorTitle(dynamic error) {
if (error is ApiError) {
switch (error.type) {
case ApiErrorType.network:
return 'Connection Problem';
case ApiErrorType.timeout:
return 'Request Timeout';
case ApiErrorType.authentication:
return 'Authentication Required';
case ApiErrorType.authorization:
return 'Access Denied';
case ApiErrorType.validation:
return 'Invalid Input';
case ApiErrorType.badRequest:
return 'Bad Request';
case ApiErrorType.notFound:
return 'Not Found';
case ApiErrorType.server:
return 'Server Error';
case ApiErrorType.rateLimit:
return 'Rate Limited';
case ApiErrorType.cancelled:
return 'Request Cancelled';
case ApiErrorType.security:
return 'Security Error';
case ApiErrorType.unknown:
return 'Unknown Error';
}
}
return 'Error';
}
Duration _getSnackbarDuration(dynamic error) {
if (error is ApiError) {
switch (error.type) {
case ApiErrorType.validation:
case ApiErrorType.badRequest:
return const Duration(seconds: 6); // Longer for validation errors
case ApiErrorType.rateLimit:
return const Duration(seconds: 8); // Longer for rate limits
default:
return const Duration(seconds: 4);
}
}
return const Duration(seconds: 4);
}
}
/// Global instance for easy access
final enhancedErrorService = EnhancedErrorService();

View File

@@ -0,0 +1,405 @@
import 'package:flutter/foundation.dart';
import 'api_error.dart';
/// Comprehensive error response parser
/// Handles various API error response formats and extracts structured information
class ErrorParser {
/// Parse general error response from API
ParsedErrorResponse parseErrorResponse(dynamic responseData) {
if (responseData == null) {
return const ParsedErrorResponse();
}
try {
if (responseData is Map<String, dynamic>) {
return _parseErrorMap(responseData);
} else if (responseData is String) {
return _parseErrorString(responseData);
} else if (responseData is List) {
return _parseErrorList(responseData);
} else {
return ParsedErrorResponse(
message: 'Unexpected error format',
metadata: {'rawData': responseData.toString()},
);
}
} catch (e) {
debugPrint('ErrorParser: Error parsing response: $e');
return ParsedErrorResponse(
message: 'Failed to parse error response',
metadata: {
'parseError': e.toString(),
'rawData': responseData.toString(),
},
);
}
}
/// Parse validation error (422) with field-specific errors
ParsedErrorResponse parseValidationError(dynamic responseData) {
final baseResult = parseErrorResponse(responseData);
if (responseData is Map<String, dynamic>) {
final fieldErrors = _extractFieldErrors(responseData);
return ParsedErrorResponse(
message: baseResult.message ?? 'Validation failed',
code: baseResult.code,
errors: baseResult.errors,
fieldErrors: fieldErrors,
metadata: baseResult.metadata,
);
}
return baseResult;
}
/// Parse error response from a Map (most common format)
ParsedErrorResponse _parseErrorMap(Map<String, dynamic> data) {
final message = _extractMessage(data);
final code = _extractCode(data);
final errors = _extractGeneralErrors(data);
final fieldErrors = _extractFieldErrors(data);
final metadata = _extractMetadata(data);
return ParsedErrorResponse(
message: message,
code: code,
errors: errors,
fieldErrors: fieldErrors,
metadata: metadata,
);
}
/// Parse error response from a String
ParsedErrorResponse _parseErrorString(String data) {
return ParsedErrorResponse(message: data, metadata: {'format': 'string'});
}
/// Parse error response from a List
ParsedErrorResponse _parseErrorList(List<dynamic> data) {
final errors = <String>[];
for (final item in data) {
if (item is String) {
errors.add(item);
} else if (item is Map<String, dynamic>) {
final message = _extractMessage(item);
if (message != null) {
errors.add(message);
}
} else {
errors.add(item.toString());
}
}
return ParsedErrorResponse(
message: errors.isNotEmpty ? errors.first : 'Multiple errors occurred',
errors: errors,
metadata: {'format': 'list', 'count': data.length},
);
}
/// Extract error message from various possible fields
String? _extractMessage(Map<String, dynamic> data) {
// Common error message fields in order of preference
const messageFields = [
'message',
'error',
'detail',
'description',
'msg',
'error_description',
'title',
'summary',
];
for (final field in messageFields) {
final value = data[field];
if (value is String && value.isNotEmpty) {
return value;
}
}
return null;
}
/// Extract error code from response
String? _extractCode(Map<String, dynamic> data) {
const codeFields = [
'code',
'error_code',
'errorCode',
'type',
'error_type',
'errorType',
];
for (final field in codeFields) {
final value = data[field];
if (value is String && value.isNotEmpty) {
return value;
} else if (value is int) {
return value.toString();
}
}
return null;
}
/// Extract general error messages (non-field-specific)
List<String> _extractGeneralErrors(Map<String, dynamic> data) {
final errors = <String>[];
// Check for error arrays
const errorArrayFields = ['errors', 'messages', 'details', 'issues'];
for (final field in errorArrayFields) {
final value = data[field];
if (value is List) {
for (final item in value) {
if (item is String && item.isNotEmpty) {
errors.add(item);
} else if (item is Map<String, dynamic>) {
final message = _extractMessage(item);
if (message != null) {
errors.add(message);
}
}
}
}
}
return errors;
}
/// Extract field-specific validation errors
Map<String, List<String>> _extractFieldErrors(Map<String, dynamic> data) {
final fieldErrors = <String, List<String>>{};
// Common patterns for field errors
_extractFromFieldErrorsObject(data, fieldErrors);
_extractFromValidationErrorsArray(data, fieldErrors);
_extractFromDetailsObject(data, fieldErrors);
_extractFromOpenAPIFormat(data, fieldErrors);
return fieldErrors;
}
/// Extract from 'field_errors' or 'fieldErrors' object
void _extractFromFieldErrorsObject(
Map<String, dynamic> data,
Map<String, List<String>> fieldErrors,
) {
const fieldErrorFields = [
'field_errors',
'fieldErrors',
'validation_errors',
'validationErrors',
'field_messages',
'fieldMessages',
];
for (final field in fieldErrorFields) {
final value = data[field];
if (value is Map<String, dynamic>) {
for (final entry in value.entries) {
final fieldName = entry.key;
final fieldValue = entry.value;
final errors = <String>[];
if (fieldValue is String) {
errors.add(fieldValue);
} else if (fieldValue is List) {
for (final item in fieldValue) {
if (item is String) {
errors.add(item);
} else {
errors.add(item.toString());
}
}
}
if (errors.isNotEmpty) {
fieldErrors[fieldName] = errors;
}
}
}
}
}
/// Extract from validation errors array format
void _extractFromValidationErrorsArray(
Map<String, dynamic> data,
Map<String, List<String>> fieldErrors,
) {
const arrayFields = ['errors', 'details', 'issues'];
for (final field in arrayFields) {
final value = data[field];
if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final field =
item['field'] as String? ??
item['property'] as String? ??
item['path'] as String?;
final message = _extractMessage(item);
if (field != null && message != null) {
fieldErrors.putIfAbsent(field, () => []).add(message);
}
}
}
}
}
}
/// Extract from 'details' object (common in some APIs)
void _extractFromDetailsObject(
Map<String, dynamic> data,
Map<String, List<String>> fieldErrors,
) {
final details = data['details'];
if (details is Map<String, dynamic>) {
for (final entry in details.entries) {
final fieldName = entry.key;
final fieldValue = entry.value;
if (fieldValue is String) {
fieldErrors.putIfAbsent(fieldName, () => []).add(fieldValue);
} else if (fieldValue is List) {
final errors = fieldValue
.map((e) => e.toString())
.where((s) => s.isNotEmpty)
.toList();
if (errors.isNotEmpty) {
fieldErrors[fieldName] = errors;
}
}
}
}
}
/// Extract from OpenAPI specification error format
void _extractFromOpenAPIFormat(
Map<String, dynamic> data,
Map<String, List<String>> fieldErrors,
) {
// OpenAPI validation errors often come in this format
final detail = data['detail'];
if (detail is List) {
for (final item in detail) {
if (item is Map<String, dynamic>) {
final loc = item['loc'];
final msg = item['msg'] as String?;
if (loc is List && loc.isNotEmpty && msg != null) {
// Location can be like ['body', 'fieldName'] or ['fieldName']
final fieldName = loc.last.toString();
fieldErrors.putIfAbsent(fieldName, () => []).add(msg);
}
}
}
}
}
/// Extract additional metadata from error response
Map<String, dynamic> _extractMetadata(Map<String, dynamic> data) {
final metadata = <String, dynamic>{};
// Common metadata fields
const metadataFields = [
'timestamp',
'request_id',
'requestId',
'trace_id',
'traceId',
'correlation_id',
'correlationId',
'instance',
'path',
'method',
'status',
'documentation',
'help',
'support',
];
for (final field in metadataFields) {
final value = data[field];
if (value != null) {
metadata[field] = value;
}
}
// Include any unrecognized fields as metadata
final recognizedFields = {
'message',
'error',
'detail',
'description',
'msg',
'error_description',
'title',
'summary',
'code',
'error_code',
'errorCode',
'type',
'error_type',
'errorType',
'errors',
'messages',
'details',
'issues',
'field_errors',
'fieldErrors',
'validation_errors',
'validationErrors',
'field_messages',
'fieldMessages',
...metadataFields,
};
for (final entry in data.entries) {
if (!recognizedFields.contains(entry.key)) {
metadata[entry.key] = entry.value;
}
}
return metadata;
}
/// Convert field name from API format to user-friendly format
String formatFieldName(String fieldName) {
// Convert snake_case to human readable
if (fieldName.contains('_')) {
return fieldName
.split('_')
.map(
(word) =>
word.isEmpty ? word : word[0].toUpperCase() + word.substring(1),
)
.join(' ');
}
// Convert camelCase to human readable
return fieldName
.replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}')
.trim();
}
/// Get user-friendly error message for a field
String formatFieldError(String fieldName, String error) {
final friendlyFieldName = formatFieldName(fieldName);
// If error already mentions the field, don't duplicate it
if (error.toLowerCase().contains(fieldName.toLowerCase()) ||
error.toLowerCase().contains(friendlyFieldName.toLowerCase())) {
return error;
}
return '$friendlyFieldName: $error';
}
}