This commit is contained in:
2025-09-26 18:48:14 +07:00
parent 382a0e7909
commit 30ed6b39b5
85 changed files with 20722 additions and 112 deletions

501
lib/core/network/README.md Normal file
View File

@@ -0,0 +1,501 @@
# Network Layer Documentation
This network layer provides a comprehensive HTTP client implementation using Dio with advanced features like authentication, retry logic, error handling, and connectivity monitoring.
## Features
-**Configured Dio client** with timeouts and base URL management
-**Authentication interceptor** with automatic token refresh
-**Comprehensive error handling** with domain-specific exceptions
-**Request/response logging** for debugging
-**Automatic retry logic** for failed requests
-**Network connectivity monitoring**
-**Certificate pinning setup** (configurable)
-**File upload/download support**
-**Standardized API response models**
## Architecture
```
lib/core/network/
├── dio_client.dart # Main HTTP client wrapper
├── api_constants.dart # API configuration and endpoints
├── network_info.dart # Connectivity monitoring
├── interceptors/
│ ├── auth_interceptor.dart # Token management and refresh
│ ├── logging_interceptor.dart # Request/response logging
│ └── error_interceptor.dart # Error handling and mapping
├── models/
│ └── api_response.dart # Standardized response models
└── README.md # This documentation
```
## Quick Start
### 1. Setup Providers
```dart
// In your app, use the pre-configured providers
final dioClient = ref.watch(dioClientProvider);
// Or manually create
final dioClient = DioClient(
networkInfo: NetworkInfoImpl(Connectivity()),
secureStorage: FlutterSecureStorage(),
);
```
### 2. Basic HTTP Requests
```dart
// GET request
final response = await dioClient.get('/users/123');
// POST request
final response = await dioClient.post('/posts', data: {
'title': 'My Post',
'content': 'Post content'
});
// PUT request
final response = await dioClient.put('/users/123', data: userData);
// DELETE request
final response = await dioClient.delete('/posts/456');
```
### 3. File Operations
```dart
// Upload file
final response = await dioClient.uploadFile(
'/upload',
File('/path/to/file.jpg'),
filename: 'avatar.jpg',
);
// Download file
await dioClient.downloadFile(
'/files/document.pdf',
'/local/path/document.pdf',
);
```
### 4. Authentication
```dart
// Store tokens after login
await dioClient.authInterceptor.storeTokens(
accessToken: 'your-access-token',
refreshToken: 'your-refresh-token',
expiresIn: 3600, // 1 hour
);
// Check authentication status
final isAuth = await dioClient.authInterceptor.isAuthenticated();
// Logout (clears tokens)
await dioClient.authInterceptor.logout();
```
### 5. Network Connectivity
```dart
// Check current connectivity
final isConnected = await dioClient.isConnected;
// Listen to connectivity changes
dioClient.connectionStream.listen((isConnected) {
if (isConnected) {
print('Connected to internet');
} else {
print('No internet connection');
}
});
```
## Configuration
### API Constants (`api_constants.dart`)
```dart
class ApiConstants {
// Environment URLs
static const String baseUrlDev = 'https://api-dev.example.com';
static const String baseUrlProd = 'https://api.example.com';
// Timeouts
static const int connectTimeout = 30000;
static const int receiveTimeout = 30000;
// Retry configuration
static const int maxRetries = 3;
static const Duration retryDelay = Duration(seconds: 1);
// Endpoints
static const String loginEndpoint = '/auth/login';
static const String userEndpoint = '/user';
}
```
### Environment Switching
```dart
// Update base URL at runtime
dioClient.updateBaseUrl('https://api-staging.example.com');
// Add custom headers
dioClient.addHeader('X-Custom-Header', 'value');
// Remove headers
dioClient.removeHeader('X-Custom-Header');
```
## Error Handling
The network layer provides comprehensive error handling with domain-specific exceptions:
```dart
try {
final response = await dioClient.get('/api/data');
// Handle success
} on DioException catch (e) {
final failure = e.networkFailure;
failure.when(
serverError: (statusCode, message, errors) {
// Handle server errors (5xx)
},
networkError: (message) {
// Handle network connectivity issues
},
timeoutError: (message) {
// Handle timeout errors
},
unauthorizedError: (message) {
// Handle authentication errors (401)
},
forbiddenError: (message) {
// Handle authorization errors (403)
},
notFoundError: (message) {
// Handle not found errors (404)
},
validationError: (message, errors) {
// Handle validation errors (422)
},
unknownError: (message) {
// Handle unknown errors
},
);
}
```
### Error Types
- **ServerError**: HTTP 5xx errors from the server
- **NetworkConnectionError**: Network connectivity issues
- **TimeoutError**: Request/response timeouts
- **UnauthorizedError**: HTTP 401 authentication failures
- **ForbiddenError**: HTTP 403 authorization failures
- **NotFoundError**: HTTP 404 resource not found
- **ValidationError**: HTTP 422 validation failures with field details
- **UnknownError**: Any other unexpected errors
## Authentication Flow
The authentication interceptor automatically handles:
1. **Adding tokens** to requests (Authorization header)
2. **Token refresh** when access token expires
3. **Retry failed requests** after token refresh
4. **Token storage** in secure storage
5. **Automatic logout** when refresh fails
### Token Storage
Tokens are securely stored using `flutter_secure_storage`:
- `access_token`: Current access token
- `refresh_token`: Refresh token for getting new access tokens
- `token_expiry`: Token expiration timestamp
### Automatic Refresh
When a request fails with 401 Unauthorized:
1. Interceptor checks if refresh token exists
2. Makes refresh request to `/auth/refresh`
3. Stores new tokens if successful
4. Retries original request with new token
5. If refresh fails, clears all tokens
## Retry Logic
Requests are automatically retried for:
- **Connection timeouts**
- **Server errors (5xx)**
- **Network connectivity issues**
Configuration:
- Maximum retries: 3
- Delay between retries: Progressive (1s, 2s, 3s)
- Only retries on recoverable errors
## Logging
Request/response logging is automatically handled by the logging interceptor:
### Log Output Example
```
🚀 REQUEST: GET https://api.example.com/api/v1/users/123
📋 Headers: {"Authorization": "***HIDDEN***", "Content-Type": "application/json"}
✅ RESPONSE: GET https://api.example.com/api/v1/users/123 [200] (245ms)
📥 Response Body: {"id": 123, "name": "John Doe"}
```
### Log Features
- **Request/response timing**
- **Sensitive header sanitization** (Authorization, API keys, etc.)
- **Body truncation** for large responses
- **Error stack traces** in debug mode
- **Configurable log levels**
### Controlling Logging
```dart
// Disable logging
dioClient.setLoggingEnabled(false);
// Create client with custom logging
final loggingInterceptor = LoggingInterceptor(
enabled: true,
logRequestBody: true,
logResponseBody: false, // Disable response body logging
maxBodyLength: 1000, // Limit body length
);
```
## Network Monitoring
The network info service provides detailed connectivity information:
```dart
final networkInfo = NetworkInfoImpl(Connectivity());
// Simple connectivity check
final isConnected = await networkInfo.isConnected;
// Detailed connection info
final details = await networkInfo.getConnectionDetails();
print(details.connectionDescription); // "Connected via WiFi"
// Connection type checks
final isWiFi = await networkInfo.isConnectedToWiFi;
final isMobile = await networkInfo.isConnectedToMobile;
```
## API Response Models
### Basic API Response
```dart
class ApiResponse<T> {
final bool success;
final String message;
final T? data;
final List<String>? errors;
final Map<String, dynamic>? meta;
// Factory constructors
factory ApiResponse.success({required T data});
factory ApiResponse.error({required String message});
}
```
### Usage with Services
```dart
class UserService {
Future<User> getUser(String id) async {
final response = await dioClient.get('/users/$id');
return handleApiResponse(
response,
(data) => User.fromJson(data),
);
}
T handleApiResponse<T>(Response response, T Function(dynamic) fromJson) {
if (response.statusCode == 200) {
return fromJson(response.data);
}
throw Exception('Request failed');
}
}
```
## Best Practices
### 1. Use Providers for Dependency Injection
```dart
final userServiceProvider = Provider((ref) {
final dioClient = ref.watch(dioClientProvider);
return UserService(dioClient);
});
```
### 2. Create Service Classes
```dart
class UserService extends BaseApiService {
UserService(super.dioClient);
Future<User> getUser(String id) => executeRequest(
() => dioClient.get('/users/$id'),
User.fromJson,
);
}
```
### 3. Handle Errors Gracefully
```dart
try {
final user = await userService.getUser('123');
// Handle success
} catch (e) {
// Show user-friendly error message
showErrorSnackBar(context, e.toString());
}
```
### 4. Use Network Status
```dart
Widget build(BuildContext context) {
final networkStatus = ref.watch(networkConnectivityProvider);
return networkStatus.when(
data: (isConnected) => isConnected
? MainContent()
: OfflineWidget(),
loading: () => LoadingWidget(),
error: (_, __) => ErrorWidget(),
);
}
```
### 5. Configure for Different Environments
```dart
class ApiEnvironment {
static String get baseUrl {
if (kDebugMode) return ApiConstants.baseUrlDev;
if (kProfileMode) return ApiConstants.baseUrlStaging;
return ApiConstants.baseUrlProd;
}
}
```
## Testing
### Mock Network Responses
```dart
class MockDioClient extends DioClient {
@override
Future<Response<T>> get<T>(String path, {options, cancelToken, queryParameters}) async {
// Return mock response
return Response(
data: {'id': 1, 'name': 'Test User'},
statusCode: 200,
requestOptions: RequestOptions(path: path),
);
}
}
```
### Test Network Info
```dart
class MockNetworkInfo implements NetworkInfo {
final bool _isConnected;
MockNetworkInfo({required bool isConnected}) : _isConnected = isConnected;
@override
Future<bool> get isConnected => Future.value(_isConnected);
}
```
## Security Considerations
### 1. Certificate Pinning
```dart
// Enable in production
class ApiConstants {
static const bool enableCertificatePinning = true;
static const List<String> certificateHashes = [
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
];
}
```
### 2. Secure Token Storage
Tokens are automatically stored in secure storage with platform-specific encryption.
### 3. Request Sanitization
Sensitive headers are automatically sanitized in logs to prevent token leakage.
## Troubleshooting
### Common Issues
1. **Connection Timeouts**
- Increase timeout values in `ApiConstants`
- Check network connectivity
- Verify server availability
2. **Authentication Failures**
- Ensure tokens are correctly stored
- Verify refresh endpoint configuration
- Check token expiration handling
3. **Certificate Errors**
- Disable certificate pinning in development
- Add proper certificate hashes for production
- Check server SSL configuration
### Debug Mode
Enable detailed logging to troubleshoot issues:
```dart
final dioClient = DioClient(
networkInfo: networkInfo,
secureStorage: secureStorage,
);
// Enable detailed logging
dioClient.setLoggingEnabled(true);
```
## Migration Guide
When migrating from basic Dio to this network layer:
1. Replace `Dio()` instances with `DioClient`
2. Update error handling to use `NetworkFailure` types
3. Use providers for dependency injection
4. Migrate to service classes extending `BaseApiService`
5. Update authentication flow to use interceptor methods
This network layer provides a solid foundation for any Flutter app requiring robust HTTP communication with proper error handling, authentication, and network monitoring.

