init cc
This commit is contained in:
501
lib/core/network/README.md
Normal file
501
lib/core/network/README.md
Normal 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.
|
||||
78
lib/core/network/api_constants.dart
Normal file
78
lib/core/network/api_constants.dart
Normal 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);
|
||||
}
|
||||
362
lib/core/network/dio_client.dart
Normal file
362
lib/core/network/dio_client.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
279
lib/core/network/interceptors/auth_interceptor.dart
Normal file
279
lib/core/network/interceptors/auth_interceptor.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
348
lib/core/network/interceptors/error_interceptor.dart
Normal file
348
lib/core/network/interceptors/error_interceptor.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
281
lib/core/network/interceptors/logging_interceptor.dart
Normal file
281
lib/core/network/interceptors/logging_interceptor.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
333
lib/core/network/models/api_response.dart
Normal file
333
lib/core/network/models/api_response.dart
Normal 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)';
|
||||
}
|
||||
233
lib/core/network/network_info.dart
Normal file
233
lib/core/network/network_info.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user