chore: initial release
This commit is contained in:
146
lib/core/auth/api_auth_interceptor.dart
Normal file
146
lib/core/auth/api_auth_interceptor.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Consistent authentication interceptor for all API requests
|
||||
/// Implements security requirements from OpenAPI specification
|
||||
class ApiAuthInterceptor extends Interceptor {
|
||||
String? _authToken;
|
||||
|
||||
// Callbacks for auth events
|
||||
void Function()? onAuthTokenInvalid;
|
||||
Future<void> Function()? onTokenInvalidated;
|
||||
|
||||
// Public endpoints that don't require authentication
|
||||
static const Set<String> _publicEndpoints = {
|
||||
'/health',
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signup',
|
||||
'/api/v1/auths/signup/enabled',
|
||||
'/api/v1/auths/trusted-header-auth',
|
||||
'/ollama/api/ps',
|
||||
'/ollama/api/version',
|
||||
'/docs',
|
||||
'/openapi.json',
|
||||
'/swagger',
|
||||
'/api/docs',
|
||||
};
|
||||
|
||||
// Endpoints that have optional authentication (work without but better with)
|
||||
static const Set<String> _optionalAuthEndpoints = {
|
||||
'/api/models',
|
||||
'/api/v1/configs/models',
|
||||
};
|
||||
|
||||
ApiAuthInterceptor({
|
||||
String? authToken,
|
||||
this.onAuthTokenInvalid,
|
||||
this.onTokenInvalidated,
|
||||
}) : _authToken = authToken;
|
||||
|
||||
void updateAuthToken(String? token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
String? get authToken => _authToken;
|
||||
|
||||
/// Check if endpoint requires authentication based on OpenAPI spec
|
||||
bool _requiresAuth(String path) {
|
||||
// Direct public endpoint match
|
||||
if (_publicEndpoints.contains(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for partial matches (e.g., /ollama/* endpoints)
|
||||
for (final publicPattern in _publicEndpoints) {
|
||||
if (publicPattern.endsWith('*') &&
|
||||
path.startsWith(
|
||||
publicPattern.substring(0, publicPattern.length - 1),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All other endpoints require authentication per OpenAPI spec
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if endpoint is better with auth but works without
|
||||
bool _hasOptionalAuth(String path) {
|
||||
return _optionalAuthEndpoints.contains(path);
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final path = options.path;
|
||||
final requiresAuth = _requiresAuth(path);
|
||||
final hasOptionalAuth = _hasOptionalAuth(path);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}',
|
||||
);
|
||||
|
||||
if (requiresAuth) {
|
||||
// Strictly required authentication
|
||||
if (_authToken == null || _authToken!.isEmpty) {
|
||||
final error = DioException(
|
||||
requestOptions: options,
|
||||
response: Response(
|
||||
requestOptions: options,
|
||||
statusCode: 401,
|
||||
data: {'detail': 'Authentication required for this endpoint'},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
handler.reject(error);
|
||||
return;
|
||||
}
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
} else if (hasOptionalAuth &&
|
||||
_authToken != null &&
|
||||
_authToken!.isNotEmpty) {
|
||||
// Optional authentication - add if available
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
|
||||
// Add other common headers for API consistency
|
||||
options.headers['Content-Type'] ??= 'application/json';
|
||||
options.headers['Accept'] ??= 'application/json';
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final statusCode = err.response?.statusCode;
|
||||
final path = err.requestOptions.path;
|
||||
|
||||
// Handle authentication errors consistently
|
||||
if (statusCode == 401) {
|
||||
// 401 always indicates invalid/expired auth token
|
||||
debugPrint('DEBUG: 401 Unauthorized on $path - clearing auth token');
|
||||
_clearAuthToken();
|
||||
} else if (statusCode == 403) {
|
||||
// 403 on protected endpoints indicates insufficient permissions or invalid token
|
||||
final requiresAuth = _requiresAuth(path);
|
||||
final optionalAuth = _hasOptionalAuth(path);
|
||||
if (requiresAuth && !optionalAuth) {
|
||||
debugPrint(
|
||||
'DEBUG: 403 Forbidden on protected endpoint $path - clearing auth token',
|
||||
);
|
||||
_clearAuthToken();
|
||||
} else {
|
||||
debugPrint(
|
||||
'DEBUG: 403 Forbidden on public/optional endpoint $path - keeping auth token',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _clearAuthToken() {
|
||||
_authToken = null;
|
||||
onAuthTokenInvalid?.call();
|
||||
onTokenInvalidated?.call();
|
||||
}
|
||||
}
|
||||
194
lib/core/auth/auth_cache_manager.dart
Normal file
194
lib/core/auth/auth_cache_manager.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'auth_state_manager.dart';
|
||||
|
||||
/// Comprehensive caching manager for auth-related operations
|
||||
/// Reduces redundant operations and improves app performance
|
||||
class AuthCacheManager {
|
||||
static final AuthCacheManager _instance = AuthCacheManager._internal();
|
||||
factory AuthCacheManager() => _instance;
|
||||
AuthCacheManager._internal();
|
||||
|
||||
// Cache for various auth-related operations
|
||||
final Map<String, dynamic> _cache = {};
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
|
||||
// Cache timeouts for different types of data
|
||||
static const Duration _shortCache = Duration(
|
||||
minutes: 2,
|
||||
); // For frequently changing data
|
||||
static const Duration _mediumCache = Duration(
|
||||
minutes: 5,
|
||||
); // For moderately stable data
|
||||
static const Duration _longCache = Duration(minutes: 15); // For stable data
|
||||
|
||||
// Cache keys
|
||||
static const String _userDataKey = 'user_data';
|
||||
static const String _serverConnectionKey = 'server_connection';
|
||||
static const String _credentialsExistKey = 'credentials_exist';
|
||||
static const String _serverConfigsKey = 'server_configs';
|
||||
|
||||
/// Cache user data with medium timeout
|
||||
void cacheUserData(dynamic userData) {
|
||||
_cache[_userDataKey] = userData;
|
||||
_cacheTimestamps[_userDataKey] = DateTime.now();
|
||||
debugPrint('DEBUG: User data cached');
|
||||
}
|
||||
|
||||
/// Get cached user data
|
||||
dynamic getCachedUserData() {
|
||||
if (_isCacheValid(_userDataKey, _mediumCache)) {
|
||||
debugPrint('DEBUG: Using cached user data');
|
||||
return _cache[_userDataKey];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache server connection status with short timeout
|
||||
void cacheServerConnection(bool isConnected) {
|
||||
_cache[_serverConnectionKey] = isConnected;
|
||||
_cacheTimestamps[_serverConnectionKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached server connection status
|
||||
bool? getCachedServerConnection() {
|
||||
if (_isCacheValid(_serverConnectionKey, _shortCache)) {
|
||||
return _cache[_serverConnectionKey] as bool?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache credentials existence with medium timeout
|
||||
void cacheCredentialsExist(bool exist) {
|
||||
_cache[_credentialsExistKey] = exist;
|
||||
_cacheTimestamps[_credentialsExistKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached credentials existence
|
||||
bool? getCachedCredentialsExist() {
|
||||
if (_isCacheValid(_credentialsExistKey, _mediumCache)) {
|
||||
return _cache[_credentialsExistKey] as bool?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Cache server configurations with long timeout
|
||||
void cacheServerConfigs(List<dynamic> configs) {
|
||||
_cache[_serverConfigsKey] = configs;
|
||||
_cacheTimestamps[_serverConfigsKey] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get cached server configurations
|
||||
List<dynamic>? getCachedServerConfigs() {
|
||||
if (_isCacheValid(_serverConfigsKey, _longCache)) {
|
||||
return _cache[_serverConfigsKey] as List<dynamic>?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if cache entry is valid
|
||||
bool _isCacheValid(String key, Duration timeout) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
return DateTime.now().difference(timestamp) < timeout;
|
||||
}
|
||||
|
||||
/// Clear specific cache entry
|
||||
void clearCacheEntry(String key) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
debugPrint('DEBUG: Cache entry cleared: $key');
|
||||
}
|
||||
|
||||
/// Clear all auth-related cache
|
||||
void clearAuthCache() {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('DEBUG: All auth cache cleared');
|
||||
}
|
||||
|
||||
/// Clear expired cache entries
|
||||
void cleanExpiredCache() {
|
||||
final now = DateTime.now();
|
||||
final expiredKeys = <String>[];
|
||||
|
||||
for (final entry in _cacheTimestamps.entries) {
|
||||
// Use the longest timeout for cleanup to be conservative
|
||||
if (now.difference(entry.value) > _longCache) {
|
||||
expiredKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in expiredKeys) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
if (expiredKeys.isNotEmpty) {
|
||||
debugPrint('DEBUG: Cleaned ${expiredKeys.length} expired cache entries');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics for monitoring
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
final now = DateTime.now();
|
||||
final stats = <String, dynamic>{};
|
||||
|
||||
stats['totalEntries'] = _cache.length;
|
||||
stats['entries'] = <String, Map<String, dynamic>>{};
|
||||
|
||||
for (final key in _cache.keys) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp != null) {
|
||||
stats['entries'][key] = {
|
||||
'age': now.difference(timestamp).inSeconds,
|
||||
'hasData': _cache[key] != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// Optimize cache by removing least recently used entries if cache gets too large
|
||||
void optimizeCache() {
|
||||
const maxCacheSize = 20; // Reasonable limit for auth cache
|
||||
|
||||
if (_cache.length <= maxCacheSize) return;
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
final sortedEntries = _cacheTimestamps.entries.toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
|
||||
// Remove oldest entries
|
||||
final entriesToRemove = sortedEntries.length - maxCacheSize;
|
||||
for (int i = 0; i < entriesToRemove; i++) {
|
||||
final key = sortedEntries[i].key;
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Cache optimized, removed $entriesToRemove old entries');
|
||||
}
|
||||
|
||||
/// Cache state from AuthState for quick access
|
||||
void cacheAuthState(AuthState authState) {
|
||||
if (authState.user != null) {
|
||||
cacheUserData(authState.user);
|
||||
}
|
||||
|
||||
// Don't cache loading or error states
|
||||
if (authState.status == AuthStatus.authenticated) {
|
||||
_cache['auth_status'] = authState.status;
|
||||
_cacheTimestamps['auth_status'] = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached auth status
|
||||
AuthStatus? getCachedAuthStatus() {
|
||||
if (_isCacheValid('auth_status', _shortCache)) {
|
||||
return _cache['auth_status'] as AuthStatus?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
562
lib/core/auth/auth_state_manager.dart
Normal file
562
lib/core/auth/auth_state_manager.dart
Normal file
@@ -0,0 +1,562 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
// Types are used through app_providers.dart
|
||||
import '../providers/app_providers.dart';
|
||||
import '../models/user.dart';
|
||||
import 'token_validator.dart';
|
||||
import 'auth_cache_manager.dart';
|
||||
|
||||
/// Comprehensive auth state representation
|
||||
@immutable
|
||||
class AuthState {
|
||||
const AuthState({
|
||||
required this.status,
|
||||
this.token,
|
||||
this.user,
|
||||
this.error,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
final AuthStatus status;
|
||||
final String? token;
|
||||
final dynamic user; // Replace with proper User type
|
||||
final String? error;
|
||||
final bool isLoading;
|
||||
|
||||
bool get isAuthenticated =>
|
||||
status == AuthStatus.authenticated && token != null;
|
||||
bool get hasValidToken => token != null && token!.isNotEmpty;
|
||||
bool get needsLogin =>
|
||||
status == AuthStatus.unauthenticated || status == AuthStatus.tokenExpired;
|
||||
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
String? token,
|
||||
dynamic user,
|
||||
String? error,
|
||||
bool? isLoading,
|
||||
bool clearToken = false,
|
||||
bool clearUser = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
token: clearToken ? null : (token ?? this.token),
|
||||
user: clearUser ? null : (user ?? this.user),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AuthState &&
|
||||
other.status == status &&
|
||||
other.token == token &&
|
||||
other.user == user &&
|
||||
other.error == error &&
|
||||
other.isLoading == isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(status, token, user, error, isLoading);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AuthState(status: $status, hasToken: ${token != null}, hasUser: ${user != null}, error: $error, isLoading: $isLoading)';
|
||||
}
|
||||
|
||||
enum AuthStatus {
|
||||
initial,
|
||||
loading,
|
||||
authenticated,
|
||||
unauthenticated,
|
||||
tokenExpired,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Unified auth state manager - single source of truth for all auth operations
|
||||
class AuthStateManager extends StateNotifier<AuthState> {
|
||||
AuthStateManager(this._ref)
|
||||
: super(const AuthState(status: AuthStatus.initial)) {
|
||||
_initialize();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
final AuthCacheManager _cacheManager = AuthCacheManager();
|
||||
|
||||
/// Initialize auth state from storage
|
||||
Future<void> _initialize() async {
|
||||
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
|
||||
|
||||
try {
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
final token = await storage.getAuthToken();
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
// Validate token before setting authenticated state
|
||||
final isValid = await _validateToken(token);
|
||||
if (isValid) {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
token: token,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
// Update API service with token
|
||||
_updateApiServiceToken(token);
|
||||
|
||||
// Load user data in background
|
||||
_loadUserData();
|
||||
} else {
|
||||
// Token is invalid, clear it
|
||||
await storage.deleteAuthToken();
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
clearError: true,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
clearError: true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Auth initialization failed: $e');
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.error,
|
||||
error: 'Failed to initialize auth: $e',
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login with credentials
|
||||
Future<bool> login(
|
||||
String username,
|
||||
String password, {
|
||||
bool rememberCredentials = false,
|
||||
}) async {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.loading,
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
// Ensure API service is available (active server/provider rebuild race)
|
||||
await _ensureApiServiceAvailable();
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
throw Exception('No server connection available');
|
||||
}
|
||||
|
||||
// Perform login API call
|
||||
final response = await api.login(username, password);
|
||||
|
||||
// Extract and validate token
|
||||
final token = response['token'] ?? response['access_token'];
|
||||
if (token == null || token.toString().trim().isEmpty) {
|
||||
throw Exception('No authentication token received');
|
||||
}
|
||||
|
||||
final tokenStr = token.toString();
|
||||
if (!_isValidTokenFormat(tokenStr)) {
|
||||
throw Exception('Invalid authentication token format');
|
||||
}
|
||||
|
||||
// Save token to storage
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
await storage.saveAuthToken(tokenStr);
|
||||
|
||||
// Save credentials if requested
|
||||
if (rememberCredentials) {
|
||||
final activeServer = await _ref.read(activeServerProvider.future);
|
||||
if (activeServer != null) {
|
||||
await storage.saveCredentials(
|
||||
serverId: activeServer.id,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
await storage.setRememberCredentials(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state and API service
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.authenticated,
|
||||
token: tokenStr,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
_updateApiServiceToken(tokenStr);
|
||||
|
||||
// Cache the successful auth state
|
||||
_cacheManager.cacheAuthState(state);
|
||||
|
||||
// Load user data in background
|
||||
_loadUserData();
|
||||
|
||||
debugPrint('DEBUG: Login successful');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Login failed: $e');
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.error,
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait briefly until the API service becomes available
|
||||
Future<void> _ensureApiServiceAvailable({
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api != null) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform silent auto-login with saved credentials
|
||||
Future<bool> silentLogin() async {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.loading,
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
final savedCredentials = await storage.getSavedCredentials();
|
||||
|
||||
if (savedCredentials == null) {
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
isLoading: false,
|
||||
clearError: true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final serverId = savedCredentials['serverId']!;
|
||||
final username = savedCredentials['username']!;
|
||||
final password = savedCredentials['password']!;
|
||||
|
||||
// Set active server if needed
|
||||
await storage.setActiveServerId(serverId);
|
||||
_ref.invalidate(activeServerProvider);
|
||||
|
||||
// Wait for server connection
|
||||
final activeServer = await _ref.read(activeServerProvider.future);
|
||||
if (activeServer == null) {
|
||||
await storage.setActiveServerId(null);
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.error,
|
||||
error: 'Server configuration not found',
|
||||
isLoading: false,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
return await login(username, password, rememberCredentials: false);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Silent login failed: $e');
|
||||
|
||||
// Clear invalid credentials on auth errors
|
||||
if (e.toString().contains('401') ||
|
||||
e.toString().contains('403') ||
|
||||
e.toString().contains('authentication') ||
|
||||
e.toString().contains('unauthorized')) {
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
await storage.deleteSavedCredentials();
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle token invalidation (called by API service)
|
||||
Future<void> onTokenInvalidated() async {
|
||||
debugPrint('DEBUG: Auth token invalidated');
|
||||
|
||||
// Clear token from storage
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
await storage.deleteAuthToken();
|
||||
|
||||
// Update state
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.tokenExpired,
|
||||
clearToken: true,
|
||||
clearUser: true,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
// Attempt silent re-login if credentials are available
|
||||
final hasCredentials = await storage.getSavedCredentials() != null;
|
||||
if (hasCredentials) {
|
||||
debugPrint('DEBUG: Attempting silent re-login after token invalidation');
|
||||
await silentLogin();
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout user
|
||||
Future<void> logout() async {
|
||||
state = state.copyWith(status: AuthStatus.loading, isLoading: true);
|
||||
|
||||
try {
|
||||
// Call server logout if possible
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api != null) {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Server logout failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all local auth data
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
await storage.clearAuthData();
|
||||
|
||||
// Update state
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
clearUser: true,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Logout complete');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Logout failed: $e');
|
||||
// Even if logout fails, clear local state
|
||||
state = state.copyWith(
|
||||
status: AuthStatus.unauthenticated,
|
||||
isLoading: false,
|
||||
clearToken: true,
|
||||
clearUser: true,
|
||||
error: 'Logout error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load user data in background with JWT extraction fallback
|
||||
Future<void> _loadUserData() async {
|
||||
try {
|
||||
// First try to extract user info from JWT token if available
|
||||
if (state.token != null) {
|
||||
final jwtUserInfo = TokenValidator.extractUserInfo(state.token!);
|
||||
if (jwtUserInfo != null) {
|
||||
debugPrint('DEBUG: Extracted user info from JWT token');
|
||||
state = state.copyWith(user: jwtUserInfo);
|
||||
|
||||
// Still try to load from server in background for complete data
|
||||
Future.microtask(() => _loadServerUserData());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to server data loading
|
||||
await _loadServerUserData();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to load user data: $e');
|
||||
// Don't update state on user data load failure
|
||||
}
|
||||
}
|
||||
|
||||
/// Load complete user data from server
|
||||
Future<void> _loadServerUserData() async {
|
||||
try {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api != null && state.isAuthenticated) {
|
||||
// Check if we already have user data from token validation
|
||||
if (state.user != null) {
|
||||
debugPrint(
|
||||
'DEBUG: User data already available from token validation',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final user = await api.getCurrentUser();
|
||||
state = state.copyWith(user: user);
|
||||
debugPrint('DEBUG: Loaded complete user data from server');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to load server user data: $e');
|
||||
// Don't update state on server data load failure - keep JWT data if available
|
||||
}
|
||||
}
|
||||
|
||||
/// Update API service with current token
|
||||
void _updateApiServiceToken(String token) {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
api?.updateAuthToken(token);
|
||||
}
|
||||
|
||||
/// Validate token format using advanced validation
|
||||
bool _isValidTokenFormat(String token) {
|
||||
final result = TokenValidator.validateTokenFormat(token);
|
||||
return result.isValid;
|
||||
}
|
||||
|
||||
/// Validate token with comprehensive validation (format + server)
|
||||
Future<bool> _validateToken(String token) async {
|
||||
// Check cache first
|
||||
final cachedResult = TokenValidationCache.getCachedResult(token);
|
||||
if (cachedResult != null) {
|
||||
debugPrint(
|
||||
'DEBUG: Using cached token validation result: ${cachedResult.isValid}',
|
||||
);
|
||||
return cachedResult.isValid;
|
||||
}
|
||||
|
||||
// Fast format validation first
|
||||
final formatResult = TokenValidator.validateTokenFormat(token);
|
||||
if (!formatResult.isValid) {
|
||||
debugPrint('DEBUG: Token format invalid: ${formatResult.message}');
|
||||
TokenValidationCache.cacheResult(token, formatResult);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If format is valid but token is expiring soon, try server validation
|
||||
if (formatResult.isExpiringSoon) {
|
||||
debugPrint('DEBUG: Token expiring soon, validating with server');
|
||||
}
|
||||
|
||||
// Server validation (async with timeout)
|
||||
try {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
debugPrint('DEBUG: No API service available for token validation');
|
||||
return formatResult.isValid; // Fall back to format validation
|
||||
}
|
||||
|
||||
User? validationUser;
|
||||
final serverResult = await TokenValidator.validateTokenWithServer(
|
||||
token,
|
||||
() async {
|
||||
// Update API with token for validation
|
||||
api.updateAuthToken(token);
|
||||
// Try to fetch user data as validation
|
||||
validationUser = await api.getCurrentUser();
|
||||
return validationUser!;
|
||||
},
|
||||
);
|
||||
|
||||
// Store the user data if validation was successful
|
||||
if (serverResult.isValid &&
|
||||
validationUser != null &&
|
||||
state.isAuthenticated) {
|
||||
state = state.copyWith(user: validationUser);
|
||||
debugPrint('DEBUG: Cached user data from token validation');
|
||||
}
|
||||
|
||||
TokenValidationCache.cacheResult(token, serverResult);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Server token validation: ${serverResult.isValid} - ${serverResult.message}',
|
||||
);
|
||||
return serverResult.isValid;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Token server validation failed: $e');
|
||||
// On network error, fall back to format validation if it was valid
|
||||
return formatResult.isValid;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user has saved credentials (with caching)
|
||||
Future<bool> hasSavedCredentials() async {
|
||||
// Check cache first
|
||||
final cachedResult = _cacheManager.getCachedCredentialsExist();
|
||||
if (cachedResult != null) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
try {
|
||||
final storage = _ref.read(optimizedStorageServiceProvider);
|
||||
final hasCredentials = await storage.hasCredentials();
|
||||
|
||||
// Cache the result
|
||||
_cacheManager.cacheCredentialsExist(hasCredentials);
|
||||
|
||||
return hasCredentials;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh current auth state
|
||||
Future<void> refresh() async {
|
||||
// Clear cache before refresh to ensure fresh data
|
||||
_cacheManager.clearAuthCache();
|
||||
TokenValidationCache.clearCache();
|
||||
|
||||
await _initialize();
|
||||
}
|
||||
|
||||
/// Clean up expired caches (called periodically)
|
||||
void cleanupCaches() {
|
||||
_cacheManager.cleanExpiredCache();
|
||||
_cacheManager.optimizeCache();
|
||||
}
|
||||
|
||||
/// Get performance statistics
|
||||
Map<String, dynamic> getPerformanceStats() {
|
||||
return {
|
||||
'authCache': _cacheManager.getCacheStats(),
|
||||
'tokenValidationCache': 'Managed by TokenValidationCache',
|
||||
'storageCache': 'Managed by OptimizedStorageService',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the unified auth state manager
|
||||
final authStateManagerProvider =
|
||||
StateNotifierProvider<AuthStateManager, AuthState>((ref) {
|
||||
return AuthStateManager(ref);
|
||||
});
|
||||
|
||||
/// Computed providers for common auth state queries
|
||||
final isAuthenticatedProvider = Provider<bool>((ref) {
|
||||
return ref.watch(
|
||||
authStateManagerProvider.select((state) => state.isAuthenticated),
|
||||
);
|
||||
});
|
||||
|
||||
final authTokenProvider2 = Provider<String?>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.token));
|
||||
});
|
||||
|
||||
final authUserProvider = Provider<dynamic>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.user));
|
||||
});
|
||||
|
||||
final authErrorProvider2 = Provider<String?>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.error));
|
||||
});
|
||||
|
||||
final isAuthLoadingProvider = Provider<bool>((ref) {
|
||||
return ref.watch(authStateManagerProvider.select((state) => state.isLoading));
|
||||
});
|
||||
259
lib/core/auth/token_validator.dart
Normal file
259
lib/core/auth/token_validator.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// JWT token validation utilities
|
||||
class TokenValidator {
|
||||
static const Duration _validationTimeout = Duration(seconds: 5);
|
||||
|
||||
/// Validate JWT token format and expiry without network call
|
||||
static TokenValidationResult validateTokenFormat(String token) {
|
||||
try {
|
||||
// Basic format check
|
||||
if (token.isEmpty || token.length < 10) {
|
||||
return TokenValidationResult.invalid('Token too short');
|
||||
}
|
||||
|
||||
// Check if it looks like a JWT (has at least 2 dots)
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 3) {
|
||||
return TokenValidationResult.invalid('Invalid JWT format');
|
||||
}
|
||||
|
||||
// Try to decode the payload to check expiry
|
||||
try {
|
||||
final payload = _decodeJWTPayload(parts[1]);
|
||||
final exp = payload['exp'] as int?;
|
||||
|
||||
if (exp != null) {
|
||||
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (expiryTime.isBefore(now)) {
|
||||
return TokenValidationResult.expired('Token expired');
|
||||
}
|
||||
|
||||
// Check if token expires soon (within 5 minutes)
|
||||
final fiveMinutesFromNow = now.add(const Duration(minutes: 5));
|
||||
if (expiryTime.isBefore(fiveMinutesFromNow)) {
|
||||
return TokenValidationResult.expiringSoon(
|
||||
'Token expires soon',
|
||||
expiryTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return TokenValidationResult.valid(
|
||||
'Token format valid',
|
||||
expiryData: exp != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(exp * 1000)
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
// If we can't decode JWT, treat as opaque token
|
||||
debugPrint(
|
||||
'DEBUG: Could not decode JWT payload, treating as opaque token: $e',
|
||||
);
|
||||
return TokenValidationResult.valid('Opaque token format valid');
|
||||
}
|
||||
} catch (e) {
|
||||
return TokenValidationResult.invalid('Token validation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate token with server (async with timeout)
|
||||
static Future<TokenValidationResult> validateTokenWithServer(
|
||||
String token,
|
||||
Future<dynamic> Function() serverValidationCall,
|
||||
) async {
|
||||
try {
|
||||
// First check format
|
||||
final formatResult = validateTokenFormat(token);
|
||||
if (!formatResult.isValid) {
|
||||
return formatResult;
|
||||
}
|
||||
|
||||
// If format is good, try server validation with timeout
|
||||
final validationFuture = serverValidationCall();
|
||||
|
||||
final result = await validationFuture.timeout(
|
||||
_validationTimeout,
|
||||
onTimeout: () => throw Exception('Token validation timeout'),
|
||||
);
|
||||
|
||||
return TokenValidationResult.valid(
|
||||
'Server validation successful',
|
||||
serverData: result,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.toString().contains('timeout')) {
|
||||
return TokenValidationResult.networkError(
|
||||
'Validation timeout - using cached result',
|
||||
);
|
||||
} else if (e.toString().contains('401') || e.toString().contains('403')) {
|
||||
return TokenValidationResult.invalid('Server rejected token');
|
||||
} else {
|
||||
return TokenValidationResult.networkError(
|
||||
'Network error during validation: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode JWT payload (without signature verification)
|
||||
static Map<String, dynamic> _decodeJWTPayload(String base64Payload) {
|
||||
// Add padding if needed
|
||||
String padded = base64Payload;
|
||||
while (padded.length % 4 != 0) {
|
||||
padded += '=';
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
final decoded = base64Url.decode(padded);
|
||||
final jsonString = utf8.decode(decoded);
|
||||
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Extract user information from JWT token (if available)
|
||||
static Map<String, dynamic>? extractUserInfo(String token) {
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
final payload = _decodeJWTPayload(parts[1]);
|
||||
|
||||
// Extract common user fields
|
||||
return {
|
||||
'sub': payload['sub'], // Subject (user ID)
|
||||
'username':
|
||||
payload['username'] ??
|
||||
payload['name'] ??
|
||||
payload['preferred_username'],
|
||||
'email': payload['email'],
|
||||
'roles': payload['roles'] ?? payload['groups'],
|
||||
'exp': payload['exp'],
|
||||
'iat': payload['iat'], // Issued at
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Could not extract user info from token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a cache key for token validation results
|
||||
static String generateCacheKey(String token) {
|
||||
final bytes = utf8.encode(token);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString().substring(
|
||||
0,
|
||||
16,
|
||||
); // Use first 16 chars as cache key
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of token validation
|
||||
class TokenValidationResult {
|
||||
const TokenValidationResult._(
|
||||
this.isValid,
|
||||
this.status,
|
||||
this.message, {
|
||||
this.expiryData,
|
||||
this.serverData,
|
||||
});
|
||||
|
||||
const TokenValidationResult.valid(
|
||||
String message, {
|
||||
DateTime? expiryData,
|
||||
dynamic serverData,
|
||||
}) : this._(
|
||||
true,
|
||||
TokenValidationStatus.valid,
|
||||
message,
|
||||
expiryData: expiryData,
|
||||
serverData: serverData,
|
||||
);
|
||||
|
||||
const TokenValidationResult.invalid(String message)
|
||||
: this._(false, TokenValidationStatus.invalid, message);
|
||||
|
||||
const TokenValidationResult.expired(String message)
|
||||
: this._(false, TokenValidationStatus.expired, message);
|
||||
|
||||
const TokenValidationResult.expiringSoon(String message, DateTime expiryTime)
|
||||
: this._(
|
||||
true,
|
||||
TokenValidationStatus.expiringSoon,
|
||||
message,
|
||||
expiryData: expiryTime,
|
||||
);
|
||||
|
||||
const TokenValidationResult.networkError(String message)
|
||||
: this._(false, TokenValidationStatus.networkError, message);
|
||||
|
||||
final bool isValid;
|
||||
final TokenValidationStatus status;
|
||||
final String message;
|
||||
final DateTime? expiryData;
|
||||
final dynamic serverData;
|
||||
|
||||
bool get isExpired => status == TokenValidationStatus.expired;
|
||||
bool get isExpiringSoon => status == TokenValidationStatus.expiringSoon;
|
||||
bool get hasNetworkError => status == TokenValidationStatus.networkError;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TokenValidationResult(isValid: $isValid, status: $status, message: $message)';
|
||||
}
|
||||
|
||||
enum TokenValidationStatus {
|
||||
valid,
|
||||
invalid,
|
||||
expired,
|
||||
expiringSoon,
|
||||
networkError,
|
||||
}
|
||||
|
||||
/// Cache for token validation results
|
||||
class TokenValidationCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
|
||||
static void cacheResult(String token, TokenValidationResult result) {
|
||||
final key = TokenValidator.generateCacheKey(token);
|
||||
_cache[key] = _CacheEntry(result, DateTime.now());
|
||||
|
||||
// Clean old entries
|
||||
_cleanCache();
|
||||
}
|
||||
|
||||
static TokenValidationResult? getCachedResult(String token) {
|
||||
final key = TokenValidator.generateCacheKey(token);
|
||||
final entry = _cache[key];
|
||||
|
||||
if (entry != null &&
|
||||
DateTime.now().difference(entry.timestamp) < _cacheTimeout) {
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
static void _cleanCache() {
|
||||
final now = DateTime.now();
|
||||
_cache.removeWhere(
|
||||
(key, entry) => now.difference(entry.timestamp) > _cacheTimeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
const _CacheEntry(this.result, this.timestamp);
|
||||
|
||||
final TokenValidationResult result;
|
||||
final DateTime timestamp;
|
||||
}
|
||||
397
lib/core/error/api_error.dart
Normal file
397
lib/core/error/api_error.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Standardized API error representation
|
||||
/// Provides consistent error information across all API operations
|
||||
@immutable
|
||||
class ApiError implements Exception {
|
||||
const ApiError._({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.endpoint,
|
||||
this.method,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.fieldErrors = const {},
|
||||
this.originalError,
|
||||
this.technical,
|
||||
this.retryAfter,
|
||||
this.timeoutDuration,
|
||||
});
|
||||
|
||||
// Factory constructors for different error types
|
||||
const ApiError.network({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
}) : this._(
|
||||
type: ApiErrorType.network,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
originalError: originalError,
|
||||
technical: technical,
|
||||
);
|
||||
|
||||
const ApiError.timeout({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Duration? timeoutDuration,
|
||||
}) : this._(
|
||||
type: ApiErrorType.timeout,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
timeoutDuration: timeoutDuration,
|
||||
);
|
||||
|
||||
const ApiError.authentication({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.authentication,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
const ApiError.authorization({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.authorization,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
const ApiError.validation({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, List<String>> fieldErrors = const {},
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.validation,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: 422,
|
||||
fieldErrors: fieldErrors,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.badRequest({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.badRequest,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: 400,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.notFound({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
}) : this._(
|
||||
type: ApiErrorType.notFound,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode ?? 404,
|
||||
);
|
||||
|
||||
const ApiError.server({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.server,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
details: details,
|
||||
);
|
||||
|
||||
const ApiError.rateLimit({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
Duration? retryAfter,
|
||||
}) : this._(
|
||||
type: ApiErrorType.rateLimit,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode ?? 429,
|
||||
retryAfter: retryAfter,
|
||||
);
|
||||
|
||||
const ApiError.cancelled({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) : this._(
|
||||
type: ApiErrorType.cancelled,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
);
|
||||
|
||||
const ApiError.security({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) : this._(
|
||||
type: ApiErrorType.security,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
);
|
||||
|
||||
const ApiError.unknown({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
}) : this._(
|
||||
type: ApiErrorType.unknown,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
originalError: originalError,
|
||||
technical: technical,
|
||||
);
|
||||
|
||||
const ApiError.client({
|
||||
required String message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
}) : this._(
|
||||
type: ApiErrorType.badRequest,
|
||||
message: message,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
statusCode: statusCode,
|
||||
details: details,
|
||||
);
|
||||
|
||||
final ApiErrorType type;
|
||||
final String message;
|
||||
final String? endpoint;
|
||||
final String? method;
|
||||
final int? statusCode;
|
||||
final ParsedErrorResponse? details;
|
||||
final Map<String, List<String>> fieldErrors;
|
||||
final dynamic originalError;
|
||||
final String? technical;
|
||||
final Duration? retryAfter;
|
||||
final Duration? timeoutDuration;
|
||||
|
||||
/// Check if this error has field-specific validation errors
|
||||
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||
|
||||
/// Check if this error is retryable
|
||||
bool get isRetryable {
|
||||
switch (type) {
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.timeout:
|
||||
case ApiErrorType.server:
|
||||
case ApiErrorType.rateLimit:
|
||||
return true;
|
||||
case ApiErrorType.authentication:
|
||||
case ApiErrorType.authorization:
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
case ApiErrorType.notFound:
|
||||
case ApiErrorType.cancelled:
|
||||
case ApiErrorType.security:
|
||||
case ApiErrorType.unknown:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all field error messages as a flattened list
|
||||
List<String> get allFieldErrorMessages {
|
||||
final messages = <String>[];
|
||||
for (final entry in fieldErrors.entries) {
|
||||
final field = entry.key;
|
||||
final errors = entry.value;
|
||||
for (final error in errors) {
|
||||
messages.add('$field: $error');
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Get first field error message for quick display
|
||||
String? get firstFieldError {
|
||||
if (fieldErrors.isEmpty) return null;
|
||||
final firstEntry = fieldErrors.entries.first;
|
||||
final field = firstEntry.key;
|
||||
final firstError = firstEntry.value.first;
|
||||
return '$field: $firstError';
|
||||
}
|
||||
|
||||
/// Create a copy with updated fields
|
||||
ApiError copyWith({
|
||||
ApiErrorType? type,
|
||||
String? message,
|
||||
String? endpoint,
|
||||
String? method,
|
||||
int? statusCode,
|
||||
ParsedErrorResponse? details,
|
||||
Map<String, List<String>>? fieldErrors,
|
||||
dynamic originalError,
|
||||
String? technical,
|
||||
Duration? retryAfter,
|
||||
Duration? timeoutDuration,
|
||||
}) {
|
||||
return ApiError._(
|
||||
type: type ?? this.type,
|
||||
message: message ?? this.message,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
method: method ?? this.method,
|
||||
statusCode: statusCode ?? this.statusCode,
|
||||
details: details ?? this.details,
|
||||
fieldErrors: fieldErrors ?? this.fieldErrors,
|
||||
originalError: originalError ?? this.originalError,
|
||||
technical: technical ?? this.technical,
|
||||
retryAfter: retryAfter ?? this.retryAfter,
|
||||
timeoutDuration: timeoutDuration ?? this.timeoutDuration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to map for logging and debugging
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'message': message,
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
'statusCode': statusCode,
|
||||
'fieldErrors': fieldErrors,
|
||||
'technical': technical,
|
||||
'retryAfter': retryAfter?.inSeconds,
|
||||
'timeoutDuration': timeoutDuration?.inSeconds,
|
||||
'isRetryable': isRetryable,
|
||||
'hasFieldErrors': hasFieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write('ApiError(');
|
||||
buffer.write('type: ${type.name}, ');
|
||||
buffer.write('message: $message');
|
||||
|
||||
if (endpoint != null) {
|
||||
buffer.write(', endpoint: $endpoint');
|
||||
}
|
||||
|
||||
if (method != null) {
|
||||
buffer.write(', method: $method');
|
||||
}
|
||||
|
||||
if (statusCode != null) {
|
||||
buffer.write(', statusCode: $statusCode');
|
||||
}
|
||||
|
||||
if (hasFieldErrors) {
|
||||
buffer.write(', fieldErrors: ${fieldErrors.length}');
|
||||
}
|
||||
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ApiError &&
|
||||
other.type == type &&
|
||||
other.message == message &&
|
||||
other.endpoint == endpoint &&
|
||||
other.method == method &&
|
||||
other.statusCode == statusCode &&
|
||||
mapEquals(other.fieldErrors, fieldErrors);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
type,
|
||||
message,
|
||||
endpoint,
|
||||
method,
|
||||
statusCode,
|
||||
fieldErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of API errors for categorization and handling
|
||||
enum ApiErrorType {
|
||||
network, // Connection issues, DNS resolution, etc.
|
||||
timeout, // Request timeout (send, receive, connection)
|
||||
authentication, // 401, invalid credentials, expired tokens
|
||||
authorization, // 403, insufficient permissions
|
||||
validation, // 422, field validation errors
|
||||
badRequest, // 400, malformed request
|
||||
notFound, // 404, resource not found
|
||||
server, // 5xx server errors
|
||||
rateLimit, // 429, too many requests
|
||||
cancelled, // Request was cancelled
|
||||
security, // Certificate, SSL/TLS issues
|
||||
unknown, // Unexpected or unhandled errors
|
||||
}
|
||||
|
||||
/// Parsed error response from API
|
||||
/// Contains structured error information from server responses
|
||||
class ParsedErrorResponse {
|
||||
const ParsedErrorResponse({
|
||||
this.message,
|
||||
this.code,
|
||||
this.errors = const [],
|
||||
this.fieldErrors = const {},
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
final String? message;
|
||||
final String? code;
|
||||
final List<String> errors;
|
||||
final Map<String, List<String>> fieldErrors;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||
bool get hasGeneralErrors => errors.isNotEmpty;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ParsedErrorResponse(message: $message, errors: ${errors.length}, fieldErrors: ${fieldErrors.length})';
|
||||
}
|
||||
}
|
||||
408
lib/core/error/api_error_handler.dart
Normal file
408
lib/core/error/api_error_handler.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error.dart';
|
||||
import 'error_parser.dart';
|
||||
|
||||
/// Comprehensive API error handler with structured error parsing
|
||||
/// Handles all types of API errors and converts them to standardized format
|
||||
class ApiErrorHandler {
|
||||
static final ApiErrorHandler _instance = ApiErrorHandler._internal();
|
||||
factory ApiErrorHandler() => _instance;
|
||||
ApiErrorHandler._internal();
|
||||
|
||||
final ErrorParser _errorParser = ErrorParser();
|
||||
|
||||
/// Transform any exception into standardized ApiError
|
||||
ApiError transformError(
|
||||
dynamic error, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, dynamic>? requestData,
|
||||
}) {
|
||||
try {
|
||||
if (error is DioException) {
|
||||
return _handleDioException(error, endpoint: endpoint, method: method);
|
||||
} else if (error is ApiError) {
|
||||
return error;
|
||||
} else {
|
||||
return ApiError.unknown(
|
||||
message: 'An unexpected error occurred',
|
||||
originalError: error,
|
||||
technical: error.toString(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback error if transformation itself fails
|
||||
debugPrint('ApiErrorHandler: Error transforming exception: $e');
|
||||
return ApiError.unknown(
|
||||
message: 'A system error occurred',
|
||||
originalError: error,
|
||||
technical: 'Error transformation failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle DioException with detailed error parsing
|
||||
ApiError _handleDioException(
|
||||
DioException dioError, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
}) {
|
||||
final statusCode = dioError.response?.statusCode;
|
||||
final responseData = dioError.response?.data;
|
||||
final requestPath = endpoint ?? dioError.requestOptions.path;
|
||||
final httpMethod = method ?? dioError.requestOptions.method;
|
||||
|
||||
// Log error details for debugging
|
||||
_logErrorDetails(dioError, requestPath, httpMethod);
|
||||
|
||||
switch (dioError.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Connection timeout - please check your internet connection',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.connectTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.sendTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Request send timeout - the upload took too long',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.sendTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return ApiError.timeout(
|
||||
message: 'Response timeout - the server took too long to respond',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
timeoutDuration: dioError.requestOptions.receiveTimeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return ApiError.security(
|
||||
message:
|
||||
'Security certificate error - unable to verify server identity',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return ApiError.network(
|
||||
message:
|
||||
'Network connection error - please check your internet connection',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
originalError: dioError,
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return ApiError.cancelled(
|
||||
message: 'Request was cancelled',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
return _handleBadResponse(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
statusCode,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
return ApiError.unknown(
|
||||
message: 'An unexpected network error occurred',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
originalError: dioError,
|
||||
technical: dioError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle bad response errors with detailed status code analysis
|
||||
ApiError _handleBadResponse(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
int? statusCode,
|
||||
dynamic responseData,
|
||||
) {
|
||||
if (statusCode == null) {
|
||||
return ApiError.server(
|
||||
message: 'Invalid server response',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: null,
|
||||
);
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return _handleBadRequest(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case 401:
|
||||
return ApiError.authentication(
|
||||
message: 'Authentication failed - please sign in again',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 403:
|
||||
return ApiError.authorization(
|
||||
message: 'Access denied - you don\'t have permission for this action',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 404:
|
||||
return ApiError.notFound(
|
||||
message: 'The requested resource was not found',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
|
||||
case 422:
|
||||
return _handleValidationError(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
responseData,
|
||||
);
|
||||
|
||||
case 429:
|
||||
return ApiError.rateLimit(
|
||||
message: 'Too many requests - please wait before trying again',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
retryAfter: _extractRetryAfter(dioError.response?.headers),
|
||||
);
|
||||
|
||||
default:
|
||||
if (statusCode >= 500) {
|
||||
return _handleServerError(
|
||||
dioError,
|
||||
requestPath,
|
||||
httpMethod,
|
||||
statusCode,
|
||||
responseData,
|
||||
);
|
||||
} else {
|
||||
return ApiError.client(
|
||||
message: 'Client error occurred',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
details: _errorParser.parseErrorResponse(responseData),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle 400 Bad Request with detailed parsing
|
||||
ApiError _handleBadRequest(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseErrorResponse(responseData);
|
||||
|
||||
return ApiError.badRequest(
|
||||
message:
|
||||
parsedError.message ?? 'Invalid request - please check your input',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle 422 Validation Error with field-specific parsing
|
||||
ApiError _handleValidationError(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseValidationError(responseData);
|
||||
|
||||
return ApiError.validation(
|
||||
message: 'Validation failed - please check your input',
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
fieldErrors: parsedError.fieldErrors,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle server errors (5xx)
|
||||
ApiError _handleServerError(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
int statusCode,
|
||||
dynamic responseData,
|
||||
) {
|
||||
final parsedError = _errorParser.parseErrorResponse(responseData);
|
||||
|
||||
String message;
|
||||
switch (statusCode) {
|
||||
case 500:
|
||||
message = 'Internal server error - please try again later';
|
||||
break;
|
||||
case 502:
|
||||
message = 'Bad gateway - the server is temporarily unavailable';
|
||||
break;
|
||||
case 503:
|
||||
message = 'Service unavailable - the server is temporarily down';
|
||||
break;
|
||||
case 504:
|
||||
message = 'Gateway timeout - the server took too long to respond';
|
||||
break;
|
||||
default:
|
||||
message = 'Server error occurred - please try again later';
|
||||
}
|
||||
|
||||
return ApiError.server(
|
||||
message: message,
|
||||
endpoint: requestPath,
|
||||
method: httpMethod,
|
||||
statusCode: statusCode,
|
||||
details: parsedError,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract retry-after header for rate limiting
|
||||
Duration? _extractRetryAfter(Headers? headers) {
|
||||
if (headers == null) return null;
|
||||
|
||||
final retryAfterHeader =
|
||||
headers.value('retry-after') ??
|
||||
headers.value('Retry-After') ??
|
||||
headers.value('X-RateLimit-Reset-After');
|
||||
|
||||
if (retryAfterHeader != null) {
|
||||
final seconds = int.tryParse(retryAfterHeader);
|
||||
if (seconds != null) {
|
||||
return Duration(seconds: seconds);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Log error details for debugging and monitoring
|
||||
void _logErrorDetails(
|
||||
DioException dioError,
|
||||
String requestPath,
|
||||
String httpMethod,
|
||||
) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('🔴 API Error Details:');
|
||||
debugPrint(' Method: ${httpMethod.toUpperCase()}');
|
||||
debugPrint(' Endpoint: $requestPath');
|
||||
debugPrint(' Type: ${dioError.type}');
|
||||
debugPrint(' Status: ${dioError.response?.statusCode}');
|
||||
|
||||
if (dioError.response?.data != null) {
|
||||
debugPrint(' Response: ${dioError.response?.data}');
|
||||
}
|
||||
|
||||
if (dioError.requestOptions.data != null) {
|
||||
debugPrint(' Request Data: ${dioError.requestOptions.data}');
|
||||
}
|
||||
|
||||
debugPrint(' Error: ${dioError.message}');
|
||||
}
|
||||
|
||||
// In production, you would send this to your error tracking service
|
||||
// FirebaseCrashlytics.instance.recordError(dioError, stackTrace);
|
||||
// Sentry.captureException(dioError);
|
||||
}
|
||||
|
||||
/// Check if error is retryable
|
||||
bool isRetryable(ApiError error) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.timeout:
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.server:
|
||||
return true;
|
||||
case ApiErrorType.rateLimit:
|
||||
return true; // Can retry after waiting
|
||||
case ApiErrorType.authentication:
|
||||
return false; // Need new token
|
||||
case ApiErrorType.authorization:
|
||||
case ApiErrorType.notFound:
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return false; // Client errors aren't retryable
|
||||
case ApiErrorType.cancelled:
|
||||
case ApiErrorType.security:
|
||||
case ApiErrorType.unknown:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggested retry delay for retryable errors
|
||||
Duration? getRetryDelay(ApiError error) {
|
||||
if (!isRetryable(error)) return null;
|
||||
|
||||
switch (error.type) {
|
||||
case ApiErrorType.rateLimit:
|
||||
return error.retryAfter ?? const Duration(minutes: 1);
|
||||
case ApiErrorType.timeout:
|
||||
return const Duration(seconds: 5);
|
||||
case ApiErrorType.network:
|
||||
return const Duration(seconds: 3);
|
||||
case ApiErrorType.server:
|
||||
return const Duration(seconds: 10);
|
||||
default:
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly error message with actionable advice
|
||||
String getUserMessage(ApiError error) {
|
||||
final baseMessage = error.message;
|
||||
|
||||
// Add actionable advice based on error type
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return '$baseMessage\n\nPlease check your internet connection and try again.';
|
||||
case ApiErrorType.timeout:
|
||||
return '$baseMessage\n\nThis might be due to a slow connection. Try again in a moment.';
|
||||
case ApiErrorType.authentication:
|
||||
return '$baseMessage\n\nPlease sign in again to continue.';
|
||||
case ApiErrorType.authorization:
|
||||
return '$baseMessage\n\nContact support if you believe this is an error.';
|
||||
case ApiErrorType.validation:
|
||||
return '$baseMessage\n\nPlease correct the highlighted fields and try again.';
|
||||
case ApiErrorType.rateLimit:
|
||||
final delay = error.retryAfter;
|
||||
if (delay != null) {
|
||||
final minutes = delay.inMinutes;
|
||||
final seconds = delay.inSeconds % 60;
|
||||
return '$baseMessage\n\nPlease wait ${minutes > 0 ? '${minutes}m ' : ''}${seconds}s before trying again.';
|
||||
}
|
||||
return '$baseMessage\n\nPlease wait a moment before trying again.';
|
||||
case ApiErrorType.server:
|
||||
return '$baseMessage\n\nOur servers are experiencing issues. Please try again in a few minutes.';
|
||||
default:
|
||||
return baseMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
lib/core/error/api_error_interceptor.dart
Normal file
239
lib/core/error/api_error_interceptor.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error_handler.dart';
|
||||
import 'api_error.dart';
|
||||
|
||||
/// Dio interceptor for automatic error handling and transformation
|
||||
/// Converts all HTTP errors into standardized ApiError format
|
||||
class ApiErrorInterceptor extends Interceptor {
|
||||
final ApiErrorHandler _errorHandler = ApiErrorHandler();
|
||||
final bool logErrors;
|
||||
final bool throwApiErrors;
|
||||
|
||||
ApiErrorInterceptor({this.logErrors = true, this.throwApiErrors = true});
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
try {
|
||||
// Transform the error into our standardized format
|
||||
final apiError = _errorHandler.transformError(
|
||||
err,
|
||||
endpoint: err.requestOptions.path,
|
||||
method: err.requestOptions.method,
|
||||
);
|
||||
|
||||
if (logErrors) {
|
||||
_logApiError(apiError, err);
|
||||
}
|
||||
|
||||
if (throwApiErrors) {
|
||||
// Replace the DioException with our ApiError
|
||||
final enhancedError = DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
type: err.type,
|
||||
error: apiError,
|
||||
message: apiError.message,
|
||||
);
|
||||
handler.reject(enhancedError);
|
||||
} else {
|
||||
// Store the ApiError in the response extra data
|
||||
if (err.response != null) {
|
||||
err.response!.extra['apiError'] = apiError;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if error transformation fails
|
||||
debugPrint('ApiErrorInterceptor: Failed to transform error: $e');
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
// Check for errors in successful responses (some APIs return errors with 200 status)
|
||||
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
// Check for error indicators in successful responses
|
||||
if (_isErrorResponse(data)) {
|
||||
final apiError = _errorHandler.transformError(
|
||||
data,
|
||||
endpoint: response.requestOptions.path,
|
||||
method: response.requestOptions.method,
|
||||
);
|
||||
|
||||
if (logErrors) {
|
||||
debugPrint('🟡 API Error in successful response: $apiError');
|
||||
}
|
||||
|
||||
// Store the error for later handling
|
||||
response.extra['apiError'] = apiError;
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// Check if a successful response actually contains an error
|
||||
bool _isErrorResponse(Map<String, dynamic> data) {
|
||||
// Common error indicators in successful responses
|
||||
const errorIndicators = [
|
||||
'error',
|
||||
'errors',
|
||||
'error_message',
|
||||
'errorMessage',
|
||||
'success',
|
||||
];
|
||||
|
||||
for (final indicator in errorIndicators) {
|
||||
if (data.containsKey(indicator)) {
|
||||
final value = data[indicator];
|
||||
|
||||
// Check for explicit error indicators
|
||||
if (indicator == 'success' && value == false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for error messages or arrays
|
||||
if (indicator != 'success' && value != null) {
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return true;
|
||||
} else if (value is List && value.isNotEmpty) {
|
||||
return true;
|
||||
} else if (value is Map && value.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Log API error with structured information
|
||||
void _logApiError(ApiError apiError, DioException originalError) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final typeIcon = _getErrorTypeIcon(apiError.type);
|
||||
debugPrint('$typeIcon API Error [${apiError.type.name.toUpperCase()}]');
|
||||
debugPrint(' Method: ${apiError.method?.toUpperCase() ?? 'UNKNOWN'}');
|
||||
debugPrint(' Endpoint: ${apiError.endpoint ?? 'unknown'}');
|
||||
debugPrint(' Status: ${apiError.statusCode ?? 'N/A'}');
|
||||
debugPrint(' Message: ${apiError.message}');
|
||||
|
||||
if (apiError.hasFieldErrors) {
|
||||
debugPrint(' Field Errors:');
|
||||
for (final entry in apiError.fieldErrors.entries) {
|
||||
final field = entry.key;
|
||||
final errors = entry.value;
|
||||
debugPrint(' $field: ${errors.join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
if (apiError.technical != null) {
|
||||
debugPrint(' Technical: ${apiError.technical}');
|
||||
}
|
||||
|
||||
if (apiError.retryAfter != null) {
|
||||
debugPrint(' Retry After: ${apiError.retryAfter!.inSeconds}s');
|
||||
}
|
||||
|
||||
// Log original error type for debugging
|
||||
debugPrint(' Original Type: ${originalError.type}');
|
||||
|
||||
// Log request details if available
|
||||
final requestData = originalError.requestOptions.data;
|
||||
if (requestData != null && requestData.toString().length < 500) {
|
||||
debugPrint(' Request: $requestData');
|
||||
}
|
||||
|
||||
// Log response data if available and not too large
|
||||
final responseData = originalError.response?.data;
|
||||
if (responseData != null && responseData.toString().length < 1000) {
|
||||
debugPrint(' Response: $responseData');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get emoji icon for error type
|
||||
String _getErrorTypeIcon(ApiErrorType type) {
|
||||
switch (type) {
|
||||
case ApiErrorType.network:
|
||||
return '🌐';
|
||||
case ApiErrorType.timeout:
|
||||
return '⏱️';
|
||||
case ApiErrorType.authentication:
|
||||
return '🔐';
|
||||
case ApiErrorType.authorization:
|
||||
return '🚫';
|
||||
case ApiErrorType.validation:
|
||||
return '✏️';
|
||||
case ApiErrorType.badRequest:
|
||||
return '❌';
|
||||
case ApiErrorType.notFound:
|
||||
return '🔍';
|
||||
case ApiErrorType.server:
|
||||
return '🔥';
|
||||
case ApiErrorType.rateLimit:
|
||||
return '🐌';
|
||||
case ApiErrorType.cancelled:
|
||||
return '🛑';
|
||||
case ApiErrorType.security:
|
||||
return '🔒';
|
||||
case ApiErrorType.unknown:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract ApiError from DioException if available
|
||||
static ApiError? extractApiError(DioException error) {
|
||||
return error.error is ApiError ? error.error as ApiError : null;
|
||||
}
|
||||
|
||||
/// Extract ApiError from Response if available
|
||||
static ApiError? extractApiErrorFromResponse(Response response) {
|
||||
return response.extra['apiError'] as ApiError?;
|
||||
}
|
||||
|
||||
/// Check if DioException contains an ApiError
|
||||
static bool hasApiError(DioException error) {
|
||||
return extractApiError(error) != null;
|
||||
}
|
||||
|
||||
/// Get user-friendly message from DioException
|
||||
static String getUserMessage(DioException error) {
|
||||
final apiError = extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return ApiErrorHandler().getUserMessage(apiError);
|
||||
}
|
||||
|
||||
// Fallback to basic DioException handling
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Connection timeout - please check your internet connection';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Network connection error - please check your internet connection';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return 'Authentication failed - please sign in again';
|
||||
} else if (statusCode == 403) {
|
||||
return 'Access denied - you don\'t have permission for this action';
|
||||
} else if (statusCode == 404) {
|
||||
return 'The requested resource was not found';
|
||||
} else if (statusCode != null && statusCode >= 500) {
|
||||
return 'Server error occurred - please try again later';
|
||||
}
|
||||
return 'An error occurred with your request';
|
||||
case DioExceptionType.cancel:
|
||||
return 'Request was cancelled';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'Security certificate error - unable to verify server identity';
|
||||
case DioExceptionType.unknown:
|
||||
return 'An unexpected error occurred - please try again';
|
||||
}
|
||||
}
|
||||
}
|
||||
467
lib/core/error/enhanced_error_service.dart
Normal file
467
lib/core/error/enhanced_error_service.dart
Normal file
@@ -0,0 +1,467 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'api_error.dart';
|
||||
import 'api_error_handler.dart';
|
||||
import 'api_error_interceptor.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced error service with comprehensive error handling capabilities
|
||||
/// Provides unified error management across the application
|
||||
class EnhancedErrorService {
|
||||
static final EnhancedErrorService _instance =
|
||||
EnhancedErrorService._internal();
|
||||
factory EnhancedErrorService() => _instance;
|
||||
EnhancedErrorService._internal();
|
||||
|
||||
final ApiErrorHandler _errorHandler = ApiErrorHandler();
|
||||
|
||||
/// Transform any error into ApiError format
|
||||
ApiError transformError(
|
||||
dynamic error, {
|
||||
String? endpoint,
|
||||
String? method,
|
||||
Map<String, dynamic>? requestData,
|
||||
}) {
|
||||
return _errorHandler.transformError(
|
||||
error,
|
||||
endpoint: endpoint,
|
||||
method: method,
|
||||
requestData: requestData,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String getUserMessage(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.getUserMessage(error);
|
||||
} else if (error is DioException) {
|
||||
return ApiErrorInterceptor.getUserMessage(error);
|
||||
} else {
|
||||
return _getGenericErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get technical error details for debugging
|
||||
String getTechnicalDetails(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return error.technical ?? error.toString();
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return apiError.technical ?? apiError.toString();
|
||||
}
|
||||
return '${error.type}: ${error.message}';
|
||||
} else {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if error is retryable
|
||||
bool isRetryable(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.isRetryable(error);
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return _errorHandler.isRetryable(apiError);
|
||||
}
|
||||
return _isDioErrorRetryable(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get suggested retry delay
|
||||
Duration? getRetryDelay(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
return _errorHandler.getRetryDelay(error);
|
||||
} else if (error is DioException) {
|
||||
final apiError = ApiErrorInterceptor.extractApiError(error);
|
||||
if (apiError != null) {
|
||||
return _errorHandler.getRetryDelay(apiError);
|
||||
}
|
||||
return _getDioRetryDelay(error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Show error snackbar with appropriate styling and actions
|
||||
void showErrorSnackbar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
Duration? duration,
|
||||
bool showTechnicalDetails = false,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
final retryDelay = getRetryDelay(error);
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(error),
|
||||
color: AppTheme.neutral50,
|
||||
size: IconSize.md,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: AppTheme.neutral50),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
getTechnicalDetails(error),
|
||||
style: TextStyle(
|
||||
color: AppTheme.neutral50.withValues(alpha: Alpha.strong),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
backgroundColor: _getErrorColor(error),
|
||||
duration: duration ?? _getSnackbarDuration(error),
|
||||
action: isRetryableError && onRetry != null
|
||||
? SnackBarAction(
|
||||
label: retryDelay != null && retryDelay.inSeconds > 5
|
||||
? 'Retry (${retryDelay.inSeconds}s)'
|
||||
: 'Retry',
|
||||
textColor: AppTheme.neutral50,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
/// Show error dialog with detailed information and recovery options
|
||||
Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
String? title,
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showTechnicalDetails = false,
|
||||
}) async {
|
||||
final message = getUserMessage(error);
|
||||
final technicalDetails = getTechnicalDetails(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(_getErrorIcon(error), color: _getErrorColor(error)),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(child: Text(title ?? _getErrorTitle(error))),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(message),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
const Text(
|
||||
'Technical Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral100,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
technicalDetails,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.monospaceFontFamily,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (isRetryableError && onRetry != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onRetry();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onDismiss?.call();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error widget for displaying in UI
|
||||
Widget buildErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showTechnicalDetails = false,
|
||||
EdgeInsets? padding,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final technicalDetails = getTechnicalDetails(error);
|
||||
final isRetryableError = isRetryable(error);
|
||||
|
||||
return Container(
|
||||
padding: padding ?? const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(error),
|
||||
size: IconSize.xxl,
|
||||
color: _getErrorColor(error),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
_getErrorTitle(error),
|
||||
style: const TextStyle(
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppTheme.neutral600),
|
||||
),
|
||||
if (showTechnicalDetails) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Spacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.neutral100,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
technicalDetails,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppTypography.monospaceFontFamily,
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isRetryableError && onRetry != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Log error with structured information
|
||||
void logError(
|
||||
dynamic error, {
|
||||
String? context,
|
||||
Map<String, dynamic>? additionalData,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (kDebugMode) {
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
debugPrint('🔴 ERROR [$timestamp] ${context ?? 'Unknown Context'}');
|
||||
debugPrint(' Message: ${getUserMessage(error)}');
|
||||
debugPrint(' Technical: ${getTechnicalDetails(error)}');
|
||||
|
||||
if (additionalData != null && additionalData.isNotEmpty) {
|
||||
debugPrint(' Additional Data: $additionalData');
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
debugPrint(' Stack Trace: $stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
// In production, send to error tracking service
|
||||
// FirebaseCrashlytics.instance.recordError(error, stackTrace, context: context);
|
||||
// Sentry.captureException(error, stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
String _getGenericErrorMessage(dynamic error) {
|
||||
if (error is Exception) {
|
||||
return 'An error occurred: ${error.toString()}';
|
||||
}
|
||||
return 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
bool _isDioErrorRetryable(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Duration? _getDioRetryDelay(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const Duration(seconds: 5);
|
||||
case DioExceptionType.connectionError:
|
||||
return const Duration(seconds: 3);
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return const Duration(seconds: 10);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData _getErrorIcon(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return Icons.wifi_off;
|
||||
case ApiErrorType.timeout:
|
||||
return Icons.timer_off;
|
||||
case ApiErrorType.authentication:
|
||||
return Icons.lock;
|
||||
case ApiErrorType.authorization:
|
||||
return Icons.block;
|
||||
case ApiErrorType.validation:
|
||||
return Icons.edit_off;
|
||||
case ApiErrorType.badRequest:
|
||||
return Icons.error_outline;
|
||||
case ApiErrorType.notFound:
|
||||
return Icons.search_off;
|
||||
case ApiErrorType.server:
|
||||
return Icons.dns;
|
||||
case ApiErrorType.rateLimit:
|
||||
return Icons.speed;
|
||||
case ApiErrorType.cancelled:
|
||||
return Icons.cancel;
|
||||
case ApiErrorType.security:
|
||||
return Icons.security;
|
||||
case ApiErrorType.unknown:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
return Icons.error_outline;
|
||||
}
|
||||
|
||||
Color _getErrorColor(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
case ApiErrorType.timeout:
|
||||
return AppTheme.warning;
|
||||
case ApiErrorType.authentication:
|
||||
case ApiErrorType.authorization:
|
||||
return AppTheme.error;
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return AppTheme.warning;
|
||||
case ApiErrorType.server:
|
||||
return AppTheme.error;
|
||||
case ApiErrorType.rateLimit:
|
||||
return AppTheme.info;
|
||||
default:
|
||||
return AppTheme.error;
|
||||
}
|
||||
}
|
||||
return AppTheme.error;
|
||||
}
|
||||
|
||||
String _getErrorTitle(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.network:
|
||||
return 'Connection Problem';
|
||||
case ApiErrorType.timeout:
|
||||
return 'Request Timeout';
|
||||
case ApiErrorType.authentication:
|
||||
return 'Authentication Required';
|
||||
case ApiErrorType.authorization:
|
||||
return 'Access Denied';
|
||||
case ApiErrorType.validation:
|
||||
return 'Invalid Input';
|
||||
case ApiErrorType.badRequest:
|
||||
return 'Bad Request';
|
||||
case ApiErrorType.notFound:
|
||||
return 'Not Found';
|
||||
case ApiErrorType.server:
|
||||
return 'Server Error';
|
||||
case ApiErrorType.rateLimit:
|
||||
return 'Rate Limited';
|
||||
case ApiErrorType.cancelled:
|
||||
return 'Request Cancelled';
|
||||
case ApiErrorType.security:
|
||||
return 'Security Error';
|
||||
case ApiErrorType.unknown:
|
||||
return 'Unknown Error';
|
||||
}
|
||||
}
|
||||
return 'Error';
|
||||
}
|
||||
|
||||
Duration _getSnackbarDuration(dynamic error) {
|
||||
if (error is ApiError) {
|
||||
switch (error.type) {
|
||||
case ApiErrorType.validation:
|
||||
case ApiErrorType.badRequest:
|
||||
return const Duration(seconds: 6); // Longer for validation errors
|
||||
case ApiErrorType.rateLimit:
|
||||
return const Duration(seconds: 8); // Longer for rate limits
|
||||
default:
|
||||
return const Duration(seconds: 4);
|
||||
}
|
||||
}
|
||||
return const Duration(seconds: 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// Global instance for easy access
|
||||
final enhancedErrorService = EnhancedErrorService();
|
||||
405
lib/core/error/error_parser.dart
Normal file
405
lib/core/error/error_parser.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_error.dart';
|
||||
|
||||
/// Comprehensive error response parser
|
||||
/// Handles various API error response formats and extracts structured information
|
||||
class ErrorParser {
|
||||
/// Parse general error response from API
|
||||
ParsedErrorResponse parseErrorResponse(dynamic responseData) {
|
||||
if (responseData == null) {
|
||||
return const ParsedErrorResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
return _parseErrorMap(responseData);
|
||||
} else if (responseData is String) {
|
||||
return _parseErrorString(responseData);
|
||||
} else if (responseData is List) {
|
||||
return _parseErrorList(responseData);
|
||||
} else {
|
||||
return ParsedErrorResponse(
|
||||
message: 'Unexpected error format',
|
||||
metadata: {'rawData': responseData.toString()},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ErrorParser: Error parsing response: $e');
|
||||
return ParsedErrorResponse(
|
||||
message: 'Failed to parse error response',
|
||||
metadata: {
|
||||
'parseError': e.toString(),
|
||||
'rawData': responseData.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse validation error (422) with field-specific errors
|
||||
ParsedErrorResponse parseValidationError(dynamic responseData) {
|
||||
final baseResult = parseErrorResponse(responseData);
|
||||
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
final fieldErrors = _extractFieldErrors(responseData);
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: baseResult.message ?? 'Validation failed',
|
||||
code: baseResult.code,
|
||||
errors: baseResult.errors,
|
||||
fieldErrors: fieldErrors,
|
||||
metadata: baseResult.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
/// Parse error response from a Map (most common format)
|
||||
ParsedErrorResponse _parseErrorMap(Map<String, dynamic> data) {
|
||||
final message = _extractMessage(data);
|
||||
final code = _extractCode(data);
|
||||
final errors = _extractGeneralErrors(data);
|
||||
final fieldErrors = _extractFieldErrors(data);
|
||||
final metadata = _extractMetadata(data);
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: message,
|
||||
code: code,
|
||||
errors: errors,
|
||||
fieldErrors: fieldErrors,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse error response from a String
|
||||
ParsedErrorResponse _parseErrorString(String data) {
|
||||
return ParsedErrorResponse(message: data, metadata: {'format': 'string'});
|
||||
}
|
||||
|
||||
/// Parse error response from a List
|
||||
ParsedErrorResponse _parseErrorList(List<dynamic> data) {
|
||||
final errors = <String>[];
|
||||
|
||||
for (final item in data) {
|
||||
if (item is String) {
|
||||
errors.add(item);
|
||||
} else if (item is Map<String, dynamic>) {
|
||||
final message = _extractMessage(item);
|
||||
if (message != null) {
|
||||
errors.add(message);
|
||||
}
|
||||
} else {
|
||||
errors.add(item.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedErrorResponse(
|
||||
message: errors.isNotEmpty ? errors.first : 'Multiple errors occurred',
|
||||
errors: errors,
|
||||
metadata: {'format': 'list', 'count': data.length},
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract error message from various possible fields
|
||||
String? _extractMessage(Map<String, dynamic> data) {
|
||||
// Common error message fields in order of preference
|
||||
const messageFields = [
|
||||
'message',
|
||||
'error',
|
||||
'detail',
|
||||
'description',
|
||||
'msg',
|
||||
'error_description',
|
||||
'title',
|
||||
'summary',
|
||||
];
|
||||
|
||||
for (final field in messageFields) {
|
||||
final value = data[field];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract error code from response
|
||||
String? _extractCode(Map<String, dynamic> data) {
|
||||
const codeFields = [
|
||||
'code',
|
||||
'error_code',
|
||||
'errorCode',
|
||||
'type',
|
||||
'error_type',
|
||||
'errorType',
|
||||
];
|
||||
|
||||
for (final field in codeFields) {
|
||||
final value = data[field];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
} else if (value is int) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract general error messages (non-field-specific)
|
||||
List<String> _extractGeneralErrors(Map<String, dynamic> data) {
|
||||
final errors = <String>[];
|
||||
|
||||
// Check for error arrays
|
||||
const errorArrayFields = ['errors', 'messages', 'details', 'issues'];
|
||||
|
||||
for (final field in errorArrayFields) {
|
||||
final value = data[field];
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is String && item.isNotEmpty) {
|
||||
errors.add(item);
|
||||
} else if (item is Map<String, dynamic>) {
|
||||
final message = _extractMessage(item);
|
||||
if (message != null) {
|
||||
errors.add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// Extract field-specific validation errors
|
||||
Map<String, List<String>> _extractFieldErrors(Map<String, dynamic> data) {
|
||||
final fieldErrors = <String, List<String>>{};
|
||||
|
||||
// Common patterns for field errors
|
||||
_extractFromFieldErrorsObject(data, fieldErrors);
|
||||
_extractFromValidationErrorsArray(data, fieldErrors);
|
||||
_extractFromDetailsObject(data, fieldErrors);
|
||||
_extractFromOpenAPIFormat(data, fieldErrors);
|
||||
|
||||
return fieldErrors;
|
||||
}
|
||||
|
||||
/// Extract from 'field_errors' or 'fieldErrors' object
|
||||
void _extractFromFieldErrorsObject(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
const fieldErrorFields = [
|
||||
'field_errors',
|
||||
'fieldErrors',
|
||||
'validation_errors',
|
||||
'validationErrors',
|
||||
'field_messages',
|
||||
'fieldMessages',
|
||||
];
|
||||
|
||||
for (final field in fieldErrorFields) {
|
||||
final value = data[field];
|
||||
if (value is Map<String, dynamic>) {
|
||||
for (final entry in value.entries) {
|
||||
final fieldName = entry.key;
|
||||
final fieldValue = entry.value;
|
||||
|
||||
final errors = <String>[];
|
||||
if (fieldValue is String) {
|
||||
errors.add(fieldValue);
|
||||
} else if (fieldValue is List) {
|
||||
for (final item in fieldValue) {
|
||||
if (item is String) {
|
||||
errors.add(item);
|
||||
} else {
|
||||
errors.add(item.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
fieldErrors[fieldName] = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from validation errors array format
|
||||
void _extractFromValidationErrorsArray(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
const arrayFields = ['errors', 'details', 'issues'];
|
||||
|
||||
for (final field in arrayFields) {
|
||||
final value = data[field];
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final field =
|
||||
item['field'] as String? ??
|
||||
item['property'] as String? ??
|
||||
item['path'] as String?;
|
||||
final message = _extractMessage(item);
|
||||
|
||||
if (field != null && message != null) {
|
||||
fieldErrors.putIfAbsent(field, () => []).add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from 'details' object (common in some APIs)
|
||||
void _extractFromDetailsObject(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
final details = data['details'];
|
||||
if (details is Map<String, dynamic>) {
|
||||
for (final entry in details.entries) {
|
||||
final fieldName = entry.key;
|
||||
final fieldValue = entry.value;
|
||||
|
||||
if (fieldValue is String) {
|
||||
fieldErrors.putIfAbsent(fieldName, () => []).add(fieldValue);
|
||||
} else if (fieldValue is List) {
|
||||
final errors = fieldValue
|
||||
.map((e) => e.toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (errors.isNotEmpty) {
|
||||
fieldErrors[fieldName] = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract from OpenAPI specification error format
|
||||
void _extractFromOpenAPIFormat(
|
||||
Map<String, dynamic> data,
|
||||
Map<String, List<String>> fieldErrors,
|
||||
) {
|
||||
// OpenAPI validation errors often come in this format
|
||||
final detail = data['detail'];
|
||||
if (detail is List) {
|
||||
for (final item in detail) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final loc = item['loc'];
|
||||
final msg = item['msg'] as String?;
|
||||
|
||||
if (loc is List && loc.isNotEmpty && msg != null) {
|
||||
// Location can be like ['body', 'fieldName'] or ['fieldName']
|
||||
final fieldName = loc.last.toString();
|
||||
fieldErrors.putIfAbsent(fieldName, () => []).add(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract additional metadata from error response
|
||||
Map<String, dynamic> _extractMetadata(Map<String, dynamic> data) {
|
||||
final metadata = <String, dynamic>{};
|
||||
|
||||
// Common metadata fields
|
||||
const metadataFields = [
|
||||
'timestamp',
|
||||
'request_id',
|
||||
'requestId',
|
||||
'trace_id',
|
||||
'traceId',
|
||||
'correlation_id',
|
||||
'correlationId',
|
||||
'instance',
|
||||
'path',
|
||||
'method',
|
||||
'status',
|
||||
'documentation',
|
||||
'help',
|
||||
'support',
|
||||
];
|
||||
|
||||
for (final field in metadataFields) {
|
||||
final value = data[field];
|
||||
if (value != null) {
|
||||
metadata[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Include any unrecognized fields as metadata
|
||||
final recognizedFields = {
|
||||
'message',
|
||||
'error',
|
||||
'detail',
|
||||
'description',
|
||||
'msg',
|
||||
'error_description',
|
||||
'title',
|
||||
'summary',
|
||||
'code',
|
||||
'error_code',
|
||||
'errorCode',
|
||||
'type',
|
||||
'error_type',
|
||||
'errorType',
|
||||
'errors',
|
||||
'messages',
|
||||
'details',
|
||||
'issues',
|
||||
'field_errors',
|
||||
'fieldErrors',
|
||||
'validation_errors',
|
||||
'validationErrors',
|
||||
'field_messages',
|
||||
'fieldMessages',
|
||||
...metadataFields,
|
||||
};
|
||||
|
||||
for (final entry in data.entries) {
|
||||
if (!recognizedFields.contains(entry.key)) {
|
||||
metadata[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// Convert field name from API format to user-friendly format
|
||||
String formatFieldName(String fieldName) {
|
||||
// Convert snake_case to human readable
|
||||
if (fieldName.contains('_')) {
|
||||
return fieldName
|
||||
.split('_')
|
||||
.map(
|
||||
(word) =>
|
||||
word.isEmpty ? word : word[0].toUpperCase() + word.substring(1),
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Convert camelCase to human readable
|
||||
return fieldName
|
||||
.replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Get user-friendly error message for a field
|
||||
String formatFieldError(String fieldName, String error) {
|
||||
final friendlyFieldName = formatFieldName(fieldName);
|
||||
|
||||
// If error already mentions the field, don't duplicate it
|
||||
if (error.toLowerCase().contains(fieldName.toLowerCase()) ||
|
||||
error.toLowerCase().contains(friendlyFieldName.toLowerCase())) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return '$friendlyFieldName: $error';
|
||||
}
|
||||
}
|
||||
23
lib/core/models/chat_message.dart
Normal file
23
lib/core/models/chat_message.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_message.freezed.dart';
|
||||
part 'chat_message.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ChatMessage with _$ChatMessage {
|
||||
const factory ChatMessage({
|
||||
required String id,
|
||||
required String role, // 'user', 'assistant', 'system'
|
||||
required String content,
|
||||
required DateTime timestamp,
|
||||
String? model,
|
||||
@Default(false) bool isStreaming,
|
||||
List<String>? attachmentIds,
|
||||
Map<String, dynamic>? metadata,
|
||||
List<Map<String, dynamic>>? sources,
|
||||
Map<String, dynamic>? usage,
|
||||
}) = _ChatMessage;
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChatMessageFromJson(json);
|
||||
}
|
||||
27
lib/core/models/conversation.dart
Normal file
27
lib/core/models/conversation.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'chat_message.dart';
|
||||
|
||||
part 'conversation.freezed.dart';
|
||||
part 'conversation.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Conversation with _$Conversation {
|
||||
const factory Conversation({
|
||||
required String id,
|
||||
required String title,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
String? model,
|
||||
String? systemPrompt,
|
||||
@Default([]) List<ChatMessage> messages,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
@Default(false) bool pinned,
|
||||
@Default(false) bool archived,
|
||||
String? shareId,
|
||||
String? folderId,
|
||||
@Default([]) List<String> tags,
|
||||
}) = _Conversation;
|
||||
|
||||
factory Conversation.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConversationFromJson(json);
|
||||
}
|
||||
23
lib/core/models/file_info.dart
Normal file
23
lib/core/models/file_info.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'file_info.freezed.dart';
|
||||
part 'file_info.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class FileInfo with _$FileInfo {
|
||||
const factory FileInfo({
|
||||
required String id,
|
||||
required String filename,
|
||||
required String originalFilename,
|
||||
required int size,
|
||||
required String mimeType,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
String? userId,
|
||||
String? hash,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) = _FileInfo;
|
||||
|
||||
factory FileInfo.fromJson(Map<String, dynamic> json) =>
|
||||
_$FileInfoFromJson(json);
|
||||
}
|
||||
41
lib/core/models/folder.dart
Normal file
41
lib/core/models/folder.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'folder.freezed.dart';
|
||||
part 'folder.g.dart';
|
||||
|
||||
// Timestamp converter for Unix timestamps
|
||||
class TimestampConverter implements JsonConverter<DateTime, dynamic> {
|
||||
const TimestampConverter();
|
||||
|
||||
@override
|
||||
DateTime fromJson(dynamic json) {
|
||||
if (json is String) {
|
||||
return DateTime.parse(json);
|
||||
} else if (json is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(json * 1000);
|
||||
} else {
|
||||
throw ArgumentError('Invalid date format: $json');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic toJson(DateTime object) {
|
||||
return object.millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class Folder with _$Folder {
|
||||
const factory Folder({
|
||||
required String id,
|
||||
required String name,
|
||||
@TimestampConverter() required DateTime createdAt,
|
||||
@TimestampConverter() required DateTime updatedAt,
|
||||
String? parentId,
|
||||
@Default([]) List<String> conversationIds,
|
||||
@Default([]) List<Folder> subfolders,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
}) = _Folder;
|
||||
|
||||
factory Folder.fromJson(Map<String, dynamic> json) => _$FolderFromJson(json);
|
||||
}
|
||||
35
lib/core/models/knowledge_base.dart
Normal file
35
lib/core/models/knowledge_base.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'knowledge_base.freezed.dart';
|
||||
part 'knowledge_base.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class KnowledgeBase with _$KnowledgeBase {
|
||||
const factory KnowledgeBase({
|
||||
required String id,
|
||||
required String name,
|
||||
String? description,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
@Default(0) int itemCount,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
}) = _KnowledgeBase;
|
||||
|
||||
factory KnowledgeBase.fromJson(Map<String, dynamic> json) =>
|
||||
_$KnowledgeBaseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class KnowledgeBaseItem with _$KnowledgeBaseItem {
|
||||
const factory KnowledgeBaseItem({
|
||||
required String id,
|
||||
required String content,
|
||||
String? title,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
}) = _KnowledgeBaseItem;
|
||||
|
||||
factory KnowledgeBaseItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$KnowledgeBaseItemFromJson(json);
|
||||
}
|
||||
93
lib/core/models/model.dart
Normal file
93
lib/core/models/model.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'model.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Model with _$Model {
|
||||
const Model._();
|
||||
|
||||
const factory Model({
|
||||
required String id,
|
||||
required String name,
|
||||
String? description,
|
||||
@Default(false) bool isMultimodal,
|
||||
@Default(false) bool supportsStreaming,
|
||||
@Default(false) bool supportsRAG,
|
||||
Map<String, dynamic>? capabilities,
|
||||
Map<String, dynamic>? metadata,
|
||||
List<String>? supportedParameters,
|
||||
}) = _Model;
|
||||
|
||||
factory Model.fromJson(Map<String, dynamic> json) {
|
||||
// Handle different response formats from OpenWebUI
|
||||
|
||||
// Extract architecture info for capabilities
|
||||
final architecture = json['architecture'] as Map<String, dynamic>?;
|
||||
final modality = architecture?['modality'] as String?;
|
||||
final inputModalities = architecture?['input_modalities'] as List?;
|
||||
|
||||
// Determine if multimodal based on architecture
|
||||
final isMultimodal =
|
||||
modality?.contains('image') == true ||
|
||||
inputModalities?.contains('image') == true;
|
||||
|
||||
// Extract supported parameters robustly (top-level or nested under provider keys)
|
||||
List? supportedParams =
|
||||
(json['supported_parameters'] as List?) ??
|
||||
(json['supportedParameters'] as List?);
|
||||
|
||||
if (supportedParams == null) {
|
||||
const providerKeys = [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'google',
|
||||
'meta',
|
||||
'mistral',
|
||||
'cohere',
|
||||
'xai',
|
||||
'perplexity',
|
||||
'deepseek',
|
||||
'groq',
|
||||
];
|
||||
for (final key in providerKeys) {
|
||||
final provider = json[key] as Map<String, dynamic>?;
|
||||
final list =
|
||||
(provider?['supported_parameters'] as List?) ??
|
||||
(provider?['supportedParameters'] as List?);
|
||||
if (list != null) {
|
||||
supportedParams = list;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine streaming support from supported parameters if known
|
||||
final supportsStreaming = supportedParams?.contains('stream') ?? true;
|
||||
|
||||
// Convert supported parameters to List<String> if present
|
||||
final supportedParamsList = supportedParams
|
||||
?.map((e) => e.toString())
|
||||
.toList();
|
||||
|
||||
return Model(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
isMultimodal: isMultimodal,
|
||||
supportsStreaming: supportsStreaming,
|
||||
supportsRAG: json['supportsRAG'] as bool? ?? false,
|
||||
supportedParameters: supportedParamsList,
|
||||
capabilities: {
|
||||
'architecture': architecture,
|
||||
'pricing': json['pricing'],
|
||||
'context_length': json['context_length'],
|
||||
'supported_parameters': supportedParamsList ?? supportedParams,
|
||||
},
|
||||
metadata: {
|
||||
'canonical_slug': json['canonical_slug'],
|
||||
'created': json['created'],
|
||||
'connection_type': json['connection_type'],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/core/models/server_config.dart
Normal file
19
lib/core/models/server_config.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'server_config.freezed.dart';
|
||||
part 'server_config.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ServerConfig with _$ServerConfig {
|
||||
const factory ServerConfig({
|
||||
required String id,
|
||||
required String name,
|
||||
required String url,
|
||||
String? apiKey,
|
||||
DateTime? lastConnected,
|
||||
@Default(false) bool isActive,
|
||||
}) = _ServerConfig;
|
||||
|
||||
factory ServerConfig.fromJson(Map<String, dynamic> json) =>
|
||||
_$ServerConfigFromJson(json);
|
||||
}
|
||||
33
lib/core/models/user.dart
Normal file
33
lib/core/models/user.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class User with _$User {
|
||||
const User._();
|
||||
|
||||
const factory User({
|
||||
required String id,
|
||||
required String username,
|
||||
required String email,
|
||||
String? name,
|
||||
String? profileImage,
|
||||
required String role,
|
||||
@Default(true) bool isActive,
|
||||
}) = _User;
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
// Handle different field names from OpenWebUI API
|
||||
return User(
|
||||
id: json['id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? json['name'] as String? ?? '',
|
||||
email: json['email'] as String? ?? '',
|
||||
name: json['name'] as String?,
|
||||
profileImage:
|
||||
json['profile_image_url'] as String? ??
|
||||
json['profileImage'] as String?,
|
||||
role: json['role'] as String? ?? 'user',
|
||||
isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/core/models/user_settings.dart
Normal file
40
lib/core/models/user_settings.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user_settings.freezed.dart';
|
||||
part 'user_settings.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class UserSettings with _$UserSettings {
|
||||
const factory UserSettings({
|
||||
// Chat preferences
|
||||
@Default(true) bool showReadReceipts,
|
||||
@Default(true) bool enableNotifications,
|
||||
@Default(false) bool enableSounds,
|
||||
@Default('auto') String theme, // 'light', 'dark', 'auto'
|
||||
// AI preferences
|
||||
@Default(0.7) double temperature,
|
||||
@Default(2048) int maxTokens,
|
||||
@Default(false) bool streamResponses,
|
||||
@Default(false) bool webSearchEnabled,
|
||||
|
||||
// Privacy settings
|
||||
@Default(true) bool saveConversations,
|
||||
@Default(false) bool shareUsageData,
|
||||
|
||||
// Interface preferences
|
||||
@Default('comfortable')
|
||||
String density, // 'compact', 'comfortable', 'spacious'
|
||||
@Default(14.0) double fontSize,
|
||||
@Default('en') String language,
|
||||
|
||||
// Accessibility settings
|
||||
@Default(false) bool reduceMotion,
|
||||
@Default(true) bool hapticFeedback,
|
||||
|
||||
// Advanced settings
|
||||
@Default({}) Map<String, dynamic> customSettings,
|
||||
}) = _UserSettings;
|
||||
|
||||
factory UserSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserSettingsFromJson(json);
|
||||
}
|
||||
750
lib/core/providers/app_providers.dart
Normal file
750
lib/core/providers/app_providers.dart
Normal file
@@ -0,0 +1,750 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/storage_service.dart';
|
||||
// (removed duplicate) import '../services/optimized_storage_service.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../auth/auth_state_manager.dart';
|
||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import '../services/attachment_upload_queue.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/model.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/user_settings.dart';
|
||||
import '../models/folder.dart';
|
||||
import '../models/file_info.dart';
|
||||
import '../models/knowledge_base.dart';
|
||||
import '../services/optimized_storage_service.dart';
|
||||
|
||||
// Storage providers
|
||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||
throw UnimplementedError();
|
||||
});
|
||||
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
return const FlutterSecureStorage();
|
||||
});
|
||||
|
||||
final storageServiceProvider = Provider<StorageService>((ref) {
|
||||
return StorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
prefs: ref.watch(sharedPreferencesProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// Optimized storage service provider
|
||||
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
ref,
|
||||
) {
|
||||
return OptimizedStorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
prefs: ref.watch(sharedPreferencesProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// Theme provider
|
||||
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>((
|
||||
ref,
|
||||
) {
|
||||
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||
return ThemeModeNotifier(storage);
|
||||
});
|
||||
|
||||
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
final OptimizedStorageService _storage;
|
||||
|
||||
ThemeModeNotifier(this._storage) : super(ThemeMode.system) {
|
||||
_loadTheme();
|
||||
}
|
||||
|
||||
void _loadTheme() {
|
||||
final mode = _storage.getThemeMode();
|
||||
if (mode != null) {
|
||||
state = ThemeMode.values.firstWhere(
|
||||
(e) => e.toString() == mode,
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setTheme(ThemeMode mode) {
|
||||
state = mode;
|
||||
_storage.setThemeMode(mode.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Server connection providers - optimized with caching
|
||||
final serverConfigsProvider = FutureProvider<List<ServerConfig>>((ref) async {
|
||||
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||
return storage.getServerConfigs();
|
||||
});
|
||||
|
||||
final activeServerProvider = FutureProvider<ServerConfig?>((ref) async {
|
||||
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||
final configs = await ref.watch(serverConfigsProvider.future);
|
||||
final activeId = await storage.getActiveServerId();
|
||||
|
||||
if (activeId == null || configs.isEmpty) return null;
|
||||
|
||||
return configs.firstWhere(
|
||||
(config) => config.id == activeId,
|
||||
orElse: () => configs.first,
|
||||
);
|
||||
});
|
||||
|
||||
final serverConnectionStateProvider = Provider<bool>((ref) {
|
||||
final activeServer = ref.watch(activeServerProvider);
|
||||
return activeServer.maybeWhen(
|
||||
data: (server) => server != null,
|
||||
orElse: () => false,
|
||||
);
|
||||
});
|
||||
|
||||
// API Service provider with unified auth integration
|
||||
final apiServiceProvider = Provider<ApiService?>((ref) {
|
||||
// If reviewer mode is enabled, skip creating ApiService
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
return null;
|
||||
}
|
||||
final activeServer = ref.watch(activeServerProvider);
|
||||
|
||||
return activeServer.maybeWhen(
|
||||
data: (server) {
|
||||
if (server == null) return null;
|
||||
|
||||
final apiService = ApiService(
|
||||
serverConfig: server,
|
||||
authToken: null, // Will be set by auth state manager
|
||||
);
|
||||
|
||||
// Keep callbacks in sync so interceptor can notify auth manager
|
||||
apiService.setAuthCallbacks(
|
||||
onAuthTokenInvalid: () {},
|
||||
onTokenInvalidated: () async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
await authManager.onTokenInvalidated();
|
||||
},
|
||||
);
|
||||
|
||||
// Set up callback for unified auth state manager
|
||||
// (legacy properties kept during transition)
|
||||
apiService.onTokenInvalidated = () async {
|
||||
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||
await authManager.onTokenInvalidated();
|
||||
};
|
||||
|
||||
// Keep legacy callback for backward compatibility during transition
|
||||
apiService.onAuthTokenInvalid = () {
|
||||
// This will be removed once migration is complete
|
||||
debugPrint('DEBUG: Legacy auth invalidation callback triggered');
|
||||
};
|
||||
|
||||
// Initialize with any existing token immediately
|
||||
final token = ref.read(authTokenProvider3);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
apiService.updateAuthToken(token);
|
||||
}
|
||||
|
||||
return apiService;
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
});
|
||||
|
||||
// Attachment upload queue provider
|
||||
final attachmentUploadQueueProvider = Provider<AttachmentUploadQueue?>((ref) {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return null;
|
||||
|
||||
final queue = AttachmentUploadQueue();
|
||||
// Initialize once; subsequent calls are no-ops due to singleton
|
||||
queue.initialize(
|
||||
onUpload: (filePath, fileName) => api.uploadFile(filePath, fileName),
|
||||
);
|
||||
|
||||
return queue;
|
||||
});
|
||||
|
||||
// Auth providers
|
||||
// Auth token integration with API service - using unified auth system
|
||||
final apiTokenUpdaterProvider = Provider<void>((ref) {
|
||||
// Listen to unified auth token changes and update API service
|
||||
ref.listen(authTokenProvider3, (previous, next) {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api != null && next != null && next.isNotEmpty) {
|
||||
api.updateAuthToken(next);
|
||||
debugPrint('DEBUG: Updated API service with unified auth token');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
final currentUserProvider = FutureProvider<User?>((ref) async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider2);
|
||||
|
||||
if (api == null || !isAuthenticated) return null;
|
||||
|
||||
try {
|
||||
return await api.getCurrentUser();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper provider to force refresh auth state - now using unified system
|
||||
final refreshAuthStateProvider = Provider<void>((ref) {
|
||||
// This provider can be invalidated to force refresh the unified auth system
|
||||
ref.read(refreshAuthProvider);
|
||||
return;
|
||||
});
|
||||
|
||||
// Model providers
|
||||
final modelsProvider = FutureProvider<List<Model>>((ref) async {
|
||||
// Reviewer mode returns mock models
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
return [
|
||||
const Model(
|
||||
id: 'demo/gemma-2-mini',
|
||||
name: 'Gemma 2 Mini (Demo)',
|
||||
description: 'Demo model for reviewer mode',
|
||||
isMultimodal: true,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
const Model(
|
||||
id: 'demo/llama-3-8b',
|
||||
name: 'Llama 3 8B (Demo)',
|
||||
description: 'Fast text model for demo',
|
||||
isMultimodal: false,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
debugPrint('DEBUG: Fetching models from server');
|
||||
final models = await api.getModels();
|
||||
debugPrint('DEBUG: Successfully fetched ${models.length} models');
|
||||
return models;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to fetch models: $e');
|
||||
|
||||
// If models endpoint returns 403, this should now clear auth token
|
||||
// and redirect user to login since it's marked as a core endpoint
|
||||
if (e.toString().contains('403')) {
|
||||
debugPrint(
|
||||
'DEBUG: Models endpoint returned 403 - authentication may be invalid',
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
final selectedModelProvider = StateProvider<Model?>((ref) => null);
|
||||
|
||||
// Conversation providers - Now using correct OpenWebUI API
|
||||
final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
// Provide a simple local demo conversation list
|
||||
return [
|
||||
Conversation(
|
||||
id: 'demo-conv-1',
|
||||
title: 'Welcome to Conduit (Demo)',
|
||||
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
messages: [],
|
||||
),
|
||||
];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
debugPrint('DEBUG: No API service available');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('DEBUG: Fetching conversations from OpenWebUI API...');
|
||||
final conversations = await api.getConversations(limit: 50);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully fetched ${conversations.length} conversations',
|
||||
);
|
||||
return conversations;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('DEBUG: Error fetching conversations: $e');
|
||||
debugPrint('DEBUG: Stack trace: $stackTrace');
|
||||
|
||||
// If conversations endpoint returns 403, this should now clear auth token
|
||||
// and redirect user to login since it's marked as a core endpoint
|
||||
if (e.toString().contains('403')) {
|
||||
debugPrint(
|
||||
'DEBUG: Conversations endpoint returned 403 - authentication may be invalid',
|
||||
);
|
||||
}
|
||||
|
||||
// Return empty list instead of re-throwing to allow app to continue functioning
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
final activeConversationProvider = StateProvider<Conversation?>((ref) => null);
|
||||
|
||||
// Provider to load full conversation with messages
|
||||
final loadConversationProvider = FutureProvider.family<Conversation, String>((
|
||||
ref,
|
||||
conversationId,
|
||||
) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
throw Exception('No API service available');
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Loading full conversation: $conversationId');
|
||||
final fullConversation = await api.getConversation(conversationId);
|
||||
debugPrint(
|
||||
'DEBUG: Loaded conversation with ${fullConversation.messages.length} messages',
|
||||
);
|
||||
|
||||
return fullConversation;
|
||||
});
|
||||
|
||||
// Provider to automatically load and set the default model from OpenWebUI
|
||||
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return null;
|
||||
|
||||
try {
|
||||
// Get all available models first
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isEmpty) {
|
||||
debugPrint('DEBUG: No models available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a model is already selected
|
||||
final currentSelected = ref.read(selectedModelProvider);
|
||||
if (currentSelected != null) {
|
||||
debugPrint('DEBUG: Model already selected: ${currentSelected.name}');
|
||||
return currentSelected;
|
||||
}
|
||||
|
||||
Model? selectedModel;
|
||||
|
||||
// Try to get the server's default model configuration
|
||||
try {
|
||||
final defaultModelId = await api.getDefaultModel();
|
||||
|
||||
if (defaultModelId != null && defaultModelId.isNotEmpty) {
|
||||
// Find the model that matches the default model ID
|
||||
try {
|
||||
selectedModel = models.firstWhere(
|
||||
(model) =>
|
||||
model.id == defaultModelId ||
|
||||
model.name == defaultModelId ||
|
||||
model.id.contains(defaultModelId) ||
|
||||
model.name.contains(defaultModelId),
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Found server default model: ${selectedModel.name}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Default model "$defaultModelId" not found in available models',
|
||||
);
|
||||
selectedModel = models.first;
|
||||
}
|
||||
} else {
|
||||
// No server default, use first available model
|
||||
selectedModel = models.first;
|
||||
debugPrint(
|
||||
'DEBUG: No server default model, using first available: ${selectedModel.name}',
|
||||
);
|
||||
}
|
||||
} catch (apiError) {
|
||||
debugPrint('DEBUG: Failed to get default model from server: $apiError');
|
||||
// Use first available model as fallback
|
||||
selectedModel = models.first;
|
||||
debugPrint(
|
||||
'DEBUG: Using first available model as fallback: ${selectedModel.name}',
|
||||
);
|
||||
}
|
||||
|
||||
// Set the selected model
|
||||
ref.read(selectedModelProvider.notifier).state = selectedModel;
|
||||
debugPrint('DEBUG: Set default model: ${selectedModel.name}');
|
||||
|
||||
return selectedModel;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error setting default model: $e');
|
||||
|
||||
// Final fallback: try to select any available model
|
||||
try {
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isNotEmpty) {
|
||||
final fallbackModel = models.first;
|
||||
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
||||
debugPrint(
|
||||
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
||||
);
|
||||
return fallbackModel;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
debugPrint('DEBUG: Error in fallback model selection: $fallbackError');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Background model loading provider that doesn't block UI
|
||||
// This just schedules the loading, doesn't wait for it
|
||||
final backgroundModelLoadProvider = Provider<void>((ref) {
|
||||
// Ensure API token updater is initialized
|
||||
ref.watch(apiTokenUpdaterProvider);
|
||||
|
||||
// Schedule background loading without blocking
|
||||
Future.microtask(() async {
|
||||
// Wait a bit to ensure auth is complete
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
debugPrint('DEBUG: Starting background model loading');
|
||||
|
||||
// Load default model in background
|
||||
try {
|
||||
await ref.read(defaultModelProvider.future);
|
||||
debugPrint('DEBUG: Background model loading completed');
|
||||
} catch (e) {
|
||||
// Ignore errors in background loading
|
||||
debugPrint('DEBUG: Background model loading failed: $e');
|
||||
}
|
||||
});
|
||||
|
||||
// Return immediately, don't block the UI
|
||||
return;
|
||||
});
|
||||
|
||||
// Search query provider
|
||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
// Server-side search provider for chats
|
||||
final serverSearchProvider = FutureProvider.family<List<Conversation>, String>((
|
||||
ref,
|
||||
query,
|
||||
) async {
|
||||
if (query.trim().isEmpty) {
|
||||
// Return empty list for empty query instead of all conversations
|
||||
return [];
|
||||
}
|
||||
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
debugPrint('DEBUG: Performing server-side search for: "$query"');
|
||||
|
||||
// Use the new server-side search API
|
||||
final searchResult = await api.searchChats(
|
||||
query: query.trim(),
|
||||
archived: false, // Only search non-archived conversations
|
||||
limit: 50,
|
||||
sortBy: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
);
|
||||
|
||||
// Extract conversations from search result
|
||||
final List<dynamic> conversationsData = searchResult['conversations'] ?? [];
|
||||
|
||||
// Convert to Conversation objects
|
||||
final List<Conversation> conversations = conversationsData.map((data) {
|
||||
return Conversation.fromJson(data as Map<String, dynamic>);
|
||||
}).toList();
|
||||
|
||||
debugPrint('DEBUG: Server search returned ${conversations.length} results');
|
||||
return conversations;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Server search failed, fallback to local: $e');
|
||||
|
||||
// Fallback to local search if server search fails
|
||||
final allConversations = await ref.read(conversationsProvider.future);
|
||||
return allConversations.where((conv) {
|
||||
return !conv.archived &&
|
||||
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
conv.messages.any(
|
||||
(msg) =>
|
||||
msg.content.toLowerCase().contains(query.toLowerCase()),
|
||||
));
|
||||
}).toList();
|
||||
}
|
||||
});
|
||||
|
||||
final filteredConversationsProvider = Provider<List<Conversation>>((ref) {
|
||||
final conversations = ref.watch(conversationsProvider);
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
|
||||
// Use server-side search when there's a query
|
||||
if (query.trim().isNotEmpty) {
|
||||
final searchResults = ref.watch(serverSearchProvider(query));
|
||||
return searchResults.maybeWhen(
|
||||
data: (results) => results,
|
||||
loading: () {
|
||||
// While server search is loading, show local filtered results
|
||||
return conversations.maybeWhen(
|
||||
data: (convs) => convs.where((conv) {
|
||||
return !conv.archived &&
|
||||
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
conv.messages.any(
|
||||
(msg) => msg.content.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
),
|
||||
));
|
||||
}).toList(),
|
||||
orElse: () => [],
|
||||
);
|
||||
},
|
||||
error: (_, stackTrace) {
|
||||
// On error, fallback to local search
|
||||
return conversations.maybeWhen(
|
||||
data: (convs) => convs.where((conv) {
|
||||
return !conv.archived &&
|
||||
(conv.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
conv.messages.any(
|
||||
(msg) => msg.content.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
),
|
||||
));
|
||||
}).toList(),
|
||||
orElse: () => [],
|
||||
);
|
||||
},
|
||||
orElse: () => [],
|
||||
);
|
||||
}
|
||||
|
||||
// When no search query, show all non-archived conversations
|
||||
return conversations.maybeWhen(
|
||||
data: (convs) {
|
||||
if (ref.watch(reviewerModeProvider)) {
|
||||
return convs; // Already filtered above for demo
|
||||
}
|
||||
// Filter out archived conversations (they should be in a separate view)
|
||||
final filtered = convs.where((conv) => !conv.archived).toList();
|
||||
|
||||
// Sort: pinned conversations first, then by updated date
|
||||
filtered.sort((a, b) {
|
||||
// Pinned conversations come first
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
|
||||
// Within same pin status, sort by updated date (newest first)
|
||||
return b.updatedAt.compareTo(a.updatedAt);
|
||||
});
|
||||
|
||||
return filtered;
|
||||
},
|
||||
orElse: () => [],
|
||||
);
|
||||
});
|
||||
|
||||
// Provider for archived conversations
|
||||
final archivedConversationsProvider = Provider<List<Conversation>>((ref) {
|
||||
final conversations = ref.watch(conversationsProvider);
|
||||
|
||||
return conversations.maybeWhen(
|
||||
data: (convs) {
|
||||
if (ref.watch(reviewerModeProvider)) {
|
||||
return convs.where((c) => c.archived).toList();
|
||||
}
|
||||
// Only show archived conversations
|
||||
final archived = convs.where((conv) => conv.archived).toList();
|
||||
|
||||
// Sort by updated date (newest first)
|
||||
archived.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
return archived;
|
||||
},
|
||||
orElse: () => [],
|
||||
);
|
||||
});
|
||||
|
||||
// Reviewer mode provider (persisted)
|
||||
final reviewerModeProvider = StateNotifierProvider<ReviewerModeNotifier, bool>(
|
||||
(ref) => ReviewerModeNotifier(ref.watch(optimizedStorageServiceProvider)),
|
||||
);
|
||||
|
||||
class ReviewerModeNotifier extends StateNotifier<bool> {
|
||||
final OptimizedStorageService _storage;
|
||||
ReviewerModeNotifier(this._storage) : super(false) {
|
||||
_load();
|
||||
}
|
||||
Future<void> _load() async {
|
||||
final enabled = await _storage.getReviewerMode();
|
||||
state = enabled;
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
state = enabled;
|
||||
await _storage.setReviewerMode(enabled);
|
||||
}
|
||||
|
||||
Future<void> toggle() => setEnabled(!state);
|
||||
}
|
||||
|
||||
// User Settings providers
|
||||
final userSettingsProvider = FutureProvider<UserSettings>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
// Return default settings if no API
|
||||
return const UserSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
final settingsData = await api.getUserSettings();
|
||||
return UserSettings.fromJson(settingsData);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching user settings: $e');
|
||||
// Return default settings on error
|
||||
return const UserSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// Server Banners provider
|
||||
final serverBannersProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
return await api.getBanners();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching banners: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Conversation Suggestions provider
|
||||
final conversationSuggestionsProvider = FutureProvider<List<String>>((
|
||||
ref,
|
||||
) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
return await api.getSuggestions();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching suggestions: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Folders provider
|
||||
final foldersProvider = FutureProvider<List<Folder>>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final foldersData = await api.getFolders();
|
||||
return foldersData
|
||||
.map((folderData) => Folder.fromJson(folderData))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching folders: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Files provider
|
||||
final userFilesProvider = FutureProvider<List<FileInfo>>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final filesData = await api.getUserFiles();
|
||||
return filesData.map((fileData) => FileInfo.fromJson(fileData)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching files: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// File content provider
|
||||
final fileContentProvider = FutureProvider.family<String, String>((
|
||||
ref,
|
||||
fileId,
|
||||
) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) throw Exception('No API service available');
|
||||
|
||||
try {
|
||||
return await api.getFileContent(fileId);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching file content: $e');
|
||||
throw Exception('Failed to load file content: $e');
|
||||
}
|
||||
});
|
||||
|
||||
// Knowledge Base providers
|
||||
final knowledgeBasesProvider = FutureProvider<List<KnowledgeBase>>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final kbData = await api.getKnowledgeBases();
|
||||
return kbData.map((data) => KnowledgeBase.fromJson(data)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching knowledge bases: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
final knowledgeBaseItemsProvider =
|
||||
FutureProvider.family<List<KnowledgeBaseItem>, String>((ref, kbId) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final itemsData = await api.getKnowledgeBaseItems(kbId);
|
||||
return itemsData
|
||||
.map((data) => KnowledgeBaseItem.fromJson(data))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching knowledge base items: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Audio providers
|
||||
final availableVoicesProvider = FutureProvider<List<String>>((ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
return await api.getAvailableVoices();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching voices: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Image Generation providers
|
||||
final imageModelsProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
return await api.getImageModels();
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error fetching image models: $e');
|
||||
return [];
|
||||
}
|
||||
});
|
||||
278
lib/core/services/animation_service.dart
Normal file
278
lib/core/services/animation_service.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Service for managing animations with performance optimization and accessibility
|
||||
class AnimationService {
|
||||
/// Get optimized animation duration based on context and settings
|
||||
static Duration getOptimizedDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration, {
|
||||
bool respectReducedMotion = true,
|
||||
}) {
|
||||
if (respectReducedMotion && MediaQuery.of(context).disableAnimations) {
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
// Optimize for 60fps - keep animations under 300ms for snappy feel
|
||||
final optimizedDuration = Duration(
|
||||
milliseconds: (defaultDuration.inMilliseconds * 0.8).round().clamp(
|
||||
100,
|
||||
300,
|
||||
),
|
||||
);
|
||||
|
||||
return optimizedDuration;
|
||||
}
|
||||
|
||||
/// Get optimized curve for smooth 60fps animations
|
||||
static Curve getOptimizedCurve({Curve defaultCurve = Curves.easeInOut}) {
|
||||
// Use curves that are optimized for mobile performance
|
||||
final curveType = defaultCurve.runtimeType.toString();
|
||||
|
||||
// Replace performance-heavy curves with lighter alternatives
|
||||
if (curveType.contains('Bounce')) {
|
||||
return Curves.easeInOutQuart; // Replace heavy bounce with smooth curve
|
||||
} else if (curveType.contains('Elastic')) {
|
||||
return Curves.easeInOutBack; // Lighter alternative to elastic
|
||||
} else if (defaultCurve == Curves.easeInOut) {
|
||||
return Curves.easeInOutCubic; // Better performance than default
|
||||
}
|
||||
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
/// Create performant fade transition
|
||||
static Widget createOptimizedFadeTransition({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create performant slide transition
|
||||
static Widget createOptimizedSlideTransition({
|
||||
required Widget child,
|
||||
required Animation<Offset> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return SlideTransition(position: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create performant scale transition
|
||||
static Widget createOptimizedScaleTransition({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create optimized page transition
|
||||
static PageRouteBuilder createOptimizedPageRoute({
|
||||
required Widget page,
|
||||
Duration? transitionDuration,
|
||||
PageTransitionType type = PageTransitionType.slide,
|
||||
}) {
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration:
|
||||
transitionDuration ?? const Duration(milliseconds: 250),
|
||||
reverseTransitionDuration:
|
||||
transitionDuration ?? const Duration(milliseconds: 200),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final optimizedCurve = getOptimizedCurve();
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: optimizedCurve,
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case PageTransitionType.fade:
|
||||
return FadeTransition(opacity: curvedAnimation, child: child);
|
||||
case PageTransitionType.slide:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
case PageTransitionType.scale:
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(curvedAnimation),
|
||||
child: FadeTransition(opacity: curvedAnimation, child: child),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create staggered animation for lists
|
||||
static Widget createStaggeredListAnimation({
|
||||
required Widget child,
|
||||
required int index,
|
||||
Duration? delay,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: duration ?? const Duration(milliseconds: 200),
|
||||
curve: getOptimizedCurve(),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(opacity: value, child: child),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create performant shimmer animation
|
||||
static Widget createOptimizedShimmer({
|
||||
required Widget child,
|
||||
Duration? duration,
|
||||
Color? baseColor,
|
||||
Color? highlightColor,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: duration ?? const Duration(milliseconds: 1500),
|
||||
curve: Curves.linear,
|
||||
builder: (context, value, child) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor ?? context.conduitTheme.shimmerBase,
|
||||
highlightColor ?? context.conduitTheme.shimmerHighlight,
|
||||
baseColor ?? context.conduitTheme.shimmerBase,
|
||||
],
|
||||
stops: [0.0, value, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create optimized rotation animation
|
||||
static Widget createOptimizedRotation({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
double turns = 1.0,
|
||||
}) {
|
||||
return RotationTransition(
|
||||
turns: Tween<double>(begin: 0, end: turns).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if device can handle complex animations
|
||||
static bool canHandleComplexAnimations(BuildContext context) {
|
||||
// Simple heuristic based on screen density and platform
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final totalPixels = screenSize.width * screenSize.height * devicePixelRatio;
|
||||
|
||||
// If total pixels exceed 4M, assume it's a high-end device
|
||||
return totalPixels > 4000000;
|
||||
}
|
||||
|
||||
/// Create adaptive animation based on device capability
|
||||
static Widget createAdaptiveAnimation({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
required Widget Function(Widget) complexAnimation,
|
||||
required Widget Function(Widget) simpleAnimation,
|
||||
}) {
|
||||
if (canHandleComplexAnimations(context) &&
|
||||
!MediaQuery.of(context).disableAnimations) {
|
||||
return complexAnimation(child);
|
||||
} else {
|
||||
return simpleAnimation(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum for page transition types
|
||||
enum PageTransitionType { fade, slide, scale }
|
||||
|
||||
/// Provider for reduced motion preference
|
||||
final reducedMotionProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
/// Provider for animation performance settings
|
||||
final animationPerformanceProvider = StateProvider<AnimationPerformance>((ref) {
|
||||
return AnimationPerformance.adaptive;
|
||||
});
|
||||
|
||||
/// Animation performance levels
|
||||
enum AnimationPerformance {
|
||||
high, // All animations enabled
|
||||
adaptive, // Adaptive based on device
|
||||
reduced, // Simplified animations
|
||||
minimal, // Essential animations only
|
||||
}
|
||||
|
||||
/// Provider for managing animation settings
|
||||
final animationSettingsProvider =
|
||||
StateNotifierProvider<AnimationSettingsNotifier, AnimationSettings>(
|
||||
(ref) => AnimationSettingsNotifier(),
|
||||
);
|
||||
|
||||
class AnimationSettings {
|
||||
final bool reduceMotion;
|
||||
final AnimationPerformance performance;
|
||||
final double animationSpeed;
|
||||
|
||||
const AnimationSettings({
|
||||
this.reduceMotion = false,
|
||||
this.performance = AnimationPerformance.adaptive,
|
||||
this.animationSpeed = 1.0,
|
||||
});
|
||||
|
||||
AnimationSettings copyWith({
|
||||
bool? reduceMotion,
|
||||
AnimationPerformance? performance,
|
||||
double? animationSpeed,
|
||||
}) {
|
||||
return AnimationSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
performance: performance ?? this.performance,
|
||||
animationSpeed: animationSpeed ?? this.animationSpeed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimationSettingsNotifier extends StateNotifier<AnimationSettings> {
|
||||
AnimationSettingsNotifier() : super(const AnimationSettings());
|
||||
|
||||
void setReduceMotion(bool reduce) {
|
||||
state = state.copyWith(reduceMotion: reduce);
|
||||
}
|
||||
|
||||
void setPerformance(AnimationPerformance performance) {
|
||||
state = state.copyWith(performance: performance);
|
||||
}
|
||||
|
||||
void setAnimationSpeed(double speed) {
|
||||
state = state.copyWith(animationSpeed: speed.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
Duration adjustDuration(Duration baseDuration) {
|
||||
if (state.reduceMotion) return Duration.zero;
|
||||
|
||||
final adjustedMs = (baseDuration.inMilliseconds / state.animationSpeed)
|
||||
.round();
|
||||
return Duration(milliseconds: adjustedMs);
|
||||
}
|
||||
}
|
||||
3522
lib/core/services/api_service.dart
Normal file
3522
lib/core/services/api_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
347
lib/core/services/attachment_upload_queue.dart
Normal file
347
lib/core/services/attachment_upload_queue.dart
Normal file
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Status of a queued attachment upload
|
||||
enum QueuedAttachmentStatus { pending, uploading, completed, failed, cancelled }
|
||||
|
||||
/// Metadata for a queued attachment
|
||||
class QueuedAttachment {
|
||||
final String id; // local queue id
|
||||
final String filePath;
|
||||
final String fileName;
|
||||
final int fileSize;
|
||||
final String? mimeType;
|
||||
final String? checksum;
|
||||
final DateTime enqueuedAt;
|
||||
|
||||
// Upload state
|
||||
int retryCount;
|
||||
DateTime? nextRetryAt;
|
||||
QueuedAttachmentStatus status;
|
||||
String? lastError;
|
||||
String? fileId; // server-side file id once uploaded
|
||||
|
||||
QueuedAttachment({
|
||||
required this.id,
|
||||
required this.filePath,
|
||||
required this.fileName,
|
||||
required this.fileSize,
|
||||
this.mimeType,
|
||||
this.checksum,
|
||||
DateTime? enqueuedAt,
|
||||
this.retryCount = 0,
|
||||
this.nextRetryAt,
|
||||
this.status = QueuedAttachmentStatus.pending,
|
||||
this.lastError,
|
||||
this.fileId,
|
||||
}) : enqueuedAt = enqueuedAt ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'filePath': filePath,
|
||||
'fileName': fileName,
|
||||
'fileSize': fileSize,
|
||||
'mimeType': mimeType,
|
||||
'checksum': checksum,
|
||||
'enqueuedAt': enqueuedAt.toIso8601String(),
|
||||
'retryCount': retryCount,
|
||||
'nextRetryAt': nextRetryAt?.toIso8601String(),
|
||||
'status': status.name,
|
||||
'lastError': lastError,
|
||||
'fileId': fileId,
|
||||
};
|
||||
|
||||
factory QueuedAttachment.fromJson(Map<String, dynamic> json) =>
|
||||
QueuedAttachment(
|
||||
id: json['id'] as String,
|
||||
filePath: json['filePath'] as String,
|
||||
fileName: json['fileName'] as String,
|
||||
fileSize: (json['fileSize'] as num).toInt(),
|
||||
mimeType: json['mimeType'] as String?,
|
||||
checksum: json['checksum'] as String?,
|
||||
enqueuedAt:
|
||||
DateTime.tryParse(json['enqueuedAt'] ?? '') ?? DateTime.now(),
|
||||
retryCount: (json['retryCount'] as num?)?.toInt() ?? 0,
|
||||
nextRetryAt: json['nextRetryAt'] != null
|
||||
? DateTime.tryParse(json['nextRetryAt'])
|
||||
: null,
|
||||
status: QueuedAttachmentStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
orElse: () => QueuedAttachmentStatus.pending,
|
||||
),
|
||||
lastError: json['lastError'] as String?,
|
||||
fileId: json['fileId'] as String?,
|
||||
);
|
||||
|
||||
QueuedAttachment copyWith({
|
||||
int? retryCount,
|
||||
DateTime? nextRetryAt,
|
||||
QueuedAttachmentStatus? status,
|
||||
String? lastError,
|
||||
String? fileId,
|
||||
}) => QueuedAttachment(
|
||||
id: id,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
mimeType: mimeType,
|
||||
checksum: checksum,
|
||||
enqueuedAt: enqueuedAt,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
|
||||
status: status ?? this.status,
|
||||
lastError: lastError ?? this.lastError,
|
||||
fileId: fileId ?? this.fileId,
|
||||
);
|
||||
}
|
||||
|
||||
typedef UploadCallback =
|
||||
Future<String> Function(String filePath, String fileName);
|
||||
typedef AttachmentsEventCallback = void Function(List<QueuedAttachment> queue);
|
||||
|
||||
/// A lightweight background queue to upload attachments when back online.
|
||||
class AttachmentUploadQueue {
|
||||
static final AttachmentUploadQueue _instance =
|
||||
AttachmentUploadQueue._internal();
|
||||
factory AttachmentUploadQueue() => _instance;
|
||||
AttachmentUploadQueue._internal();
|
||||
|
||||
static const String _prefsKey = 'attachment_upload_queue';
|
||||
static const int _maxRetries = 4;
|
||||
static const Duration _baseRetryDelay = Duration(seconds: 5);
|
||||
static const Duration _maxRetryDelay = Duration(minutes: 5);
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final List<QueuedAttachment> _queue = [];
|
||||
Timer? _retryTimer;
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Dependencies
|
||||
UploadCallback? _onUpload;
|
||||
AttachmentsEventCallback? _onQueueChanged;
|
||||
|
||||
// Streams
|
||||
final _queueController = StreamController<List<QueuedAttachment>>.broadcast();
|
||||
Stream<List<QueuedAttachment>> get queueStream => _queueController.stream;
|
||||
|
||||
List<QueuedAttachment> get queue => List.unmodifiable(_queue);
|
||||
|
||||
Future<void> initialize({
|
||||
required UploadCallback onUpload,
|
||||
AttachmentsEventCallback? onQueueChanged,
|
||||
}) async {
|
||||
_onUpload = onUpload;
|
||||
_onQueueChanged = onQueueChanged;
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
await _load();
|
||||
_startPeriodicProcessing();
|
||||
debugPrint(
|
||||
'DEBUG: AttachmentUploadQueue initialized with ${_queue.length} items',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> enqueue({
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
required int fileSize,
|
||||
String? mimeType,
|
||||
String? checksum,
|
||||
}) async {
|
||||
final id = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final item = QueuedAttachment(
|
||||
id: id,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
mimeType: mimeType,
|
||||
checksum: checksum,
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
);
|
||||
_queue.add(item);
|
||||
await _save();
|
||||
_notify();
|
||||
_processSafe();
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> processQueue() async {
|
||||
if (_isProcessing) return;
|
||||
if (_onUpload == null) return;
|
||||
|
||||
_isProcessing = true;
|
||||
try {
|
||||
// Quick network probe using Dio HEAD to common health path if possible
|
||||
final dio = Dio();
|
||||
try {
|
||||
await dio.head('/api/health').timeout(const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
// Best effort; continue and let upload fail if actually offline
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final pending = _queue.where(
|
||||
(e) =>
|
||||
(e.status == QueuedAttachmentStatus.pending ||
|
||||
e.status == QueuedAttachmentStatus.failed) &&
|
||||
(e.nextRetryAt == null || now.isAfter(e.nextRetryAt!)),
|
||||
);
|
||||
|
||||
for (final item in List<QueuedAttachment>.from(pending)) {
|
||||
await _processSingle(item);
|
||||
}
|
||||
} finally {
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processSingle(QueuedAttachment item) async {
|
||||
if (_onUpload == null) return;
|
||||
try {
|
||||
_update(item.id, item.copyWith(status: QueuedAttachmentStatus.uploading));
|
||||
|
||||
final fileId = await _onUpload!.call(item.filePath, item.fileName);
|
||||
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.completed,
|
||||
fileId: fileId,
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
),
|
||||
);
|
||||
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'DEBUG: Attachment ${item.id} uploaded successfully (fileId=$fileId)',
|
||||
);
|
||||
} catch (e) {
|
||||
final retries = item.retryCount + 1;
|
||||
if (retries >= _maxRetries) {
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.failed,
|
||||
retryCount: retries,
|
||||
lastError: e.toString(),
|
||||
),
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'WARNING: Attachment ${item.id} failed after $_maxRetries attempts',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final delay = _retryDelayWithJitter(retries);
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
retryCount: retries,
|
||||
nextRetryAt: DateTime.now().add(delay),
|
||||
lastError: e.toString(),
|
||||
),
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'DEBUG: Scheduled retry for attachment ${item.id} in ${delay.inSeconds}s',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Duration _retryDelayWithJitter(int retryCount) {
|
||||
final base = _baseRetryDelay.inMilliseconds;
|
||||
final exp = min(
|
||||
base * pow(2, retryCount - 1),
|
||||
_maxRetryDelay.inMilliseconds.toDouble(),
|
||||
).toInt();
|
||||
final jitter = Random().nextInt(1000); // up to 1s jitter
|
||||
return Duration(milliseconds: exp + jitter);
|
||||
}
|
||||
|
||||
void _startPeriodicProcessing() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer.periodic(
|
||||
const Duration(seconds: 10),
|
||||
(_) => _processSafe(),
|
||||
);
|
||||
// Also kick once after a short delay
|
||||
Timer(const Duration(milliseconds: 500), _processSafe);
|
||||
}
|
||||
|
||||
void _processSafe() {
|
||||
// Fire and forget
|
||||
unawaited(processQueue());
|
||||
}
|
||||
|
||||
void _update(String id, QueuedAttachment updated) {
|
||||
final idx = _queue.indexWhere((e) => e.id == id);
|
||||
if (idx != -1) {
|
||||
_queue[idx] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
_queue.removeWhere((e) => e.id == id);
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> retry(String id) async {
|
||||
final idx = _queue.indexWhere((e) => e.id == id);
|
||||
if (idx == -1) return;
|
||||
_queue[idx] = _queue[idx].copyWith(
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
_processSafe();
|
||||
}
|
||||
|
||||
Future<void> clearFailed() async {
|
||||
_queue.removeWhere((e) => e.status == QueuedAttachmentStatus.failed);
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_queue.clear();
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
// Utilities
|
||||
Future<void> _load() async {
|
||||
final jsonStr = (_prefs ?? await SharedPreferences.getInstance()).getString(
|
||||
_prefsKey,
|
||||
);
|
||||
if (jsonStr == null || jsonStr.isEmpty) return;
|
||||
final list = (jsonDecode(jsonStr) as List).cast<Map<String, dynamic>>();
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map(QueuedAttachment.fromJson));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final prefs = _prefs ?? await SharedPreferences.getInstance();
|
||||
final list = _queue.map((e) => e.toJson()).toList(growable: false);
|
||||
await prefs.setString(_prefsKey, jsonEncode(list));
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
_onQueueChanged?.call(queue);
|
||||
_queueController.add(queue);
|
||||
}
|
||||
}
|
||||
118
lib/core/services/connectivity_service.dart
Normal file
118
lib/core/services/connectivity_service.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../providers/app_providers.dart';
|
||||
|
||||
enum ConnectivityStatus { online, offline, checking }
|
||||
|
||||
class ConnectivityService {
|
||||
final Dio _dio;
|
||||
Timer? _connectivityTimer;
|
||||
final _connectivityController =
|
||||
StreamController<ConnectivityStatus>.broadcast();
|
||||
ConnectivityStatus _lastStatus = ConnectivityStatus.checking;
|
||||
|
||||
ConnectivityService(this._dio) {
|
||||
_startConnectivityMonitoring();
|
||||
}
|
||||
|
||||
Stream<ConnectivityStatus> get connectivityStream =>
|
||||
_connectivityController.stream;
|
||||
ConnectivityStatus get currentStatus => _lastStatus;
|
||||
|
||||
void _startConnectivityMonitoring() {
|
||||
// Initial check after a brief delay to avoid showing offline during startup
|
||||
Timer(const Duration(milliseconds: 1000), () {
|
||||
_checkConnectivity();
|
||||
});
|
||||
|
||||
// Check every 5 seconds
|
||||
_connectivityTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
_checkConnectivity();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
try {
|
||||
// DNS lookup is a lightweight, permission-free reachability check
|
||||
final result = await InternetAddress.lookup(
|
||||
'google.com',
|
||||
).timeout(const Duration(seconds: 3));
|
||||
|
||||
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
|
||||
_updateStatus(ConnectivityStatus.online);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Swallow and continue to HTTP reachability check
|
||||
}
|
||||
|
||||
// As a secondary check, hit a public 204 endpoint that returns quickly
|
||||
try {
|
||||
await _dio
|
||||
.get(
|
||||
'https://www.google.com/generate_204',
|
||||
options: Options(
|
||||
method: 'GET',
|
||||
sendTimeout: const Duration(seconds: 3),
|
||||
receiveTimeout: const Duration(seconds: 3),
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status != null && status < 400,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
_updateStatus(ConnectivityStatus.online);
|
||||
} catch (_) {
|
||||
_updateStatus(ConnectivityStatus.offline);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(ConnectivityStatus status) {
|
||||
if (_lastStatus != status) {
|
||||
_lastStatus = status;
|
||||
_connectivityController.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> checkConnectivity() async {
|
||||
await _checkConnectivity();
|
||||
return _lastStatus == ConnectivityStatus.online;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connectivityTimer?.cancel();
|
||||
_connectivityController.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Providers
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
final dio = ref.watch(dioProvider);
|
||||
final service = ConnectivityService(dio);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
final connectivityStatusProvider = StreamProvider<ConnectivityStatus>((ref) {
|
||||
final service = ref.watch(connectivityServiceProvider);
|
||||
return service.connectivityStream;
|
||||
});
|
||||
|
||||
final isOnlineProvider = Provider<bool>((ref) {
|
||||
// In reviewer mode, treat app as online to enable flows
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) return true;
|
||||
final status = ref.watch(connectivityStatusProvider);
|
||||
return status.when(
|
||||
data: (status) => status == ConnectivityStatus.online,
|
||||
loading: () => true, // Assume online while checking
|
||||
error: (_, _) =>
|
||||
true, // Assume online on error to avoid false offline states
|
||||
);
|
||||
});
|
||||
|
||||
// Dio provider (if not already defined elsewhere)
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
return Dio(); // This should be configured with your base URL
|
||||
});
|
||||
67
lib/core/services/deep_link_service.dart
Normal file
67
lib/core/services/deep_link_service.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/files/views/files_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
|
||||
/// Service for handling deep links and navigation routing
|
||||
class DeepLinkService {
|
||||
/// Route to chat tab
|
||||
static void navigateToChat(BuildContext context) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// In single-screen mode, files/profile deep links route via navigator
|
||||
static void navigateToFiles(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const FilesPage()),
|
||||
);
|
||||
}
|
||||
|
||||
static void navigateToProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ProfilePage()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse route and determine target tab
|
||||
static String? parsePath(String route) {
|
||||
switch (route) {
|
||||
case '/chat':
|
||||
case '/main/chat':
|
||||
return '/chat';
|
||||
case '/files':
|
||||
case '/main/files':
|
||||
return '/files';
|
||||
case '/profile':
|
||||
case '/main/profile':
|
||||
return '/profile';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link navigation
|
||||
static Widget handleDeepLink(String route) {
|
||||
final path = parsePath(route);
|
||||
switch (path) {
|
||||
case '/files':
|
||||
return const FilesPage();
|
||||
case '/profile':
|
||||
return const ProfilePage();
|
||||
case '/chat':
|
||||
default:
|
||||
return const ChatPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for deep link navigation
|
||||
final deepLinkProvider = Provider<DeepLinkService>((ref) => DeepLinkService());
|
||||
396
lib/core/services/enhanced_accessibility_service.dart
Normal file
396
lib/core/services/enhanced_accessibility_service.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced accessibility service for WCAG 2.2 AA compliance
|
||||
class EnhancedAccessibilityService {
|
||||
/// Announce text to screen readers
|
||||
static void announce(
|
||||
String message, {
|
||||
TextDirection textDirection = TextDirection.ltr,
|
||||
}) {
|
||||
SemanticsService.announce(message, textDirection);
|
||||
}
|
||||
|
||||
/// Announce loading state
|
||||
static void announceLoading(String loadingMessage) {
|
||||
announce('Loading: $loadingMessage');
|
||||
}
|
||||
|
||||
/// Announce error with helpful context
|
||||
static void announceError(String error, {String? suggestion}) {
|
||||
final message = suggestion != null
|
||||
? 'Error: $error. $suggestion'
|
||||
: 'Error: $error';
|
||||
announce(message);
|
||||
}
|
||||
|
||||
/// Announce success with context
|
||||
static void announceSuccess(String successMessage) {
|
||||
announce('Success: $successMessage');
|
||||
}
|
||||
|
||||
/// Check if reduce motion is enabled
|
||||
static bool shouldReduceMotion(BuildContext context) {
|
||||
return MediaQuery.of(context).disableAnimations;
|
||||
}
|
||||
|
||||
/// Get appropriate animation duration based on motion settings
|
||||
static Duration getAnimationDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration,
|
||||
) {
|
||||
return shouldReduceMotion(context) ? Duration.zero : defaultDuration;
|
||||
}
|
||||
|
||||
/// Get text scale factor with bounds for accessibility
|
||||
static double getBoundedTextScaleFactor(BuildContext context) {
|
||||
final textScaler = MediaQuery.of(context).textScaler;
|
||||
final textScaleFactor = textScaler.scale(1.0);
|
||||
// Ensure text doesn't get too small or too large
|
||||
return textScaleFactor.clamp(0.8, 3.0);
|
||||
}
|
||||
|
||||
/// Create accessible button with proper semantics
|
||||
static Widget createAccessibleButton({
|
||||
required Widget child,
|
||||
required VoidCallback? onPressed,
|
||||
required String semanticLabel,
|
||||
String? semanticHint,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: true,
|
||||
enabled: onPressed != null,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(44, 44), // WCAG minimum touch target
|
||||
backgroundColor: isDestructive ? context.conduitTheme.error : null,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible icon button with proper semantics
|
||||
static Widget createAccessibleIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback? onPressed,
|
||||
required String semanticLabel,
|
||||
String? semanticHint,
|
||||
Color? iconColor,
|
||||
double iconSize = 24,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: true,
|
||||
enabled: onPressed != null,
|
||||
child: SizedBox(
|
||||
width: 44, // Minimum touch target
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize, color: iconColor),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible text field with proper labels
|
||||
static Widget createAccessibleTextField({
|
||||
required String label,
|
||||
TextEditingController? controller,
|
||||
String? hintText,
|
||||
String? errorText,
|
||||
bool isRequired = false,
|
||||
TextInputType? keyboardType,
|
||||
bool obscureText = false,
|
||||
ValueChanged<String>? onChanged,
|
||||
}) {
|
||||
final effectiveLabel = isRequired ? '$label *' : label;
|
||||
|
||||
return Semantics(
|
||||
label: effectiveLabel,
|
||||
hint: hintText,
|
||||
textField: true,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: effectiveLabel,
|
||||
hintText: hintText,
|
||||
errorText: errorText,
|
||||
helperText: isRequired ? '* Required field' : null,
|
||||
prefixIcon: errorText != null
|
||||
? Builder(
|
||||
builder: (context) => Icon(
|
||||
Icons.error_outline,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible card with proper semantics
|
||||
static Widget createAccessibleCard({
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
String? semanticLabel,
|
||||
String? semanticHint,
|
||||
bool isSelected = false,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: onTap != null,
|
||||
selected: isSelected,
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible loading indicator
|
||||
static Widget createAccessibleLoadingIndicator({
|
||||
String? loadingMessage,
|
||||
double size = 24,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: loadingMessage ?? 'Loading',
|
||||
liveRegion: true,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible image with alt text
|
||||
static Widget createAccessibleImage({
|
||||
required ImageProvider image,
|
||||
required String altText,
|
||||
bool isDecorative = false,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit fit = BoxFit.cover,
|
||||
}) {
|
||||
if (isDecorative) {
|
||||
return Semantics(
|
||||
excludeSemantics: true,
|
||||
child: Image(image: image, width: width, height: height, fit: fit),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
label: altText,
|
||||
image: true,
|
||||
child: Image(image: image, width: width, height: height, fit: fit),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible toggle switch
|
||||
static Widget createAccessibleSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
required String label,
|
||||
String? description,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => Semantics(
|
||||
label: label,
|
||||
value: value ? 'On' : 'Off',
|
||||
hint: description,
|
||||
toggled: value,
|
||||
onTap: onChanged != null ? () => onChanged(!value) : null,
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
subtitle: description != null
|
||||
? Text(
|
||||
description,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
)
|
||||
: null,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible slider
|
||||
static Widget createAccessibleSlider({
|
||||
required double value,
|
||||
required ValueChanged<double>? onChanged,
|
||||
required String label,
|
||||
double min = 0.0,
|
||||
double max = 1.0,
|
||||
int? divisions,
|
||||
String Function(double)? valueFormatter,
|
||||
}) {
|
||||
final formattedValue =
|
||||
valueFormatter?.call(value) ?? value.toStringAsFixed(1);
|
||||
|
||||
return Semantics(
|
||||
label: label,
|
||||
value: formattedValue,
|
||||
increasedValue:
|
||||
valueFormatter?.call((value + 0.1).clamp(min, max)) ??
|
||||
(value + 0.1).clamp(min, max).toStringAsFixed(1),
|
||||
decreasedValue:
|
||||
valueFormatter?.call((value - 0.1).clamp(min, max)) ??
|
||||
(value - 0.1).clamp(min, max).toStringAsFixed(1),
|
||||
onIncrease: onChanged != null
|
||||
? () => onChanged((value + 0.1).clamp(min, max))
|
||||
: null,
|
||||
onDecrease: onChanged != null
|
||||
? () => onChanged((value - 0.1).clamp(min, max))
|
||||
: null,
|
||||
child: Slider(
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
onChanged: onChanged,
|
||||
label: formattedValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible modal with focus management
|
||||
static Future<T?> showAccessibleModal<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
required String title,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (context) => Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
label: 'Dialog: $title',
|
||||
child: AlertDialog(
|
||||
title: Semantics(header: true, child: Text(title)),
|
||||
content: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check color contrast ratio (simplified implementation)
|
||||
static bool hasGoodContrast(Color foreground, Color background) {
|
||||
// Simplified contrast calculation
|
||||
final fgLuminance = _getLuminance(foreground);
|
||||
final bgLuminance = _getLuminance(background);
|
||||
|
||||
final lighter = fgLuminance > bgLuminance ? fgLuminance : bgLuminance;
|
||||
final darker = fgLuminance > bgLuminance ? bgLuminance : fgLuminance;
|
||||
|
||||
final contrast = (lighter + 0.05) / (darker + 0.05);
|
||||
|
||||
// WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
||||
return contrast >= 4.5;
|
||||
}
|
||||
|
||||
/// Calculate relative luminance of a color
|
||||
static double _getLuminance(Color color) {
|
||||
final r = _gammaCorrect((color.r * 255.0).round() / 255.0);
|
||||
final g = _gammaCorrect((color.g * 255.0).round() / 255.0);
|
||||
final b = _gammaCorrect((color.b * 255.0).round() / 255.0);
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/// Apply gamma correction
|
||||
static double _gammaCorrect(double value) {
|
||||
return value <= 0.03928
|
||||
? value / 12.92
|
||||
: math.pow((value + 0.055) / 1.055, 2.4).toDouble();
|
||||
}
|
||||
|
||||
/// Provide haptic feedback if available
|
||||
static void hapticFeedback() {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
/// Create accessible focus border
|
||||
static BoxDecoration createFocusBorder({
|
||||
required bool hasFocus,
|
||||
Color? focusColor,
|
||||
double borderWidth = 2.0,
|
||||
BorderRadius? borderRadius,
|
||||
}) {
|
||||
return BoxDecoration(
|
||||
border: hasFocus
|
||||
? Border.all(
|
||||
color:
|
||||
focusColor ??
|
||||
AppTheme.brandPrimary, // Brand primary as fallback
|
||||
width: borderWidth,
|
||||
)
|
||||
: null,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible text with proper scaling
|
||||
static Widget createAccessibleText(
|
||||
String text, {
|
||||
TextStyle? style,
|
||||
TextAlign? textAlign,
|
||||
bool isHeader = false,
|
||||
int? maxLines,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final textScaleFactor = getBoundedTextScaleFactor(context);
|
||||
|
||||
Widget textWidget = Text(
|
||||
text,
|
||||
style:
|
||||
style?.copyWith(
|
||||
fontSize: style.fontSize != null
|
||||
? style.fontSize! * textScaleFactor
|
||||
: null,
|
||||
) ??
|
||||
TextStyle(fontSize: AppTypography.bodyLarge * textScaleFactor),
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: maxLines != null ? TextOverflow.ellipsis : null,
|
||||
);
|
||||
|
||||
if (isHeader) {
|
||||
textWidget = Semantics(header: true, child: textWidget);
|
||||
}
|
||||
|
||||
return textWidget;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
241
lib/core/services/error_handling_service.dart
Normal file
241
lib/core/services/error_handling_service.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
import '../../shared/widgets/themed_dialogs.dart';
|
||||
import 'user_friendly_error_handler.dart';
|
||||
|
||||
class ErrorHandlingService {
|
||||
static final _userFriendlyHandler = UserFriendlyErrorHandler();
|
||||
|
||||
static String getErrorMessage(dynamic error) {
|
||||
// Use the enhanced user-friendly error handler
|
||||
return _userFriendlyHandler.getUserMessage(error);
|
||||
}
|
||||
|
||||
/// Get recovery actions for an error
|
||||
static List<ErrorRecoveryAction> getRecoveryActions(dynamic error) {
|
||||
return _userFriendlyHandler.getRecoveryActions(error);
|
||||
}
|
||||
|
||||
static void showErrorSnackBar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
String? customMessage,
|
||||
}) {
|
||||
if (customMessage != null) {
|
||||
// Use custom message if provided
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(customMessage),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Retry',
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Use enhanced error handler
|
||||
_userFriendlyHandler.showErrorSnackbar(context, error, onRetry: onRetry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show enhanced error dialog with recovery options
|
||||
static Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showDetails = false,
|
||||
}) async {
|
||||
return _userFriendlyHandler.showErrorDialog(
|
||||
context,
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static void showSuccessSnackBar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: duration ?? const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) async {
|
||||
return await ThemedDialogs.confirm(
|
||||
context,
|
||||
title: title,
|
||||
message: content,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDestructive: isDestructive,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildErrorWidget({
|
||||
required String message,
|
||||
VoidCallback? onRetry,
|
||||
IconData? icon,
|
||||
dynamic error,
|
||||
}) {
|
||||
if (error != null) {
|
||||
// Use enhanced error handler for full error objects
|
||||
return _userFriendlyHandler.buildErrorWidget(error, onRetry: onRetry);
|
||||
}
|
||||
|
||||
// Fallback to legacy implementation for string messages
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build enhanced error widget with recovery actions
|
||||
static Widget buildEnhancedErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showDetails = false,
|
||||
}) {
|
||||
return _userFriendlyHandler.buildErrorWidget(
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
onDismiss: onDismiss,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildLoadingWidget({String? message}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: theme.colorScheme.primary),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildEmptyStateWidget({
|
||||
required String title,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
Widget? action,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.inbox_outlined,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
action,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
373
lib/core/services/error_recovery_service.dart
Normal file
373
lib/core/services/error_recovery_service.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced error recovery service with retry strategies and user feedback
|
||||
class ErrorRecoveryService {
|
||||
final Map<String, RetryConfig> _retryConfigs = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
|
||||
ErrorRecoveryService(Dio dio);
|
||||
|
||||
/// Execute an operation with automatic retry and recovery
|
||||
Future<T> executeWithRecovery<T>({
|
||||
required String operationId,
|
||||
required Future<T> Function() operation,
|
||||
RetryConfig? retryConfig,
|
||||
RecoveryAction? recoveryAction,
|
||||
}) async {
|
||||
final config = retryConfig ?? RetryConfig.defaultConfig();
|
||||
_retryConfigs[operationId] = config;
|
||||
|
||||
int attempts = 0;
|
||||
Exception? lastError;
|
||||
|
||||
while (attempts < config.maxRetries) {
|
||||
try {
|
||||
final result = await operation();
|
||||
_clearRetryState(operationId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
lastError = error is Exception ? error : Exception(error.toString());
|
||||
|
||||
final shouldRetry = _shouldRetry(error, attempts, config);
|
||||
if (!shouldRetry || attempts >= config.maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute recovery action if provided
|
||||
if (recoveryAction != null) {
|
||||
try {
|
||||
await recoveryAction.execute(error, attempts);
|
||||
} catch (recoveryError) {
|
||||
// Recovery action failed, continue with retry
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
final delay = _calculateRetryDelay(attempts, config);
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
_clearRetryState(operationId);
|
||||
throw ErrorRecoveryException(lastError!, attempts);
|
||||
}
|
||||
|
||||
/// Check if we should retry based on error type and configuration
|
||||
bool _shouldRetry(dynamic error, int attempts, RetryConfig config) {
|
||||
if (attempts >= config.maxRetries) return false;
|
||||
|
||||
// Check cooldown period
|
||||
final lastRetry = _lastRetryTimes[config.operationId];
|
||||
if (lastRetry != null) {
|
||||
final timeSinceLastRetry = DateTime.now().difference(lastRetry);
|
||||
if (timeSinceLastRetry < config.cooldownPeriod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Network errors are usually retryable
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
// Retry on server errors (5xx) but not client errors (4xx)
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom retry conditions
|
||||
return config.retryCondition?.call(error) ?? false;
|
||||
}
|
||||
|
||||
Duration _calculateRetryDelay(int attempt, RetryConfig config) {
|
||||
if (config.retryStrategy == RetryStrategy.exponentialBackoff) {
|
||||
final baseDelay = config.baseDelay.inMilliseconds;
|
||||
final delay = baseDelay * pow(2, attempt - 1);
|
||||
final jitter = Random().nextDouble() * 0.1 * delay; // Add 10% jitter
|
||||
return Duration(milliseconds: (delay + jitter).round());
|
||||
} else {
|
||||
return config.baseDelay;
|
||||
}
|
||||
}
|
||||
|
||||
void _clearRetryState(String operationId) {
|
||||
_retryConfigs.remove(operationId);
|
||||
_lastRetryTimes.remove(operationId);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String getErrorMessage(dynamic error) {
|
||||
if (error is ErrorRecoveryException) {
|
||||
return _getRecoveryErrorMessage(error);
|
||||
}
|
||||
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return 'The connection is taking too long. Please check your internet and try again.';
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Failed to send your request. Please try again.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The server is taking too long to respond. Please try again.';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Unable to connect. Please check your internet connection.';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
} else if (statusCode == 403) {
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
} else if (statusCode == 404) {
|
||||
return 'The requested resource was not found.';
|
||||
} else if (statusCode != null && statusCode >= 500) {
|
||||
return 'The server is experiencing issues. Please try again later.';
|
||||
}
|
||||
return 'Something went wrong with your request.';
|
||||
case DioExceptionType.cancel:
|
||||
return 'The request was cancelled.';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'There\'s a security issue with the connection.';
|
||||
case DioExceptionType.unknown:
|
||||
return 'Something unexpected happened. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
String _getRecoveryErrorMessage(ErrorRecoveryException error) {
|
||||
final attempts = error.attempts;
|
||||
final originalError = getErrorMessage(error.originalError);
|
||||
|
||||
return 'Failed after $attempts attempts: $originalError';
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for retry behavior
|
||||
class RetryConfig {
|
||||
final String operationId;
|
||||
final int maxRetries;
|
||||
final Duration baseDelay;
|
||||
final Duration cooldownPeriod;
|
||||
final RetryStrategy retryStrategy;
|
||||
final bool Function(dynamic error)? retryCondition;
|
||||
|
||||
const RetryConfig({
|
||||
required this.operationId,
|
||||
this.maxRetries = 3,
|
||||
this.baseDelay = const Duration(seconds: 1),
|
||||
this.cooldownPeriod = const Duration(seconds: 5),
|
||||
this.retryStrategy = RetryStrategy.exponentialBackoff,
|
||||
this.retryCondition,
|
||||
});
|
||||
|
||||
static RetryConfig defaultConfig() => const RetryConfig(
|
||||
operationId: 'default',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 1),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig networkConfig() => const RetryConfig(
|
||||
operationId: 'network',
|
||||
maxRetries: 5,
|
||||
baseDelay: Duration(milliseconds: 500),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig chatConfig() => const RetryConfig(
|
||||
operationId: 'chat',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 2),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
}
|
||||
|
||||
enum RetryStrategy { fixed, exponentialBackoff }
|
||||
|
||||
/// Recovery action to execute between retries
|
||||
abstract class RecoveryAction {
|
||||
Future<void> execute(dynamic error, int attempt);
|
||||
}
|
||||
|
||||
/// Reconnect to server recovery action
|
||||
class ReconnectAction extends RecoveryAction {
|
||||
final Future<void> Function() reconnectFunction;
|
||||
|
||||
ReconnectAction(this.reconnectFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 1) {
|
||||
// Only try to reconnect on the first retry
|
||||
await reconnectFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh token recovery action
|
||||
class RefreshTokenAction extends RecoveryAction {
|
||||
final Future<void> Function() refreshFunction;
|
||||
|
||||
RefreshTokenAction(this.refreshFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (error is DioException && error.response?.statusCode == 401) {
|
||||
await refreshFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache recovery action
|
||||
class ClearCacheAction extends RecoveryAction {
|
||||
final Future<void> Function() clearCacheFunction;
|
||||
|
||||
ClearCacheAction(this.clearCacheFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 2) {
|
||||
// Clear cache on second attempt
|
||||
await clearCacheFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error recovery exception
|
||||
class ErrorRecoveryException implements Exception {
|
||||
final Exception originalError;
|
||||
final int attempts;
|
||||
|
||||
const ErrorRecoveryException(this.originalError, this.attempts);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ErrorRecoveryException: $originalError (after $attempts attempts)';
|
||||
}
|
||||
|
||||
/// Providers
|
||||
final errorRecoveryServiceProvider = Provider<ErrorRecoveryService>((ref) {
|
||||
// This should use the same Dio instance as the API service
|
||||
final dio = Dio(); // Replace with actual Dio provider
|
||||
return ErrorRecoveryService(dio);
|
||||
});
|
||||
|
||||
/// Error boundary widget for handling UI errors
|
||||
class ErrorBoundary extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Widget Function(Object error, VoidCallback retry)? errorBuilder;
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const ErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.errorBuilder,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorBoundary> createState() => _ErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _ErrorBoundaryState extends State<ErrorBoundary> {
|
||||
Object? error;
|
||||
StackTrace? stackTrace;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (error != null) {
|
||||
return widget.errorBuilder?.call(error!, _retry) ??
|
||||
_buildDefaultErrorWidget();
|
||||
}
|
||||
|
||||
return ErrorDetector(
|
||||
onError: (error, stackTrace) {
|
||||
setState(() {
|
||||
this.error = error;
|
||||
this.stackTrace = stackTrace;
|
||||
});
|
||||
widget.onError?.call(error, stackTrace);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultErrorWidget() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
ElevatedButton(onPressed: _retry, child: const Text('Try Again')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
error = null;
|
||||
stackTrace = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to detect and handle errors in child widgets
|
||||
class ErrorDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final void Function(Object error, StackTrace stackTrace) onError;
|
||||
|
||||
const ErrorDetector({super.key, required this.child, required this.onError});
|
||||
|
||||
@override
|
||||
State<ErrorDetector> createState() => _ErrorDetectorState();
|
||||
}
|
||||
|
||||
class _ErrorDetectorState extends State<ErrorDetector> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Set up error handling
|
||||
FlutterError.onError = (details) {
|
||||
widget.onError(details.exception, details.stack ?? StackTrace.current);
|
||||
};
|
||||
}
|
||||
}
|
||||
408
lib/core/services/focus_management_service.dart
Normal file
408
lib/core/services/focus_management_service.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
/// Comprehensive focus management service for accessibility
|
||||
class FocusManagementService {
|
||||
static final Map<String, FocusNode> _focusNodes = {};
|
||||
static final Map<String, FocusNode> _disposedNodes = {};
|
||||
static FocusNode? _lastFocusedNode;
|
||||
static final List<FocusNode> _focusHistory = [];
|
||||
|
||||
/// Register a focus node with a unique identifier
|
||||
static FocusNode registerFocusNode(
|
||||
String identifier, {
|
||||
String? debugLabel,
|
||||
FocusOnKeyEventCallback? onKeyEvent,
|
||||
bool skipTraversal = false,
|
||||
bool canRequestFocus = true,
|
||||
}) {
|
||||
// Check if node already exists
|
||||
if (_focusNodes.containsKey(identifier)) {
|
||||
return _focusNodes[identifier]!;
|
||||
}
|
||||
|
||||
// Create new focus node
|
||||
final focusNode = FocusNode(
|
||||
debugLabel: debugLabel ?? identifier,
|
||||
onKeyEvent: onKeyEvent,
|
||||
skipTraversal: skipTraversal,
|
||||
canRequestFocus: canRequestFocus,
|
||||
);
|
||||
|
||||
// Add listener to track focus changes
|
||||
focusNode.addListener(() {
|
||||
if (focusNode.hasFocus) {
|
||||
_onFocusChanged(focusNode);
|
||||
}
|
||||
});
|
||||
|
||||
_focusNodes[identifier] = focusNode;
|
||||
return focusNode;
|
||||
}
|
||||
|
||||
/// Get a registered focus node
|
||||
static FocusNode? getFocusNode(String identifier) {
|
||||
return _focusNodes[identifier];
|
||||
}
|
||||
|
||||
/// Dispose a focus node
|
||||
static void disposeFocusNode(String identifier) {
|
||||
final node = _focusNodes.remove(identifier);
|
||||
if (node != null) {
|
||||
_disposedNodes[identifier] = node;
|
||||
node.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all focus nodes
|
||||
static void disposeAll() {
|
||||
for (final node in _focusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_focusNodes.clear();
|
||||
_focusHistory.clear();
|
||||
_lastFocusedNode = null;
|
||||
}
|
||||
|
||||
/// Request focus for a specific node
|
||||
static void requestFocus(String identifier) {
|
||||
final node = _focusNodes[identifier];
|
||||
if (node != null && node.canRequestFocus) {
|
||||
node.requestFocus();
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unfocus current focus
|
||||
static void unfocus(
|
||||
BuildContext context, {
|
||||
UnfocusDisposition disposition = UnfocusDisposition.scope,
|
||||
}) {
|
||||
FocusScope.of(context).unfocus(disposition: disposition);
|
||||
}
|
||||
|
||||
/// Move focus to next focusable element
|
||||
static bool nextFocus(BuildContext context) {
|
||||
return FocusScope.of(context).nextFocus();
|
||||
}
|
||||
|
||||
/// Move focus to previous focusable element
|
||||
static bool previousFocus(BuildContext context) {
|
||||
return FocusScope.of(context).previousFocus();
|
||||
}
|
||||
|
||||
/// Track focus changes
|
||||
static void _onFocusChanged(FocusNode node) {
|
||||
_lastFocusedNode = node;
|
||||
_focusHistory.add(node);
|
||||
|
||||
// Limit history size
|
||||
if (_focusHistory.length > 10) {
|
||||
_focusHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore last focus
|
||||
static void restoreLastFocus() {
|
||||
if (_lastFocusedNode != null && _lastFocusedNode!.canRequestFocus) {
|
||||
_lastFocusedNode!.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get focus history
|
||||
static List<FocusNode> getFocusHistory() {
|
||||
return List.unmodifiable(_focusHistory);
|
||||
}
|
||||
|
||||
/// Create a focus trap for modal dialogs
|
||||
static Widget createFocusTrap({
|
||||
required Widget child,
|
||||
bool autofocus = true,
|
||||
}) {
|
||||
return FocusScope(autofocus: autofocus, child: child);
|
||||
}
|
||||
|
||||
/// Create keyboard navigation handler
|
||||
static FocusOnKeyEventCallback createKeyboardNavigationHandler({
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onEscape,
|
||||
VoidCallback? onTab,
|
||||
VoidCallback? onArrowUp,
|
||||
VoidCallback? onArrowDown,
|
||||
VoidCallback? onArrowLeft,
|
||||
VoidCallback? onArrowRight,
|
||||
}) {
|
||||
return (FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
onEnter?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
onEscape?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
onTab?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
onArrowUp?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
onArrowDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
onArrowLeft?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
onArrowRight?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus manager widget that manages focus for its children
|
||||
class FocusManager extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool autofocus;
|
||||
final bool trapFocus;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
|
||||
const FocusManager({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.autofocus = false,
|
||||
this.trapFocus = false,
|
||||
this.onKeyEvent,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusManager> createState() => _FocusManagerState();
|
||||
}
|
||||
|
||||
class _FocusManagerState extends State<FocusManager> {
|
||||
late FocusScopeNode _focusScopeNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusScopeNode = FocusScopeNode(
|
||||
debugLabel: 'FocusManager',
|
||||
onKeyEvent: widget.onKeyEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusScopeNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = FocusScope(
|
||||
node: _focusScopeNode,
|
||||
autofocus: widget.autofocus,
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
if (widget.trapFocus) {
|
||||
child = FocusTraversalGroup(
|
||||
policy: OrderedTraversalPolicy(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessible form field with proper focus management
|
||||
class AccessibleFormField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final bool autofocus;
|
||||
final String? semanticLabel;
|
||||
final String? errorSemanticLabel;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final VoidCallback? onEditingComplete;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
final Widget? suffixIcon;
|
||||
final Widget? prefixIcon;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const AccessibleFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
this.validator,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.autofocus = false,
|
||||
this.semanticLabel,
|
||||
this.errorSemanticLabel,
|
||||
this.onChanged,
|
||||
this.onEditingComplete,
|
||||
this.onSubmitted,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.suffixIcon,
|
||||
this.prefixIcon,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AccessibleFormField> createState() => _AccessibleFormFieldState();
|
||||
}
|
||||
|
||||
class _AccessibleFormFieldState extends State<AccessibleFormField> {
|
||||
late FocusNode _focusNode;
|
||||
String? _errorText;
|
||||
bool _hasFocus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.label);
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {
|
||||
_hasFocus = _focusNode.hasFocus;
|
||||
});
|
||||
|
||||
// Announce focus change for screen readers
|
||||
if (_hasFocus) {
|
||||
final announcement =
|
||||
widget.semanticLabel ??
|
||||
'${widget.label} text field. ${widget.hint ?? ''}';
|
||||
SemanticsService.announce(announcement, TextDirection.ltr);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel ?? widget.label,
|
||||
hint: widget.hint,
|
||||
textField: true,
|
||||
enabled: widget.enabled,
|
||||
focusable: true,
|
||||
focused: _hasFocus,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: _hasFocus
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: _hasFocus ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Text field
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
validator: (value) {
|
||||
final error = widget.validator?.call(value);
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
|
||||
// Announce error for screen readers
|
||||
if (error != null) {
|
||||
final errorAnnouncement =
|
||||
widget.errorSemanticLabel ?? 'Error: $error';
|
||||
SemanticsService.announce(errorAnnouncement, TextDirection.ltr);
|
||||
}
|
||||
|
||||
return error;
|
||||
},
|
||||
keyboardType: widget.keyboardType,
|
||||
obscureText: widget.obscureText,
|
||||
autofocus: widget.autofocus,
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
maxLines: widget.maxLines,
|
||||
maxLength: widget.maxLength,
|
||||
enabled: widget.enabled,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
errorText: _errorText,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
457
lib/core/services/input_validation_service.dart
Normal file
457
lib/core/services/input_validation_service.dart
Normal file
@@ -0,0 +1,457 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Comprehensive input validation service
|
||||
class InputValidationService {
|
||||
// Email regex pattern
|
||||
static final RegExp _emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
// Strong password regex (min 8 chars, 1 upper, 1 lower, 1 number, 1 special)
|
||||
static final RegExp _strongPasswordRegex = RegExp(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
|
||||
);
|
||||
|
||||
/// Validate email address
|
||||
static String? validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email is required';
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
if (!_emailRegex.hasMatch(trimmed)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate URL
|
||||
static String? validateUrl(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'URL is required' : null;
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
|
||||
// Add protocol if missing
|
||||
String urlToValidate = trimmed;
|
||||
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||
urlToValidate = 'https://$trimmed';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(urlToValidate);
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate password strength
|
||||
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (checkStrength && !_strongPasswordRegex.hasMatch(value)) {
|
||||
return 'Password must contain uppercase, lowercase, number, and special character';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate confirm password
|
||||
static String? validateConfirmPassword(String? value, String password) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
|
||||
if (value != password) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate required field
|
||||
static String? validateRequired(
|
||||
String? value, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '$fieldName is required';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate minimum length
|
||||
static String? validateMinLength(
|
||||
String? value,
|
||||
int minLength, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$fieldName is required';
|
||||
}
|
||||
|
||||
if (value.length < minLength) {
|
||||
return '$fieldName must be at least $minLength characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate maximum length
|
||||
static String? validateMaxLength(
|
||||
String? value,
|
||||
int maxLength, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value != null && value.length > maxLength) {
|
||||
return '$fieldName must be at most $maxLength characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate numeric input
|
||||
static String? validateNumber(
|
||||
String? value, {
|
||||
double? min,
|
||||
double? max,
|
||||
bool allowDecimal = true,
|
||||
bool required = true,
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'Number is required' : null;
|
||||
}
|
||||
|
||||
final number = allowDecimal ? double.tryParse(value) : int.tryParse(value);
|
||||
|
||||
if (number == null) {
|
||||
return allowDecimal
|
||||
? 'Please enter a valid number'
|
||||
: 'Please enter a whole number';
|
||||
}
|
||||
|
||||
if (min != null && number < min) {
|
||||
return 'Value must be at least $min';
|
||||
}
|
||||
|
||||
if (max != null && number > max) {
|
||||
return 'Value must be at most $max';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate phone number
|
||||
static String? validatePhoneNumber(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'Phone number is required' : null;
|
||||
}
|
||||
|
||||
// Remove all non-digits
|
||||
final digitsOnly = value.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
if (digitsOnly.length < 10) {
|
||||
return 'Please enter a valid phone number';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate alphanumeric input
|
||||
static String? validateAlphanumeric(
|
||||
String? value, {
|
||||
bool allowSpaces = false,
|
||||
bool required = true,
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? '$fieldName is required' : null;
|
||||
}
|
||||
|
||||
final pattern = allowSpaces ? r'^[a-zA-Z0-9\s]+$' : r'^[a-zA-Z0-9]+$';
|
||||
if (!RegExp(pattern).hasMatch(value)) {
|
||||
return allowSpaces
|
||||
? '$fieldName can only contain letters, numbers, and spaces'
|
||||
: '$fieldName can only contain letters and numbers';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate username
|
||||
static String? validateUsername(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
|
||||
if (value.length > 20) {
|
||||
return 'Username must be at most 20 characters';
|
||||
}
|
||||
|
||||
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
||||
return 'Username can only contain letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate email or username (flexible login)
|
||||
static String? validateEmailOrUsername(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email or username is required';
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
|
||||
// If it contains @ symbol, validate as email
|
||||
if (trimmed.contains('@')) {
|
||||
return validateEmail(value);
|
||||
}
|
||||
|
||||
// Otherwise validate as username
|
||||
return validateUsername(value);
|
||||
}
|
||||
|
||||
/// Sanitize input to prevent XSS
|
||||
static String sanitizeInput(String input) {
|
||||
return input
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('/', '/');
|
||||
}
|
||||
|
||||
/// Create input formatter for numeric input
|
||||
static List<TextInputFormatter> numericInputFormatters({
|
||||
bool allowDecimal = true,
|
||||
bool allowNegative = false,
|
||||
}) {
|
||||
return [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(
|
||||
allowDecimal
|
||||
? (allowNegative ? r'[0-9.-]' : r'[0-9.]')
|
||||
: (allowNegative ? r'[0-9-]' : r'[0-9]'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Create input formatter for alphanumeric input
|
||||
static List<TextInputFormatter> alphanumericInputFormatters({
|
||||
bool allowSpaces = false,
|
||||
}) {
|
||||
return [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(allowSpaces ? r'[a-zA-Z0-9\s]' : r'[a-zA-Z0-9]'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Create input formatter for phone number
|
||||
static List<TextInputFormatter> phoneNumberFormatters() {
|
||||
return [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(15),
|
||||
_PhoneNumberFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Validate file size
|
||||
static String? validateFileSize(int sizeInBytes, {int maxSizeInMB = 10}) {
|
||||
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
||||
if (sizeInBytes > maxSizeInBytes) {
|
||||
return 'File size must be less than ${maxSizeInMB}MB';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate file extension
|
||||
static String? validateFileExtension(
|
||||
String fileName,
|
||||
List<String> allowedExtensions,
|
||||
) {
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
if (!allowedExtensions.contains(extension)) {
|
||||
return 'File type not allowed. Allowed types: ${allowedExtensions.join(', ')}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Composite validator that runs multiple validators
|
||||
static String? Function(String?) combine(
|
||||
List<String? Function(String?)> validators,
|
||||
) {
|
||||
return (String? value) {
|
||||
for (final validator in validators) {
|
||||
final result = validator(value);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom phone number formatter
|
||||
class _PhoneNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final text = newValue.text;
|
||||
|
||||
if (text.length <= 3) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
if (text.length <= 6) {
|
||||
final newText = '(${text.substring(0, 3)}) ${text.substring(3)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
|
||||
if (text.length <= 10) {
|
||||
final newText =
|
||||
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
|
||||
final newText =
|
||||
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6, 10)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Form field wrapper with validation
|
||||
class ValidatedFormField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?) validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final bool autofocus;
|
||||
final void Function(String)? onChanged;
|
||||
final void Function(String)? onFieldSubmitted;
|
||||
final FocusNode? focusNode;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
|
||||
const ValidatedFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
this.inputFormatters,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.autofocus = false,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
this.focusNode,
|
||||
this.maxLines = 1,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidatedFormField> createState() => _ValidatedFormFieldState();
|
||||
}
|
||||
|
||||
class _ValidatedFormFieldState extends State<ValidatedFormField> {
|
||||
String? _errorText;
|
||||
bool _hasInteracted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_validate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_validate);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _validate() {
|
||||
if (!_hasInteracted) return;
|
||||
|
||||
final error = widget.validator(widget.controller.text);
|
||||
if (error != _errorText) {
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
validator: (value) {
|
||||
setState(() {
|
||||
_hasInteracted = true;
|
||||
});
|
||||
return widget.validator(value);
|
||||
},
|
||||
inputFormatters: widget.inputFormatters,
|
||||
keyboardType: widget.keyboardType,
|
||||
obscureText: widget.obscureText,
|
||||
autofocus: widget.autofocus,
|
||||
maxLines: widget.maxLines,
|
||||
enabled: widget.enabled,
|
||||
onChanged: (value) {
|
||||
if (!_hasInteracted) {
|
||||
setState(() {
|
||||
_hasInteracted = true;
|
||||
});
|
||||
}
|
||||
_validate();
|
||||
widget.onChanged?.call(value);
|
||||
},
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
errorText: _errorText,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
250
lib/core/services/navigation_service.dart
Normal file
250
lib/core/services/navigation_service.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
// ThemedDialogs handles theming; no direct use of extensions here
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/auth/views/connect_signin_page.dart';
|
||||
import '../../features/settings/views/searchable_settings_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
import '../../features/files/views/files_page.dart';
|
||||
import '../../features/chat/views/conversation_search_page.dart';
|
||||
import '../../shared/widgets/themed_dialogs.dart';
|
||||
|
||||
import '../../features/navigation/views/chats_list_page.dart';
|
||||
|
||||
/// Centralized navigation service to handle all routing logic
|
||||
/// Prevents navigation stack issues and memory leaks
|
||||
class NavigationService {
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
static NavigatorState? get navigator => navigatorKey.currentState;
|
||||
static BuildContext? get context => navigatorKey.currentContext;
|
||||
|
||||
// Navigation stack tracking for analytics and debugging
|
||||
static final List<String> _navigationStack = [];
|
||||
static List<String> get navigationStack =>
|
||||
List.unmodifiable(_navigationStack);
|
||||
|
||||
// Prevent duplicate navigation
|
||||
static String? _currentRoute;
|
||||
static bool _isNavigating = false;
|
||||
static DateTime? _lastNavigationTime;
|
||||
|
||||
/// Navigate to a named route with optional arguments
|
||||
static Future<T?> navigateTo<T>(
|
||||
String routeName, {
|
||||
Object? arguments,
|
||||
bool replace = false,
|
||||
bool clearStack = false,
|
||||
}) async {
|
||||
// Only block if we're already navigating to the exact same route
|
||||
// Allow navigation to different routes even if currently navigating
|
||||
if (_isNavigating && _currentRoute == routeName) {
|
||||
debugPrint('Navigation blocked: Already navigating to same route');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prevent rapid successive navigation attempts
|
||||
final now = DateTime.now();
|
||||
if (_lastNavigationTime != null &&
|
||||
now.difference(_lastNavigationTime!).inMilliseconds < 300) {
|
||||
debugPrint('Navigation blocked: Too rapid navigation attempts');
|
||||
return null;
|
||||
}
|
||||
|
||||
_isNavigating = true;
|
||||
|
||||
try {
|
||||
// Add haptic feedback for navigation
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Track navigation
|
||||
if (!replace && !clearStack) {
|
||||
_navigationStack.add(routeName);
|
||||
}
|
||||
_currentRoute = routeName;
|
||||
|
||||
if (clearStack) {
|
||||
_navigationStack.clear();
|
||||
_navigationStack.add(routeName);
|
||||
return await navigator?.pushNamedAndRemoveUntil<T>(
|
||||
routeName,
|
||||
(route) => false,
|
||||
arguments: arguments,
|
||||
);
|
||||
} else if (replace) {
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack.removeLast();
|
||||
}
|
||||
_navigationStack.add(routeName);
|
||||
return await navigator?.pushReplacementNamed<T, T>(
|
||||
routeName,
|
||||
arguments: arguments,
|
||||
);
|
||||
} else {
|
||||
return await navigator?.pushNamed<T>(routeName, arguments: arguments);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Navigation error: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isNavigating = false;
|
||||
_lastNavigationTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate back with optional result
|
||||
static void goBack<T>([T? result]) {
|
||||
if (navigator?.canPop() == true) {
|
||||
HapticFeedback.lightImpact();
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack.removeLast();
|
||||
}
|
||||
_currentRoute = _navigationStack.isEmpty ? null : _navigationStack.last;
|
||||
navigator?.pop<T>(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if can navigate back
|
||||
static bool canGoBack() {
|
||||
return navigator?.canPop() == true;
|
||||
}
|
||||
|
||||
/// Show confirmation dialog before navigation
|
||||
static Future<bool> confirmNavigation({
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = 'Continue',
|
||||
String cancelText = 'Cancel',
|
||||
}) async {
|
||||
if (context == null) return false;
|
||||
|
||||
final result = await ThemedDialogs.confirm(
|
||||
context!,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Removed tabbed main navigation
|
||||
|
||||
/// Navigate to chat
|
||||
static Future<void> navigateToChat({String? conversationId}) {
|
||||
return navigateTo(
|
||||
Routes.chat,
|
||||
arguments: {'conversationId': conversationId},
|
||||
replace: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to login
|
||||
static Future<void> navigateToLogin() {
|
||||
return navigateTo(Routes.login, clearStack: true);
|
||||
}
|
||||
|
||||
/// Navigate to settings
|
||||
static Future<void> navigateToSettings() {
|
||||
return navigateTo(Routes.settings);
|
||||
}
|
||||
|
||||
/// Navigate to profile
|
||||
static Future<void> navigateToProfile() {
|
||||
return navigateTo(Routes.profile);
|
||||
}
|
||||
|
||||
/// Navigate to server connection
|
||||
static Future<void> navigateToServerConnection() {
|
||||
return navigateTo(Routes.serverConnection);
|
||||
}
|
||||
|
||||
/// Navigate to search
|
||||
static Future<void> navigateToSearch() {
|
||||
return navigateTo(Routes.search);
|
||||
}
|
||||
|
||||
/// Navigate to chats list
|
||||
static Future<void> navigateToChatsList() {
|
||||
return navigateTo(Routes.chatsList);
|
||||
}
|
||||
|
||||
/// Clear navigation stack (useful for logout)
|
||||
static void clearNavigationStack() {
|
||||
_navigationStack.clear();
|
||||
_currentRoute = null;
|
||||
}
|
||||
|
||||
/// Set current route (useful for initial app state)
|
||||
static void setCurrentRoute(String routeName) {
|
||||
_currentRoute = routeName;
|
||||
if (!_navigationStack.contains(routeName)) {
|
||||
_navigationStack.add(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate routes
|
||||
static Route<dynamic>? generateRoute(RouteSettings settings) {
|
||||
Widget page;
|
||||
|
||||
switch (settings.name) {
|
||||
// Removed tabbed main navigation
|
||||
|
||||
case Routes.chat:
|
||||
page = const ChatPage();
|
||||
break;
|
||||
|
||||
case Routes.login:
|
||||
page = const ConnectAndSignInPage();
|
||||
break;
|
||||
|
||||
case Routes.settings:
|
||||
page = const SearchableSettingsPage();
|
||||
break;
|
||||
|
||||
case Routes.profile:
|
||||
page = const ProfilePage();
|
||||
break;
|
||||
|
||||
case Routes.serverConnection:
|
||||
page = const ConnectAndSignInPage();
|
||||
break;
|
||||
|
||||
case Routes.search:
|
||||
page = const ConversationSearchPage();
|
||||
break;
|
||||
|
||||
case Routes.files:
|
||||
page = const FilesPage();
|
||||
break;
|
||||
|
||||
case Routes.chatsList:
|
||||
page = const ChatsListPage();
|
||||
break;
|
||||
|
||||
// Removed navigation drawer route
|
||||
|
||||
default:
|
||||
page = Scaffold(
|
||||
body: Center(child: Text('Route not found: ${settings.name}')),
|
||||
);
|
||||
}
|
||||
|
||||
return MaterialPageRoute(builder: (_) => page, settings: settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route names
|
||||
class Routes {
|
||||
static const String chat = '/chat';
|
||||
static const String login = '/login';
|
||||
static const String settings = '/settings';
|
||||
static const String profile = '/profile';
|
||||
static const String serverConnection = '/server-connection';
|
||||
static const String search = '/search';
|
||||
static const String files = '/files';
|
||||
static const String chatsList = '/chats-list';
|
||||
}
|
||||
427
lib/core/services/navigation_state_service.dart
Normal file
427
lib/core/services/navigation_state_service.dart
Normal file
@@ -0,0 +1,427 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Navigation state data model
|
||||
class NavigationState {
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final DateTime timestamp;
|
||||
final String? conversationId;
|
||||
final int? tabIndex;
|
||||
|
||||
NavigationState({
|
||||
required this.routeName,
|
||||
this.arguments = const {},
|
||||
DateTime? timestamp,
|
||||
this.conversationId,
|
||||
this.tabIndex,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'routeName': routeName,
|
||||
'arguments': arguments,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'conversationId': conversationId,
|
||||
'tabIndex': tabIndex,
|
||||
};
|
||||
|
||||
factory NavigationState.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationState(
|
||||
routeName: json['routeName'] ?? '/',
|
||||
arguments: json['arguments'] ?? {},
|
||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||
conversationId: json['conversationId'],
|
||||
tabIndex: json['tabIndex'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service to manage navigation state preservation and restoration
|
||||
class NavigationStateService {
|
||||
static final NavigationStateService _instance =
|
||||
NavigationStateService._internal();
|
||||
factory NavigationStateService() => _instance;
|
||||
NavigationStateService._internal();
|
||||
|
||||
static const String _navigationStackKey = 'navigation_stack';
|
||||
static const String _currentStateKey = 'current_navigation_state';
|
||||
static const String _deepLinkStateKey = 'deep_link_state';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final List<NavigationState> _navigationStack = [];
|
||||
NavigationState? _currentState;
|
||||
final ValueNotifier<NavigationState?> _stateNotifier = ValueNotifier(null);
|
||||
|
||||
/// Initialize the service
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadNavigationState();
|
||||
debugPrint('DEBUG: NavigationStateService initialized');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to initialize NavigationStateService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current navigation state as a ValueNotifier for listening to changes
|
||||
ValueNotifier<NavigationState?> get stateNotifier => _stateNotifier;
|
||||
|
||||
/// Get current navigation state
|
||||
NavigationState? get currentState => _currentState;
|
||||
|
||||
/// Get navigation stack
|
||||
List<NavigationState> get navigationStack =>
|
||||
List.unmodifiable(_navigationStack);
|
||||
|
||||
/// Push a new navigation state
|
||||
Future<void> pushState({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
}) async {
|
||||
try {
|
||||
final state = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
tabIndex: tabIndex,
|
||||
);
|
||||
|
||||
_navigationStack.add(state);
|
||||
_currentState = state;
|
||||
_stateNotifier.value = state;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state pushed - ${state.routeName}');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to push navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the last navigation state
|
||||
Future<NavigationState?> popState() async {
|
||||
try {
|
||||
if (_navigationStack.isEmpty) return null;
|
||||
|
||||
final poppedState = _navigationStack.removeLast();
|
||||
_currentState = _navigationStack.isNotEmpty
|
||||
? _navigationStack.last
|
||||
: null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state popped - ${poppedState.routeName}');
|
||||
return poppedState;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to pop navigation state: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update current state with new information
|
||||
Future<void> updateCurrentState({
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
Map<String, dynamic>? additionalArgs,
|
||||
}) async {
|
||||
try {
|
||||
if (_currentState == null) return;
|
||||
|
||||
final updatedArgs = <String, dynamic>{
|
||||
..._currentState!.arguments,
|
||||
if (additionalArgs != null) ...additionalArgs,
|
||||
};
|
||||
|
||||
final updatedState = NavigationState(
|
||||
routeName: _currentState!.routeName,
|
||||
arguments: updatedArgs,
|
||||
conversationId: conversationId ?? _currentState!.conversationId,
|
||||
tabIndex: tabIndex ?? _currentState!.tabIndex,
|
||||
timestamp: _currentState!.timestamp,
|
||||
);
|
||||
|
||||
// Update both current state and last item in stack
|
||||
_currentState = updatedState;
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack[_navigationStack.length - 1] = updatedState;
|
||||
}
|
||||
|
||||
_stateNotifier.value = updatedState;
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state updated');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to update navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear navigation stack but preserve current state
|
||||
Future<void> clearStack() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
if (_currentState != null) {
|
||||
_navigationStack.add(_currentState!);
|
||||
}
|
||||
await _saveNavigationState();
|
||||
debugPrint('DEBUG: Navigation stack cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation stack: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace entire navigation stack
|
||||
Future<void> replaceStack(List<NavigationState> newStack) async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_navigationStack.addAll(newStack);
|
||||
_currentState = newStack.isNotEmpty ? newStack.last : null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
debugPrint(
|
||||
'DEBUG: Navigation stack replaced with ${newStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to replace navigation stack: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link by preserving navigation context
|
||||
Future<void> handleDeepLink({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
bool preserveStack = true,
|
||||
}) async {
|
||||
try {
|
||||
// Save deep link state for restoration
|
||||
final deepLinkState = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
|
||||
await _saveDeepLinkState(deepLinkState);
|
||||
|
||||
if (preserveStack) {
|
||||
// Add to existing stack instead of replacing
|
||||
await pushState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
} else {
|
||||
// Replace stack with deep link
|
||||
await replaceStack([deepLinkState]);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Deep link handled - $routeName');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to handle deep link: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the conversation context from current navigation state
|
||||
String? getConversationContext() {
|
||||
return _currentState?.conversationId;
|
||||
}
|
||||
|
||||
/// Get the current tab index
|
||||
int? getCurrentTabIndex() {
|
||||
return _currentState?.tabIndex;
|
||||
}
|
||||
|
||||
/// Generate breadcrumb navigation based on current stack
|
||||
List<NavigationBreadcrumb> generateBreadcrumbs() {
|
||||
final breadcrumbs = <NavigationBreadcrumb>[];
|
||||
|
||||
for (int i = 0; i < _navigationStack.length; i++) {
|
||||
final state = _navigationStack[i];
|
||||
final isLast = i == _navigationStack.length - 1;
|
||||
|
||||
breadcrumbs.add(
|
||||
NavigationBreadcrumb(
|
||||
title: _getRouteTitle(state.routeName),
|
||||
routeName: state.routeName,
|
||||
arguments: state.arguments,
|
||||
isActive: isLast,
|
||||
canNavigateBack: i > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/// Check if we can navigate back
|
||||
bool canGoBack() {
|
||||
return _navigationStack.length > 1;
|
||||
}
|
||||
|
||||
/// Get previous state without popping
|
||||
NavigationState? getPreviousState() {
|
||||
if (_navigationStack.length < 2) return null;
|
||||
return _navigationStack[_navigationStack.length - 2];
|
||||
}
|
||||
|
||||
/// Restore navigation state on app startup
|
||||
Future<void> restoreNavigationState(NavigatorState navigator) async {
|
||||
try {
|
||||
await _loadNavigationState();
|
||||
|
||||
if (_currentState != null) {
|
||||
// Attempt to restore to the last known state
|
||||
debugPrint(
|
||||
'DEBUG: Restoring navigation to ${_currentState!.routeName}',
|
||||
);
|
||||
|
||||
// This would need to be implemented based on your routing setup
|
||||
// navigator.pushNamedAndRemoveUntil(
|
||||
// _currentState!.routeName,
|
||||
// (route) => false,
|
||||
// arguments: _currentState!.arguments,
|
||||
// );
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to restore navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all navigation state
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_currentState = null;
|
||||
_stateNotifier.value = null;
|
||||
|
||||
await _prefs?.remove(_navigationStackKey);
|
||||
await _prefs?.remove(_currentStateKey);
|
||||
await _prefs?.remove(_deepLinkStateKey);
|
||||
|
||||
debugPrint('DEBUG: All navigation state cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save navigation state to persistent storage
|
||||
Future<void> _saveNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Save navigation stack
|
||||
final stackJson = _navigationStack
|
||||
.map((state) => state.toJson())
|
||||
.toList();
|
||||
await _prefs!.setString(_navigationStackKey, jsonEncode(stackJson));
|
||||
|
||||
// Save current state
|
||||
if (_currentState != null) {
|
||||
await _prefs!.setString(
|
||||
_currentStateKey,
|
||||
jsonEncode(_currentState!.toJson()),
|
||||
);
|
||||
} else {
|
||||
await _prefs!.remove(_currentStateKey);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load navigation state from persistent storage
|
||||
Future<void> _loadNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Load navigation stack
|
||||
final stackJsonString = _prefs!.getString(_navigationStackKey);
|
||||
if (stackJsonString != null) {
|
||||
final stackJson = jsonDecode(stackJsonString) as List;
|
||||
_navigationStack.clear();
|
||||
for (final stateJson in stackJson) {
|
||||
if (stateJson is Map<String, dynamic>) {
|
||||
_navigationStack.add(NavigationState.fromJson(stateJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load current state
|
||||
final currentStateJsonString = _prefs!.getString(_currentStateKey);
|
||||
if (currentStateJsonString != null) {
|
||||
final currentStateJson =
|
||||
jsonDecode(currentStateJsonString) as Map<String, dynamic>;
|
||||
_currentState = NavigationState.fromJson(currentStateJson);
|
||||
_stateNotifier.value = _currentState;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Navigation state loaded - ${_navigationStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to load navigation state: $e');
|
||||
// Clear corrupted state
|
||||
await clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save deep link state for restoration
|
||||
Future<void> _saveDeepLinkState(NavigationState state) async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson()));
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save deep link state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly title for route name
|
||||
String _getRouteTitle(String routeName) {
|
||||
switch (routeName) {
|
||||
case '/':
|
||||
case '/home':
|
||||
return 'Home';
|
||||
case '/chat':
|
||||
return 'Chat';
|
||||
case '/settings':
|
||||
return 'Settings';
|
||||
case '/profile':
|
||||
return 'Profile';
|
||||
case '/conversations':
|
||||
return 'Conversations';
|
||||
default:
|
||||
// Convert route name to title case
|
||||
return routeName
|
||||
.replaceAll('/', '')
|
||||
.split('_')
|
||||
.map(
|
||||
(word) => word.isNotEmpty
|
||||
? '${word[0].toUpperCase()}${word.substring(1)}'
|
||||
: '',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Breadcrumb navigation item
|
||||
class NavigationBreadcrumb {
|
||||
final String title;
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final bool isActive;
|
||||
final bool canNavigateBack;
|
||||
|
||||
NavigationBreadcrumb({
|
||||
required this.title,
|
||||
required this.routeName,
|
||||
required this.arguments,
|
||||
required this.isActive,
|
||||
required this.canNavigateBack,
|
||||
});
|
||||
}
|
||||
375
lib/core/services/optimized_storage_service.dart
Normal file
375
lib/core/services/optimized_storage_service.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/conversation.dart';
|
||||
|
||||
/// Optimized storage service with single secure storage implementation
|
||||
/// Eliminates dual storage overhead and improves performance
|
||||
class OptimizedStorageService {
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
OptimizedStorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _prefs = prefs,
|
||||
_secureCredentialStorage = SecureCredentialStorage();
|
||||
|
||||
// Optimized key names with versioning
|
||||
static const String _authTokenKey = 'auth_token_v3';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
static const String _onboardingSeenKey = 'onboarding_seen_v1';
|
||||
static const String _reviewerModeKey = 'reviewer_mode_v1';
|
||||
|
||||
// Cache for frequently accessed data
|
||||
final Map<String, dynamic> _cache = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
|
||||
/// Auth Token Management (Optimized with caching)
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
_cache[_authTokenKey] = token;
|
||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
||||
debugPrint('DEBUG: Auth token saved and cached');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save auth token: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_authTokenKey)) {
|
||||
final cachedToken = _cache[_authTokenKey] as String?;
|
||||
if (cachedToken != null) {
|
||||
debugPrint('DEBUG: Using cached auth token');
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final token = await _secureCredentialStorage.getAuthToken();
|
||||
if (token != null) {
|
||||
_cache[_authTokenKey] = token;
|
||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve auth token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAuthToken() async {
|
||||
try {
|
||||
await _secureCredentialStorage.deleteAuthToken();
|
||||
_cache.remove(_authTokenKey);
|
||||
_cacheTimestamps.remove(_authTokenKey);
|
||||
debugPrint('DEBUG: Auth token deleted and cache cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential Management (Single storage implementation)
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Cache the fact that credentials exist (not the credentials themselves)
|
||||
_cache['has_credentials'] = true;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
debugPrint('DEBUG: Credentials saved via optimized storage');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save credentials: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
try {
|
||||
// Use single storage implementation - no fallback needed
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
|
||||
// Update cache flag
|
||||
_cache['has_credentials'] = credentials != null;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
return credentials;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve credentials: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
try {
|
||||
await _secureCredentialStorage.deleteSavedCredentials();
|
||||
_cache.remove('has_credentials');
|
||||
_cacheTimestamps.remove('has_credentials');
|
||||
debugPrint('DEBUG: Credentials deleted via optimized storage');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete credentials: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick check if credentials exist (uses cache)
|
||||
Future<bool> hasCredentials() async {
|
||||
if (_isCacheValid('has_credentials')) {
|
||||
return _cache['has_credentials'] == true;
|
||||
}
|
||||
|
||||
final credentials = await getSavedCredentials();
|
||||
return credentials != null;
|
||||
}
|
||||
|
||||
/// Remember Credentials Flag
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
}
|
||||
|
||||
/// Server Configuration (Optimized)
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
try {
|
||||
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
|
||||
await _secureCredentialStorage.saveServerConfigs(jsonString);
|
||||
|
||||
// Cache config count for quick checks
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
debugPrint('DEBUG: Server configs saved (${configs.length} configs)');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save server configs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ServerConfig>> getServerConfigs() async {
|
||||
try {
|
||||
final jsonString = await _secureCredentialStorage.getServerConfigs();
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_cache['server_config_count'] = 0;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
return [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
final configs = decoded
|
||||
.map((item) => ServerConfig.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// Update cache
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve server configs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Active Server Management
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId != null) {
|
||||
await _prefs.setString(_activeServerIdKey, serverId);
|
||||
} else {
|
||||
await _prefs.remove(_activeServerIdKey);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_activeServerIdKey)) {
|
||||
return _cache[_activeServerIdKey] as String?;
|
||||
}
|
||||
|
||||
final serverId = _prefs.getString(_activeServerIdKey);
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/// Theme Management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
/// Onboarding
|
||||
Future<bool> getOnboardingSeen() async {
|
||||
return _prefs.getBool(_onboardingSeenKey) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setOnboardingSeen(bool seen) async {
|
||||
await _prefs.setBool(_onboardingSeenKey, seen);
|
||||
}
|
||||
|
||||
/// Reviewer mode (persisted)
|
||||
Future<bool> getReviewerMode() async {
|
||||
return _prefs.getBool(_reviewerModeKey) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setReviewerMode(bool enabled) async {
|
||||
await _prefs.setBool(_reviewerModeKey, enabled);
|
||||
}
|
||||
|
||||
/// Local Conversations (Optimized with compression)
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
try {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
return decoded.map((item) => Conversation.fromJson(item)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve local conversations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
// Only save essential data to reduce storage size
|
||||
final lightweightConversations = conversations
|
||||
.map(
|
||||
(conv) => {
|
||||
'id': conv.id,
|
||||
'title': conv.title,
|
||||
'updatedAt': conv.updatedAt.toIso8601String(),
|
||||
'messageCount': conv.messages.length,
|
||||
// Don't save full message content locally
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
final jsonString = jsonEncode(lightweightConversations);
|
||||
await _prefs.setString(_localConversationsKey, jsonString);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Saved ${conversations.length} local conversations (lightweight)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save local conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch Operations for Performance
|
||||
Future<void> clearAuthData() async {
|
||||
try {
|
||||
// Clear auth-related data in batch
|
||||
await Future.wait([
|
||||
deleteAuthToken(),
|
||||
deleteSavedCredentials(),
|
||||
_prefs.remove(_rememberCredentialsKey),
|
||||
_prefs.remove(_activeServerIdKey),
|
||||
]);
|
||||
|
||||
// Clear related cache entries
|
||||
_cache.removeWhere(
|
||||
(key, value) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
);
|
||||
_cacheTimestamps.removeWhere(
|
||||
(key, value) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Auth data cleared in batch operation');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear auth data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await Future.wait([_secureCredentialStorage.clearAll(), _prefs.clear()]);
|
||||
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
|
||||
debugPrint('DEBUG: All storage cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear all storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage Health Check
|
||||
Future<bool> isSecureStorageAvailable() async {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
}
|
||||
|
||||
/// Cache Management Utilities
|
||||
bool _isCacheValid(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
return DateTime.now().difference(timestamp) < _cacheTimeout;
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('DEBUG: Storage cache cleared');
|
||||
}
|
||||
|
||||
/// Migration from old storage service (one-time operation)
|
||||
Future<void> migrateFromLegacyStorage() async {
|
||||
try {
|
||||
debugPrint('DEBUG: Starting migration from legacy storage');
|
||||
|
||||
// This would be called once during app upgrade
|
||||
// Implementation would depend on the specific migration needs
|
||||
// For now, the SecureCredentialStorage already handles legacy migration
|
||||
|
||||
debugPrint('DEBUG: Legacy storage migration completed');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Legacy storage migration failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance Monitoring
|
||||
Map<String, dynamic> getStorageStats() {
|
||||
return {
|
||||
'cacheSize': _cache.length,
|
||||
'cachedKeys': _cache.keys.toList(),
|
||||
'lastAccess': _cacheTimestamps.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
408
lib/core/services/platform_service.dart
Normal file
408
lib/core/services/platform_service.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Service for platform-specific features and polish
|
||||
class PlatformService {
|
||||
/// Check if running on iOS
|
||||
static bool get isIOS => Platform.isIOS;
|
||||
|
||||
/// Check if running on Android
|
||||
static bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
/// Provide haptic feedback appropriate for the action
|
||||
static void hapticFeedback({HapticType type = HapticType.light}) {
|
||||
if (isIOS) {
|
||||
_iOSHapticFeedback(type);
|
||||
} else if (isAndroid) {
|
||||
_androidHapticFeedback(type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide haptic feedback respecting user preferences
|
||||
static void hapticFeedbackWithSettings({
|
||||
HapticType type = HapticType.light,
|
||||
required bool hapticEnabled,
|
||||
}) {
|
||||
if (hapticEnabled) {
|
||||
hapticFeedback(type: type);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS-specific haptic feedback
|
||||
static void _iOSHapticFeedback(HapticType type) {
|
||||
switch (type) {
|
||||
case HapticType.light:
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case HapticType.selection:
|
||||
HapticFeedback.selectionClick();
|
||||
break;
|
||||
case HapticType.success:
|
||||
// iOS has specific success haptics in newer versions
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.warning:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.error:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Android-specific haptic feedback
|
||||
static void _androidHapticFeedback(HapticType type) {
|
||||
switch (type) {
|
||||
case HapticType.light:
|
||||
case HapticType.selection:
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
case HapticType.success:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
case HapticType.warning:
|
||||
case HapticType.error:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate button style
|
||||
static ButtonStyle getPlatformButtonStyle({
|
||||
Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
EdgeInsetsGeometry? padding,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
// Return Material button style for both platforms since ButtonStyle is a Material concept
|
||||
return ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
padding: padding,
|
||||
elevation: isDestructive ? 0 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get platform-appropriate card elevation
|
||||
static double getPlatformCardElevation({bool isRaised = false}) {
|
||||
if (isIOS) {
|
||||
return 0; // iOS prefers flat design
|
||||
} else {
|
||||
return isRaised ? 4.0 : 1.0; // Android Material elevation
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate border radius
|
||||
static BorderRadius getPlatformBorderRadius({double radius = 12}) {
|
||||
if (isIOS) {
|
||||
return BorderRadius.circular(
|
||||
radius + 2,
|
||||
); // iOS prefers slightly more rounded
|
||||
} else {
|
||||
return BorderRadius.circular(radius); // Android standard
|
||||
}
|
||||
}
|
||||
|
||||
/// Create platform-appropriate navigation transition
|
||||
static Route<T> createPlatformRoute<T>({
|
||||
required Widget page,
|
||||
RouteSettings? settings,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoPageRoute<T>(
|
||||
builder: (context) => page,
|
||||
settings: settings,
|
||||
);
|
||||
} else {
|
||||
return MaterialPageRoute<T>(
|
||||
builder: (context) => page,
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show platform-appropriate action sheet
|
||||
static Future<T?> showPlatformActionSheet<T>({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
List<PlatformActionSheetAction>? actions,
|
||||
PlatformActionSheetAction? cancelAction,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return showCupertinoModalPopup<T>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoActionSheet(
|
||||
title: Text(title),
|
||||
actions: actions
|
||||
?.map(
|
||||
(action) => CupertinoActionSheetAction(
|
||||
onPressed: action.onPressed,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cancelButton: cancelAction != null
|
||||
? CupertinoActionSheetAction(
|
||||
onPressed: cancelAction.onPressed,
|
||||
child: Text(cancelAction.title),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
...actions?.map(
|
||||
(action) => ListTile(
|
||||
title: Text(
|
||||
action.title,
|
||||
style: TextStyle(
|
||||
color: action.isDestructive
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onTap: action.onPressed,
|
||||
),
|
||||
) ??
|
||||
[],
|
||||
if (cancelAction != null)
|
||||
ListTile(
|
||||
title: Text(cancelAction.title),
|
||||
onTap: cancelAction.onPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show platform-appropriate alert dialog
|
||||
static Future<bool?> showPlatformAlert({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = 'OK',
|
||||
String? cancelText,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
if (cancelText != null)
|
||||
CupertinoDialogAction(
|
||||
child: Text(cancelText),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: isDestructive,
|
||||
child: Text(confirmText),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
content,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
if (cancelText != null)
|
||||
TextButton(
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDestructive
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
child: Text(confirmText),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate loading indicator
|
||||
static Widget getPlatformLoadingIndicator({double size = 20, Color? color}) {
|
||||
if (isIOS) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CupertinoActivityIndicator(color: color),
|
||||
);
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: color != null
|
||||
? AlwaysStoppedAnimation<Color>(color)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate switch widget
|
||||
static Widget getPlatformSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeTrackColor: activeColor,
|
||||
);
|
||||
} else {
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: activeColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply platform-specific status bar styling
|
||||
static void setPlatformStatusBarStyle({
|
||||
bool isDarkContent = false,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarBrightness: isDarkContent
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
statusBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
statusBarColor: backgroundColor,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: backgroundColor ?? Colors.transparent,
|
||||
statusBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
systemNavigationBarColor: backgroundColor,
|
||||
systemNavigationBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if device supports dynamic colors (Android 12+)
|
||||
static bool supportsDynamicColors() {
|
||||
// This would require platform channel implementation
|
||||
// For now, return false
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get platform-appropriate text selection controls
|
||||
static TextSelectionControls getPlatformTextSelectionControls() {
|
||||
if (isIOS) {
|
||||
return cupertinoTextSelectionControls;
|
||||
} else {
|
||||
return materialTextSelectionControls;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create platform-specific app bar
|
||||
static PreferredSizeWidget createPlatformAppBar({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
bool centerTitle = false,
|
||||
Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoNavigationBar(
|
||||
middle: Text(title),
|
||||
trailing: actions != null && actions.isNotEmpty
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: actions)
|
||||
: null,
|
||||
leading: leading,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
} else {
|
||||
return AppBar(
|
||||
title: Text(title),
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of haptic feedback
|
||||
enum HapticType { light, medium, heavy, selection, success, warning, error }
|
||||
|
||||
/// Action sheet action configuration
|
||||
class PlatformActionSheetAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDestructive;
|
||||
|
||||
const PlatformActionSheetAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
326
lib/core/services/secure_credential_storage.dart
Normal file
326
lib/core/services/secure_credential_storage.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Enhanced secure credential storage with platform-specific optimizations
|
||||
class SecureCredentialStorage {
|
||||
late final FlutterSecureStorage _secureStorage;
|
||||
|
||||
SecureCredentialStorage() {
|
||||
_secureStorage = FlutterSecureStorage(
|
||||
aOptions: _getAndroidOptions(),
|
||||
iOptions: _getIOSOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
static const String _credentialsKey = 'user_credentials_v2';
|
||||
static const String _serverConfigsKey = 'server_configs_v2';
|
||||
static const String _authTokenKey = 'auth_token_v2';
|
||||
|
||||
/// Get Android-specific secure storage options
|
||||
AndroidOptions _getAndroidOptions() {
|
||||
return const AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
sharedPreferencesName: 'conduit_secure_prefs',
|
||||
preferencesKeyPrefix: 'conduit_',
|
||||
resetOnError: true,
|
||||
// Use more compatible encryption algorithms
|
||||
keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding,
|
||||
storageCipherAlgorithm: StorageCipherAlgorithm.AES_CBC_PKCS7Padding,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get iOS-specific secure storage options
|
||||
IOSOptions _getIOSOptions() {
|
||||
return const IOSOptions(
|
||||
groupId: 'group.conduit.app',
|
||||
accountName: 'conduit_secure_storage',
|
||||
synchronizable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Save user credentials securely
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
// First check if secure storage is available
|
||||
final isAvailable = await isSecureStorageAvailable();
|
||||
if (!isAvailable) {
|
||||
throw Exception('Secure storage is not available on this device');
|
||||
}
|
||||
|
||||
final credentials = {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
'deviceId': await _getDeviceFingerprint(),
|
||||
'version': '2.0', // Version for migration purposes
|
||||
};
|
||||
|
||||
final encryptedData = await _encryptData(jsonEncode(credentials));
|
||||
await _secureStorage.write(key: _credentialsKey, value: encryptedData);
|
||||
|
||||
// Verify the save was successful by attempting to read it back
|
||||
final verifyData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (verifyData == null || verifyData.isEmpty) {
|
||||
throw Exception(
|
||||
'Failed to verify credential save - storage returned null',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Credentials saved and verified securely');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save credentials: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve saved credentials
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
try {
|
||||
final encryptedData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (encryptedData == null || encryptedData.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonString = await _decryptData(encryptedData);
|
||||
final decoded = jsonDecode(jsonString);
|
||||
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('Warning: Invalid credentials format');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate device fingerprint for additional security, but be more lenient
|
||||
final savedDeviceId = decoded['deviceId']?.toString();
|
||||
if (savedDeviceId != null) {
|
||||
final currentDeviceId = await _getDeviceFingerprint();
|
||||
|
||||
if (savedDeviceId != currentDeviceId) {
|
||||
debugPrint(
|
||||
'Info: Device fingerprint changed, but allowing credential access for better UX',
|
||||
);
|
||||
// Don't clear credentials immediately - allow the user to continue
|
||||
// They can re-login if needed, which will update the fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
debugPrint(
|
||||
'Warning: Invalid saved credentials format - missing required fields',
|
||||
);
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if credentials are too old (optional expiration)
|
||||
final savedAt = decoded['savedAt']?.toString();
|
||||
if (savedAt != null) {
|
||||
try {
|
||||
final savedTime = DateTime.parse(savedAt);
|
||||
final now = DateTime.now();
|
||||
final daysSinceCreated = now.difference(savedTime).inDays;
|
||||
|
||||
// Warn if credentials are very old (but don't delete them)
|
||||
if (daysSinceCreated > 90) {
|
||||
debugPrint(
|
||||
'Info: Saved credentials are $daysSinceCreated days old',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Could not parse savedAt timestamp: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'serverId': decoded['serverId']?.toString() ?? '',
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve credentials: $e');
|
||||
// Don't delete credentials on retrieval errors - they might be recoverable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete saved credentials
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
debugPrint('DEBUG: Credentials deleted');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete credentials: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save auth token securely
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
try {
|
||||
final encryptedToken = await _encryptData(token);
|
||||
await _secureStorage.write(key: _authTokenKey, value: encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save auth token: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get auth token
|
||||
Future<String?> getAuthToken() async {
|
||||
try {
|
||||
final encryptedToken = await _secureStorage.read(key: _authTokenKey);
|
||||
if (encryptedToken == null) return null;
|
||||
|
||||
return await _decryptData(encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve auth token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete auth token
|
||||
Future<void> deleteAuthToken() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save server configurations securely
|
||||
Future<void> saveServerConfigs(String configsJson) async {
|
||||
try {
|
||||
final encryptedConfigs = await _encryptData(configsJson);
|
||||
await _secureStorage.write(
|
||||
key: _serverConfigsKey,
|
||||
value: encryptedConfigs,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save server configs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server configurations
|
||||
Future<String?> getServerConfigs() async {
|
||||
try {
|
||||
final encryptedConfigs = await _secureStorage.read(
|
||||
key: _serverConfigsKey,
|
||||
);
|
||||
if (encryptedConfigs == null) return null;
|
||||
|
||||
return await _decryptData(encryptedConfigs);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve server configs: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if secure storage is available
|
||||
Future<bool> isSecureStorageAvailable() async {
|
||||
try {
|
||||
// Test write and read
|
||||
const testKey = 'test_availability';
|
||||
const testValue = 'test';
|
||||
|
||||
await _secureStorage.write(key: testKey, value: testValue);
|
||||
final result = await _secureStorage.read(key: testKey);
|
||||
await _secureStorage.delete(key: testKey);
|
||||
|
||||
return result == testValue;
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Secure storage not available: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all secure data
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _secureStorage.deleteAll();
|
||||
debugPrint('DEBUG: All secure data cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear secure data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt data using additional layer of encryption
|
||||
Future<String> _encryptData(String data) async {
|
||||
try {
|
||||
// For now, return the data as-is since FlutterSecureStorage already provides encryption
|
||||
// In a more advanced implementation, you could add an additional layer of AES encryption
|
||||
return data;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to encrypt data: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt data
|
||||
Future<String> _decryptData(String encryptedData) async {
|
||||
try {
|
||||
// For now, return the data as-is since FlutterSecureStorage handles decryption
|
||||
// This matches the encryption method above
|
||||
return encryptedData;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to decrypt data: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a device fingerprint for additional security
|
||||
Future<String> _getDeviceFingerprint() async {
|
||||
try {
|
||||
// Create a more stable device fingerprint
|
||||
final platformInfo = {
|
||||
'platform': Platform.operatingSystem,
|
||||
// Use only major version to avoid fingerprint changes on minor updates
|
||||
'majorVersion': Platform.operatingSystemVersion.split('.').first,
|
||||
'isPhysicalDevice': true, // In a real implementation, you'd detect this
|
||||
// Add a static component to ensure consistency
|
||||
'appId': 'conduit_app_v1',
|
||||
};
|
||||
|
||||
final fingerprintData = jsonEncode(platformInfo);
|
||||
final bytes = utf8.encode(fingerprintData);
|
||||
final digest = sha256.convert(bytes);
|
||||
|
||||
return digest.toString();
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Failed to generate device fingerprint: $e');
|
||||
// Return a consistent fallback fingerprint
|
||||
return 'stable_fallback_device_id';
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate from old storage format if needed
|
||||
Future<void> migrateFromOldStorage(
|
||||
Map<String, String>? oldCredentials,
|
||||
) async {
|
||||
if (oldCredentials == null) return;
|
||||
|
||||
try {
|
||||
await saveCredentials(
|
||||
serverId: oldCredentials['serverId'] ?? '',
|
||||
username: oldCredentials['username'] ?? '',
|
||||
password: oldCredentials['password'] ?? '',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully migrated credentials to new secure format',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to migrate credentials: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
275
lib/core/services/settings_service.dart
Normal file
275
lib/core/services/settings_service.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'animation_service.dart';
|
||||
|
||||
/// Service for managing app-wide settings including accessibility preferences
|
||||
class SettingsService {
|
||||
static const String _reduceMotionKey = 'reduce_motion';
|
||||
static const String _animationSpeedKey = 'animation_speed';
|
||||
static const String _hapticFeedbackKey = 'haptic_feedback';
|
||||
static const String _highContrastKey = 'high_contrast';
|
||||
static const String _largeTextKey = 'large_text';
|
||||
static const String _darkModeKey = 'dark_mode';
|
||||
|
||||
/// Get reduced motion preference
|
||||
static Future<bool> getReduceMotion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_reduceMotionKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set reduced motion preference
|
||||
static Future<void> setReduceMotion(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_reduceMotionKey, value);
|
||||
}
|
||||
|
||||
/// Get animation speed multiplier (0.5 - 2.0)
|
||||
static Future<double> getAnimationSpeed() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getDouble(_animationSpeedKey) ?? 1.0;
|
||||
}
|
||||
|
||||
/// Set animation speed multiplier
|
||||
static Future<void> setAnimationSpeed(double value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_animationSpeedKey, value.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
/// Get haptic feedback preference
|
||||
static Future<bool> getHapticFeedback() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hapticFeedbackKey) ?? true;
|
||||
}
|
||||
|
||||
/// Set haptic feedback preference
|
||||
static Future<void> setHapticFeedback(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hapticFeedbackKey, value);
|
||||
}
|
||||
|
||||
/// Get high contrast preference
|
||||
static Future<bool> getHighContrast() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_highContrastKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set high contrast preference
|
||||
static Future<void> setHighContrast(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_highContrastKey, value);
|
||||
}
|
||||
|
||||
/// Get large text preference
|
||||
static Future<bool> getLargeText() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_largeTextKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set large text preference
|
||||
static Future<void> setLargeText(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_largeTextKey, value);
|
||||
}
|
||||
|
||||
/// Get dark mode preference
|
||||
static Future<bool> getDarkMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_darkModeKey) ?? true; // Default to dark
|
||||
}
|
||||
|
||||
/// Set dark mode preference
|
||||
static Future<void> setDarkMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_darkModeKey, value);
|
||||
}
|
||||
|
||||
/// Load all settings
|
||||
static Future<AppSettings> loadSettings() async {
|
||||
return AppSettings(
|
||||
reduceMotion: await getReduceMotion(),
|
||||
animationSpeed: await getAnimationSpeed(),
|
||||
hapticFeedback: await getHapticFeedback(),
|
||||
highContrast: await getHighContrast(),
|
||||
largeText: await getLargeText(),
|
||||
darkMode: await getDarkMode(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save all settings
|
||||
static Future<void> saveSettings(AppSettings settings) async {
|
||||
await Future.wait([
|
||||
setReduceMotion(settings.reduceMotion),
|
||||
setAnimationSpeed(settings.animationSpeed),
|
||||
setHapticFeedback(settings.hapticFeedback),
|
||||
setHighContrast(settings.highContrast),
|
||||
setLargeText(settings.largeText),
|
||||
setDarkMode(settings.darkMode),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Get effective animation duration considering all settings
|
||||
static Duration getEffectiveAnimationDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration,
|
||||
AppSettings settings,
|
||||
) {
|
||||
// Check system reduced motion first
|
||||
if (MediaQuery.of(context).disableAnimations || settings.reduceMotion) {
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
// Apply user animation speed preference
|
||||
final adjustedMs =
|
||||
(defaultDuration.inMilliseconds / settings.animationSpeed).round();
|
||||
return Duration(milliseconds: adjustedMs.clamp(50, 1000));
|
||||
}
|
||||
|
||||
/// Get text scale factor considering user preferences
|
||||
static double getEffectiveTextScaleFactor(
|
||||
BuildContext context,
|
||||
AppSettings settings,
|
||||
) {
|
||||
final textScaler = MediaQuery.of(context).textScaler;
|
||||
double baseScale = textScaler.scale(1.0);
|
||||
|
||||
// Apply large text preference
|
||||
if (settings.largeText) {
|
||||
baseScale *= 1.3;
|
||||
}
|
||||
|
||||
// Ensure reasonable bounds
|
||||
return baseScale.clamp(0.8, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for app settings
|
||||
class AppSettings {
|
||||
final bool reduceMotion;
|
||||
final double animationSpeed;
|
||||
final bool hapticFeedback;
|
||||
final bool highContrast;
|
||||
final bool largeText;
|
||||
final bool darkMode;
|
||||
|
||||
const AppSettings({
|
||||
this.reduceMotion = false,
|
||||
this.animationSpeed = 1.0,
|
||||
this.hapticFeedback = true,
|
||||
this.highContrast = false,
|
||||
this.largeText = false,
|
||||
this.darkMode = true,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
bool? reduceMotion,
|
||||
double? animationSpeed,
|
||||
bool? hapticFeedback,
|
||||
bool? highContrast,
|
||||
bool? largeText,
|
||||
bool? darkMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
animationSpeed: animationSpeed ?? this.animationSpeed,
|
||||
hapticFeedback: hapticFeedback ?? this.hapticFeedback,
|
||||
highContrast: highContrast ?? this.highContrast,
|
||||
largeText: largeText ?? this.largeText,
|
||||
darkMode: darkMode ?? this.darkMode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AppSettings &&
|
||||
other.reduceMotion == reduceMotion &&
|
||||
other.animationSpeed == animationSpeed &&
|
||||
other.hapticFeedback == hapticFeedback &&
|
||||
other.highContrast == highContrast &&
|
||||
other.largeText == largeText &&
|
||||
other.darkMode == darkMode;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
reduceMotion,
|
||||
animationSpeed,
|
||||
hapticFeedback,
|
||||
highContrast,
|
||||
largeText,
|
||||
darkMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for app settings
|
||||
final appSettingsProvider =
|
||||
StateNotifierProvider<AppSettingsNotifier, AppSettings>(
|
||||
(ref) => AppSettingsNotifier(),
|
||||
);
|
||||
|
||||
class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
AppSettingsNotifier() : super(const AppSettings()) {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final settings = await SettingsService.loadSettings();
|
||||
state = settings;
|
||||
}
|
||||
|
||||
Future<void> setReduceMotion(bool value) async {
|
||||
state = state.copyWith(reduceMotion: value);
|
||||
await SettingsService.setReduceMotion(value);
|
||||
}
|
||||
|
||||
Future<void> setAnimationSpeed(double value) async {
|
||||
state = state.copyWith(animationSpeed: value);
|
||||
await SettingsService.setAnimationSpeed(value);
|
||||
}
|
||||
|
||||
Future<void> setHapticFeedback(bool value) async {
|
||||
state = state.copyWith(hapticFeedback: value);
|
||||
await SettingsService.setHapticFeedback(value);
|
||||
}
|
||||
|
||||
Future<void> setHighContrast(bool value) async {
|
||||
state = state.copyWith(highContrast: value);
|
||||
await SettingsService.setHighContrast(value);
|
||||
}
|
||||
|
||||
Future<void> setLargeText(bool value) async {
|
||||
state = state.copyWith(largeText: value);
|
||||
await SettingsService.setLargeText(value);
|
||||
}
|
||||
|
||||
Future<void> setDarkMode(bool value) async {
|
||||
state = state.copyWith(darkMode: value);
|
||||
await SettingsService.setDarkMode(value);
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
const defaultSettings = AppSettings();
|
||||
await SettingsService.saveSettings(defaultSettings);
|
||||
state = defaultSettings;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for checking if haptic feedback should be enabled
|
||||
final hapticEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.hapticFeedback;
|
||||
});
|
||||
|
||||
/// Provider for effective animation settings
|
||||
final effectiveAnimationSettingsProvider = Provider<AnimationSettings>((ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
|
||||
return AnimationSettings(
|
||||
reduceMotion: appSettings.reduceMotion,
|
||||
performance: AnimationPerformance.adaptive,
|
||||
animationSpeed: appSettings.animationSpeed,
|
||||
);
|
||||
});
|
||||
372
lib/core/services/storage_service.dart
Normal file
372
lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/conversation.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
|
||||
class StorageService {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
StorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _secureStorage = secureStorage,
|
||||
_prefs = prefs,
|
||||
_secureCredentialStorage = SecureCredentialStorage();
|
||||
|
||||
// Secure storage keys
|
||||
static const String _authTokenKey = 'auth_token';
|
||||
static const String _serverConfigsKey = 'server_configs';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _credentialsKey = 'saved_credentials';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
|
||||
// Shared preferences keys
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
|
||||
// Auth token management - using enhanced secure storage
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
await _secureStorage.write(key: _authTokenKey, value: token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
final token = await _secureCredentialStorage.getAuthToken();
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
}
|
||||
|
||||
// Fallback to legacy storage
|
||||
return await _secureStorage.read(key: _authTokenKey);
|
||||
}
|
||||
|
||||
Future<void> deleteAuthToken() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteAuthToken();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to delete from enhanced storage: $e');
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
}
|
||||
|
||||
// Credential management for auto-login - using enhanced secure storage
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
// Check if enhanced secure storage is available
|
||||
final isSecureAvailable = await _secureCredentialStorage
|
||||
.isSecureStorageAvailable();
|
||||
if (!isSecureAvailable) {
|
||||
debugPrint(
|
||||
'DEBUG: Enhanced secure storage not available, using legacy storage',
|
||||
);
|
||||
throw Exception('Enhanced secure storage not available');
|
||||
}
|
||||
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
debugPrint('DEBUG: Credentials saved using enhanced secure storage');
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
|
||||
// Fallback to legacy storage
|
||||
try {
|
||||
final credentials = {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
await _secureStorage.write(
|
||||
key: _credentialsKey,
|
||||
value: jsonEncode(credentials),
|
||||
);
|
||||
|
||||
// Verify the fallback save
|
||||
final verifyData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (verifyData == null || verifyData.isEmpty) {
|
||||
throw Exception(
|
||||
'Failed to save credentials even with fallback storage',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Credentials saved using fallback storage');
|
||||
} catch (fallbackError) {
|
||||
debugPrint(
|
||||
'ERROR: Both enhanced and fallback credential storage failed: $fallbackError',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
// Try enhanced secure storage first
|
||||
try {
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
if (credentials != null) {
|
||||
return credentials;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
}
|
||||
|
||||
// Fallback to legacy storage and migrate if found
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _credentialsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return null;
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
// Validate that credentials have required fields
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
debugPrint('Warning: Invalid saved credentials format');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
final legacyCredentials = {
|
||||
'serverId': decoded['serverId']?.toString() ?? '',
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
|
||||
// Attempt to migrate to enhanced storage
|
||||
try {
|
||||
await _secureCredentialStorage.migrateFromOldStorage(legacyCredentials);
|
||||
// If migration successful, clean up legacy storage
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully migrated credentials to enhanced storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to migrate credentials: $e');
|
||||
}
|
||||
|
||||
return legacyCredentials;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading saved credentials: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteSavedCredentials();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to delete from enhanced storage: $e');
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
await setRememberCredentials(false);
|
||||
}
|
||||
|
||||
// Remember credentials preference
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
}
|
||||
|
||||
// Server configuration management
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
final json = configs.map((c) => c.toJson()).toList();
|
||||
await _secureStorage.write(key: _serverConfigsKey, value: jsonEncode(json));
|
||||
}
|
||||
|
||||
Future<List<ServerConfig>> getServerConfigs() async {
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _serverConfigsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
debugPrint('Warning: Server configs data is not a list, resetting');
|
||||
return [];
|
||||
}
|
||||
|
||||
final configs = <ServerConfig>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('name') &&
|
||||
item.containsKey('url')) {
|
||||
configs.add(ServerConfig.fromJson(item));
|
||||
} else {
|
||||
debugPrint(
|
||||
'Warning: Skipping invalid server config: missing required fields',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to parse server config: $e');
|
||||
// Continue with other configs
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading server configs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId == null) {
|
||||
await _secureStorage.delete(key: _activeServerIdKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _activeServerIdKey, value: serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
return await _secureStorage.read(key: _activeServerIdKey);
|
||||
}
|
||||
|
||||
// Theme management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
// Local conversation management
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
debugPrint(
|
||||
'Warning: Local conversations data is not a list, resetting',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
final conversations = <Conversation>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('title') &&
|
||||
item.containsKey('createdAt') &&
|
||||
item.containsKey('updatedAt')) {
|
||||
conversations.add(Conversation.fromJson(item));
|
||||
} else {
|
||||
debugPrint(
|
||||
'Warning: Skipping invalid conversation: missing required fields',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to parse conversation: $e');
|
||||
// Continue with other conversations
|
||||
}
|
||||
}
|
||||
|
||||
return conversations;
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing local conversations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
final json = conversations.map((c) => c.toJson()).toList();
|
||||
await _prefs.setString(_localConversationsKey, jsonEncode(json));
|
||||
} catch (e) {
|
||||
debugPrint('Error saving local conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.add(conversation);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
Future<void> updateLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
final index = conversations.indexWhere((c) => c.id == conversation.id);
|
||||
if (index != -1) {
|
||||
conversations[index] = conversation;
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteLocalConversation(String conversationId) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.removeWhere((c) => c.id == conversationId);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
Future<void> clearAll() async {
|
||||
// Clear enhanced secure storage
|
||||
try {
|
||||
await _secureCredentialStorage.clearAll();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to clear enhanced storage: $e');
|
||||
}
|
||||
|
||||
// Clear legacy storage
|
||||
await _secureStorage.deleteAll();
|
||||
await _prefs.clear();
|
||||
|
||||
debugPrint('DEBUG: All storage cleared');
|
||||
}
|
||||
|
||||
// Clear only auth-related data (keeping server configs and other settings)
|
||||
Future<void> clearAuthData() async {
|
||||
await deleteAuthToken();
|
||||
await deleteSavedCredentials();
|
||||
debugPrint('DEBUG: Auth data cleared');
|
||||
}
|
||||
|
||||
/// Check if enhanced secure storage is available
|
||||
Future<bool> isEnhancedSecureStorageAvailable() async {
|
||||
try {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to check enhanced storage availability: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
563
lib/core/services/user_friendly_error_handler.dart
Normal file
563
lib/core/services/user_friendly_error_handler.dart
Normal file
@@ -0,0 +1,563 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// User-friendly error messages and recovery actions
|
||||
class UserFriendlyErrorHandler {
|
||||
static final UserFriendlyErrorHandler _instance =
|
||||
UserFriendlyErrorHandler._internal();
|
||||
factory UserFriendlyErrorHandler() => _instance;
|
||||
UserFriendlyErrorHandler._internal();
|
||||
|
||||
/// Convert technical errors to user-friendly messages
|
||||
String getUserMessage(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
|
||||
if (_isNetworkError(errorString)) {
|
||||
return _getNetworkErrorMessage(errorString);
|
||||
} else if (_isValidationError(errorString)) {
|
||||
return _getValidationErrorMessage(errorString);
|
||||
} else if (_isServerError(errorString)) {
|
||||
return _getServerErrorMessage(errorString);
|
||||
} else if (_isAuthenticationError(errorString)) {
|
||||
return _getAuthenticationErrorMessage(errorString);
|
||||
} else if (_isFileError(errorString)) {
|
||||
return _getFileErrorMessage(errorString);
|
||||
} else if (_isPermissionError(errorString)) {
|
||||
return _getPermissionErrorMessage(errorString);
|
||||
}
|
||||
|
||||
// Log technical details for debugging
|
||||
_logError(error);
|
||||
|
||||
// Return generic user-friendly message
|
||||
return 'Something unexpected happened. Please try again.';
|
||||
}
|
||||
|
||||
/// Get recovery actions for the error
|
||||
List<ErrorRecoveryAction> getRecoveryActions(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
|
||||
if (_isNetworkError(errorString)) {
|
||||
return _getNetworkRecoveryActions();
|
||||
} else if (_isServerError(errorString)) {
|
||||
return _getServerRecoveryActions();
|
||||
} else if (_isAuthenticationError(errorString)) {
|
||||
return _getAuthRecoveryActions();
|
||||
} else if (_isFileError(errorString)) {
|
||||
return _getFileRecoveryActions();
|
||||
} else if (_isPermissionError(errorString)) {
|
||||
return _getPermissionRecoveryActions();
|
||||
}
|
||||
|
||||
return _getGenericRecoveryActions();
|
||||
}
|
||||
|
||||
/// Build error widget with recovery options
|
||||
Widget buildErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showDetails = false,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
|
||||
return ErrorCard(
|
||||
message: message,
|
||||
actions: actions,
|
||||
onRetry: onRetry,
|
||||
onDismiss: onDismiss,
|
||||
showDetails: showDetails,
|
||||
technicalDetails: showDetails ? error.toString() : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error dialog with recovery options
|
||||
Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showDetails = false,
|
||||
}) async {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => ErrorDialog(
|
||||
message: message,
|
||||
actions: actions,
|
||||
onRetry: onRetry,
|
||||
showDetails: showDetails,
|
||||
technicalDetails: showDetails ? error.toString() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error snackbar with quick action
|
||||
void showErrorSnackbar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
final primaryAction = actions.isNotEmpty ? actions.first : null;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
action: primaryAction != null && onRetry != null
|
||||
? SnackBarAction(
|
||||
label: primaryAction.label,
|
||||
onPressed: onRetry,
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
)
|
||||
: null,
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Network error detection and handling
|
||||
bool _isNetworkError(String error) {
|
||||
return error.contains('socketexception') ||
|
||||
error.contains('network') ||
|
||||
error.contains('connection') ||
|
||||
error.contains('timeout') ||
|
||||
error.contains('handshake') ||
|
||||
error.contains('no address associated');
|
||||
}
|
||||
|
||||
String _getNetworkErrorMessage(String error) {
|
||||
if (error.contains('timeout')) {
|
||||
return 'Connection timed out. Please check your internet connection and try again.';
|
||||
} else if (error.contains('no address associated')) {
|
||||
return 'Cannot reach the server. Please check your server URL and internet connection.';
|
||||
} else if (error.contains('connection refused')) {
|
||||
return 'Server is not responding. Please verify the server is running and accessible.';
|
||||
}
|
||||
return 'Network connection problem. Please check your internet connection.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getNetworkRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Retry',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Try the request again',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Check Connection',
|
||||
action: ErrorActionType.checkConnection,
|
||||
description: 'Verify your internet connection',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Server error detection and handling
|
||||
bool _isServerError(String error) {
|
||||
return error.contains('500') ||
|
||||
error.contains('502') ||
|
||||
error.contains('503') ||
|
||||
error.contains('504') ||
|
||||
error.contains('server error') ||
|
||||
error.contains('internal server error');
|
||||
}
|
||||
|
||||
String _getServerErrorMessage(String error) {
|
||||
if (error.contains('500')) {
|
||||
return 'Server is experiencing issues. This is usually temporary.';
|
||||
} else if (error.contains('502') || error.contains('503')) {
|
||||
return 'Server is temporarily unavailable. Please try again in a moment.';
|
||||
} else if (error.contains('504')) {
|
||||
return 'Server took too long to respond. Please try again.';
|
||||
}
|
||||
return 'Server is having problems. Please try again later.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getServerRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry your request',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Wait & Retry',
|
||||
action: ErrorActionType.retryLater,
|
||||
description: 'Wait a moment then try again',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Authentication error detection and handling
|
||||
bool _isAuthenticationError(String error) {
|
||||
return error.contains('401') ||
|
||||
error.contains('403') ||
|
||||
error.contains('unauthorized') ||
|
||||
error.contains('forbidden') ||
|
||||
error.contains('authentication') ||
|
||||
error.contains('token');
|
||||
}
|
||||
|
||||
String _getAuthenticationErrorMessage(String error) {
|
||||
if (error.contains('401') || error.contains('unauthorized')) {
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
} else if (error.contains('403') || error.contains('forbidden')) {
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
} else if (error.contains('token')) {
|
||||
return 'Authentication token is invalid. Please sign in again.';
|
||||
}
|
||||
return 'Authentication problem. Please sign in again.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getAuthRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Sign In',
|
||||
action: ErrorActionType.signIn,
|
||||
description: 'Sign in to your account',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the request',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Validation error detection and handling
|
||||
bool _isValidationError(String error) {
|
||||
return error.contains('validation') ||
|
||||
error.contains('invalid') ||
|
||||
error.contains('format') ||
|
||||
error.contains('required') ||
|
||||
error.contains('400');
|
||||
}
|
||||
|
||||
String _getValidationErrorMessage(String error) {
|
||||
if (error.contains('email')) {
|
||||
return 'Please enter a valid email address.';
|
||||
} else if (error.contains('password')) {
|
||||
return 'Password doesn\'t meet requirements. Please check and try again.';
|
||||
} else if (error.contains('required')) {
|
||||
return 'Please fill in all required fields.';
|
||||
} else if (error.contains('format')) {
|
||||
return 'Some information is in the wrong format. Please check and try again.';
|
||||
}
|
||||
return 'Please check your input and try again.';
|
||||
}
|
||||
|
||||
// File error detection and handling
|
||||
bool _isFileError(String error) {
|
||||
return error.contains('file') ||
|
||||
error.contains('path') ||
|
||||
error.contains('directory') ||
|
||||
error.contains('not found') ||
|
||||
error.contains('access denied');
|
||||
}
|
||||
|
||||
String _getFileErrorMessage(String error) {
|
||||
if (error.contains('not found')) {
|
||||
return 'File not found. It may have been moved or deleted.';
|
||||
} else if (error.contains('access denied')) {
|
||||
return 'Cannot access the file. Please check permissions.';
|
||||
} else if (error.contains('too large')) {
|
||||
return 'File is too large. Please choose a smaller file.';
|
||||
}
|
||||
return 'Problem with the file. Please try a different file.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getFileRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Choose Different File',
|
||||
action: ErrorActionType.chooseFile,
|
||||
description: 'Select another file',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the operation',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Permission error detection and handling
|
||||
bool _isPermissionError(String error) {
|
||||
return error.contains('permission') ||
|
||||
error.contains('denied') ||
|
||||
error.contains('unauthorized') ||
|
||||
error.contains('access');
|
||||
}
|
||||
|
||||
String _getPermissionErrorMessage(String error) {
|
||||
if (error.contains('camera')) {
|
||||
return 'Camera permission is required. Please enable it in settings.';
|
||||
} else if (error.contains('storage')) {
|
||||
return 'Storage permission is required. Please enable it in settings.';
|
||||
} else if (error.contains('microphone')) {
|
||||
return 'Microphone permission is required. Please enable it in settings.';
|
||||
}
|
||||
return 'Permission required. Please check app permissions in settings.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getPermissionRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Open Settings',
|
||||
action: ErrorActionType.openSettings,
|
||||
description: 'Open app settings to grant permissions',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry after granting permission',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getGenericRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the operation',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Go Back',
|
||||
action: ErrorActionType.goBack,
|
||||
description: 'Return to previous screen',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Log technical error details for debugging
|
||||
void _logError(dynamic error) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ERROR: $error');
|
||||
if (error is Error) {
|
||||
debugPrint('STACK TRACE: ${error.stackTrace}');
|
||||
}
|
||||
}
|
||||
|
||||
// In production, you might want to send this to a crash reporting service
|
||||
// FirebaseCrashlytics.instance.recordError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Error recovery action definition
|
||||
class ErrorRecoveryAction {
|
||||
final String label;
|
||||
final ErrorActionType action;
|
||||
final String description;
|
||||
final VoidCallback? customAction;
|
||||
|
||||
ErrorRecoveryAction({
|
||||
required this.label,
|
||||
required this.action,
|
||||
required this.description,
|
||||
this.customAction,
|
||||
});
|
||||
}
|
||||
|
||||
/// Types of error recovery actions
|
||||
enum ErrorActionType {
|
||||
retry,
|
||||
retryLater,
|
||||
goBack,
|
||||
signIn,
|
||||
openSettings,
|
||||
checkConnection,
|
||||
chooseFile,
|
||||
contactSupport,
|
||||
dismiss,
|
||||
}
|
||||
|
||||
/// Error card widget
|
||||
class ErrorCard extends StatelessWidget {
|
||||
final String message;
|
||||
final List<ErrorRecoveryAction> actions;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onDismiss;
|
||||
final bool showDetails;
|
||||
final String? technicalDetails;
|
||||
|
||||
const ErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.actions,
|
||||
this.onRetry,
|
||||
this.onDismiss,
|
||||
this.showDetails = false,
|
||||
this.technicalDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(Spacing.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: IconSize.lg,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm + Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: actions.take(2).map((action) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => _handleAction(context, action),
|
||||
child: Text(action.label),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (showDetails && technicalDetails != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ExpansionTile(
|
||||
title: const Text('Technical Details'),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: SelectableText(
|
||||
technicalDetails!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleAction(BuildContext context, ErrorRecoveryAction action) {
|
||||
if (action.customAction != null) {
|
||||
action.customAction!();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.action) {
|
||||
case ErrorActionType.retry:
|
||||
onRetry?.call();
|
||||
break;
|
||||
case ErrorActionType.goBack:
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
case ErrorActionType.dismiss:
|
||||
onDismiss?.call();
|
||||
break;
|
||||
case ErrorActionType.signIn:
|
||||
// Navigate to sign in page
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
break;
|
||||
case ErrorActionType.openSettings:
|
||||
// Open app settings - would need platform-specific implementation
|
||||
break;
|
||||
default:
|
||||
onRetry?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error dialog widget
|
||||
class ErrorDialog extends StatelessWidget {
|
||||
final String message;
|
||||
final List<ErrorRecoveryAction> actions;
|
||||
final VoidCallback? onRetry;
|
||||
final bool showDetails;
|
||||
final String? technicalDetails;
|
||||
|
||||
const ErrorDialog({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.actions,
|
||||
this.onRetry,
|
||||
this.showDetails = false,
|
||||
this.technicalDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(width: Spacing.sm + Spacing.xs),
|
||||
const Text('Error'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(message),
|
||||
if (showDetails && technicalDetails != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ExpansionTile(
|
||||
title: const Text('Technical Details'),
|
||||
children: [
|
||||
SelectableText(
|
||||
technicalDetails!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
if (actions.isNotEmpty)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
if (actions.first.action == ErrorActionType.retry) {
|
||||
onRetry?.call();
|
||||
}
|
||||
},
|
||||
child: Text(actions.first.label),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
158
lib/core/utils/reasoning_parser.dart
Normal file
158
lib/core/utils/reasoning_parser.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Utility class for parsing and extracting reasoning/thinking content from messages
|
||||
class ReasoningParser {
|
||||
/// Parses a message and extracts reasoning content
|
||||
static ReasoningContent? parseReasoningContent(String content) {
|
||||
if (content.isEmpty) return null;
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'DEBUG: Parsing content: ${content.substring(0, content.length > 200 ? 200 : content.length)}...',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if content contains reasoning
|
||||
if (!content.contains('<details type="reasoning"')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: No reasoning content found in text');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Found reasoning tags in content');
|
||||
}
|
||||
|
||||
// Match the <details> tag with type="reasoning"
|
||||
final reasoningRegex = RegExp(
|
||||
r'<details\s+type="reasoning"\s+done="(true|false)"\s+duration="(\d+)"[^>]*>\s*<summary>([^<]*)</summary>\s*(.*?)\s*</details>',
|
||||
multiLine: true,
|
||||
dotAll: true,
|
||||
);
|
||||
|
||||
final match = reasoningRegex.firstMatch(content);
|
||||
if (match == null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Regex did not match - checking pattern');
|
||||
}
|
||||
// Try a more flexible regex to debug
|
||||
final flexRegex = RegExp(
|
||||
r'<details[^>]*type="reasoning"[^>]*>.*?</details>',
|
||||
multiLine: true,
|
||||
dotAll: true,
|
||||
);
|
||||
final flexMatch = flexRegex.firstMatch(content);
|
||||
if (flexMatch != null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Found flexible match: ${flexMatch.group(0)}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: No flexible match found either');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Regex matched successfully');
|
||||
}
|
||||
|
||||
final isDone = match.group(1) == 'true';
|
||||
final duration = int.tryParse(match.group(2) ?? '0') ?? 0;
|
||||
final summary = match.group(3)?.trim() ?? '';
|
||||
final reasoning = match.group(4)?.trim() ?? '';
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'DEBUG: Parsed values - isDone: $isDone, duration: $duration, summary: $summary',
|
||||
);
|
||||
debugPrint('DEBUG: Reasoning content length: ${reasoning.length}');
|
||||
}
|
||||
|
||||
// Remove the reasoning section from the main content
|
||||
final mainContent = content.replaceAll(reasoningRegex, '').trim();
|
||||
|
||||
return ReasoningContent(
|
||||
reasoning: reasoning,
|
||||
summary: summary,
|
||||
duration: duration,
|
||||
isDone: isDone,
|
||||
mainContent: mainContent,
|
||||
originalContent: content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks if a message contains reasoning content
|
||||
static bool hasReasoningContent(String content) {
|
||||
return content.contains('<details type="reasoning"');
|
||||
}
|
||||
|
||||
/// Formats the duration for display
|
||||
static String formatDuration(int seconds) {
|
||||
if (seconds == 0) return 'instant';
|
||||
if (seconds < 60) return '$seconds second${seconds == 1 ? '' : 's'}';
|
||||
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
|
||||
if (remainingSeconds == 0) {
|
||||
return '$minutes minute${minutes == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
return '$minutes min ${remainingSeconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Model class for reasoning content
|
||||
class ReasoningContent {
|
||||
final String reasoning;
|
||||
final String summary;
|
||||
final int duration;
|
||||
final bool isDone;
|
||||
final String mainContent;
|
||||
final String originalContent;
|
||||
|
||||
const ReasoningContent({
|
||||
required this.reasoning,
|
||||
required this.summary,
|
||||
required this.duration,
|
||||
required this.isDone,
|
||||
required this.mainContent,
|
||||
required this.originalContent,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ReasoningContent &&
|
||||
runtimeType == other.runtimeType &&
|
||||
reasoning == other.reasoning &&
|
||||
summary == other.summary &&
|
||||
duration == other.duration &&
|
||||
isDone == other.isDone &&
|
||||
mainContent == other.mainContent &&
|
||||
originalContent == other.originalContent;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
reasoning.hashCode ^
|
||||
summary.hashCode ^
|
||||
duration.hashCode ^
|
||||
isDone.hashCode ^
|
||||
mainContent.hashCode ^
|
||||
originalContent.hashCode;
|
||||
|
||||
String get formattedDuration => ReasoningParser.formatDuration(duration);
|
||||
|
||||
/// Gets the cleaned reasoning text (removes leading '>')
|
||||
String get cleanedReasoning {
|
||||
// Split by lines and clean each line
|
||||
return reasoning
|
||||
.split('\n')
|
||||
.map((line) => line.startsWith('>') ? line.substring(1).trim() : line)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
105
lib/core/utils/stream_chunker.dart
Normal file
105
lib/core/utils/stream_chunker.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
/// Utility class to chunk large text streams into smaller pieces for smoother UI updates
|
||||
class StreamChunker {
|
||||
/// Splits large text chunks into smaller pieces for more fluid streaming
|
||||
/// Similar to OpenWebUI's approach for better UX
|
||||
static Stream<String> chunkStream(
|
||||
Stream<String> inputStream, {
|
||||
bool enableChunking = true,
|
||||
int minChunkSize = 16, // increase to reduce UI thrash
|
||||
int maxChunkLength = 12, // larger chunks improve performance
|
||||
Duration delayBetweenChunks = const Duration(milliseconds: 8),
|
||||
}) async* {
|
||||
final random = Random();
|
||||
|
||||
await for (final chunk in inputStream) {
|
||||
if (!enableChunking || chunk.length < minChunkSize) {
|
||||
// Small chunks pass through as-is
|
||||
yield chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split large chunks into smaller pieces
|
||||
String remaining = chunk;
|
||||
while (remaining.isNotEmpty) {
|
||||
// Random chunk size between 4 and maxChunkLength characters
|
||||
// But prefer to break at word boundaries when possible
|
||||
int chunkSize = min(
|
||||
max(4, random.nextInt(maxChunkLength) + 1),
|
||||
remaining.length,
|
||||
);
|
||||
|
||||
// Try to find a word boundary (space) within the chunk size
|
||||
if (chunkSize < remaining.length) {
|
||||
final nextSpace = remaining.indexOf(' ', chunkSize);
|
||||
if (nextSpace != -1 && nextSpace <= chunkSize + 2) {
|
||||
// Include the space in the chunk for natural word breaks
|
||||
chunkSize = nextSpace + 1;
|
||||
}
|
||||
}
|
||||
|
||||
final pieceToYield = remaining.substring(0, chunkSize);
|
||||
yield pieceToYield;
|
||||
remaining = remaining.substring(chunkSize);
|
||||
|
||||
// Add small delay between chunks for fluid animation
|
||||
// Skip delay for last piece to avoid unnecessary wait
|
||||
if (remaining.isNotEmpty && delayBetweenChunks.inMicroseconds > 0) {
|
||||
await Future.delayed(delayBetweenChunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alternative method that chunks by words instead of characters
|
||||
static Stream<String> chunkByWords(
|
||||
Stream<String> inputStream, {
|
||||
bool enableChunking = true,
|
||||
int wordsPerChunk = 1,
|
||||
Duration delayBetweenWords = const Duration(milliseconds: 50),
|
||||
}) async* {
|
||||
if (!enableChunking) {
|
||||
yield* inputStream;
|
||||
return;
|
||||
}
|
||||
|
||||
String buffer = '';
|
||||
|
||||
await for (final chunk in inputStream) {
|
||||
buffer += chunk;
|
||||
|
||||
// Split by spaces and yield word by word
|
||||
final words = buffer.split(' ');
|
||||
|
||||
// Keep the last "word" in buffer as it might be incomplete
|
||||
if (words.length > 1) {
|
||||
buffer = words.last;
|
||||
final completeWords = words.sublist(0, words.length - 1);
|
||||
|
||||
for (int i = 0; i < completeWords.length; i++) {
|
||||
final word = completeWords[i];
|
||||
// Add space back except for the first word if buffer was empty
|
||||
final wordWithSpace =
|
||||
(i < completeWords.length - 1 || buffer.isNotEmpty)
|
||||
? '$word '
|
||||
: word;
|
||||
|
||||
yield wordWithSpace;
|
||||
|
||||
// Add delay between words for smooth streaming effect
|
||||
if (i < completeWords.length - 1 &&
|
||||
delayBetweenWords.inMicroseconds > 0) {
|
||||
await Future.delayed(delayBetweenWords);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield any remaining buffer content
|
||||
if (buffer.isNotEmpty) {
|
||||
yield buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
416
lib/core/validation/api_validator.dart
Normal file
416
lib/core/validation/api_validator.dart
Normal file
@@ -0,0 +1,416 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'schema_registry.dart';
|
||||
import 'validation_result.dart';
|
||||
import 'field_mapper.dart';
|
||||
|
||||
/// Comprehensive API request and response validator
|
||||
/// Validates against OpenAPI specification schemas
|
||||
class ApiValidator {
|
||||
static final ApiValidator _instance = ApiValidator._internal();
|
||||
factory ApiValidator() => _instance;
|
||||
ApiValidator._internal();
|
||||
|
||||
final SchemaRegistry _schemaRegistry = SchemaRegistry();
|
||||
final FieldMapper _fieldMapper = FieldMapper();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Initialize validator with OpenAPI schemas
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
await _schemaRegistry.loadSchemas();
|
||||
_initialized = true;
|
||||
debugPrint('ApiValidator: Successfully initialized with schemas');
|
||||
} catch (e) {
|
||||
debugPrint('ApiValidator: Failed to initialize: $e');
|
||||
// Continue without validation if schemas can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate request payload before sending to API
|
||||
ValidationResult validateRequest(
|
||||
dynamic data,
|
||||
String endpoint, {
|
||||
String method = 'GET',
|
||||
}) {
|
||||
if (!_initialized) {
|
||||
return ValidationResult.warning(
|
||||
'Validator not initialized - skipping validation',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final schema = _schemaRegistry.getRequestSchema(endpoint, method);
|
||||
if (schema == null) {
|
||||
return ValidationResult.warning(
|
||||
'No schema found for $method $endpoint',
|
||||
);
|
||||
}
|
||||
|
||||
// Transform field names for API (camelCase -> snake_case)
|
||||
final transformedData = _fieldMapper.toApiFormat(data);
|
||||
|
||||
// Validate against schema
|
||||
return _validateAgainstSchema(transformedData, schema, 'request');
|
||||
} catch (e) {
|
||||
return ValidationResult.error('Request validation failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate response payload after receiving from API
|
||||
ValidationResult validateResponse(
|
||||
dynamic data,
|
||||
String endpoint, {
|
||||
String method = 'GET',
|
||||
int? statusCode,
|
||||
}) {
|
||||
if (!_initialized) {
|
||||
return ValidationResult.warning(
|
||||
'Validator not initialized - skipping validation',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final schema = _schemaRegistry.getResponseSchema(
|
||||
endpoint,
|
||||
method,
|
||||
statusCode,
|
||||
);
|
||||
if (schema == null) {
|
||||
return ValidationResult.warning(
|
||||
'No schema found for $method $endpoint response',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate against schema first
|
||||
final validationResult = _validateAgainstSchema(data, schema, 'response');
|
||||
if (!validationResult.isValid) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// Transform field names from API (snake_case -> camelCase)
|
||||
final transformedData = _fieldMapper.fromApiFormat(data);
|
||||
|
||||
return ValidationResult.success(
|
||||
'Response validated successfully',
|
||||
data: transformedData,
|
||||
);
|
||||
} catch (e) {
|
||||
return ValidationResult.error('Response validation failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate data against a specific schema
|
||||
ValidationResult _validateAgainstSchema(
|
||||
dynamic data,
|
||||
Map<String, dynamic> schema,
|
||||
String context,
|
||||
) {
|
||||
final errors = <String>[];
|
||||
final warnings = <String>[];
|
||||
|
||||
try {
|
||||
_validateValue(data, schema, '', errors, warnings);
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
return ValidationResult.error(
|
||||
'Schema validation failed for $context',
|
||||
errors: errors,
|
||||
warnings: warnings,
|
||||
);
|
||||
}
|
||||
|
||||
if (warnings.isNotEmpty) {
|
||||
return ValidationResult.warning(
|
||||
'Schema validation passed with warnings for $context',
|
||||
warnings: warnings,
|
||||
);
|
||||
}
|
||||
|
||||
return ValidationResult.success('Schema validation passed for $context');
|
||||
} catch (e) {
|
||||
return ValidationResult.error('Schema validation error for $context: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively validate a value against schema
|
||||
void _validateValue(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
final type = schema['type'] as String?;
|
||||
final required = schema['required'] as List<dynamic>? ?? [];
|
||||
|
||||
// Handle null values
|
||||
if (value == null) {
|
||||
if (required.isNotEmpty && path.isNotEmpty) {
|
||||
errors.add('Required field missing: $path');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
switch (type) {
|
||||
case 'object':
|
||||
_validateObject(value, schema, path, errors, warnings);
|
||||
break;
|
||||
case 'array':
|
||||
_validateArray(value, schema, path, errors, warnings);
|
||||
break;
|
||||
case 'string':
|
||||
_validateString(value, schema, path, errors, warnings);
|
||||
break;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
_validateNumber(value, schema, path, errors, warnings);
|
||||
break;
|
||||
case 'boolean':
|
||||
_validateBoolean(value, schema, path, errors, warnings);
|
||||
break;
|
||||
default:
|
||||
// Unknown type - add warning but don't fail
|
||||
warnings.add('Unknown schema type "$type" at $path');
|
||||
}
|
||||
}
|
||||
|
||||
void _validateObject(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
if (value is! Map) {
|
||||
errors.add('Expected object at $path, got ${value.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final valueMap = value as Map<String, dynamic>;
|
||||
final properties = schema['properties'] as Map<String, dynamic>? ?? {};
|
||||
final required = (schema['required'] as List<dynamic>? ?? [])
|
||||
.cast<String>();
|
||||
|
||||
// Check required fields
|
||||
for (final requiredField in required) {
|
||||
if (!valueMap.containsKey(requiredField)) {
|
||||
errors.add(
|
||||
'Required field missing: ${path.isEmpty ? '' : '$path.'}$requiredField',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each property
|
||||
for (final entry in valueMap.entries) {
|
||||
final fieldName = entry.key;
|
||||
final fieldValue = entry.value;
|
||||
final fieldPath = path.isEmpty ? fieldName : '$path.$fieldName';
|
||||
|
||||
if (properties.containsKey(fieldName)) {
|
||||
_validateValue(
|
||||
fieldValue,
|
||||
properties[fieldName],
|
||||
fieldPath,
|
||||
errors,
|
||||
warnings,
|
||||
);
|
||||
} else {
|
||||
// Additional property - warn but don't error
|
||||
warnings.add('Additional property found: $fieldPath');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validateArray(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
if (value is! List) {
|
||||
errors.add('Expected array at $path, got ${value.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final array = value;
|
||||
final items = schema['items'] as Map<String, dynamic>?;
|
||||
final minItems = schema['minItems'] as int?;
|
||||
final maxItems = schema['maxItems'] as int?;
|
||||
|
||||
// Validate array constraints
|
||||
if (minItems != null && array.length < minItems) {
|
||||
errors.add(
|
||||
'Array at $path has ${array.length} items, minimum is $minItems',
|
||||
);
|
||||
}
|
||||
|
||||
if (maxItems != null && array.length > maxItems) {
|
||||
errors.add(
|
||||
'Array at $path has ${array.length} items, maximum is $maxItems',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each item
|
||||
if (items != null) {
|
||||
for (int i = 0; i < array.length; i++) {
|
||||
_validateValue(array[i], items, '$path[$i]', errors, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validateString(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
if (value is! String) {
|
||||
errors.add('Expected string at $path, got ${value.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final string = value;
|
||||
final minLength = schema['minLength'] as int?;
|
||||
final maxLength = schema['maxLength'] as int?;
|
||||
final pattern = schema['pattern'] as String?;
|
||||
final format = schema['format'] as String?;
|
||||
|
||||
if (minLength != null && string.length < minLength) {
|
||||
errors.add(
|
||||
'String at $path is ${string.length} chars, minimum is $minLength',
|
||||
);
|
||||
}
|
||||
|
||||
if (maxLength != null && string.length > maxLength) {
|
||||
errors.add(
|
||||
'String at $path is ${string.length} chars, maximum is $maxLength',
|
||||
);
|
||||
}
|
||||
|
||||
if (pattern != null) {
|
||||
try {
|
||||
final regex = RegExp(pattern);
|
||||
if (!regex.hasMatch(string)) {
|
||||
errors.add('String at $path does not match pattern: $pattern');
|
||||
}
|
||||
} catch (e) {
|
||||
warnings.add('Invalid regex pattern at $path: $pattern');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate common formats
|
||||
if (format != null) {
|
||||
_validateStringFormat(string, format, path, errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
void _validateStringFormat(
|
||||
String value,
|
||||
String format,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
switch (format) {
|
||||
case 'email':
|
||||
final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$');
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
errors.add('Invalid email format at $path: $value');
|
||||
}
|
||||
break;
|
||||
case 'uri':
|
||||
case 'url':
|
||||
try {
|
||||
Uri.parse(value);
|
||||
} catch (e) {
|
||||
errors.add('Invalid URL format at $path: $value');
|
||||
}
|
||||
break;
|
||||
case 'date':
|
||||
try {
|
||||
DateTime.parse(value);
|
||||
} catch (e) {
|
||||
errors.add('Invalid date format at $path: $value');
|
||||
}
|
||||
break;
|
||||
case 'date-time':
|
||||
try {
|
||||
DateTime.parse(value);
|
||||
} catch (e) {
|
||||
errors.add('Invalid datetime format at $path: $value');
|
||||
}
|
||||
break;
|
||||
case 'uuid':
|
||||
final uuidRegex = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
if (!uuidRegex.hasMatch(value)) {
|
||||
errors.add('Invalid UUID format at $path: $value');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
warnings.add('Unknown string format "$format" at $path');
|
||||
}
|
||||
}
|
||||
|
||||
void _validateNumber(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
if (value is! num) {
|
||||
errors.add('Expected number at $path, got ${value.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final number = value;
|
||||
final minimum = schema['minimum'] as num?;
|
||||
final maximum = schema['maximum'] as num?;
|
||||
final multipleOf = schema['multipleOf'] as num?;
|
||||
|
||||
if (minimum != null && number < minimum) {
|
||||
errors.add('Number at $path is $number, minimum is $minimum');
|
||||
}
|
||||
|
||||
if (maximum != null && number > maximum) {
|
||||
errors.add('Number at $path is $number, maximum is $maximum');
|
||||
}
|
||||
|
||||
if (multipleOf != null && number % multipleOf != 0) {
|
||||
errors.add('Number at $path ($number) is not a multiple of $multipleOf');
|
||||
}
|
||||
}
|
||||
|
||||
void _validateBoolean(
|
||||
dynamic value,
|
||||
Map<String, dynamic> schema,
|
||||
String path,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
) {
|
||||
if (value is! bool) {
|
||||
errors.add('Expected boolean at $path, got ${value.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform and validate data for API consumption
|
||||
Map<String, dynamic> transformForApi(Map<String, dynamic> data) {
|
||||
return _fieldMapper.toApiFormat(data);
|
||||
}
|
||||
|
||||
/// Transform and validate data from API response
|
||||
Map<String, dynamic> transformFromApi(Map<String, dynamic> data) {
|
||||
return _fieldMapper.fromApiFormat(data);
|
||||
}
|
||||
}
|
||||
267
lib/core/validation/field_mapper.dart
Normal file
267
lib/core/validation/field_mapper.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Handles field name transformations between API and client formats
|
||||
/// Converts between snake_case (API) and camelCase (client)
|
||||
class FieldMapper {
|
||||
static final FieldMapper _instance = FieldMapper._internal();
|
||||
factory FieldMapper() => _instance;
|
||||
FieldMapper._internal();
|
||||
|
||||
// Cache for converted field names to improve performance
|
||||
final Map<String, String> _toCamelCaseCache = {};
|
||||
final Map<String, String> _toSnakeCaseCache = {};
|
||||
|
||||
// Special field mappings that don't follow standard conversion rules
|
||||
static const Map<String, String> _specialApiToClient = {
|
||||
'created_at': 'createdAt',
|
||||
'updated_at': 'updatedAt',
|
||||
'user_id': 'userId',
|
||||
'chat_id': 'chatId',
|
||||
'message_id': 'messageId',
|
||||
'session_id': 'sessionId',
|
||||
'folder_id': 'folderId',
|
||||
'share_id': 'shareId',
|
||||
'model_id': 'modelId',
|
||||
'tool_id': 'toolId',
|
||||
'function_id': 'functionId',
|
||||
'file_id': 'fileId',
|
||||
'knowledge_base_id': 'knowledgeBaseId',
|
||||
'channel_id': 'channelId',
|
||||
'note_id': 'noteId',
|
||||
'prompt_id': 'promptId',
|
||||
'memory_id': 'memoryId',
|
||||
'is_private': 'isPrivate',
|
||||
'is_enabled': 'isEnabled',
|
||||
'is_active': 'isActive',
|
||||
'is_archived': 'isArchived',
|
||||
'is_pinned': 'isPinned',
|
||||
'api_key': 'apiKey',
|
||||
'access_token': 'accessToken',
|
||||
'refresh_token': 'refreshToken',
|
||||
'content_type': 'contentType',
|
||||
'file_size': 'fileSize',
|
||||
'file_type': 'fileType',
|
||||
'mime_type': 'mimeType',
|
||||
};
|
||||
|
||||
static const Map<String, String> _specialClientToApi = {
|
||||
'createdAt': 'created_at',
|
||||
'updatedAt': 'updated_at',
|
||||
'userId': 'user_id',
|
||||
'chatId': 'chat_id',
|
||||
'messageId': 'message_id',
|
||||
'sessionId': 'session_id',
|
||||
'folderId': 'folder_id',
|
||||
'shareId': 'share_id',
|
||||
'modelId': 'model_id',
|
||||
'toolId': 'tool_id',
|
||||
'functionId': 'function_id',
|
||||
'fileId': 'file_id',
|
||||
'knowledgeBaseId': 'knowledge_base_id',
|
||||
'channelId': 'channel_id',
|
||||
'noteId': 'note_id',
|
||||
'promptId': 'prompt_id',
|
||||
'memoryId': 'memory_id',
|
||||
'isPrivate': 'is_private',
|
||||
'isEnabled': 'is_enabled',
|
||||
'isActive': 'is_active',
|
||||
'isArchived': 'is_archived',
|
||||
'isPinned': 'is_pinned',
|
||||
'apiKey': 'api_key',
|
||||
'accessToken': 'access_token',
|
||||
'refreshToken': 'refresh_token',
|
||||
'contentType': 'content_type',
|
||||
'fileSize': 'file_size',
|
||||
'fileType': 'file_type',
|
||||
'mimeType': 'mime_type',
|
||||
};
|
||||
|
||||
/// Transform data from client format (camelCase) to API format (snake_case)
|
||||
dynamic toApiFormat(dynamic data) {
|
||||
if (data == null) return null;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
return _transformMap(data, _toSnakeCase);
|
||||
} else if (data is List) {
|
||||
return data.map((item) => toApiFormat(item)).toList();
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform data from API format (snake_case) to client format (camelCase)
|
||||
dynamic fromApiFormat(dynamic data) {
|
||||
if (data == null) return null;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
return _transformMap(data, _toCamelCase);
|
||||
} else if (data is List) {
|
||||
return data.map((item) => fromApiFormat(item)).toList();
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform a map using the provided key transformation function
|
||||
Map<String, dynamic> _transformMap(
|
||||
Map<String, dynamic> map,
|
||||
String Function(String) keyTransform,
|
||||
) {
|
||||
final transformed = <String, dynamic>{};
|
||||
|
||||
for (final entry in map.entries) {
|
||||
final transformedKey = keyTransform(entry.key);
|
||||
dynamic transformedValue = entry.value;
|
||||
|
||||
// Recursively transform nested objects and arrays
|
||||
if (transformedValue is Map<String, dynamic>) {
|
||||
transformedValue = _transformMap(transformedValue, keyTransform);
|
||||
} else if (transformedValue is List) {
|
||||
transformedValue = transformedValue.map((item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
return _transformMap(item, keyTransform);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
transformed[transformedKey] = transformedValue;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/// Convert snake_case to camelCase
|
||||
String _toCamelCase(String snakeCase) {
|
||||
// Check cache first
|
||||
if (_toCamelCaseCache.containsKey(snakeCase)) {
|
||||
return _toCamelCaseCache[snakeCase]!;
|
||||
}
|
||||
|
||||
// Check special mappings
|
||||
if (_specialApiToClient.containsKey(snakeCase)) {
|
||||
final result = _specialApiToClient[snakeCase]!;
|
||||
_toCamelCaseCache[snakeCase] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Standard conversion
|
||||
if (!snakeCase.contains('_')) {
|
||||
_toCamelCaseCache[snakeCase] = snakeCase;
|
||||
return snakeCase;
|
||||
}
|
||||
|
||||
final words = snakeCase.split('_');
|
||||
final result =
|
||||
words.first + words.skip(1).map((word) => _capitalize(word)).join('');
|
||||
|
||||
_toCamelCaseCache[snakeCase] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Convert camelCase to snake_case
|
||||
String _toSnakeCase(String camelCase) {
|
||||
// Check cache first
|
||||
if (_toSnakeCaseCache.containsKey(camelCase)) {
|
||||
return _toSnakeCaseCache[camelCase]!;
|
||||
}
|
||||
|
||||
// Check special mappings
|
||||
if (_specialClientToApi.containsKey(camelCase)) {
|
||||
final result = _specialClientToApi[camelCase]!;
|
||||
_toSnakeCaseCache[camelCase] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Standard conversion
|
||||
final result = camelCase.replaceAllMapped(
|
||||
RegExp(r'[A-Z]'),
|
||||
(match) => '_${match.group(0)!.toLowerCase()}',
|
||||
);
|
||||
|
||||
_toSnakeCaseCache[camelCase] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Capitalize first letter of a word
|
||||
String _capitalize(String word) {
|
||||
if (word.isEmpty) return word;
|
||||
return word[0].toUpperCase() + word.substring(1).toLowerCase();
|
||||
}
|
||||
|
||||
/// Convert a single field name from snake_case to camelCase
|
||||
String fieldToCamelCase(String snakeCase) {
|
||||
return _toCamelCase(snakeCase);
|
||||
}
|
||||
|
||||
/// Convert a single field name from camelCase to snake_case
|
||||
String fieldToSnakeCase(String camelCase) {
|
||||
return _toSnakeCase(camelCase);
|
||||
}
|
||||
|
||||
/// Get all cached transformations for debugging
|
||||
Map<String, dynamic> getCacheInfo() {
|
||||
return {
|
||||
'toCamelCacheSize': _toCamelCaseCache.length,
|
||||
'toSnakeCacheSize': _toSnakeCaseCache.length,
|
||||
'specialMappingsCount': _specialApiToClient.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Clear transformation caches
|
||||
void clearCache() {
|
||||
_toCamelCaseCache.clear();
|
||||
_toSnakeCaseCache.clear();
|
||||
debugPrint('FieldMapper: Cleared transformation caches');
|
||||
}
|
||||
|
||||
/// Add custom field mapping
|
||||
void addCustomMapping(String apiField, String clientField) {
|
||||
_specialApiToClient[apiField] = clientField;
|
||||
_specialClientToApi[clientField] = apiField;
|
||||
|
||||
// Clear relevant cache entries
|
||||
_toCamelCaseCache.remove(apiField);
|
||||
_toSnakeCaseCache.remove(clientField);
|
||||
|
||||
debugPrint('FieldMapper: Added custom mapping: $apiField <-> $clientField');
|
||||
}
|
||||
|
||||
/// Validate that field transformations are reversible
|
||||
bool validateTransformations() {
|
||||
final errors = <String>[];
|
||||
|
||||
// Test special mappings
|
||||
for (final entry in _specialApiToClient.entries) {
|
||||
final apiField = entry.key;
|
||||
final clientField = entry.value;
|
||||
|
||||
// Test API -> Client -> API
|
||||
final backToApi = _toSnakeCase(clientField);
|
||||
if (backToApi != apiField) {
|
||||
errors.add(
|
||||
'$apiField -> $clientField -> $backToApi (should be $apiField)',
|
||||
);
|
||||
}
|
||||
|
||||
// Test Client -> API -> Client
|
||||
final backToClient = _toCamelCase(apiField);
|
||||
if (backToClient != clientField) {
|
||||
errors.add(
|
||||
'$clientField -> $apiField -> $backToClient (should be $clientField)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
debugPrint('FieldMapper: Transformation validation errors:');
|
||||
for (final error in errors) {
|
||||
debugPrint(' $error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('FieldMapper: All transformations validated successfully');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
368
lib/core/validation/schema_registry.dart
Normal file
368
lib/core/validation/schema_registry.dart
Normal file
@@ -0,0 +1,368 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Registry for OpenAPI schemas
|
||||
/// Loads and provides access to request/response schemas for validation
|
||||
class SchemaRegistry {
|
||||
static final SchemaRegistry _instance = SchemaRegistry._internal();
|
||||
factory SchemaRegistry() => _instance;
|
||||
SchemaRegistry._internal();
|
||||
|
||||
Map<String, dynamic>? _openApiSpec;
|
||||
final Map<String, Map<String, dynamic>> _requestSchemaCache = {};
|
||||
final Map<String, Map<String, dynamic>> _responseSchemaCache = {};
|
||||
|
||||
bool get isLoaded => _openApiSpec != null;
|
||||
|
||||
/// Load schemas from OpenAPI specification
|
||||
Future<void> loadSchemas() async {
|
||||
try {
|
||||
debugPrint('SchemaRegistry: Loading OpenAPI specification...');
|
||||
|
||||
// Try to load from assets first, then from file system as fallback
|
||||
String openApiContent;
|
||||
try {
|
||||
openApiContent = await rootBundle.loadString('assets/openapi.json');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'SchemaRegistry: Could not load from assets, trying file system...',
|
||||
);
|
||||
// Fallback - in a real app you might load from network or local file
|
||||
throw Exception('OpenAPI specification not found in assets');
|
||||
}
|
||||
|
||||
_openApiSpec = jsonDecode(openApiContent) as Map<String, dynamic>;
|
||||
|
||||
debugPrint(
|
||||
'SchemaRegistry: Successfully loaded OpenAPI spec with ${_getPaths().length} paths',
|
||||
);
|
||||
|
||||
// Pre-process and cache commonly used schemas
|
||||
await _buildSchemaCache();
|
||||
} catch (e) {
|
||||
debugPrint('SchemaRegistry: Failed to load schemas: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get request schema for endpoint and method
|
||||
Map<String, dynamic>? getRequestSchema(String endpoint, String method) {
|
||||
if (!isLoaded) return null;
|
||||
|
||||
final cacheKey = '${method.toUpperCase()}:$endpoint:request';
|
||||
if (_requestSchemaCache.containsKey(cacheKey)) {
|
||||
return _requestSchemaCache[cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
final pathItem = _findPathItem(endpoint);
|
||||
if (pathItem == null) return null;
|
||||
|
||||
final operation = pathItem[method.toLowerCase()] as Map<String, dynamic>?;
|
||||
if (operation == null) return null;
|
||||
|
||||
final requestBody = operation['requestBody'] as Map<String, dynamic>?;
|
||||
if (requestBody == null) return null;
|
||||
|
||||
final content = requestBody['content'] as Map<String, dynamic>?;
|
||||
if (content == null) return null;
|
||||
|
||||
// Try to find JSON content type
|
||||
final jsonContent =
|
||||
content['application/json'] as Map<String, dynamic>? ??
|
||||
content.values.first as Map<String, dynamic>?;
|
||||
|
||||
if (jsonContent == null) return null;
|
||||
|
||||
final schema = _resolveSchema(
|
||||
jsonContent['schema'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
if (schema != null) {
|
||||
_requestSchemaCache[cacheKey] = schema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'SchemaRegistry: Error getting request schema for $method $endpoint: $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get response schema for endpoint, method, and status code
|
||||
Map<String, dynamic>? getResponseSchema(
|
||||
String endpoint,
|
||||
String method,
|
||||
int? statusCode,
|
||||
) {
|
||||
if (!isLoaded) return null;
|
||||
|
||||
final code = statusCode?.toString() ?? '200';
|
||||
final cacheKey = '${method.toUpperCase()}:$endpoint:response:$code';
|
||||
|
||||
if (_responseSchemaCache.containsKey(cacheKey)) {
|
||||
return _responseSchemaCache[cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
final pathItem = _findPathItem(endpoint);
|
||||
if (pathItem == null) return null;
|
||||
|
||||
final operation = pathItem[method.toLowerCase()] as Map<String, dynamic>?;
|
||||
if (operation == null) return null;
|
||||
|
||||
final responses = operation['responses'] as Map<String, dynamic>?;
|
||||
if (responses == null) return null;
|
||||
|
||||
// Try to find the specific status code, or fall back to 'default' or '200'
|
||||
final response =
|
||||
responses[code] as Map<String, dynamic>? ??
|
||||
responses['default'] as Map<String, dynamic>? ??
|
||||
responses['200'] as Map<String, dynamic>?;
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
final content = response['content'] as Map<String, dynamic>?;
|
||||
if (content == null) return null;
|
||||
|
||||
// Try to find JSON content type
|
||||
final jsonContent =
|
||||
content['application/json'] as Map<String, dynamic>? ??
|
||||
content.values.first as Map<String, dynamic>?;
|
||||
|
||||
if (jsonContent == null) return null;
|
||||
|
||||
final schema = _resolveSchema(
|
||||
jsonContent['schema'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
if (schema != null) {
|
||||
_responseSchemaCache[cacheKey] = schema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'SchemaRegistry: Error getting response schema for $method $endpoint ($code): $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find path item that matches the given endpoint
|
||||
Map<String, dynamic>? _findPathItem(String endpoint) {
|
||||
final paths = _getPaths();
|
||||
|
||||
// Try exact match first
|
||||
if (paths.containsKey(endpoint)) {
|
||||
return paths[endpoint] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
// Try to find parameterized routes
|
||||
for (final pathPattern in paths.keys) {
|
||||
if (_matchesPathPattern(endpoint, pathPattern)) {
|
||||
return paths[pathPattern] as Map<String, dynamic>?;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if endpoint matches a path pattern with parameters
|
||||
bool _matchesPathPattern(String endpoint, String pattern) {
|
||||
// Convert OpenAPI path parameters {id} to regex
|
||||
final regexPattern = pattern.replaceAllMapped(
|
||||
RegExp(r'\{([^}]+)\}'),
|
||||
(match) => r'([^/]+)',
|
||||
);
|
||||
|
||||
final regex = RegExp('^$regexPattern\$');
|
||||
return regex.hasMatch(endpoint);
|
||||
}
|
||||
|
||||
/// Get paths from OpenAPI spec
|
||||
Map<String, dynamic> _getPaths() {
|
||||
return _openApiSpec?['paths'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Resolve schema references ($ref)
|
||||
Map<String, dynamic>? _resolveSchema(Map<String, dynamic>? schema) {
|
||||
if (schema == null) return null;
|
||||
|
||||
// Handle $ref
|
||||
final ref = schema['\$ref'] as String?;
|
||||
if (ref != null) {
|
||||
return _resolveReference(ref);
|
||||
}
|
||||
|
||||
// Handle allOf, oneOf, anyOf
|
||||
if (schema.containsKey('allOf')) {
|
||||
return _mergeAllOfSchemas(schema['allOf'] as List);
|
||||
}
|
||||
|
||||
if (schema.containsKey('oneOf') || schema.containsKey('anyOf')) {
|
||||
// For now, just take the first schema in oneOf/anyOf
|
||||
final schemas = (schema['oneOf'] ?? schema['anyOf']) as List;
|
||||
if (schemas.isNotEmpty) {
|
||||
return _resolveSchema(schemas.first as Map<String, dynamic>?);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve nested schemas
|
||||
final resolved = Map<String, dynamic>.from(schema);
|
||||
|
||||
if (resolved.containsKey('properties')) {
|
||||
final properties = resolved['properties'] as Map<String, dynamic>;
|
||||
final resolvedProperties = <String, dynamic>{};
|
||||
|
||||
for (final entry in properties.entries) {
|
||||
resolvedProperties[entry.key] = _resolveSchema(
|
||||
entry.value as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
resolved['properties'] = resolvedProperties;
|
||||
}
|
||||
|
||||
if (resolved.containsKey('items')) {
|
||||
resolved['items'] = _resolveSchema(
|
||||
resolved['items'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Resolve $ref reference
|
||||
Map<String, dynamic>? _resolveReference(String ref) {
|
||||
if (!ref.startsWith('#/')) {
|
||||
debugPrint('SchemaRegistry: External references not supported: $ref');
|
||||
return null;
|
||||
}
|
||||
|
||||
final path = ref.substring(2).split('/');
|
||||
dynamic current = _openApiSpec;
|
||||
|
||||
for (final segment in path) {
|
||||
if (current is Map<String, dynamic> && current.containsKey(segment)) {
|
||||
current = current[segment];
|
||||
} else {
|
||||
debugPrint('SchemaRegistry: Could not resolve reference: $ref');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return _resolveSchema(current as Map<String, dynamic>?);
|
||||
}
|
||||
|
||||
/// Merge allOf schemas
|
||||
Map<String, dynamic> _mergeAllOfSchemas(List schemas) {
|
||||
final merged = <String, dynamic>{};
|
||||
final mergedProperties = <String, dynamic>{};
|
||||
final mergedRequired = <String>[];
|
||||
|
||||
for (final schema in schemas) {
|
||||
final resolvedSchema = _resolveSchema(schema as Map<String, dynamic>?);
|
||||
if (resolvedSchema == null) continue;
|
||||
|
||||
// Merge top-level properties
|
||||
merged.addAll(resolvedSchema);
|
||||
|
||||
// Merge properties
|
||||
if (resolvedSchema.containsKey('properties')) {
|
||||
mergedProperties.addAll(
|
||||
resolvedSchema['properties'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
// Merge required fields
|
||||
if (resolvedSchema.containsKey('required')) {
|
||||
mergedRequired.addAll(
|
||||
(resolvedSchema['required'] as List).cast<String>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedProperties.isNotEmpty) {
|
||||
merged['properties'] = mergedProperties;
|
||||
}
|
||||
|
||||
if (mergedRequired.isNotEmpty) {
|
||||
merged['required'] = mergedRequired;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// Pre-build cache of commonly used schemas
|
||||
Future<void> _buildSchemaCache() async {
|
||||
if (!isLoaded) return;
|
||||
|
||||
final paths = _getPaths();
|
||||
int cachedCount = 0;
|
||||
|
||||
for (final pathEntry in paths.entries) {
|
||||
final path = pathEntry.key;
|
||||
final pathItem = pathEntry.value as Map<String, dynamic>;
|
||||
|
||||
for (final method in ['get', 'post', 'put', 'delete', 'patch']) {
|
||||
if (pathItem.containsKey(method)) {
|
||||
// Cache request schema
|
||||
getRequestSchema(path, method);
|
||||
|
||||
// Cache common response schemas
|
||||
getResponseSchema(path, method, 200);
|
||||
getResponseSchema(path, method, 201);
|
||||
getResponseSchema(path, method, 400);
|
||||
getResponseSchema(path, method, 401);
|
||||
getResponseSchema(path, method, 403);
|
||||
getResponseSchema(path, method, 404);
|
||||
getResponseSchema(path, method, 422);
|
||||
getResponseSchema(path, method, 500);
|
||||
|
||||
cachedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'SchemaRegistry: Pre-cached schemas for $cachedCount operations',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all available endpoints
|
||||
List<String> getAvailableEndpoints() {
|
||||
if (!isLoaded) return [];
|
||||
return _getPaths().keys.toList();
|
||||
}
|
||||
|
||||
/// Get available methods for an endpoint
|
||||
List<String> getAvailableMethods(String endpoint) {
|
||||
final pathItem = _findPathItem(endpoint);
|
||||
if (pathItem == null) return [];
|
||||
|
||||
return pathItem.keys
|
||||
.where(
|
||||
(key) => [
|
||||
'get',
|
||||
'post',
|
||||
'put',
|
||||
'delete',
|
||||
'patch',
|
||||
'head',
|
||||
'options',
|
||||
].contains(key),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
void clearCache() {
|
||||
_requestSchemaCache.clear();
|
||||
_responseSchemaCache.clear();
|
||||
}
|
||||
}
|
||||
217
lib/core/validation/validation_interceptor.dart
Normal file
217
lib/core/validation/validation_interceptor.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'api_validator.dart';
|
||||
import 'validation_result.dart';
|
||||
|
||||
/// Dio interceptor for automatic API validation
|
||||
/// Validates requests and responses against OpenAPI schemas
|
||||
class ValidationInterceptor extends Interceptor {
|
||||
final ApiValidator _validator = ApiValidator();
|
||||
final bool enableRequestValidation;
|
||||
final bool enableResponseValidation;
|
||||
final bool throwOnValidationError;
|
||||
final bool logValidationResults;
|
||||
|
||||
ValidationInterceptor({
|
||||
this.enableRequestValidation = true,
|
||||
this.enableResponseValidation = true,
|
||||
this.throwOnValidationError = false,
|
||||
this.logValidationResults = true,
|
||||
});
|
||||
|
||||
/// Initialize the validator
|
||||
Future<void> initialize() async {
|
||||
await _validator.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (enableRequestValidation && options.data != null) {
|
||||
try {
|
||||
final result = _validator.validateRequest(
|
||||
options.data,
|
||||
options.path,
|
||||
method: options.method,
|
||||
);
|
||||
|
||||
if (logValidationResults) {
|
||||
_logValidationResult(result, 'REQUEST', options.path, options.method);
|
||||
}
|
||||
|
||||
if (!result.isValid && throwOnValidationError) {
|
||||
throw ValidationException(result);
|
||||
}
|
||||
|
||||
// Transform data if validation succeeded
|
||||
if (result.isValid && options.data is Map<String, dynamic>) {
|
||||
options.data = _validator.transformForApi(
|
||||
options.data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is ValidationException) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
error: e,
|
||||
type: DioExceptionType.unknown,
|
||||
message: 'Request validation failed: ${e.result.message}',
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
debugPrint('ValidationInterceptor: Request validation error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (enableResponseValidation && response.data != null) {
|
||||
try {
|
||||
final result = _validator.validateResponse(
|
||||
response.data,
|
||||
response.requestOptions.path,
|
||||
method: response.requestOptions.method,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
|
||||
if (logValidationResults) {
|
||||
_logValidationResult(
|
||||
result,
|
||||
'RESPONSE',
|
||||
response.requestOptions.path,
|
||||
response.requestOptions.method,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.isValid && throwOnValidationError) {
|
||||
throw ValidationException(result);
|
||||
}
|
||||
|
||||
// Transform data if validation succeeded and data is available
|
||||
if (result.isValid && result.data != null) {
|
||||
response.data = result.data;
|
||||
} else if (result.isValid && response.data is Map<String, dynamic>) {
|
||||
response.data = _validator.transformFromApi(
|
||||
response.data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
// Store validation result in response for debugging
|
||||
if (kDebugMode) {
|
||||
response.extra['validationResult'] = result;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is ValidationException) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: e,
|
||||
type: DioExceptionType.unknown,
|
||||
message: 'Response validation failed: ${e.result.message}',
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
debugPrint('ValidationInterceptor: Response validation error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// Try to validate error responses too
|
||||
if (enableResponseValidation && err.response?.data != null) {
|
||||
try {
|
||||
final result = _validator.validateResponse(
|
||||
err.response!.data,
|
||||
err.requestOptions.path,
|
||||
method: err.requestOptions.method,
|
||||
statusCode: err.response!.statusCode,
|
||||
);
|
||||
|
||||
if (logValidationResults) {
|
||||
_logValidationResult(
|
||||
result,
|
||||
'ERROR_RESPONSE',
|
||||
err.requestOptions.path,
|
||||
err.requestOptions.method,
|
||||
statusCode: err.response!.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
// Transform error response data
|
||||
if (result.isValid && result.data != null) {
|
||||
err.response!.data = result.data;
|
||||
} else if (result.isValid &&
|
||||
err.response!.data is Map<String, dynamic>) {
|
||||
err.response!.data = _validator.transformFromApi(
|
||||
err.response!.data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
// Store validation result for debugging
|
||||
if (kDebugMode) {
|
||||
err.response!.extra['validationResult'] = result;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'ValidationInterceptor: Error response validation failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// Log validation results in a structured format
|
||||
void _logValidationResult(
|
||||
ValidationResult result,
|
||||
String type,
|
||||
String path,
|
||||
String method, {
|
||||
int? statusCode,
|
||||
}) {
|
||||
if (!logValidationResults) return;
|
||||
|
||||
final statusText = statusCode != null ? ' ($statusCode)' : '';
|
||||
final icon = result.isValid ? '✅' : '❌';
|
||||
|
||||
debugPrint(
|
||||
'$icon Validation $type: ${method.toUpperCase()} $path$statusText - ${result.status.name}',
|
||||
);
|
||||
|
||||
if (result.hasErrors) {
|
||||
debugPrint(' Errors: ${result.errors.join(', ')}');
|
||||
}
|
||||
|
||||
if (result.hasWarnings) {
|
||||
debugPrint(' Warnings: ${result.warnings.join(', ')}');
|
||||
}
|
||||
|
||||
if (result.message.isNotEmpty &&
|
||||
result.status != ValidationStatus.success) {
|
||||
debugPrint(' Message: ${result.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get validation statistics
|
||||
Map<String, dynamic> getStats() {
|
||||
return {
|
||||
'requestValidationEnabled': enableRequestValidation,
|
||||
'responseValidationEnabled': enableResponseValidation,
|
||||
'throwOnError': throwOnValidationError,
|
||||
'loggingEnabled': logValidationResults,
|
||||
'validatorInitialized': _validator.isInitialized,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
lib/core/validation/validation_result.dart
Normal file
100
lib/core/validation/validation_result.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
/// Result of API validation operations
|
||||
class ValidationResult {
|
||||
const ValidationResult._({
|
||||
required this.isValid,
|
||||
required this.status,
|
||||
required this.message,
|
||||
this.errors = const [],
|
||||
this.warnings = const [],
|
||||
this.data,
|
||||
});
|
||||
|
||||
const ValidationResult.success(
|
||||
String message, {
|
||||
dynamic data,
|
||||
List<String> warnings = const [],
|
||||
}) : this._(
|
||||
isValid: true,
|
||||
status: ValidationStatus.success,
|
||||
message: message,
|
||||
warnings: warnings,
|
||||
data: data,
|
||||
);
|
||||
|
||||
const ValidationResult.warning(
|
||||
String message, {
|
||||
List<String> warnings = const [],
|
||||
dynamic data,
|
||||
}) : this._(
|
||||
isValid: true,
|
||||
status: ValidationStatus.warning,
|
||||
message: message,
|
||||
warnings: warnings,
|
||||
data: data,
|
||||
);
|
||||
|
||||
const ValidationResult.error(
|
||||
String message, {
|
||||
List<String> errors = const [],
|
||||
List<String> warnings = const [],
|
||||
}) : this._(
|
||||
isValid: false,
|
||||
status: ValidationStatus.error,
|
||||
message: message,
|
||||
errors: errors,
|
||||
warnings: warnings,
|
||||
);
|
||||
|
||||
final bool isValid;
|
||||
final ValidationStatus status;
|
||||
final String message;
|
||||
final List<String> errors;
|
||||
final List<String> warnings;
|
||||
final dynamic data;
|
||||
|
||||
bool get hasWarnings => warnings.isNotEmpty;
|
||||
bool get hasErrors => errors.isNotEmpty;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write('ValidationResult(');
|
||||
buffer.write('status: $status, ');
|
||||
buffer.write('message: $message');
|
||||
|
||||
if (hasErrors) {
|
||||
buffer.write(', errors: ${errors.length}');
|
||||
}
|
||||
|
||||
if (hasWarnings) {
|
||||
buffer.write(', warnings: ${warnings.length}');
|
||||
}
|
||||
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Convert to a detailed map for logging/debugging
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'isValid': isValid,
|
||||
'status': status.name,
|
||||
'message': message,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'hasData': data != null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum ValidationStatus { success, warning, error }
|
||||
|
||||
/// Exception thrown when validation fails critically
|
||||
class ValidationException implements Exception {
|
||||
const ValidationException(this.result);
|
||||
|
||||
final ValidationResult result;
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationException: ${result.message}';
|
||||
}
|
||||
308
lib/core/widgets/error_boundary.dart
Normal file
308
lib/core/widgets/error_boundary.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
import '../error/enhanced_error_service.dart';
|
||||
|
||||
/// Error boundary widget that catches and handles errors in child widgets
|
||||
class ErrorBoundary extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
final Widget Function(Object error, StackTrace? stack)? errorBuilder;
|
||||
final void Function(Object error, StackTrace? stack)? onError;
|
||||
final bool showErrorDialog;
|
||||
final bool allowRetry;
|
||||
|
||||
const ErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.errorBuilder,
|
||||
this.onError,
|
||||
this.showErrorDialog = false,
|
||||
this.allowRetry = true,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ErrorBoundary> createState() => _ErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
||||
Object? _error;
|
||||
StackTrace? _stackTrace;
|
||||
bool _hasError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Set up Flutter error handling for this widget
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
// Forward to any previously registered handler to avoid interfering
|
||||
if (previousOnError != null) {
|
||||
previousOnError(details);
|
||||
}
|
||||
_handleError(details.exception, details.stack);
|
||||
};
|
||||
}
|
||||
|
||||
void _handleError(Object error, StackTrace? stack) {
|
||||
// Log error
|
||||
enhancedErrorService.logError(
|
||||
error,
|
||||
context: 'ErrorBoundary',
|
||||
stackTrace: stack,
|
||||
);
|
||||
|
||||
// Call custom error handler if provided
|
||||
widget.onError?.call(error, stack);
|
||||
|
||||
// Update state
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = error;
|
||||
_stackTrace = stack;
|
||||
_hasError = true;
|
||||
});
|
||||
|
||||
// Show error dialog if requested
|
||||
if (widget.showErrorDialog && context.mounted) {
|
||||
enhancedErrorService.showErrorDialog(context, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_stackTrace = null;
|
||||
_hasError = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_hasError && _error != null) {
|
||||
// Use custom error builder if provided
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(_error!, _stackTrace);
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return Scaffold(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
enhancedErrorService.getUserMessage(_error!),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (widget.allowRetry) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap child in error handler
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||
_handleError(details.exception, details.stack);
|
||||
return const SizedBox.shrink();
|
||||
};
|
||||
|
||||
try {
|
||||
return widget.child;
|
||||
} catch (error, stack) {
|
||||
_handleError(error, stack);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget that handles async operations with proper error handling
|
||||
class AsyncErrorBoundary extends ConsumerWidget {
|
||||
final Future<Widget> Function() builder;
|
||||
final Widget? loadingWidget;
|
||||
final Widget Function(Object error)? errorWidget;
|
||||
final bool showRetry;
|
||||
|
||||
const AsyncErrorBoundary({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.loadingWidget,
|
||||
this.errorWidget,
|
||||
this.showRetry = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return FutureBuilder<Widget>(
|
||||
future: builder(),
|
||||
builder: (context, snapshot) {
|
||||
// Loading state
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return loadingWidget ??
|
||||
const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (snapshot.hasError) {
|
||||
final error = snapshot.error!;
|
||||
|
||||
// Log error
|
||||
enhancedErrorService.logError(
|
||||
error,
|
||||
context: 'AsyncErrorBoundary',
|
||||
stackTrace: snapshot.stackTrace,
|
||||
);
|
||||
|
||||
// Use custom error widget if provided
|
||||
if (errorWidget != null) {
|
||||
return errorWidget!(error);
|
||||
}
|
||||
|
||||
// Default error widget
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
enhancedErrorService.getUserMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (showRetry) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
// Force rebuild to retry
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
return snapshot.data ?? const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream error boundary for handling stream errors
|
||||
class StreamErrorBoundary<T> extends ConsumerWidget {
|
||||
final Stream<T> stream;
|
||||
final Widget Function(T data) builder;
|
||||
final Widget? loadingWidget;
|
||||
final Widget Function(Object error)? errorWidget;
|
||||
final T? initialData;
|
||||
|
||||
const StreamErrorBoundary({
|
||||
super.key,
|
||||
required this.stream,
|
||||
required this.builder,
|
||||
this.loadingWidget,
|
||||
this.errorWidget,
|
||||
this.initialData,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return StreamBuilder<T>(
|
||||
stream: stream,
|
||||
initialData: initialData,
|
||||
builder: (context, snapshot) {
|
||||
// Error state
|
||||
if (snapshot.hasError) {
|
||||
final error = snapshot.error!;
|
||||
|
||||
// Log error
|
||||
enhancedErrorService.logError(
|
||||
error,
|
||||
context: 'StreamErrorBoundary',
|
||||
stackTrace: snapshot.stackTrace,
|
||||
);
|
||||
|
||||
// Use custom error widget if provided
|
||||
if (errorWidget != null) {
|
||||
return errorWidget!(error);
|
||||
}
|
||||
|
||||
// Default error widget
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
enhancedErrorService.getUserMessage(error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (!snapshot.hasData) {
|
||||
return loadingWidget ??
|
||||
const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Success state
|
||||
return builder(snapshot.data as T);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user