View File

@@ -0,0 +1,78 @@
/// API constants for network configuration
class ApiConstants {
// Private constructor to prevent instantiation
const ApiConstants._();
// Base URLs for different environments
static const String baseUrlDev = 'https://api-dev.example.com';
static const String baseUrlStaging = 'https://api-staging.example.com';
static const String baseUrlProd = 'https://api.example.com';
// Current environment base URL
// In a real app, this would be determined by build configuration
static const String baseUrl = baseUrlDev;
// API versioning
static const String apiVersion = 'v1';
static const String apiPath = '/api/$apiVersion';
// Timeout configurations (in milliseconds)
static const int connectTimeout = 30000; // 30 seconds
static const int receiveTimeout = 30000; // 30 seconds
static const int sendTimeout = 30000; // 30 seconds
// Retry configurations
static const int maxRetries = 3;
static const Duration retryDelay = Duration(seconds: 1);
// Headers
static const String contentType = 'application/json';
static const String accept = 'application/json';
static const String userAgent = 'BaseFlutter/1.0.0';
// Authentication
static const String authHeaderKey = 'Authorization';
static const String bearerPrefix = 'Bearer';
static const String apiKeyHeaderKey = 'X-API-Key';
// Common API endpoints
static const String authEndpoint = '/auth';
static const String loginEndpoint = '$authEndpoint/login';
static const String refreshEndpoint = '$authEndpoint/refresh';
static const String logoutEndpoint = '$authEndpoint/logout';
static const String userEndpoint = '/user';
static const String profileEndpoint = '$userEndpoint/profile';
// Example service endpoints (for demonstration)
static const String todosEndpoint = '/todos';
static const String postsEndpoint = '/posts';
static const String usersEndpoint = '/users';
// Cache configurations
static const Duration cacheMaxAge = Duration(minutes: 5);
static const String cacheControlHeader = 'Cache-Control';
static const String etagHeader = 'ETag';
static const String ifNoneMatchHeader = 'If-None-Match';
// Error codes
static const int unauthorizedCode = 401;
static const int forbiddenCode = 403;
static const int notFoundCode = 404;
static const int internalServerErrorCode = 500;
static const int badGatewayCode = 502;
static const int serviceUnavailableCode = 503;
// Certificate pinning (for production)
static const List<String> certificateHashes = [
// Add SHA256 hashes of your server certificates here
// Example: 'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
];
// Development flags
static const bool enableLogging = true;
static const bool enableCertificatePinning = false; // Disabled for development
// API rate limiting
static const int maxRequestsPerMinute = 100;
static const Duration rateLimitWindow = Duration(minutes: 1);
}

