runable
This commit is contained in:
449
lib/core/network/README.md
Normal file
449
lib/core/network/README.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# API Integration Infrastructure - Worker App
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive HTTP client infrastructure built with **Dio** and **Riverpod 3.0** for the Worker Flutter application. This setup provides robust API integration with authentication, caching, retry logic, error handling, and offline support.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/core/network/
|
||||
├── dio_client.dart # Main HTTP client with Riverpod providers
|
||||
├── api_interceptor.dart # Authentication, logging, and error interceptors
|
||||
├── network_info.dart # Network connectivity monitoring
|
||||
├── api_constants.dart # API endpoints and configuration
|
||||
├── exceptions.dart # Custom exception definitions
|
||||
└── failures.dart # Domain-level failure types
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Dio HTTP Client (`dio_client.dart`)
|
||||
|
||||
**DioClient Class**
|
||||
- Wrapper around Dio with full method support (GET, POST, PUT, PATCH, DELETE)
|
||||
- File upload with multipart/form-data
|
||||
- File download with progress tracking
|
||||
- Cache management utilities
|
||||
|
||||
**Riverpod Providers**
|
||||
- `dioProvider` - Configured Dio instance with all interceptors
|
||||
- `dioClientProvider` - DioClient wrapper instance
|
||||
- `cacheStoreProvider` - Hive-based cache storage
|
||||
- `cacheOptionsProvider` - Cache configuration
|
||||
|
||||
**Configuration**
|
||||
- Base URL: Configurable per environment (dev/staging/prod)
|
||||
- Timeouts: 30s connection, 30s receive, 30s send
|
||||
- Headers: JSON content-type, Vietnamese language by default
|
||||
- Cache: 7-day max-stale, no caching on auth errors (401, 403)
|
||||
|
||||
### 2. Interceptors (`api_interceptor.dart`)
|
||||
|
||||
#### AuthInterceptor
|
||||
- **Token Injection**: Automatically adds Bearer token to requests
|
||||
- **Token Refresh**: Handles 401 errors with automatic token refresh
|
||||
- **Public Endpoints**: Skips auth for login/OTP/register endpoints
|
||||
- **Language Header**: Adds Vietnamese language preference
|
||||
- **Storage**: Uses SharedPreferences for token persistence
|
||||
|
||||
#### LoggingInterceptor
|
||||
- **Request Logging**: Method, URL, headers, body, query parameters
|
||||
- **Response Logging**: Status code, response data (truncated)
|
||||
- **Error Logging**: Error type, status code, error data
|
||||
- **Security**: Sanitizes sensitive fields (password, OTP, tokens)
|
||||
- **Format**: Beautiful formatted logs with separators
|
||||
|
||||
#### ErrorTransformerInterceptor
|
||||
- **Dio Error Mapping**: Transforms DioException to custom exceptions
|
||||
- **Status Code Handling**:
|
||||
- 400 → ValidationException/BadRequestException
|
||||
- 401 → UnauthorizedException/TokenExpiredException/InvalidOTPException
|
||||
- 403 → ForbiddenException
|
||||
- 404 → NotFoundException
|
||||
- 409 → ConflictException
|
||||
- 422 → ValidationException with field errors
|
||||
- 429 → RateLimitException with retry-after
|
||||
- 5xx → ServerException/ServiceUnavailableException
|
||||
- **Connection Errors**: Timeout, NoInternet, etc.
|
||||
|
||||
#### RetryInterceptor
|
||||
- **Exponential Backoff**: Configurable delay multiplier
|
||||
- **Max Retries**: 3 attempts by default
|
||||
- **Retry Conditions**:
|
||||
- Connection timeout/errors
|
||||
- 5xx server errors (except 501)
|
||||
- 408 Request Timeout
|
||||
- 429 Too Many Requests
|
||||
- **Network Check**: Verifies connectivity before retrying
|
||||
|
||||
### 3. Network Monitoring (`network_info.dart`)
|
||||
|
||||
**NetworkInfo Interface**
|
||||
- Connection status checking
|
||||
- Connection type detection (WiFi, Mobile, Ethernet, etc.)
|
||||
- Real-time connectivity monitoring via Stream
|
||||
|
||||
**NetworkStatus Class**
|
||||
- Connection state (connected/disconnected)
|
||||
- Connection type
|
||||
- Timestamp
|
||||
- Convenience methods (isWiFi, isMobile, isMetered)
|
||||
|
||||
**Riverpod Providers**
|
||||
- `networkInfoProvider` - NetworkInfo implementation
|
||||
- `isConnectedProvider` - Current connection status
|
||||
- `connectionTypeProvider` - Current connection type
|
||||
- `networkStatusStreamProvider` - Stream of status changes
|
||||
- `NetworkStatusNotifier` - Reactive network status state
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
**Exceptions (`exceptions.dart`)**
|
||||
- NetworkException - Base network error
|
||||
- NoInternetException - No connectivity
|
||||
- TimeoutException - Connection timeout
|
||||
- ServerException - 5xx errors
|
||||
- ServiceUnavailableException - 503 errors
|
||||
- AuthException - Authentication errors (401, 403)
|
||||
- ValidationException - Request validation errors
|
||||
- NotFoundException - 404 errors
|
||||
- ConflictException - 409 errors
|
||||
- RateLimitException - 429 errors
|
||||
- PaymentException - Payment-related errors
|
||||
- CacheException - Cache errors
|
||||
- StorageException - Local storage errors
|
||||
- ParseException - JSON parsing errors
|
||||
|
||||
**Failures (`failures.dart`)**
|
||||
- Immutable Freezed classes for domain-level errors
|
||||
- User-friendly Vietnamese error messages
|
||||
- Properties:
|
||||
- `message` - Display message
|
||||
- `isCritical` - Requires immediate attention
|
||||
- `canRetry` - Can be retried
|
||||
- `statusCode` - HTTP status if available
|
||||
|
||||
### 5. API Constants (`api_constants.dart`)
|
||||
|
||||
**Configuration**
|
||||
- Base URLs (dev, staging, production)
|
||||
- API version prefix (/v1)
|
||||
- Timeout durations (30s)
|
||||
- Retry configuration (3 attempts, exponential backoff)
|
||||
- Cache durations (24h products, 1h profile, 48h categories)
|
||||
- Request headers (JSON, Vietnamese language)
|
||||
|
||||
**Endpoints**
|
||||
- Authentication: /auth/request-otp, /auth/verify-otp, /auth/register, etc.
|
||||
- Loyalty: /loyalty/points, /loyalty/rewards, /loyalty/referral, etc.
|
||||
- Products: /products, /products/search, /categories, etc.
|
||||
- Orders: /orders, /payments, etc.
|
||||
- Projects & Quotes: /projects, /quotes, etc.
|
||||
- Chat: /chat/messages, /ws/chat (WebSocket)
|
||||
- Account: /profile, /addresses, etc.
|
||||
- Promotions & Notifications
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic GET Request
|
||||
|
||||
```dart
|
||||
// Using DioClient with Riverpod
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
|
||||
try {
|
||||
final response = await dioClient.get(
|
||||
ApiConstants.getProducts,
|
||||
queryParameters: {'page': '1', 'limit': '20'},
|
||||
);
|
||||
|
||||
final products = response.data;
|
||||
} on NoInternetException catch (e) {
|
||||
// Handle no internet
|
||||
} on ServerException catch (e) {
|
||||
// Handle server error
|
||||
}
|
||||
```
|
||||
|
||||
### POST Request with Authentication
|
||||
|
||||
```dart
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
|
||||
try {
|
||||
final response = await dioClient.post(
|
||||
ApiConstants.createOrder,
|
||||
data: {
|
||||
'items': [...],
|
||||
'deliveryAddress': {...},
|
||||
'paymentMethod': 'COD',
|
||||
},
|
||||
);
|
||||
|
||||
final order = Order.fromJson(response.data);
|
||||
} on ValidationException catch (e) {
|
||||
// Handle validation errors
|
||||
print(e.errors); // Map<String, List<String>>
|
||||
}
|
||||
```
|
||||
|
||||
### File Upload
|
||||
|
||||
```dart
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
'name': 'John Doe',
|
||||
'avatar': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: 'avatar.jpg',
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await dioClient.uploadFile(
|
||||
ApiConstants.uploadAvatar,
|
||||
formData: formData,
|
||||
onSendProgress: (sent, total) {
|
||||
print('Upload progress: ${(sent / total * 100).toStringAsFixed(0)}%');
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Network Status Monitoring
|
||||
|
||||
```dart
|
||||
// Check current connection status
|
||||
final isConnected = await ref.watch(isConnectedProvider.future);
|
||||
|
||||
if (!isConnected) {
|
||||
// Show offline message
|
||||
}
|
||||
|
||||
// Listen to connection changes
|
||||
ref.listen(
|
||||
networkStatusStreamProvider,
|
||||
(previous, next) {
|
||||
next.whenData((status) {
|
||||
if (status.isConnected) {
|
||||
// Back online - sync data
|
||||
} else {
|
||||
// Offline - show message
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
|
||||
```dart
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
|
||||
// Clear all cache
|
||||
await dioClient.clearCache();
|
||||
|
||||
// Clear specific endpoint cache
|
||||
await dioClient.clearCacheByPath(ApiConstants.getProducts);
|
||||
|
||||
// Force refresh from network
|
||||
final response = await dioClient.get(
|
||||
ApiConstants.getProducts,
|
||||
options: ApiRequestOptions.forceNetwork.toDioOptions(),
|
||||
);
|
||||
|
||||
// Use cache-first strategy
|
||||
final response = await dioClient.get(
|
||||
ApiConstants.getCategories,
|
||||
options: ApiRequestOptions.cached.toDioOptions(),
|
||||
);
|
||||
```
|
||||
|
||||
### Custom Error Handling
|
||||
|
||||
```dart
|
||||
try {
|
||||
final response = await dioClient.post(...);
|
||||
} on ValidationException catch (e) {
|
||||
// Show field-specific errors
|
||||
e.errors?.forEach((field, messages) {
|
||||
print('$field: ${messages.join(", ")}');
|
||||
});
|
||||
} on RateLimitException catch (e) {
|
||||
// Show rate limit message
|
||||
if (e.retryAfter != null) {
|
||||
print('Try again in ${e.retryAfter} seconds');
|
||||
}
|
||||
} on TokenExpiredException catch (e) {
|
||||
// Token refresh failed - redirect to login
|
||||
ref.read(authProvider.notifier).logout();
|
||||
} catch (e) {
|
||||
// Generic error
|
||||
print('Error: $e');
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
dio: ^5.4.3+1 # HTTP client
|
||||
connectivity_plus: ^6.0.3 # Network monitoring
|
||||
pretty_dio_logger: ^1.3.1 # Request/response logging
|
||||
dio_cache_interceptor: ^3.5.0 # Response caching
|
||||
dio_cache_interceptor_hive_store: ^3.2.2 # Hive storage for cache
|
||||
flutter_riverpod: ^3.0.0 # State management
|
||||
riverpod_annotation: ^3.0.0 # Code generation
|
||||
shared_preferences: ^2.2.3 # Token storage
|
||||
path_provider: ^2.1.3 # Cache directory
|
||||
freezed_annotation: ^3.0.0 # Immutable models
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment-Specific Base URLs
|
||||
|
||||
Update `ApiConstants.baseUrl` based on build flavor:
|
||||
|
||||
```dart
|
||||
// For dev environment
|
||||
static const String baseUrl = devBaseUrl;
|
||||
|
||||
// For production
|
||||
static const String baseUrl = prodBaseUrl;
|
||||
```
|
||||
|
||||
### Timeout Configuration
|
||||
|
||||
Adjust timeouts in `ApiConstants`:
|
||||
|
||||
```dart
|
||||
static const Duration connectionTimeout = Duration(milliseconds: 30000);
|
||||
static const Duration receiveTimeout = Duration(milliseconds: 30000);
|
||||
static const Duration sendTimeout = Duration(milliseconds: 30000);
|
||||
```
|
||||
|
||||
### Retry Configuration
|
||||
|
||||
Customize retry behavior in `ApiConstants`:
|
||||
|
||||
```dart
|
||||
static const int maxRetryAttempts = 3;
|
||||
static const Duration initialRetryDelay = Duration(milliseconds: 1000);
|
||||
static const Duration maxRetryDelay = Duration(milliseconds: 5000);
|
||||
static const double retryDelayMultiplier = 2.0;
|
||||
```
|
||||
|
||||
### Cache Configuration
|
||||
|
||||
Adjust cache settings in `cacheOptionsProvider`:
|
||||
|
||||
```dart
|
||||
CacheOptions(
|
||||
store: store,
|
||||
maxStale: const Duration(days: 7),
|
||||
hitCacheOnErrorExcept: [401, 403],
|
||||
priority: CachePriority.high,
|
||||
allowPostMethod: false,
|
||||
);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Connection Testing
|
||||
|
||||
```dart
|
||||
// Test network connectivity
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
final connectionType = await networkInfo.connectionType;
|
||||
|
||||
print('Connected: $isConnected');
|
||||
print('Type: ${connectionType.displayNameVi}');
|
||||
```
|
||||
|
||||
### API Endpoint Testing
|
||||
|
||||
```dart
|
||||
// Test authentication endpoint
|
||||
try {
|
||||
final response = await dioClient.post(
|
||||
ApiConstants.requestOtp,
|
||||
data: {'phone': '+84912345678'},
|
||||
);
|
||||
print('OTP sent successfully');
|
||||
} catch (e) {
|
||||
print('Failed: $e');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use DioClient**: Don't create raw Dio instances
|
||||
2. **Handle specific exceptions**: Catch specific error types for better UX
|
||||
3. **Check connectivity**: Verify network status before critical requests
|
||||
4. **Use cache strategically**: Cache static data (categories, products)
|
||||
5. **Monitor network changes**: Listen to connectivity stream for sync
|
||||
6. **Clear cache appropriately**: Clear on logout, version updates
|
||||
7. **Log in debug only**: Disable logging in production
|
||||
8. **Sanitize sensitive data**: Never log passwords, tokens, OTP codes
|
||||
9. **Use retry wisely**: Don't retry POST/PUT/DELETE by default
|
||||
10. **Validate responses**: Check response.data structure before parsing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Offline request queue implementation
|
||||
- [ ] Request deduplication
|
||||
- [ ] GraphQL support
|
||||
- [ ] WebSocket integration for real-time chat
|
||||
- [ ] Certificate pinning for security
|
||||
- [ ] Request compression (gzip)
|
||||
- [ ] Multi-part upload progress
|
||||
- [ ] Background sync when network restored
|
||||
- [ ] Advanced caching strategies (stale-while-revalidate)
|
||||
- [ ] Request cancellation tokens
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Token Refresh Loop
|
||||
|
||||
**Solution**: Check refresh token expiry and clear auth data if expired
|
||||
|
||||
### Issue: Cache Not Working
|
||||
|
||||
**Solution**: Verify CacheStore initialization and directory permissions
|
||||
|
||||
### Issue: Network Detection Fails
|
||||
|
||||
**Solution**: Add required permissions to AndroidManifest.xml and Info.plist
|
||||
|
||||
### Issue: Timeout on Large Files
|
||||
|
||||
**Solution**: Increase timeout or use download with progress callback
|
||||
|
||||
### Issue: Interceptor Order Matters
|
||||
|
||||
**Current Order**:
|
||||
1. Logging (first - logs everything)
|
||||
2. Auth (adds tokens)
|
||||
3. Cache (caches responses)
|
||||
4. Retry (retries failures)
|
||||
5. Error Transformer (last - transforms errors)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about the API integration:
|
||||
- Check logs for detailed error information
|
||||
- Verify network connectivity using NetworkInfo
|
||||
- Review interceptor configuration
|
||||
- Check API endpoint constants
|
||||
|
||||
---
|
||||
|
||||
**Generated for Worker App**
|
||||
Version: 1.0.0
|
||||
Last Updated: 2025-10-17
|
||||
572
lib/core/network/api_interceptor.dart
Normal file
572
lib/core/network/api_interceptor.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
/// API interceptors for request/response handling
|
||||
///
|
||||
/// Provides interceptors for:
|
||||
/// - Authentication token injection
|
||||
/// - Request/response logging
|
||||
/// - Error transformation
|
||||
/// - Token refresh handling
|
||||
library;
|
||||
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/errors/exceptions.dart';
|
||||
|
||||
part 'api_interceptor.g.dart';
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
// ============================================================================
|
||||
|
||||
/// Keys for storing auth tokens in SharedPreferences
|
||||
class AuthStorageKeys {
|
||||
static const String accessToken = 'auth_access_token';
|
||||
static const String refreshToken = 'auth_refresh_token';
|
||||
static const String tokenExpiry = 'auth_token_expiry';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Interceptor
|
||||
// ============================================================================
|
||||
|
||||
/// Interceptor for adding authentication tokens to requests
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._prefs, this._dio);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
// Check if this endpoint requires authentication
|
||||
if (_requiresAuth(options.path)) {
|
||||
final token = await _getAccessToken();
|
||||
|
||||
if (token != null) {
|
||||
// Add bearer token to headers
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
|
||||
// Add language header
|
||||
options.headers['Accept-Language'] = ApiConstants.acceptLanguageVi;
|
||||
|
||||
// Add content-type and accept headers if not already set
|
||||
options.headers['Content-Type'] ??= ApiConstants.contentTypeJson;
|
||||
options.headers['Accept'] ??= ApiConstants.acceptJson;
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// Check if error is 401 Unauthorized
|
||||
if (err.response?.statusCode == 401) {
|
||||
// Try to refresh token
|
||||
final refreshed = await _refreshAccessToken();
|
||||
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
try {
|
||||
final response = await _retry(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} catch (e) {
|
||||
// If retry fails, continue with error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// Check if endpoint requires authentication
|
||||
bool _requiresAuth(String path) {
|
||||
// Public endpoints that don't require auth
|
||||
final publicEndpoints = [
|
||||
ApiConstants.requestOtp,
|
||||
ApiConstants.verifyOtp,
|
||||
ApiConstants.register,
|
||||
];
|
||||
|
||||
return !publicEndpoints.any((endpoint) => path.contains(endpoint));
|
||||
}
|
||||
|
||||
/// Get access token from storage
|
||||
Future<String?> _getAccessToken() async {
|
||||
return _prefs.getString(AuthStorageKeys.accessToken);
|
||||
}
|
||||
|
||||
/// Get refresh token from storage
|
||||
Future<String?> _getRefreshToken() async {
|
||||
return _prefs.getString(AuthStorageKeys.refreshToken);
|
||||
}
|
||||
|
||||
/// Check if token is expired
|
||||
Future<bool> _isTokenExpired() async {
|
||||
final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
|
||||
if (expiryString == null) return true;
|
||||
|
||||
final expiry = DateTime.tryParse(expiryString);
|
||||
if (expiry == null) return true;
|
||||
|
||||
return DateTime.now().isAfter(expiry);
|
||||
}
|
||||
|
||||
/// Refresh access token using refresh token
|
||||
Future<bool> _refreshAccessToken() async {
|
||||
try {
|
||||
final refreshToken = await _getRefreshToken();
|
||||
|
||||
if (refreshToken == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call refresh token endpoint
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer $refreshToken',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
// Save new tokens
|
||||
await _prefs.setString(
|
||||
AuthStorageKeys.accessToken,
|
||||
data['accessToken'] as String,
|
||||
);
|
||||
|
||||
if (data.containsKey('refreshToken')) {
|
||||
await _prefs.setString(
|
||||
AuthStorageKeys.refreshToken,
|
||||
data['refreshToken'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.containsKey('expiresAt')) {
|
||||
await _prefs.setString(
|
||||
AuthStorageKeys.tokenExpiry,
|
||||
data['expiresAt'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Failed to refresh token',
|
||||
name: 'AuthInterceptor',
|
||||
error: e,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry failed request with new token
|
||||
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
|
||||
final token = await _getAccessToken();
|
||||
|
||||
final options = Options(
|
||||
method: requestOptions.method,
|
||||
headers: {
|
||||
...requestOptions.headers,
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
return _dio.request(
|
||||
requestOptions.path,
|
||||
data: requestOptions.data,
|
||||
queryParameters: requestOptions.queryParameters,
|
||||
options: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Logging Interceptor
|
||||
// ============================================================================
|
||||
|
||||
/// Interceptor for logging requests and responses in debug mode
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
LoggingInterceptor({
|
||||
this.enableRequestLogging = true,
|
||||
this.enableResponseLogging = true,
|
||||
this.enableErrorLogging = true,
|
||||
});
|
||||
|
||||
final bool enableRequestLogging;
|
||||
final bool enableResponseLogging;
|
||||
final bool enableErrorLogging;
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
if (enableRequestLogging) {
|
||||
developer.log(
|
||||
'╔══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
developer.log(
|
||||
'║ ${options.method} ${options.uri}',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
developer.log(
|
||||
'║ Headers: ${_sanitizeHeaders(options.headers)}',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
|
||||
if (options.data != null) {
|
||||
developer.log(
|
||||
'║ Body: ${_sanitizeBody(options.data)}',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
developer.log(
|
||||
'║ Query Parameters: ${options.queryParameters}',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'╚══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Request',
|
||||
);
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
if (enableResponseLogging) {
|
||||
developer.log(
|
||||
'╔══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Response',
|
||||
);
|
||||
developer.log(
|
||||
'║ ${response.requestOptions.method} ${response.requestOptions.uri}',
|
||||
name: 'HTTP Response',
|
||||
);
|
||||
developer.log(
|
||||
'║ Status Code: ${response.statusCode}',
|
||||
name: 'HTTP Response',
|
||||
);
|
||||
developer.log(
|
||||
'║ Data: ${_truncateData(response.data, 500)}',
|
||||
name: 'HTTP Response',
|
||||
);
|
||||
developer.log(
|
||||
'╚══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Response',
|
||||
);
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
if (enableErrorLogging) {
|
||||
developer.log(
|
||||
'╔══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ ${err.requestOptions.method} ${err.requestOptions.uri}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ Error Type: ${err.type}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ Status Code: ${err.response?.statusCode}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
developer.log(
|
||||
'║ Message: ${err.message}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
|
||||
if (err.response?.data != null) {
|
||||
developer.log(
|
||||
'║ Error Data: ${_truncateData(err.response?.data, 500)}',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'╚══════════════════════════════════════════════════════════════',
|
||||
name: 'HTTP Error',
|
||||
);
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// Sanitize headers by hiding sensitive information
|
||||
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
|
||||
final sanitized = Map<String, dynamic>.from(headers);
|
||||
|
||||
// Hide authorization token
|
||||
if (sanitized.containsKey('Authorization')) {
|
||||
sanitized['Authorization'] = '[HIDDEN]';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// Sanitize request body by hiding sensitive fields
|
||||
dynamic _sanitizeBody(dynamic body) {
|
||||
if (body is Map) {
|
||||
final sanitized = Map<dynamic, dynamic>.from(body);
|
||||
|
||||
// List of sensitive field names
|
||||
final sensitiveFields = [
|
||||
'password',
|
||||
'otp',
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'secret',
|
||||
'apiKey',
|
||||
];
|
||||
|
||||
for (final field in sensitiveFields) {
|
||||
if (sanitized.containsKey(field)) {
|
||||
sanitized[field] = '[HIDDEN]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/// Truncate data for logging to avoid huge logs
|
||||
String _truncateData(dynamic data, int maxLength) {
|
||||
final dataStr = data.toString();
|
||||
if (dataStr.length <= maxLength) {
|
||||
return dataStr;
|
||||
}
|
||||
return '${dataStr.substring(0, maxLength)}... [TRUNCATED]';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Transformer Interceptor
|
||||
// ============================================================================
|
||||
|
||||
/// Interceptor for transforming Dio errors into custom exceptions
|
||||
class ErrorTransformerInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) {
|
||||
Exception exception;
|
||||
|
||||
switch (err.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
exception = const TimeoutException();
|
||||
break;
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
exception = const NoInternetException();
|
||||
break;
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
exception = _handleBadResponse(err.response);
|
||||
break;
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
exception = NetworkException('Yêu cầu đã bị hủy');
|
||||
break;
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
exception = NetworkException(
|
||||
'Lỗi không xác định: ${err.message}',
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
exception = NetworkException(err.message ?? 'Unknown error');
|
||||
}
|
||||
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
type: err.type,
|
||||
error: exception,
|
||||
message: exception.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle bad response errors based on status code
|
||||
Exception _handleBadResponse(Response<dynamic>? response) {
|
||||
if (response == null) {
|
||||
return const ServerException();
|
||||
}
|
||||
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
final data = response.data;
|
||||
|
||||
// Extract error message from response
|
||||
String? message;
|
||||
if (data is Map<String, dynamic>) {
|
||||
message = data['message'] as String? ??
|
||||
data['error'] as String? ??
|
||||
data['msg'] as String?;
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
if (data is Map<String, dynamic> && data.containsKey('errors')) {
|
||||
final errors = data['errors'] as Map<String, dynamic>?;
|
||||
if (errors != null) {
|
||||
final validationErrors = errors.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
value is List
|
||||
? value.cast<String>()
|
||||
: [value.toString()],
|
||||
),
|
||||
);
|
||||
return ValidationException(
|
||||
message ?? 'Dữ liệu không hợp lệ',
|
||||
errors: validationErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
return BadRequestException(message ?? 'Yêu cầu không hợp lệ');
|
||||
|
||||
case 401:
|
||||
if (message?.toLowerCase().contains('token') ?? false) {
|
||||
return const TokenExpiredException();
|
||||
}
|
||||
if (message?.toLowerCase().contains('otp') ?? false) {
|
||||
return const InvalidOTPException();
|
||||
}
|
||||
return UnauthorizedException(message ?? 'Phiên đăng nhập hết hạn');
|
||||
|
||||
case 403:
|
||||
return const ForbiddenException();
|
||||
|
||||
case 404:
|
||||
return NotFoundException(message ?? 'Không tìm thấy tài nguyên');
|
||||
|
||||
case 409:
|
||||
return ConflictException(message ?? 'Tài nguyên đã tồn tại');
|
||||
|
||||
case 422:
|
||||
if (data is Map<String, dynamic> && data.containsKey('errors')) {
|
||||
final errors = data['errors'] as Map<String, dynamic>?;
|
||||
if (errors != null) {
|
||||
final validationErrors = errors.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
value is List
|
||||
? value.cast<String>()
|
||||
: [value.toString()],
|
||||
),
|
||||
);
|
||||
return ValidationException(
|
||||
message ?? 'Dữ liệu không hợp lệ',
|
||||
errors: validationErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ValidationException(message ?? 'Dữ liệu không hợp lệ');
|
||||
|
||||
case 429:
|
||||
final retryAfter = response.headers.value('retry-after');
|
||||
final retrySeconds = retryAfter != null ? int.tryParse(retryAfter) : null;
|
||||
return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
if (statusCode == 503) {
|
||||
return const ServiceUnavailableException();
|
||||
}
|
||||
return ServerException(message ?? 'Lỗi máy chủ', statusCode);
|
||||
|
||||
default:
|
||||
return NetworkException(
|
||||
message ?? 'Lỗi mạng không xác định',
|
||||
statusCode: statusCode,
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Riverpod Providers
|
||||
// ============================================================================
|
||||
|
||||
/// Provider for SharedPreferences instance
|
||||
@riverpod
|
||||
Future<SharedPreferences> sharedPreferences(Ref ref) async {
|
||||
return await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
@riverpod
|
||||
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
||||
final prefs = await ref.watch(sharedPreferencesProvider.future);
|
||||
return AuthInterceptor(prefs, dio);
|
||||
}
|
||||
|
||||
/// Provider for LoggingInterceptor
|
||||
@riverpod
|
||||
LoggingInterceptor loggingInterceptor(Ref ref) {
|
||||
// Only enable logging in debug mode
|
||||
const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter
|
||||
|
||||
return LoggingInterceptor(
|
||||
enableRequestLogging: isDebug,
|
||||
enableResponseLogging: isDebug,
|
||||
enableErrorLogging: isDebug,
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider for ErrorTransformerInterceptor
|
||||
@riverpod
|
||||
ErrorTransformerInterceptor errorTransformerInterceptor(Ref ref) {
|
||||
return ErrorTransformerInterceptor();
|
||||
}
|
||||
246
lib/core/network/api_interceptor.g.dart
Normal file
246
lib/core/network/api_interceptor.g.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'api_interceptor.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for SharedPreferences instance
|
||||
|
||||
@ProviderFor(sharedPreferences)
|
||||
const sharedPreferencesProvider = SharedPreferencesProvider._();
|
||||
|
||||
/// Provider for SharedPreferences instance
|
||||
|
||||
final class SharedPreferencesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SharedPreferences>,
|
||||
SharedPreferences,
|
||||
FutureOr<SharedPreferences>
|
||||
>
|
||||
with
|
||||
$FutureModifier<SharedPreferences>,
|
||||
$FutureProvider<SharedPreferences> {
|
||||
/// Provider for SharedPreferences instance
|
||||
const SharedPreferencesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sharedPreferencesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sharedPreferencesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SharedPreferences> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SharedPreferences> create(Ref ref) {
|
||||
return sharedPreferences(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sharedPreferencesHash() => r'dc403fbb1d968c7d5ab4ae1721a29ffe173701c7';
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
|
||||
@ProviderFor(authInterceptor)
|
||||
const authInterceptorProvider = AuthInterceptorFamily._();
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
|
||||
final class AuthInterceptorProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<AuthInterceptor>,
|
||||
AuthInterceptor,
|
||||
FutureOr<AuthInterceptor>
|
||||
>
|
||||
with $FutureModifier<AuthInterceptor>, $FutureProvider<AuthInterceptor> {
|
||||
/// Provider for AuthInterceptor
|
||||
const AuthInterceptorProvider._({
|
||||
required AuthInterceptorFamily super.from,
|
||||
required Dio super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'authInterceptorProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authInterceptorHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'authInterceptorProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<AuthInterceptor> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<AuthInterceptor> create(Ref ref) {
|
||||
final argument = this.argument as Dio;
|
||||
return authInterceptor(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AuthInterceptorProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10';
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
|
||||
final class AuthInterceptorFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<AuthInterceptor>, Dio> {
|
||||
const AuthInterceptorFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'authInterceptorProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for AuthInterceptor
|
||||
|
||||
AuthInterceptorProvider call(Dio dio) =>
|
||||
AuthInterceptorProvider._(argument: dio, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'authInterceptorProvider';
|
||||
}
|
||||
|
||||
/// Provider for LoggingInterceptor
|
||||
|
||||
@ProviderFor(loggingInterceptor)
|
||||
const loggingInterceptorProvider = LoggingInterceptorProvider._();
|
||||
|
||||
/// Provider for LoggingInterceptor
|
||||
|
||||
final class LoggingInterceptorProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
LoggingInterceptor,
|
||||
LoggingInterceptor,
|
||||
LoggingInterceptor
|
||||
>
|
||||
with $Provider<LoggingInterceptor> {
|
||||
/// Provider for LoggingInterceptor
|
||||
const LoggingInterceptorProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'loggingInterceptorProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$loggingInterceptorHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<LoggingInterceptor> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
LoggingInterceptor create(Ref ref) {
|
||||
return loggingInterceptor(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(LoggingInterceptor value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<LoggingInterceptor>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$loggingInterceptorHash() =>
|
||||
r'f3dedaeb3152d5188544232f6f270bb6908c2827';
|
||||
|
||||
/// Provider for ErrorTransformerInterceptor
|
||||
|
||||
@ProviderFor(errorTransformerInterceptor)
|
||||
const errorTransformerInterceptorProvider =
|
||||
ErrorTransformerInterceptorProvider._();
|
||||
|
||||
/// Provider for ErrorTransformerInterceptor
|
||||
|
||||
final class ErrorTransformerInterceptorProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ErrorTransformerInterceptor,
|
||||
ErrorTransformerInterceptor,
|
||||
ErrorTransformerInterceptor
|
||||
>
|
||||
with $Provider<ErrorTransformerInterceptor> {
|
||||
/// Provider for ErrorTransformerInterceptor
|
||||
const ErrorTransformerInterceptorProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'errorTransformerInterceptorProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$errorTransformerInterceptorHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ErrorTransformerInterceptor> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ErrorTransformerInterceptor create(Ref ref) {
|
||||
return errorTransformerInterceptor(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ErrorTransformerInterceptor value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ErrorTransformerInterceptor>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$errorTransformerInterceptorHash() =>
|
||||
r'15a14206b96d046054277ee0b8220838e0e9e267';
|
||||
496
lib/core/network/dio_client.dart
Normal file
496
lib/core/network/dio_client.dart
Normal file
@@ -0,0 +1,496 @@
|
||||
/// Dio HTTP client configuration for the Worker app
|
||||
///
|
||||
/// Provides a configured Dio instance with interceptors for:
|
||||
/// - Authentication
|
||||
/// - Logging
|
||||
/// - Error handling
|
||||
/// - Caching
|
||||
/// - Retry logic
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/network/api_interceptor.dart';
|
||||
import 'package:worker/core/network/network_info.dart';
|
||||
|
||||
part 'dio_client.g.dart';
|
||||
|
||||
// ============================================================================
|
||||
// Dio Client Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// HTTP client wrapper around Dio with interceptors and configuration
|
||||
class DioClient {
|
||||
DioClient(this._dio, this._cacheStore);
|
||||
|
||||
final Dio _dio;
|
||||
final CacheStore? _cacheStore;
|
||||
|
||||
/// Get the underlying Dio instance
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// Get the cache store
|
||||
CacheStore? get cacheStore => _cacheStore;
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Methods
|
||||
// ============================================================================
|
||||
|
||||
/// Perform GET request
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform POST request
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform PUT request
|
||||
Future<Response<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.put<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform PATCH request
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.patch<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform DELETE request
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload file with multipart/form-data
|
||||
Future<Response<T>> uploadFile<T>(
|
||||
String path, {
|
||||
required FormData formData,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: formData,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Download file
|
||||
Future<Response<dynamic>> downloadFile(
|
||||
String urlPath,
|
||||
String savePath, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
bool deleteOnError = true,
|
||||
String lengthHeader = Headers.contentLengthHeader,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.download(
|
||||
urlPath,
|
||||
savePath,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
deleteOnError: deleteOnError,
|
||||
lengthHeader: lengthHeader,
|
||||
options: options,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache Management
|
||||
// ============================================================================
|
||||
|
||||
/// Clear all cached responses
|
||||
Future<void> clearCache() async {
|
||||
if (_cacheStore != null) {
|
||||
await _cacheStore!.clean();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear specific cached response by key
|
||||
Future<void> clearCacheByKey(String key) async {
|
||||
if (_cacheStore != null) {
|
||||
await _cacheStore!.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache for specific path
|
||||
Future<void> clearCacheByPath(String path) async {
|
||||
if (_cacheStore != null) {
|
||||
final key = CacheOptions.defaultCacheKeyBuilder(
|
||||
RequestOptions(path: path),
|
||||
);
|
||||
await _cacheStore!.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Retry Interceptor
|
||||
// ============================================================================
|
||||
|
||||
/// Interceptor for retrying failed requests with exponential backoff
|
||||
class RetryInterceptor extends Interceptor {
|
||||
RetryInterceptor(
|
||||
this._networkInfo, {
|
||||
this.maxRetries = ApiConstants.maxRetryAttempts,
|
||||
this.initialDelay = ApiConstants.initialRetryDelay,
|
||||
this.maxDelay = ApiConstants.maxRetryDelay,
|
||||
this.delayMultiplier = ApiConstants.retryDelayMultiplier,
|
||||
});
|
||||
|
||||
final NetworkInfo _networkInfo;
|
||||
final int maxRetries;
|
||||
final Duration initialDelay;
|
||||
final Duration maxDelay;
|
||||
final double delayMultiplier;
|
||||
|
||||
@override
|
||||
void onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// Get retry count from request extra
|
||||
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
|
||||
|
||||
// Check if we should retry
|
||||
if (retries >= maxRetries || !_shouldRetry(err)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check network connectivity before retrying
|
||||
final isConnected = await _networkInfo.isConnected;
|
||||
if (!isConnected) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
final delayMs = (initialDelay.inMilliseconds *
|
||||
(delayMultiplier * (retries + 1))).toInt();
|
||||
final delay = Duration(
|
||||
milliseconds: delayMs.clamp(
|
||||
initialDelay.inMilliseconds,
|
||||
maxDelay.inMilliseconds,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait before retry
|
||||
await Future<void>.delayed(delay);
|
||||
|
||||
// Increment retry count
|
||||
err.requestOptions.extra['retries'] = retries + 1;
|
||||
|
||||
// Retry the request
|
||||
try {
|
||||
final dio = Dio();
|
||||
final response = await dio.fetch<dynamic>(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if error should trigger a retry
|
||||
bool _shouldRetry(DioException error) {
|
||||
// Retry on connection errors
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on 5xx server errors (except 501)
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500 && statusCode != 501) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on 408 Request Timeout
|
||||
if (statusCode == 408) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on 429 Too Many Requests (with delay from header)
|
||||
if (statusCode == 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Riverpod Providers
|
||||
// ============================================================================
|
||||
|
||||
/// Provider for cache store
|
||||
@riverpod
|
||||
Future<CacheStore> cacheStore(Ref ref) async {
|
||||
final directory = await getTemporaryDirectory();
|
||||
return HiveCacheStore(
|
||||
directory.path,
|
||||
hiveBoxName: 'dio_cache',
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider for cache options
|
||||
@riverpod
|
||||
Future<CacheOptions> cacheOptions(Ref ref) async {
|
||||
final store = await ref.watch(cacheStoreProvider.future);
|
||||
|
||||
return CacheOptions(
|
||||
store: store,
|
||||
maxStale: const Duration(days: 7), // Keep cache for 7 days
|
||||
hitCacheOnErrorExcept: [401, 403], // Use cache on error except auth errors
|
||||
priority: CachePriority.high,
|
||||
cipher: null, // No encryption for now
|
||||
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
|
||||
allowPostMethod: false, // Don't cache POST requests by default
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider for Dio instance with all interceptors
|
||||
@riverpod
|
||||
Future<Dio> dio(Ref ref) async {
|
||||
final dio = Dio();
|
||||
|
||||
// Base configuration
|
||||
dio
|
||||
..options = BaseOptions(
|
||||
baseUrl: ApiConstants.apiBaseUrl,
|
||||
connectTimeout: ApiConstants.connectionTimeout,
|
||||
receiveTimeout: ApiConstants.receiveTimeout,
|
||||
sendTimeout: ApiConstants.sendTimeout,
|
||||
headers: {
|
||||
'Content-Type': ApiConstants.contentTypeJson,
|
||||
'Accept': ApiConstants.acceptJson,
|
||||
'Accept-Language': ApiConstants.acceptLanguageVi,
|
||||
},
|
||||
responseType: ResponseType.json,
|
||||
validateStatus: (status) {
|
||||
// Accept all status codes and handle errors in interceptor
|
||||
return status != null && status < 500;
|
||||
},
|
||||
)
|
||||
|
||||
// Add interceptors in order
|
||||
|
||||
// 1. Logging interceptor (first to log everything)
|
||||
..interceptors.add(ref.watch(loggingInterceptorProvider))
|
||||
|
||||
// 2. Auth interceptor (add tokens to requests)
|
||||
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
|
||||
// 3. Cache interceptor
|
||||
..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future)))
|
||||
// 4. Retry interceptor
|
||||
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
|
||||
// 5. Error transformer (last to transform all errors)
|
||||
..interceptors.add(ref.watch(errorTransformerInterceptorProvider));
|
||||
|
||||
return dio;
|
||||
}
|
||||
|
||||
/// Provider for DioClient
|
||||
@riverpod
|
||||
Future<DioClient> dioClient(Ref ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
final cacheStore = await ref.watch(cacheStoreProvider.future);
|
||||
|
||||
return DioClient(dio, cacheStore);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Classes
|
||||
// ============================================================================
|
||||
|
||||
/// Options for API requests with custom cache policy
|
||||
class ApiRequestOptions {
|
||||
const ApiRequestOptions({
|
||||
this.cachePolicy,
|
||||
this.cacheDuration,
|
||||
this.forceRefresh = false,
|
||||
});
|
||||
|
||||
final CachePolicy? cachePolicy;
|
||||
final Duration? cacheDuration;
|
||||
final bool forceRefresh;
|
||||
|
||||
/// Options with cache enabled
|
||||
static const cached = ApiRequestOptions(
|
||||
cachePolicy: CachePolicy.forceCache,
|
||||
);
|
||||
|
||||
/// Options with network-first strategy
|
||||
static const networkFirst = ApiRequestOptions(
|
||||
cachePolicy: CachePolicy.refreshForceCache,
|
||||
);
|
||||
|
||||
/// Options to force refresh from network
|
||||
static const forceNetwork = ApiRequestOptions(
|
||||
cachePolicy: CachePolicy.refresh,
|
||||
forceRefresh: true,
|
||||
);
|
||||
|
||||
/// Convert to Dio Options
|
||||
Options toDioOptions() {
|
||||
return Options(
|
||||
extra: <String, dynamic>{
|
||||
if (cachePolicy != null)
|
||||
CacheResponse.cacheKey: cachePolicy!.index,
|
||||
if (cacheDuration != null)
|
||||
'maxStale': cacheDuration,
|
||||
if (forceRefresh)
|
||||
'policy': CachePolicy.refresh.index,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline request queue item
|
||||
class QueuedRequest {
|
||||
QueuedRequest({
|
||||
required this.method,
|
||||
required this.path,
|
||||
this.data,
|
||||
this.queryParameters,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory QueuedRequest.fromJson(Map<String, dynamic> json) {
|
||||
return QueuedRequest(
|
||||
method: json['method'] as String,
|
||||
path: json['path'] as String,
|
||||
data: json['data'],
|
||||
queryParameters: json['queryParameters'] as Map<String, dynamic>?,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
final String method;
|
||||
final String path;
|
||||
final dynamic data;
|
||||
final Map<String, dynamic>? queryParameters;
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'method': method,
|
||||
'path': path,
|
||||
'data': data,
|
||||
'queryParameters': queryParameters,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
177
lib/core/network/dio_client.g.dart
Normal file
177
lib/core/network/dio_client.g.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'dio_client.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for cache store
|
||||
|
||||
@ProviderFor(cacheStore)
|
||||
const cacheStoreProvider = CacheStoreProvider._();
|
||||
|
||||
/// Provider for cache store
|
||||
|
||||
final class CacheStoreProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<CacheStore>,
|
||||
CacheStore,
|
||||
FutureOr<CacheStore>
|
||||
>
|
||||
with $FutureModifier<CacheStore>, $FutureProvider<CacheStore> {
|
||||
/// Provider for cache store
|
||||
const CacheStoreProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'cacheStoreProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$cacheStoreHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<CacheStore> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<CacheStore> create(Ref ref) {
|
||||
return cacheStore(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$cacheStoreHash() => r'8cbc2688ee267e03fc5aa6bf48c3ada249cb6345';
|
||||
|
||||
/// Provider for cache options
|
||||
|
||||
@ProviderFor(cacheOptions)
|
||||
const cacheOptionsProvider = CacheOptionsProvider._();
|
||||
|
||||
/// Provider for cache options
|
||||
|
||||
final class CacheOptionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<CacheOptions>,
|
||||
CacheOptions,
|
||||
FutureOr<CacheOptions>
|
||||
>
|
||||
with $FutureModifier<CacheOptions>, $FutureProvider<CacheOptions> {
|
||||
/// Provider for cache options
|
||||
const CacheOptionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'cacheOptionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$cacheOptionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<CacheOptions> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<CacheOptions> create(Ref ref) {
|
||||
return cacheOptions(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$cacheOptionsHash() => r'6b6b951855d8c0094e36918efa79c6ba586e156d';
|
||||
|
||||
/// Provider for Dio instance with all interceptors
|
||||
|
||||
@ProviderFor(dio)
|
||||
const dioProvider = DioProvider._();
|
||||
|
||||
/// Provider for Dio instance with all interceptors
|
||||
|
||||
final class DioProvider
|
||||
extends $FunctionalProvider<AsyncValue<Dio>, Dio, FutureOr<Dio>>
|
||||
with $FutureModifier<Dio>, $FutureProvider<Dio> {
|
||||
/// Provider for Dio instance with all interceptors
|
||||
const DioProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'dioProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$dioHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Dio> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Dio> create(Ref ref) {
|
||||
return dio(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7';
|
||||
|
||||
/// Provider for DioClient
|
||||
|
||||
@ProviderFor(dioClient)
|
||||
const dioClientProvider = DioClientProvider._();
|
||||
|
||||
/// Provider for DioClient
|
||||
|
||||
final class DioClientProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<DioClient>,
|
||||
DioClient,
|
||||
FutureOr<DioClient>
|
||||
>
|
||||
with $FutureModifier<DioClient>, $FutureProvider<DioClient> {
|
||||
/// Provider for DioClient
|
||||
const DioClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'dioClientProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$dioClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<DioClient> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<DioClient> create(Ref ref) {
|
||||
return dioClient(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$dioClientHash() => r'4f6754880ccc00aa99b8ae19904e9da88950a4e1';
|
||||
365
lib/core/network/network_info.dart
Normal file
365
lib/core/network/network_info.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
/// Network connectivity information and monitoring
|
||||
///
|
||||
/// Provides real-time network status checking, connection type detection,
|
||||
/// and connectivity monitoring for the Worker app.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'network_info.g.dart';
|
||||
|
||||
// ============================================================================
|
||||
// Network Connection Types
|
||||
// ============================================================================
|
||||
|
||||
/// Types of network connections
|
||||
enum NetworkConnectionType {
|
||||
/// WiFi connection
|
||||
wifi,
|
||||
|
||||
/// Mobile data connection
|
||||
mobile,
|
||||
|
||||
/// Ethernet connection (wired)
|
||||
ethernet,
|
||||
|
||||
/// Bluetooth connection
|
||||
bluetooth,
|
||||
|
||||
/// VPN connection
|
||||
vpn,
|
||||
|
||||
/// No connection
|
||||
none,
|
||||
|
||||
/// Unknown connection type
|
||||
unknown,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Status
|
||||
// ============================================================================
|
||||
|
||||
/// Network connectivity status
|
||||
class NetworkStatus {
|
||||
const NetworkStatus({
|
||||
required this.isConnected,
|
||||
required this.connectionType,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory NetworkStatus.connected(NetworkConnectionType type) {
|
||||
return NetworkStatus(
|
||||
isConnected: true,
|
||||
connectionType: type,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
factory NetworkStatus.disconnected() {
|
||||
return NetworkStatus(
|
||||
isConnected: false,
|
||||
connectionType: NetworkConnectionType.none,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
final bool isConnected;
|
||||
final NetworkConnectionType connectionType;
|
||||
final DateTime timestamp;
|
||||
|
||||
/// Check if connected via WiFi
|
||||
bool get isWiFi => connectionType == NetworkConnectionType.wifi;
|
||||
|
||||
/// Check if connected via mobile data
|
||||
bool get isMobile => connectionType == NetworkConnectionType.mobile;
|
||||
|
||||
/// Check if connected via ethernet
|
||||
bool get isEthernet => connectionType == NetworkConnectionType.ethernet;
|
||||
|
||||
/// Check if connection is metered (mobile data)
|
||||
bool get isMetered => isMobile;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NetworkStatus(isConnected: $isConnected, type: $connectionType)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is NetworkStatus &&
|
||||
other.isConnected == isConnected &&
|
||||
other.connectionType == connectionType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isConnected, connectionType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Info Interface
|
||||
// ============================================================================
|
||||
|
||||
/// Abstract interface for network information
|
||||
abstract class NetworkInfo {
|
||||
/// Check if device is currently connected to internet
|
||||
Future<bool> get isConnected;
|
||||
|
||||
/// Get current network connection type
|
||||
Future<NetworkConnectionType> get connectionType;
|
||||
|
||||
/// Get current network status
|
||||
Future<NetworkStatus> get networkStatus;
|
||||
|
||||
/// Stream of network status changes
|
||||
Stream<NetworkStatus> get onNetworkStatusChanged;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Network Info Implementation
|
||||
// ============================================================================
|
||||
|
||||
/// Implementation of NetworkInfo using connectivity_plus
|
||||
class NetworkInfoImpl implements NetworkInfo {
|
||||
NetworkInfoImpl(this._connectivity);
|
||||
|
||||
final Connectivity _connectivity;
|
||||
StreamController<NetworkStatus>? _statusController;
|
||||
StreamSubscription<List<ConnectivityResult>>? _subscription;
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return _hasConnection(results);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NetworkConnectionType> get connectionType async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return _mapConnectivityResult(results);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NetworkStatus> get networkStatus async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
final hasConnection = _hasConnection(results);
|
||||
final type = _mapConnectivityResult(results);
|
||||
|
||||
if (hasConnection) {
|
||||
return NetworkStatus.connected(type);
|
||||
} else {
|
||||
return NetworkStatus.disconnected();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<NetworkStatus> get onNetworkStatusChanged {
|
||||
_statusController ??= StreamController<NetworkStatus>.broadcast(
|
||||
onListen: _startListening,
|
||||
onCancel: _stopListening,
|
||||
);
|
||||
return _statusController!.stream;
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
_subscription = _connectivity.onConnectivityChanged.listen(
|
||||
(results) {
|
||||
final hasConnection = _hasConnection(results);
|
||||
final type = _mapConnectivityResult(results);
|
||||
final status = hasConnection
|
||||
? NetworkStatus.connected(type)
|
||||
: NetworkStatus.disconnected();
|
||||
_statusController?.add(status);
|
||||
},
|
||||
onError: (error) {
|
||||
_statusController?.add(NetworkStatus.disconnected());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _stopListening() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
bool _hasConnection(List<ConnectivityResult> results) {
|
||||
if (results.isEmpty) return false;
|
||||
return !results.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
NetworkConnectionType _mapConnectivityResult(List<ConnectivityResult> results) {
|
||||
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
|
||||
return NetworkConnectionType.none;
|
||||
}
|
||||
|
||||
// Priority order: WiFi > Ethernet > Mobile > Bluetooth > VPN
|
||||
if (results.contains(ConnectivityResult.wifi)) {
|
||||
return NetworkConnectionType.wifi;
|
||||
} else if (results.contains(ConnectivityResult.ethernet)) {
|
||||
return NetworkConnectionType.ethernet;
|
||||
} else if (results.contains(ConnectivityResult.mobile)) {
|
||||
return NetworkConnectionType.mobile;
|
||||
} else if (results.contains(ConnectivityResult.bluetooth)) {
|
||||
return NetworkConnectionType.bluetooth;
|
||||
} else if (results.contains(ConnectivityResult.vpn)) {
|
||||
return NetworkConnectionType.vpn;
|
||||
} else {
|
||||
return NetworkConnectionType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_statusController?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Riverpod Providers
|
||||
// ============================================================================
|
||||
|
||||
/// Provider for Connectivity instance
|
||||
@riverpod
|
||||
Connectivity connectivity(Ref ref) {
|
||||
return Connectivity();
|
||||
}
|
||||
|
||||
/// Provider for NetworkInfo instance
|
||||
@riverpod
|
||||
NetworkInfo networkInfo(Ref ref) {
|
||||
final connectivity = ref.watch(connectivityProvider);
|
||||
final networkInfo = NetworkInfoImpl(connectivity);
|
||||
|
||||
// Dispose when provider is disposed
|
||||
ref.onDispose(() {
|
||||
networkInfo.dispose();
|
||||
});
|
||||
|
||||
return networkInfo;
|
||||
}
|
||||
|
||||
/// Provider for current network connection status (boolean)
|
||||
@riverpod
|
||||
Future<bool> isConnected(Ref ref) async {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
return await networkInfo.isConnected;
|
||||
}
|
||||
|
||||
/// Provider for current network connection type
|
||||
@riverpod
|
||||
Future<NetworkConnectionType> connectionType(Ref ref) async {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
return await networkInfo.connectionType;
|
||||
}
|
||||
|
||||
/// Stream provider for network status changes
|
||||
@riverpod
|
||||
Stream<NetworkStatus> networkStatusStream(Ref ref) {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
return networkInfo.onNetworkStatusChanged;
|
||||
}
|
||||
|
||||
/// Provider for current network status
|
||||
@riverpod
|
||||
class NetworkStatusNotifier extends _$NetworkStatusNotifier {
|
||||
@override
|
||||
Future<NetworkStatus> build() async {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
final status = await networkInfo.networkStatus;
|
||||
|
||||
// Listen to network changes
|
||||
ref.listen(
|
||||
networkStatusStreamProvider,
|
||||
(_, next) {
|
||||
next.whenData((newStatus) {
|
||||
state = AsyncValue.data(newStatus);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// Manually refresh network status
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
final networkInfo = ref.read(networkInfoProvider);
|
||||
state = await AsyncValue.guard(() => networkInfo.networkStatus);
|
||||
}
|
||||
|
||||
/// Check if connected
|
||||
bool get isConnected {
|
||||
return state.when(
|
||||
data: (status) => status.isConnected,
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get connection type
|
||||
NetworkConnectionType get type {
|
||||
return state.when(
|
||||
data: (status) => status.connectionType,
|
||||
loading: () => NetworkConnectionType.none,
|
||||
error: (_, __) => NetworkConnectionType.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Extensions
|
||||
// ============================================================================
|
||||
|
||||
/// Extension methods for NetworkConnectionType
|
||||
extension NetworkConnectionTypeX on NetworkConnectionType {
|
||||
/// Get display name in Vietnamese
|
||||
String get displayNameVi {
|
||||
switch (this) {
|
||||
case NetworkConnectionType.wifi:
|
||||
return 'WiFi';
|
||||
case NetworkConnectionType.mobile:
|
||||
return 'Dữ liệu di động';
|
||||
case NetworkConnectionType.ethernet:
|
||||
return 'Ethernet';
|
||||
case NetworkConnectionType.bluetooth:
|
||||
return 'Bluetooth';
|
||||
case NetworkConnectionType.vpn:
|
||||
return 'VPN';
|
||||
case NetworkConnectionType.none:
|
||||
return 'Không có kết nối';
|
||||
case NetworkConnectionType.unknown:
|
||||
return 'Không xác định';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display name in English
|
||||
String get displayNameEn {
|
||||
switch (this) {
|
||||
case NetworkConnectionType.wifi:
|
||||
return 'WiFi';
|
||||
case NetworkConnectionType.mobile:
|
||||
return 'Mobile Data';
|
||||
case NetworkConnectionType.ethernet:
|
||||
return 'Ethernet';
|
||||
case NetworkConnectionType.bluetooth:
|
||||
return 'Bluetooth';
|
||||
case NetworkConnectionType.vpn:
|
||||
return 'VPN';
|
||||
case NetworkConnectionType.none:
|
||||
return 'No Connection';
|
||||
case NetworkConnectionType.unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a valid connection type
|
||||
bool get isValid {
|
||||
return this != NetworkConnectionType.none &&
|
||||
this != NetworkConnectionType.unknown;
|
||||
}
|
||||
}
|
||||
282
lib/core/network/network_info.g.dart
Normal file
282
lib/core/network/network_info.g.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'network_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for Connectivity instance
|
||||
|
||||
@ProviderFor(connectivity)
|
||||
const connectivityProvider = ConnectivityProvider._();
|
||||
|
||||
/// Provider for Connectivity instance
|
||||
|
||||
final class ConnectivityProvider
|
||||
extends $FunctionalProvider<Connectivity, Connectivity, Connectivity>
|
||||
with $Provider<Connectivity> {
|
||||
/// Provider for Connectivity instance
|
||||
const ConnectivityProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'connectivityProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$connectivityHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<Connectivity> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Connectivity create(Ref ref) {
|
||||
return connectivity(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(Connectivity value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<Connectivity>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$connectivityHash() => r'6d67af0ea4110f6ee0246dd332f90f8901380eda';
|
||||
|
||||
/// Provider for NetworkInfo instance
|
||||
|
||||
@ProviderFor(networkInfo)
|
||||
const networkInfoProvider = NetworkInfoProvider._();
|
||||
|
||||
/// Provider for NetworkInfo instance
|
||||
|
||||
final class NetworkInfoProvider
|
||||
extends $FunctionalProvider<NetworkInfo, NetworkInfo, NetworkInfo>
|
||||
with $Provider<NetworkInfo> {
|
||||
/// Provider for NetworkInfo instance
|
||||
const NetworkInfoProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'networkInfoProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$networkInfoHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<NetworkInfo> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
NetworkInfo create(Ref ref) {
|
||||
return networkInfo(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(NetworkInfo value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<NetworkInfo>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$networkInfoHash() => r'aee276b1536c8c994273dbed1909a2c24a7c71d2';
|
||||
|
||||
/// Provider for current network connection status (boolean)
|
||||
|
||||
@ProviderFor(isConnected)
|
||||
const isConnectedProvider = IsConnectedProvider._();
|
||||
|
||||
/// Provider for current network connection status (boolean)
|
||||
|
||||
final class IsConnectedProvider
|
||||
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
|
||||
with $FutureModifier<bool>, $FutureProvider<bool> {
|
||||
/// Provider for current network connection status (boolean)
|
||||
const IsConnectedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isConnectedProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isConnectedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<bool> create(Ref ref) {
|
||||
return isConnected(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isConnectedHash() => r'c9620cadbcdee8e738f865e747dd57262236782d';
|
||||
|
||||
/// Provider for current network connection type
|
||||
|
||||
@ProviderFor(connectionType)
|
||||
const connectionTypeProvider = ConnectionTypeProvider._();
|
||||
|
||||
/// Provider for current network connection type
|
||||
|
||||
final class ConnectionTypeProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NetworkConnectionType>,
|
||||
NetworkConnectionType,
|
||||
FutureOr<NetworkConnectionType>
|
||||
>
|
||||
with
|
||||
$FutureModifier<NetworkConnectionType>,
|
||||
$FutureProvider<NetworkConnectionType> {
|
||||
/// Provider for current network connection type
|
||||
const ConnectionTypeProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'connectionTypeProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$connectionTypeHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<NetworkConnectionType> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<NetworkConnectionType> create(Ref ref) {
|
||||
return connectionType(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$connectionTypeHash() => r'413aead6c4ff6f2c1476e4795934fddb76b797e6';
|
||||
|
||||
/// Stream provider for network status changes
|
||||
|
||||
@ProviderFor(networkStatusStream)
|
||||
const networkStatusStreamProvider = NetworkStatusStreamProvider._();
|
||||
|
||||
/// Stream provider for network status changes
|
||||
|
||||
final class NetworkStatusStreamProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<NetworkStatus>,
|
||||
NetworkStatus,
|
||||
Stream<NetworkStatus>
|
||||
>
|
||||
with $FutureModifier<NetworkStatus>, $StreamProvider<NetworkStatus> {
|
||||
/// Stream provider for network status changes
|
||||
const NetworkStatusStreamProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'networkStatusStreamProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$networkStatusStreamHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$StreamProviderElement<NetworkStatus> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $StreamProviderElement(pointer);
|
||||
|
||||
@override
|
||||
Stream<NetworkStatus> create(Ref ref) {
|
||||
return networkStatusStream(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$networkStatusStreamHash() =>
|
||||
r'bdff8d93b214ebc290e81ab72fb8d51d8bfb27b1';
|
||||
|
||||
/// Provider for current network status
|
||||
|
||||
@ProviderFor(NetworkStatusNotifier)
|
||||
const networkStatusProvider = NetworkStatusNotifierProvider._();
|
||||
|
||||
/// Provider for current network status
|
||||
final class NetworkStatusNotifierProvider
|
||||
extends $AsyncNotifierProvider<NetworkStatusNotifier, NetworkStatus> {
|
||||
/// Provider for current network status
|
||||
const NetworkStatusNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'networkStatusProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$networkStatusNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
NetworkStatusNotifier create() => NetworkStatusNotifier();
|
||||
}
|
||||
|
||||
String _$networkStatusNotifierHash() =>
|
||||
r'628e313a66129282cd06dfdd561af3f0a4517b4f';
|
||||
|
||||
/// Provider for current network status
|
||||
|
||||
abstract class _$NetworkStatusNotifier extends $AsyncNotifier<NetworkStatus> {
|
||||
FutureOr<NetworkStatus> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<NetworkStatus>, NetworkStatus>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<NetworkStatus>, NetworkStatus>,
|
||||
AsyncValue<NetworkStatus>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user