chore: initial release

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

View File

@@ -0,0 +1,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();
}
}

View 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;
}
}

View 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));
});

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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'],
},
);
}
}

View 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
View 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,
);
}
}

View 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);
}

View 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 [];
}
});

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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
});

View 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());

View 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;
},
);
}
}

View 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,
],
],
),
),
);
},
);
}
}

View 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);
};
}
}

View 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,
),
),
),
),
],
),
);
}
}

View 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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#x27;')
.replaceAll('/', '&#x2F;');
}
/// 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(),
),
);
}
}

View 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';
}

View 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,
});
}

View 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(),
};
}
}

View 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,
});
}

View 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');
}
}
}

View 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,
);
});

View 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;
}
}
}

View 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),
),
],
);
}
}

View 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();
}
}

View 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;
}
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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,
};
}
}

View 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}';
}

View 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);
},
);
}
}