View File

@@ -0,0 +1,362 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_constants.dart';
import 'interceptors/auth_interceptor.dart';
import 'interceptors/error_interceptor.dart';
import 'interceptors/logging_interceptor.dart';
import 'network_info.dart';
/// Dio HTTP client wrapper with comprehensive configuration
class DioClient {
late final Dio _dio;
final NetworkInfo _networkInfo;
late final AuthInterceptor _authInterceptor;
final LoggingInterceptor _loggingInterceptor;
final ErrorInterceptor _errorInterceptor;
DioClient({
required NetworkInfo networkInfo,
required FlutterSecureStorage secureStorage,
String? baseUrl,
}) : _networkInfo = networkInfo,
_loggingInterceptor = LoggingInterceptor(),
_errorInterceptor = ErrorInterceptor() {
_dio = _createDio(baseUrl ?? ApiConstants.baseUrl);
_authInterceptor = AuthInterceptor(
secureStorage: secureStorage,
dio: _dio,
);
_setupInterceptors();
_configureHttpClient();
}
/// Getter for the underlying Dio instance
Dio get dio => _dio;
/// Create and configure Dio instance
Dio _createDio(String baseUrl) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl + ApiConstants.apiPath,
connectTimeout: const Duration(milliseconds: ApiConstants.connectTimeout),
receiveTimeout: const Duration(milliseconds: ApiConstants.receiveTimeout),
sendTimeout: const Duration(milliseconds: ApiConstants.sendTimeout),
headers: {
'Content-Type': ApiConstants.contentType,
'Accept': ApiConstants.accept,
'User-Agent': ApiConstants.userAgent,
},
responseType: ResponseType.json,
followRedirects: true,
validateStatus: (status) {
// Consider all status codes as valid to handle them in interceptors
return status != null && status < 500;
},
));
return dio;
}
/// Setup interceptors in the correct order
void _setupInterceptors() {
// Add interceptors in order:
// 1. Request logging and preparation
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// Add request start time for duration calculation
options.extra['start_time'] = DateTime.now().millisecondsSinceEpoch;
handler.next(options);
},
),
);
// 2. Authentication (adds tokens to requests)
_dio.interceptors.add(_authInterceptor);
// 3. Retry interceptor for network failures
_dio.interceptors.add(_createRetryInterceptor());
// 4. Logging (logs requests and responses)
_dio.interceptors.add(_loggingInterceptor);
// 5. Error handling (last to catch all errors)
_dio.interceptors.add(_errorInterceptor);
}
/// Configure HTTP client for certificate pinning and other security features
void _configureHttpClient() {
if (_dio.httpClientAdapter is IOHttpClientAdapter) {
final adapter = _dio.httpClientAdapter as IOHttpClientAdapter;
adapter.createHttpClient = () {
final client = HttpClient();
// Configure certificate pinning in production
if (ApiConstants.enableCertificatePinning) {
client.badCertificateCallback = (cert, host, port) {
// Implement certificate pinning logic here
// For now, return false to reject invalid certificates
return false;
};
}
// Configure timeouts
client.connectionTimeout = const Duration(
milliseconds: ApiConstants.connectTimeout,
);
return client;
};
}
}
/// Create retry interceptor for handling network failures
InterceptorsWrapper _createRetryInterceptor() {
return InterceptorsWrapper(
onError: (error, handler) async {
// Only retry on network errors, not server errors
if (_shouldRetry(error)) {
final retryCount = error.requestOptions.extra['retry_count'] as int? ?? 0;
if (retryCount < ApiConstants.maxRetries) {
error.requestOptions.extra['retry_count'] = retryCount + 1;
// Wait before retrying
await Future.delayed(
ApiConstants.retryDelay * (retryCount + 1),
);
// Check network connectivity before retry
final isConnected = await _networkInfo.isConnected;
if (!isConnected) {
handler.next(error);
return;
}
try {
final response = await _dio.fetch(error.requestOptions);
handler.resolve(response);
return;
} catch (e) {
// If retry fails, continue with original error
}
}
}
handler.next(error);
},
);
}
/// Determine if an error should trigger a retry
bool _shouldRetry(DioException error) {
// Retry on network connectivity issues
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.connectionError) {
return true;
}
// Retry on server errors (5xx)
if (error.response?.statusCode != null) {
final statusCode = error.response!.statusCode!;
return statusCode >= 500 && statusCode < 600;
}
return false;
}
// HTTP Methods
/// GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
return await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
return await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
return await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// PATCH request
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
return await _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
return await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
}
/// Upload file
Future<Response<T>> uploadFile<T>(
String path,
File file, {
String? field,
String? filename,
Map<String, dynamic>? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
}) async {
final formData = FormData();
// Add file
formData.files.add(MapEntry(
field ?? 'file',
await MultipartFile.fromFile(
file.path,
filename: filename ?? file.path.split('/').last,
),
));
// Add other form fields
if (data != null) {
data.forEach((key, value) {
formData.fields.add(MapEntry(key, value.toString()));
});
}
return await _dio.post<T>(
path,
data: formData,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
);
}
/// Download file
Future<Response> downloadFile(
String urlPath,
String savePath, {
Map<String, dynamic>? queryParameters,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) async {
return await _dio.download(
urlPath,
savePath,
queryParameters: queryParameters,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
}
// Utility Methods
/// Check network connectivity
Future<bool> get isConnected => _networkInfo.isConnected;
/// Get network connection stream
Stream<bool> get connectionStream => _networkInfo.connectionStream;
/// Update base URL (useful for environment switching)
void updateBaseUrl(String baseUrl) {
_dio.options.baseUrl = baseUrl + ApiConstants.apiPath;
}
/// Add custom header
void addHeader(String key, String value) {
_dio.options.headers[key] = value;
}
/// Remove header
void removeHeader(String key) {
_dio.options.headers.remove(key);
}
/// Clear all custom headers (keeps default headers)
void clearHeaders() {
_dio.options.headers.clear();
_dio.options.headers.addAll({
'Content-Type': ApiConstants.contentType,
'Accept': ApiConstants.accept,
'User-Agent': ApiConstants.userAgent,
});
}
/// Get auth interceptor for token management
AuthInterceptor get authInterceptor => _authInterceptor;
/// Enable/disable logging
void setLoggingEnabled(bool enabled) {
_loggingInterceptor.enabled = enabled;
}
/// Create a CancelToken for request cancellation
CancelToken createCancelToken() => CancelToken();
/// Close the client and clean up resources
void close({bool force = false}) {
_dio.close(force: force);
}
}

View File

@@ -0,0 +1,279 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../api_constants.dart';
/// Interceptor that handles authentication tokens and automatic token refresh
class AuthInterceptor extends Interceptor {
final FlutterSecureStorage _secureStorage;
final Dio _dio;
// Token storage keys
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _tokenExpiryKey = 'token_expiry';
// Track if we're currently refreshing to prevent multiple refresh attempts
bool _isRefreshing = false;
final List<Completer<void>> _refreshCompleters = [];
AuthInterceptor({
required FlutterSecureStorage secureStorage,
required Dio dio,
}) : _secureStorage = secureStorage,
_dio = dio;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
try {
// Skip auth for certain endpoints
if (_shouldSkipAuth(options.path)) {
handler.next(options);
return;
}
// Add access token to request
final accessToken = await _getAccessToken();
if (accessToken != null && accessToken.isNotEmpty) {
options.headers[ApiConstants.authHeaderKey] =
'${ApiConstants.bearerPrefix} $accessToken';
}
handler.next(options);
} catch (e) {
handler.reject(
DioException(
requestOptions: options,
error: 'Failed to add authentication token: $e',
type: DioExceptionType.unknown,
),
);
}
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Only handle 401 unauthorized errors
if (err.response?.statusCode != ApiConstants.unauthorizedCode) {
handler.next(err);
return;
}
// Skip refresh for certain endpoints
if (_shouldSkipAuth(err.requestOptions.path)) {
handler.next(err);
return;
}
try {
// Attempt to refresh token
final refreshed = await _refreshToken();
if (refreshed) {
// Retry the original request with new token
final response = await _retryRequest(err.requestOptions);
handler.resolve(response);
} else {
// Refresh failed, clear tokens and propagate error
await _clearTokens();
handler.next(err);
}
} catch (e) {
// If refresh fails, clear tokens and propagate original error
await _clearTokens();
handler.next(err);
}
}
/// Check if the endpoint should skip authentication
bool _shouldSkipAuth(String path) {
final skipAuthEndpoints = [
ApiConstants.loginEndpoint,
ApiConstants.refreshEndpoint,
// Add other public endpoints here
];
return skipAuthEndpoints.any((endpoint) => path.contains(endpoint));
}
/// Get the stored access token
Future<String?> _getAccessToken() async {
try {
return await _secureStorage.read(key: _accessTokenKey);
} catch (e) {
return null;
}
}
/// Get the stored refresh token
Future<String?> _getRefreshToken() async {
try {
return await _secureStorage.read(key: _refreshTokenKey);
} catch (e) {
return null;
}
}
/// Check if the token is expired
Future<bool> _isTokenExpired() async {
try {
final expiryString = await _secureStorage.read(key: _tokenExpiryKey);
if (expiryString == null) return true;
final expiry = DateTime.parse(expiryString);
return DateTime.now().isAfter(expiry);
} catch (e) {
return true;
}
}
/// Refresh the access token using the refresh token
Future<bool> _refreshToken() async {
// If already refreshing, wait for it to complete
if (_isRefreshing) {
final completer = Completer<void>();
_refreshCompleters.add(completer);
await completer.future;
return await _getAccessToken() != null;
}
_isRefreshing = true;
try {
final refreshToken = await _getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
return false;
}
// Make refresh request
final response = await _dio.post(
ApiConstants.refreshEndpoint,
data: {'refresh_token': refreshToken},
options: Options(
headers: {
ApiConstants.contentType: ApiConstants.contentType,
},
),
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data as Map<String, dynamic>;
// Store new tokens
await _storeTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
expiresIn: data['expires_in'] as int?,
);
return true;
}
return false;
} catch (e) {
return false;
} finally {
_isRefreshing = false;
// Complete all waiting requests
for (final completer in _refreshCompleters) {
if (!completer.isCompleted) {
completer.complete();
}
}
_refreshCompleters.clear();
}
}
/// Retry the original request with the new token
Future<Response> _retryRequest(RequestOptions requestOptions) async {
// Add the new access token
final accessToken = await _getAccessToken();
if (accessToken != null) {
requestOptions.headers[ApiConstants.authHeaderKey] =
'${ApiConstants.bearerPrefix} $accessToken';
}
// Retry the request
return await _dio.fetch(requestOptions);
}
/// Store authentication tokens securely
Future<void> storeTokens({
required String accessToken,
String? refreshToken,
int? expiresIn,
}) async {
await _storeTokens(
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: expiresIn,
);
}
Future<void> _storeTokens({
required String accessToken,
String? refreshToken,
int? expiresIn,
}) async {
try {
// Store access token
await _secureStorage.write(key: _accessTokenKey, value: accessToken);
// Store refresh token if provided
if (refreshToken != null) {
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
}
// Calculate and store expiry time
if (expiresIn != null) {
final expiry = DateTime.now().add(Duration(seconds: expiresIn));
await _secureStorage.write(
key: _tokenExpiryKey,
value: expiry.toIso8601String(),
);
}
} catch (e) {
throw Exception('Failed to store tokens: $e');
}
}
/// Clear all stored tokens
Future<void> _clearTokens() async {
try {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
await _secureStorage.delete(key: _tokenExpiryKey);
} catch (e) {
// Log error but don't throw
}
}
/// Check if user is authenticated
Future<bool> isAuthenticated() async {
final accessToken = await _getAccessToken();
if (accessToken == null || accessToken.isEmpty) {
return false;
}
// Check if token is expired
return !(await _isTokenExpired());
}
/// Logout by clearing all tokens
Future<void> logout() async {
await _clearTokens();
}
/// Get current access token (for debugging or manual API calls)
Future<String?> getCurrentAccessToken() async {
return await _getAccessToken();
}
/// Get current refresh token (for debugging)
Future<String?> getCurrentRefreshToken() async {
return await _getRefreshToken();
}
}

View File

@@ -0,0 +1,348 @@
import 'dart:io';
import 'package:dio/dio.dart';
import '../api_constants.dart';
import '../models/api_response.dart';
/// Interceptor that handles and transforms network errors into domain-specific exceptions
class ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final networkFailure = _mapDioExceptionToNetworkFailure(err);
// Create a new DioException with our custom error
final customError = DioException(
requestOptions: err.requestOptions,
response: err.response,
error: networkFailure,
type: err.type,
message: networkFailure.message,
stackTrace: err.stackTrace,
);
handler.next(customError);
}
NetworkFailure _mapDioExceptionToNetworkFailure(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutError(
message: _getTimeoutMessage(error.type),
);
case DioExceptionType.badResponse:
return _handleBadResponse(error);
case DioExceptionType.cancel:
return const NetworkConnectionError(
message: 'Request was cancelled',
);
case DioExceptionType.connectionError:
return _handleConnectionError(error);
case DioExceptionType.badCertificate:
return const NetworkConnectionError(
message: 'Certificate verification failed. Please check your connection security.',
);
case DioExceptionType.unknown:
return _handleUnknownError(error);
}
}
NetworkFailure _handleBadResponse(DioException error) {
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
switch (statusCode) {
case ApiConstants.unauthorizedCode:
return UnauthorizedError(
message: _extractErrorMessage(responseData) ??
'Authentication failed. Please log in again.',
);
case ApiConstants.forbiddenCode:
return ForbiddenError(
message: _extractErrorMessage(responseData) ??
'Access denied. You don\'t have permission to access this resource.',
);
case ApiConstants.notFoundCode:
return NotFoundError(
message: _extractErrorMessage(responseData) ??
'The requested resource was not found.',
);
case 422: // Validation error
final errors = _extractValidationErrors(responseData);
return ValidationError(
message: _extractErrorMessage(responseData) ??
'Validation failed. Please check your input.',
errors: errors,
);
case ApiConstants.internalServerErrorCode:
case ApiConstants.badGatewayCode:
case ApiConstants.serviceUnavailableCode:
return ServerError(
statusCode: statusCode!,
message: _extractErrorMessage(responseData) ??
'Server error occurred. Please try again later.',
);
default:
return ServerError(
statusCode: statusCode ?? 0,
message: _extractErrorMessage(responseData) ??
'An unexpected server error occurred.',
);
}
}
NetworkFailure _handleConnectionError(DioException error) {
// Check for specific connection error types
final originalError = error.error;
if (originalError is SocketException) {
return _handleSocketException(originalError);
}
if (originalError is HttpException) {
return NetworkConnectionError(
message: 'HTTP error: ${originalError.message}',
);
}
return const NetworkConnectionError(
message: 'Connection failed. Please check your internet connection and try again.',
);
}
NetworkFailure _handleSocketException(SocketException socketException) {
final message = socketException.message.toLowerCase();
if (message.contains('network is unreachable') ||
message.contains('no route to host')) {
return const NetworkConnectionError(
message: 'Network is unreachable. Please check your internet connection.',
);
}
if (message.contains('connection refused') ||
message.contains('connection failed')) {
return const NetworkConnectionError(
message: 'Unable to connect to server. Please try again later.',
);
}
if (message.contains('host lookup failed') ||
message.contains('nodename nor servname provided')) {
return const NetworkConnectionError(
message: 'Server not found. Please check your connection and try again.',
);
}
return NetworkConnectionError(
message: 'Connection error: ${socketException.message}',
);
}
NetworkFailure _handleUnknownError(DioException error) {
final originalError = error.error;
if (originalError is FormatException) {
return const UnknownError(
message: 'Invalid response format received from server.',
);
}
if (originalError is TypeError) {
return const UnknownError(
message: 'Data parsing error occurred.',
);
}
return UnknownError(
message: originalError?.toString() ?? 'An unexpected error occurred.',
);
}
String _getTimeoutMessage(DioExceptionType type) {
switch (type) {
case DioExceptionType.connectionTimeout:
return 'Connection timeout. Please check your internet connection and try again.';
case DioExceptionType.sendTimeout:
return 'Send timeout. Request took too long to send.';
case DioExceptionType.receiveTimeout:
return 'Receive timeout. Server took too long to respond.';
default:
return 'Request timeout. Please try again.';
}
}
String? _extractErrorMessage(dynamic responseData) {
if (responseData == null) return null;
try {
// Handle different response formats
if (responseData is Map<String, dynamic>) {
// Try common error message fields
final messageFields = ['message', 'error', 'detail', 'error_description'];
for (final field in messageFields) {
if (responseData.containsKey(field) && responseData[field] != null) {
return responseData[field].toString();
}
}
// Try to extract from nested error object
if (responseData.containsKey('error') && responseData['error'] is Map) {
final errorObj = responseData['error'] as Map<String, dynamic>;
for (final field in messageFields) {
if (errorObj.containsKey(field) && errorObj[field] != null) {
return errorObj[field].toString();
}
}
}
// Try to extract from errors array
if (responseData.containsKey('errors') && responseData['errors'] is List) {
final errors = responseData['errors'] as List;
if (errors.isNotEmpty) {
final firstError = errors.first;
if (firstError is Map<String, dynamic> && firstError.containsKey('message')) {
return firstError['message'].toString();
} else if (firstError is String) {
return firstError;
}
}
}
}
// If it's a string, return it directly
if (responseData is String) {
return responseData;
}
return null;
} catch (e) {
return null;
}
}
List<ApiError> _extractValidationErrors(dynamic responseData) {
final errors = <ApiError>[];
if (responseData == null) return errors;
try {
if (responseData is Map<String, dynamic>) {
// Handle Laravel-style validation errors
if (responseData.containsKey('errors') && responseData['errors'] is Map) {
final errorsMap = responseData['errors'] as Map<String, dynamic>;
errorsMap.forEach((field, messages) {
if (messages is List) {
for (final message in messages) {
errors.add(ApiError(
code: 'validation_error',
message: message.toString(),
field: field,
));
}
} else if (messages is String) {
errors.add(ApiError(
code: 'validation_error',
message: messages,
field: field,
));
}
});
}
// Handle array of error objects
if (responseData.containsKey('errors') && responseData['errors'] is List) {
final errorsList = responseData['errors'] as List;
for (final error in errorsList) {
if (error is Map<String, dynamic>) {
errors.add(ApiError(
code: error['code']?.toString() ?? 'validation_error',
message: error['message']?.toString() ?? 'Validation error',
field: error['field']?.toString(),
details: error['details'] as Map<String, dynamic>?,
));
} else if (error is String) {
errors.add(ApiError(
code: 'validation_error',
message: error,
));
}
}
}
}
} catch (e) {
// If parsing fails, add a generic validation error
errors.add(const ApiError(
code: 'validation_error',
message: 'Validation failed',
));
}
return errors;
}
}
/// Extension to get NetworkFailure from DioException
extension DioExceptionExtension on DioException {
NetworkFailure get networkFailure {
if (error is NetworkFailure) {
return error as NetworkFailure;
}
// Fallback mapping if not processed by interceptor
return const UnknownError(
message: 'An unexpected error occurred',
);
}
}
/// Helper extension to check error types
extension NetworkFailureExtension on NetworkFailure {
bool get isNetworkError => when(
serverError: (_, __, ___) => false,
networkError: (_) => true,
timeoutError: (_) => true,
unauthorizedError: (_) => false,
forbiddenError: (_) => false,
notFoundError: (_) => false,
validationError: (_, __) => false,
unknownError: (_) => false,
);
bool get isServerError => when(
serverError: (_, __, ___) => true,
networkError: (_) => false,
timeoutError: (_) => false,
unauthorizedError: (_) => false,
forbiddenError: (_) => false,
notFoundError: (_) => false,
validationError: (_, __) => false,
unknownError: (_) => false,
);
bool get isAuthError => when(
serverError: (_, __, ___) => false,
networkError: (_) => false,
timeoutError: (_) => false,
unauthorizedError: (_) => true,
forbiddenError: (_) => true,
notFoundError: (_) => false,
validationError: (_, __) => false,
unknownError: (_) => false,
);
}

View File

@@ -0,0 +1,281 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import '../api_constants.dart';
/// Custom logging interceptor for detailed request/response logging
class LoggingInterceptor extends Interceptor {
bool enabled;
final bool logRequestBody;
final bool logResponseBody;
final bool logHeaders;
final int maxBodyLength;
LoggingInterceptor({
this.enabled = ApiConstants.enableLogging,
this.logRequestBody = true,
this.logResponseBody = true,
this.logHeaders = true,
this.maxBodyLength = 2000,
});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (enabled) {
_logRequest(options);
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (enabled) {
_logResponse(response);
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (enabled) {
_logError(err);
}
handler.next(err);
}
void _logRequest(RequestOptions options) {
final uri = options.uri;
final method = options.method.toUpperCase();
developer.log(
'🚀 REQUEST: $method $uri',
name: 'HTTP_REQUEST',
);
// Log headers
if (logHeaders && options.headers.isNotEmpty) {
final headers = _sanitizeHeaders(options.headers);
developer.log(
'📋 Headers: ${_formatJson(headers)}',
name: 'HTTP_REQUEST',
);
}
// Log query parameters
if (options.queryParameters.isNotEmpty) {
developer.log(
'🔍 Query Parameters: ${_formatJson(options.queryParameters)}',
name: 'HTTP_REQUEST',
);
}
// Log request body
if (logRequestBody && options.data != null) {
final body = _formatRequestBody(options.data);
if (body.isNotEmpty) {
developer.log(
'📝 Request Body: $body',
name: 'HTTP_REQUEST',
);
}
}
}
void _logResponse(Response response) {
final statusCode = response.statusCode;
final method = response.requestOptions.method.toUpperCase();
final uri = response.requestOptions.uri;
final duration = DateTime.now().millisecondsSinceEpoch -
(response.requestOptions.extra['start_time'] as int? ?? 0);
// Status icon based on response code
String statusIcon;
if (statusCode != null && statusCode != 0) {
if (statusCode >= 200 && statusCode < 300) {
statusIcon = '';
} else if (statusCode >= 300 && statusCode < 400) {
statusIcon = '↩️';
} else if (statusCode >= 400 && statusCode < 500) {
statusIcon = '';
} else {
statusIcon = '💥';
}
} else {
statusIcon = '';
}
developer.log(
'$statusIcon RESPONSE: $method $uri [$statusCode] (${duration}ms)',
name: 'HTTP_RESPONSE',
);
// Log response headers
if (logHeaders && response.headers.map.isNotEmpty) {
final headers = _sanitizeHeaders(response.headers.map);
developer.log(
'📋 Response Headers: ${_formatJson(headers)}',
name: 'HTTP_RESPONSE',
);
}
// Log response body
if (logResponseBody && response.data != null) {
final body = _formatResponseBody(response.data);
if (body.isNotEmpty) {
developer.log(
'📥 Response Body: $body',
name: 'HTTP_RESPONSE',
);
}
}
}
void _logError(DioException error) {
final method = error.requestOptions.method.toUpperCase();
final uri = error.requestOptions.uri;
final statusCode = error.response?.statusCode;
developer.log(
'💥 ERROR: $method $uri [${statusCode ?? 'NO_STATUS'}] - ${error.type.name}',
name: 'HTTP_ERROR',
error: error,
);
// Log error message
if (error.message != null) {
developer.log(
'❗ Error Message: ${error.message}',
name: 'HTTP_ERROR',
);
}
// Log error response if available
if (error.response?.data != null) {
final errorBody = _formatResponseBody(error.response!.data);
if (errorBody.isNotEmpty) {
developer.log(
'📥 Error Response: $errorBody',
name: 'HTTP_ERROR',
);
}
}
// Log stack trace in debug mode
if (error.stackTrace.toString().isNotEmpty) {
developer.log(
'🔍 Stack Trace: ${error.stackTrace}',
name: 'HTTP_ERROR',
);
}
}
String _formatRequestBody(dynamic data) {
if (data == null) return '';
try {
String body;
if (data is Map || data is List) {
body = _formatJson(data);
} else if (data is FormData) {
body = _formatFormData(data);
} else {
body = data.toString();
}
return _truncateIfNeeded(body);
} catch (e) {
return 'Failed to format request body: $e';
}
}
String _formatResponseBody(dynamic data) {
if (data == null) return '';
try {
String body;
if (data is Map || data is List) {
body = _formatJson(data);
} else {
body = data.toString();
}
return _truncateIfNeeded(body);
} catch (e) {
return 'Failed to format response body: $e';
}
}
String _formatJson(dynamic data) {
try {
const encoder = JsonEncoder.withIndent(' ');
return encoder.convert(data);
} catch (e) {
return data.toString();
}
}
String _formatFormData(FormData formData) {
final buffer = StringBuffer('FormData{\n');
for (final field in formData.fields) {
buffer.writeln(' ${field.key}: ${field.value}');
}
for (final file in formData.files) {
buffer.writeln(' ${file.key}: ${file.value.filename} (${file.value.length} bytes)');
}
buffer.write('}');
return buffer.toString();
}
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = <String, dynamic>{};
headers.forEach((key, value) {
final lowerKey = key.toLowerCase();
// Sanitize sensitive headers
if (_isSensitiveHeader(lowerKey)) {
sanitized[key] = '***HIDDEN***';
} else {
sanitized[key] = value;
}
});
return sanitized;
}
bool _isSensitiveHeader(String headerName) {
const sensitiveHeaders = [
'authorization',
'cookie',
'set-cookie',
'x-api-key',
'x-auth-token',
'x-access-token',
'x-refresh-token',
];
return sensitiveHeaders.contains(headerName);
}
String _truncateIfNeeded(String text) {
if (text.length <= maxBodyLength) {
return text;
}
return '${text.substring(0, maxBodyLength)}... (truncated ${text.length - maxBodyLength} characters)';
}
}
/// Extension to add start time to request options for duration calculation
extension RequestOptionsExtension on RequestOptions {
void markStartTime() {
extra['start_time'] = DateTime.now().millisecondsSinceEpoch;
}
}

View File

@@ -0,0 +1,333 @@
/// Simple API response wrapper that standardizes all API responses
class ApiResponse<T> {
final bool success;
final String message;
final T? data;
final List<String>? errors;
final Map<String, dynamic>? meta;
final int? statusCode;
final String? timestamp;
const ApiResponse({
required this.success,
required this.message,
this.data,
this.errors,
this.meta,
this.statusCode,
this.timestamp,
});
/// Factory constructor for successful responses
factory ApiResponse.success({
required T data,
String message = 'Success',
Map<String, dynamic>? meta,
}) {
return ApiResponse(
success: true,
message: message,
data: data,
meta: meta,
statusCode: 200,
timestamp: DateTime.now().toIso8601String(),
);
}
/// Factory constructor for error responses
factory ApiResponse.error({
required String message,
List<String>? errors,
int? statusCode,
Map<String, dynamic>? meta,
}) {
return ApiResponse(
success: false,
message: message,
errors: errors,
statusCode: statusCode,
meta: meta,
timestamp: DateTime.now().toIso8601String(),
);
}
/// Create from JSON
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic)? fromJsonT,
) {
return ApiResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null && fromJsonT != null ? fromJsonT(json['data']) : null,
errors: (json['errors'] as List<dynamic>?)?.cast<String>(),
meta: json['meta'] as Map<String, dynamic>?,
statusCode: json['status_code'] as int?,
timestamp: json['timestamp'] as String?,
);
}
/// Convert to JSON
Map<String, dynamic> toJson([dynamic Function(T)? toJsonT]) {
return {
'success': success,
'message': message,
'data': data != null && toJsonT != null ? toJsonT(data as T) : data,
if (errors != null) 'errors': errors,
if (meta != null) 'meta': meta,
if (statusCode != null) 'status_code': statusCode,
if (timestamp != null) 'timestamp': timestamp,
};
}
@override
String toString() {
return 'ApiResponse(success: $success, message: $message, data: $data)';
}
}
/// Pagination metadata for paginated API responses
class PaginationMeta {
final int currentPage;
final int perPage;
final int total;
final int totalPages;
final bool hasNextPage;
final bool hasPreviousPage;
const PaginationMeta({
required this.currentPage,
required this.perPage,
required this.total,
required this.totalPages,
required this.hasNextPage,
required this.hasPreviousPage,
});
factory PaginationMeta.fromJson(Map<String, dynamic> json) {
return PaginationMeta(
currentPage: json['current_page'] ?? 0,
perPage: json['per_page'] ?? 0,
total: json['total'] ?? 0,
totalPages: json['total_pages'] ?? 0,
hasNextPage: json['has_next_page'] ?? false,
hasPreviousPage: json['has_previous_page'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'current_page': currentPage,
'per_page': perPage,
'total': total,
'total_pages': totalPages,
'has_next_page': hasNextPage,
'has_previous_page': hasPreviousPage,
};
}
}
/// Paginated API response wrapper
class PaginatedApiResponse<T> {
final bool success;
final String message;
final List<T> data;
final PaginationMeta pagination;
final List<String>? errors;
final int? statusCode;
final String? timestamp;
const PaginatedApiResponse({
required this.success,
required this.message,
required this.data,
required this.pagination,
this.errors,
this.statusCode,
this.timestamp,
});
factory PaginatedApiResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic) fromJsonT,
) {
return PaginatedApiResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: (json['data'] as List<dynamic>?)?.map(fromJsonT).toList() ?? [],
pagination: PaginationMeta.fromJson(json['pagination'] ?? {}),
errors: (json['errors'] as List<dynamic>?)?.cast<String>(),
statusCode: json['status_code'] as int?,
timestamp: json['timestamp'] as String?,
);
}
}
/// API error details for more specific error handling
class ApiError {
final String code;
final String message;
final String? field;
final Map<String, dynamic>? details;
const ApiError({
required this.code,
required this.message,
this.field,
this.details,
});
factory ApiError.fromJson(Map<String, dynamic> json) {
return ApiError(
code: json['code'] ?? '',
message: json['message'] ?? '',
field: json['field'] as String?,
details: json['details'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
return {
'code': code,
'message': message,
if (field != null) 'field': field,
if (details != null) 'details': details,
};
}
@override
String toString() => 'ApiError(code: $code, message: $message, field: $field)';
}
/// Network response wrapper that includes both success and error cases
abstract class NetworkResponse<T> {
const NetworkResponse();
}
class NetworkSuccess<T> extends NetworkResponse<T> {
final T data;
const NetworkSuccess(this.data);
@override
String toString() => 'NetworkSuccess(data: $data)';
}
class NetworkError<T> extends NetworkResponse<T> {
final NetworkFailure failure;
const NetworkError(this.failure);
@override
String toString() => 'NetworkError(failure: $failure)';
}
/// Network failure types
abstract class NetworkFailure {
final String message;
const NetworkFailure({required this.message});
/// Pattern matching helper
T when<T>({
required T Function(int statusCode, String message, List<ApiError>? errors) serverError,
required T Function(String message) networkError,
required T Function(String message) timeoutError,
required T Function(String message) unauthorizedError,
required T Function(String message) forbiddenError,
required T Function(String message) notFoundError,
required T Function(String message, List<ApiError> errors) validationError,
required T Function(String message) unknownError,
}) {
if (this is ServerError) {
final error = this as ServerError;
return serverError(error.statusCode, error.message, error.errors);
} else if (this is NetworkConnectionError) {
return networkError(message);
} else if (this is TimeoutError) {
return timeoutError(message);
} else if (this is UnauthorizedError) {
return unauthorizedError(message);
} else if (this is ForbiddenError) {
return forbiddenError(message);
} else if (this is NotFoundError) {
return notFoundError(message);
} else if (this is ValidationError) {
final error = this as ValidationError;
return validationError(error.message, error.errors);
} else {
return unknownError(message);
}
}
@override
String toString() => 'NetworkFailure(message: $message)';
}
class ServerError extends NetworkFailure {
final int statusCode;
final List<ApiError>? errors;
const ServerError({
required this.statusCode,
required String message,
this.errors,
}) : super(message: message);
@override
String toString() => 'ServerError(statusCode: $statusCode, message: $message)';
}
class NetworkConnectionError extends NetworkFailure {
const NetworkConnectionError({required String message}) : super(message: message);
@override
String toString() => 'NetworkConnectionError(message: $message)';
}
class TimeoutError extends NetworkFailure {
const TimeoutError({required String message}) : super(message: message);
@override
String toString() => 'TimeoutError(message: $message)';
}
class UnauthorizedError extends NetworkFailure {
const UnauthorizedError({required String message}) : super(message: message);
@override
String toString() => 'UnauthorizedError(message: $message)';
}
class ForbiddenError extends NetworkFailure {
const ForbiddenError({required String message}) : super(message: message);
@override
String toString() => 'ForbiddenError(message: $message)';
}
class NotFoundError extends NetworkFailure {
const NotFoundError({required String message}) : super(message: message);
@override
String toString() => 'NotFoundError(message: $message)';
}
class ValidationError extends NetworkFailure {
final List<ApiError> errors;
const ValidationError({
required String message,
required this.errors,
}) : super(message: message);
@override
String toString() => 'ValidationError(message: $message, errors: $errors)';
}
class UnknownError extends NetworkFailure {
const UnknownError({required String message}) : super(message: message);
@override
String toString() => 'UnknownError(message: $message)';
}

View File

@@ -0,0 +1,233 @@
import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
/// Abstract class defining network information interface
abstract class NetworkInfo {
Future<bool> get isConnected;
Stream<bool> get connectionStream;
Future<ConnectivityResult> get connectionStatus;
Future<bool> hasInternetConnection();
}
/// Implementation of NetworkInfo using connectivity_plus package
class NetworkInfoImpl implements NetworkInfo {
final Connectivity _connectivity;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
final StreamController<bool> _connectionController = StreamController<bool>.broadcast();
NetworkInfoImpl(this._connectivity) {
_initializeConnectivityStream();
}
void _initializeConnectivityStream() {
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
(List<ConnectivityResult> results) {
_updateConnectionStatus(results);
},
);
// Check initial connectivity status
_checkInitialConnectivity();
}
Future<void> _checkInitialConnectivity() async {
try {
final results = await _connectivity.checkConnectivity();
_updateConnectionStatus(results);
} catch (e) {
_connectionController.add(false);
}
}
void _updateConnectionStatus(List<ConnectivityResult> results) async {
final hasConnection = _hasConnectionFromResults(results);
// Double-check with internet connectivity test for reliability
if (hasConnection) {
final hasInternet = await hasInternetConnection();
_connectionController.add(hasInternet);
} else {
_connectionController.add(false);
}
}
bool _hasConnectionFromResults(List<ConnectivityResult> results) {
return results.any((result) =>
result != ConnectivityResult.none);
}
@override
Future<bool> get isConnected async {
try {
final results = await _connectivity.checkConnectivity();
if (!_hasConnectionFromResults(results)) {
return false;
}
return await hasInternetConnection();
} catch (e) {
return false;
}
}
@override
Stream<bool> get connectionStream => _connectionController.stream;
@override
Future<ConnectivityResult> get connectionStatus async {
try {
final results = await _connectivity.checkConnectivity();
// Return the first non-none result, or none if all are none
return results.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none,
);
} catch (e) {
return ConnectivityResult.none;
}
}
@override
Future<bool> hasInternetConnection() async {
try {
// Try to connect to multiple reliable hosts for better reliability
final hosts = [
'google.com',
'cloudflare.com',
'8.8.8.8', // Google DNS
];
for (final host in hosts) {
try {
final result = await InternetAddress.lookup(host).timeout(
const Duration(seconds: 5),
);
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
return true;
}
} catch (e) {
// Continue to next host if this one fails
continue;
}
}
return false;
} catch (e) {
return false;
}
}
/// Get detailed connectivity information
Future<NetworkConnectionDetails> getConnectionDetails() async {
try {
final results = await _connectivity.checkConnectivity();
final hasInternet = await hasInternetConnection();
return NetworkConnectionDetails(
connectivityResults: results,
hasInternetConnection: hasInternet,
timestamp: DateTime.now(),
);
} catch (e) {
return NetworkConnectionDetails(
connectivityResults: [ConnectivityResult.none],
hasInternetConnection: false,
timestamp: DateTime.now(),
error: e.toString(),
);
}
}
/// Check if connected to WiFi
Future<bool> get isConnectedToWiFi async {
final results = await _connectivity.checkConnectivity();
return results.contains(ConnectivityResult.wifi);
}
/// Check if connected to mobile data
Future<bool> get isConnectedToMobile async {
final results = await _connectivity.checkConnectivity();
return results.contains(ConnectivityResult.mobile);
}
/// Check if connected to ethernet (mainly for desktop/web)
Future<bool> get isConnectedToEthernet async {
final results = await _connectivity.checkConnectivity();
return results.contains(ConnectivityResult.ethernet);
}
/// Dispose of resources
void dispose() {
_connectivitySubscription?.cancel();
_connectionController.close();
}
}
/// Detailed network connection information
class NetworkConnectionDetails {
final List<ConnectivityResult> connectivityResults;
final bool hasInternetConnection;
final DateTime timestamp;
final String? error;
const NetworkConnectionDetails({
required this.connectivityResults,
required this.hasInternetConnection,
required this.timestamp,
this.error,
});
/// Check if any connection is available
bool get hasConnection =>
connectivityResults.any((result) => result != ConnectivityResult.none);
/// Get primary connection type
ConnectivityResult get primaryConnection {
return connectivityResults.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none,
);
}
/// Check if connected via WiFi
bool get isWiFi => connectivityResults.contains(ConnectivityResult.wifi);
/// Check if connected via mobile
bool get isMobile => connectivityResults.contains(ConnectivityResult.mobile);
/// Check if connected via ethernet
bool get isEthernet => connectivityResults.contains(ConnectivityResult.ethernet);
/// Get human-readable connection description
String get connectionDescription {
if (error != null) {
return 'Connection error: $error';
}
if (!hasConnection) {
return 'No connection';
}
if (!hasInternetConnection) {
return 'Connected but no internet access';
}
final types = <String>[];
if (isWiFi) types.add('WiFi');
if (isMobile) types.add('Mobile');
if (isEthernet) types.add('Ethernet');
return types.isEmpty ? 'Connected' : 'Connected via ${types.join(', ')}';
}
@override
String toString() {
return 'NetworkConnectionDetails('
'results: $connectivityResults, '
'hasInternet: $hasInternetConnection, '
'timestamp: $timestamp, '
'error: $error)';
}
}