diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart index bfb0b51..86713d0 100644 --- a/lib/core/auth/api_auth_interceptor.dart +++ b/lib/core/auth/api_auth_interceptor.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; +import '../utils/debug_logger.dart'; /// Consistent authentication interceptor for all API requests /// Implements security requirements from OpenAPI specification @@ -77,8 +77,8 @@ class ApiAuthInterceptor extends Interceptor { final requiresAuth = _requiresAuth(path); final hasOptionalAuth = _hasOptionalAuth(path); - debugPrint( - 'DEBUG: Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}', + DebugLogger.auth( + 'Auth interceptor for $path - requires: $requiresAuth, optional: $hasOptionalAuth, token present: ${_authToken != null}', ); if (requiresAuth) { @@ -109,12 +109,14 @@ class ApiAuthInterceptor extends Interceptor { customHeaders.forEach((key, value) { // Don't override critical headers that we manage final lowerKey = key.toLowerCase(); - if (lowerKey != 'authorization' && - lowerKey != 'content-type' && + if (lowerKey != 'authorization' && + lowerKey != 'content-type' && lowerKey != 'accept') { options.headers[key] = value; } else { - debugPrint('WARNING: Skipping reserved header override attempt: $key'); + DebugLogger.warning( + 'Skipping reserved header override attempt: $key', + ); } }); } @@ -134,20 +136,20 @@ class ApiAuthInterceptor extends Interceptor { // Handle authentication errors consistently if (statusCode == 401) { // 401 always indicates invalid/expired auth token - debugPrint('DEBUG: 401 Unauthorized on $path - clearing auth token'); + DebugLogger.auth('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', + DebugLogger.auth( + '403 Forbidden on protected endpoint $path - clearing auth token', ); _clearAuthToken(); } else { - debugPrint( - 'DEBUG: 403 Forbidden on public/optional endpoint $path - keeping auth token', + DebugLogger.auth( + '403 Forbidden on public/optional endpoint $path - keeping auth token', ); } } diff --git a/lib/core/auth/auth_cache_manager.dart b/lib/core/auth/auth_cache_manager.dart index 2eaa324..0ce001d 100644 --- a/lib/core/auth/auth_cache_manager.dart +++ b/lib/core/auth/auth_cache_manager.dart @@ -1,5 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'auth_state_manager.dart'; +import '../utils/debug_logger.dart'; /// Comprehensive caching manager for auth-related operations /// Reduces redundant operations and improves app performance @@ -31,13 +31,13 @@ class AuthCacheManager { void cacheUserData(dynamic userData) { _cache[_userDataKey] = userData; _cacheTimestamps[_userDataKey] = DateTime.now(); - debugPrint('DEBUG: User data cached'); + DebugLogger.storage('User data cached'); } /// Get cached user data dynamic getCachedUserData() { if (_isCacheValid(_userDataKey, _mediumCache)) { - debugPrint('DEBUG: Using cached user data'); + DebugLogger.storage('Using cached user data'); return _cache[_userDataKey]; } return null; @@ -97,14 +97,14 @@ class AuthCacheManager { void clearCacheEntry(String key) { _cache.remove(key); _cacheTimestamps.remove(key); - debugPrint('DEBUG: Cache entry cleared: $key'); + DebugLogger.storage('Cache entry cleared: $key'); } /// Clear all auth-related cache void clearAuthCache() { _cache.clear(); _cacheTimestamps.clear(); - debugPrint('DEBUG: All auth cache cleared'); + DebugLogger.storage('All auth cache cleared'); } /// Clear expired cache entries @@ -125,7 +125,9 @@ class AuthCacheManager { } if (expiredKeys.isNotEmpty) { - debugPrint('DEBUG: Cleaned ${expiredKeys.length} expired cache entries'); + DebugLogger.storage( + 'Cleaned ${expiredKeys.length} expired cache entries', + ); } } @@ -168,7 +170,9 @@ class AuthCacheManager { _cacheTimestamps.remove(key); } - debugPrint('DEBUG: Cache optimized, removed $entriesToRemove old entries'); + DebugLogger.storage( + 'Cache optimized, removed $entriesToRemove old entries', + ); } /// Cache state from AuthState for quick access diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 66b5fb0..65da1f7 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -5,6 +5,7 @@ import '../providers/app_providers.dart'; import '../models/user.dart'; import 'token_validator.dart'; import 'auth_cache_manager.dart'; +import '../utils/debug_logger.dart'; /// Comprehensive auth state representation @immutable @@ -95,10 +96,10 @@ class AuthStateManager extends StateNotifier { final token = await storage.getAuthToken(); if (token != null && token.isNotEmpty) { - debugPrint('DEBUG: Found stored token during initialization: ${token.substring(0, 10)}...'); + DebugLogger.auth('Found stored token during initialization'); // Validate token before setting authenticated state final isValid = await _validateToken(token); - debugPrint('DEBUG: Token validation result: $isValid'); + DebugLogger.auth('Token validation result: $isValid'); if (isValid) { state = state.copyWith( status: AuthStatus.authenticated, @@ -114,7 +115,7 @@ class AuthStateManager extends StateNotifier { _loadUserData(); } else { // Token is invalid, clear it - debugPrint('DEBUG: Token validation failed, deleting token'); + DebugLogger.auth('Token validation failed, deleting token'); await storage.deleteAuthToken(); state = state.copyWith( status: AuthStatus.unauthenticated, @@ -167,19 +168,19 @@ class AuthStateManager extends StateNotifier { // Use API key directly as Bearer token final tokenStr = apiKey.trim(); - + // Validate token format (consistent with credentials method) if (!_isValidTokenFormat(tokenStr)) { throw Exception('Invalid API key format'); } - + // Update API service with the API key _updateApiServiceToken(tokenStr); // Validate by attempting to fetch user info try { await api.getCurrentUser(); // Just validate, don't store user data yet - + // Save token to storage final storage = _ref.read(optimizedStorageServiceProvider); await storage.saveAuthToken(tokenStr); @@ -191,7 +192,8 @@ class AuthStateManager extends StateNotifier { // Store API key as a special credential type await storage.saveCredentials( serverId: activeServer.id, - username: 'api_key_user', // Special username to indicate API key auth + username: + 'api_key_user', // Special username to indicate API key auth password: tokenStr, // Store API key in password field ); await storage.setRememberCredentials(true); @@ -215,7 +217,7 @@ class AuthStateManager extends StateNotifier { // Load user data in background (consistent with credentials method) _loadUserData(); - debugPrint('DEBUG: API key login successful'); + DebugLogger.auth('API key login successful'); return true; } catch (e) { // If user fetch fails, the API key might be invalid @@ -300,7 +302,7 @@ class AuthStateManager extends StateNotifier { // Load user data in background _loadUserData(); - debugPrint('DEBUG: Login successful'); + DebugLogger.auth('Login successful'); return true; } catch (e) { debugPrint('ERROR: Login failed: $e'); @@ -399,7 +401,7 @@ class AuthStateManager extends StateNotifier { /// Handle token invalidation (called by API service) Future onTokenInvalidated() async { - debugPrint('DEBUG: Auth token invalidated'); + DebugLogger.auth('Auth token invalidated'); // Clear token from storage final storage = _ref.read(optimizedStorageServiceProvider); @@ -416,7 +418,7 @@ class AuthStateManager extends StateNotifier { // 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'); + DebugLogger.auth('Attempting silent re-login after token invalidation'); await silentLogin(); } } @@ -449,7 +451,7 @@ class AuthStateManager extends StateNotifier { clearError: true, ); - debugPrint('DEBUG: Logout complete'); + DebugLogger.auth('Logout complete'); } catch (e) { debugPrint('ERROR: Logout failed: $e'); // Even if logout fails, clear local state @@ -470,7 +472,7 @@ class AuthStateManager extends StateNotifier { if (state.token != null) { final jwtUserInfo = TokenValidator.extractUserInfo(state.token!); if (jwtUserInfo != null) { - debugPrint('DEBUG: Extracted user info from JWT token'); + DebugLogger.auth('Extracted user info from JWT token'); state = state.copyWith(user: jwtUserInfo); // Still try to load from server in background for complete data @@ -502,7 +504,7 @@ class AuthStateManager extends StateNotifier { final user = await api.getCurrentUser(); state = state.copyWith(user: user); - debugPrint('DEBUG: Loaded complete user data from server'); + DebugLogger.auth('Loaded complete user data from server'); } } catch (e) { debugPrint('Warning: Failed to load server user data: $e'); @@ -527,8 +529,8 @@ class AuthStateManager extends StateNotifier { // Check cache first final cachedResult = TokenValidationCache.getCachedResult(token); if (cachedResult != null) { - debugPrint( - 'DEBUG: Using cached token validation result: ${cachedResult.isValid}', + DebugLogger.auth( + 'Using cached token validation result: ${cachedResult.isValid}', ); return cachedResult.isValid; } @@ -571,13 +573,13 @@ class AuthStateManager extends StateNotifier { validationUser != null && state.isAuthenticated) { state = state.copyWith(user: validationUser); - debugPrint('DEBUG: Cached user data from token validation'); + DebugLogger.auth('Cached user data from token validation'); } TokenValidationCache.cacheResult(token, serverResult); - debugPrint( - 'DEBUG: Server token validation: ${serverResult.isValid} - ${serverResult.message}', + DebugLogger.auth( + 'Server token validation: ${serverResult.isValid} - ${serverResult.message}', ); return serverResult.isValid; } catch (e) { diff --git a/lib/core/error/api_error_handler.dart b/lib/core/error/api_error_handler.dart index 223f0f3..14e00b9 100644 --- a/lib/core/error/api_error_handler.dart +++ b/lib/core/error/api_error_handler.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'api_error.dart'; import 'error_parser.dart'; +import '../utils/debug_logger.dart'; /// Comprehensive API error handler with structured error parsing /// Handles all types of API errors and converts them to standardized format @@ -319,7 +320,7 @@ class ApiErrorHandler { debugPrint(' Status: ${dioError.response?.statusCode}'); if (dioError.response?.data != null) { - debugPrint(' Response: ${dioError.response?.data}'); + DebugLogger.error('Response data available (truncated for security)'); } if (dioError.requestOptions.data != null) { diff --git a/lib/core/error/api_error_interceptor.dart b/lib/core/error/api_error_interceptor.dart index c147b51..caea8ef 100644 --- a/lib/core/error/api_error_interceptor.dart +++ b/lib/core/error/api_error_interceptor.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'api_error_handler.dart'; import 'api_error.dart'; +import '../utils/debug_logger.dart'; /// Dio interceptor for automatic error handling and transformation /// Converts all HTTP errors into standardized ApiError format @@ -152,7 +153,7 @@ class ApiErrorInterceptor extends Interceptor { // Log response data if available and not too large final responseData = originalError.response?.data; if (responseData != null && responseData.toString().length < 1000) { - debugPrint(' Response: $responseData'); + DebugLogger.error('Response data available (truncated for security)'); } } diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 6aad60e..be8afb0 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -20,6 +20,7 @@ import '../models/file_info.dart'; import '../models/knowledge_base.dart'; import '../services/settings_service.dart'; import '../services/optimized_storage_service.dart'; +import '../utils/debug_logger.dart'; // Storage providers final sharedPreferencesProvider = Provider((ref) { @@ -236,11 +237,9 @@ final modelsProvider = FutureProvider>((ref) async { if (api == null) return []; try { - foundation.debugPrint('DEBUG: Fetching models from server'); + DebugLogger.log('Fetching models from server'); final models = await api.getModels(); - foundation.debugPrint( - 'DEBUG: Successfully fetched ${models.length} models', - ); + DebugLogger.log('Successfully fetched ${models.length} models'); return models; } catch (e) { foundation.debugPrint('ERROR: Failed to fetch models: $e'); @@ -248,8 +247,8 @@ final modelsProvider = FutureProvider>((ref) async { // 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')) { - foundation.debugPrint( - 'DEBUG: Models endpoint returned 403 - authentication may be invalid', + DebugLogger.warning( + 'Models endpoint returned 403 - authentication may be invalid', ); } @@ -280,8 +279,8 @@ final conversationsProvider = FutureProvider>((ref) async { // Check if we have a recent cache (within 5 seconds) final lastFetch = ref.read(_conversationsCacheTimestamp); if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) { - foundation.debugPrint( - 'DEBUG: Using cached conversations (fetched ${DateTime.now().difference(lastFetch).inSeconds}s ago)', + DebugLogger.log( + 'Using cached conversations (fetched ${DateTime.now().difference(lastFetch).inSeconds}s ago)', ); // Note: Can't read our own provider here, would cause a cycle // The caching is handled by Riverpod's built-in mechanism @@ -311,25 +310,23 @@ final conversationsProvider = FutureProvider>((ref) async { } final api = ref.watch(apiServiceProvider); if (api == null) { - foundation.debugPrint('DEBUG: No API service available'); + DebugLogger.log('No API service available'); return []; } try { - foundation.debugPrint( - 'DEBUG: Fetching conversations from OpenWebUI API...', - ); + DebugLogger.log('Fetching conversations from OpenWebUI API...'); final conversations = await api .getConversations(); // Fetch all conversations - foundation.debugPrint( - 'DEBUG: Successfully fetched ${conversations.length} conversations', + DebugLogger.log( + 'Successfully fetched ${conversations.length} conversations', ); // Also fetch folder information and update conversations with folder IDs try { final foldersData = await api.getFolders(); - foundation.debugPrint( - 'DEBUG: Fetched ${foldersData.length} folders for conversation mapping', + DebugLogger.log( + 'Fetched ${foldersData.length} folders for conversation mapping', ); // Parse folder data into Folder objects @@ -526,7 +523,7 @@ final defaultModelProvider = FutureProvider((ref) async { // Check if a model is manually selected final currentSelected = ref.read(selectedModelProvider); final isManualSelection = ref.read(isManualModelSelectionProvider); - + if (currentSelected != null && isManualSelection) { foundation.debugPrint( 'DEBUG: Manual model selected in reviewer mode: ${currentSelected.name}', @@ -567,7 +564,7 @@ final defaultModelProvider = FutureProvider((ref) async { // First check user's preferred default model final userSettings = ref.read(appSettingsProvider); final userDefaultModelId = userSettings.defaultModel; - + if (userDefaultModelId != null && userDefaultModelId.isNotEmpty) { try { selectedModel = models.firstWhere( @@ -910,7 +907,7 @@ final foldersProvider = FutureProvider>((ref) async { try { foundation.debugPrint('DEBUG: Fetching folders from API...'); final foldersData = await api.getFolders(); - foundation.debugPrint('DEBUG: Raw folders data: $foldersData'); + foundation.debugPrint('DEBUG: Raw folders data received successfully'); final folders = foldersData .map((folderData) => Folder.fromJson(folderData)) .toList(); diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 01ebbe2..5f10b8c 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -17,6 +17,7 @@ import '../error/api_error_interceptor.dart'; import 'sse_parser.dart'; import 'stream_recovery_service.dart'; import 'persistent_streaming_service.dart'; +import '../utils/debug_logger.dart'; class ApiService { final Dio _dio; @@ -98,19 +99,23 @@ class ApiService { if (options.data != null) { if (options.data is Map) { final dataMap = options.data as Map; - debugPrint('Data type: Map'); - debugPrint('Data keys: ${dataMap.keys.toList()}'); - debugPrint( + DebugLogger.log('Data type: Map'); + DebugLogger.log('Data keys: ${dataMap.keys.toList()}'); + DebugLogger.log( 'Has background_tasks: ${dataMap.containsKey('background_tasks')}', ); - debugPrint( + DebugLogger.log( 'Has session_id: ${dataMap.containsKey('session_id')}', ); - debugPrint('Has id: ${dataMap.containsKey('id')}'); - debugPrint('Full data: ${jsonEncode(dataMap)}'); + DebugLogger.log('Has id: ${dataMap.containsKey('id')}'); + DebugLogger.log( + 'Data structure logged (full data suppressed)', + ); } else { - debugPrint('Data type: ${options.data.runtimeType}'); - debugPrint('Data: ${options.data}'); + DebugLogger.log('Data type: ${options.data.runtimeType}'); + DebugLogger.log( + 'Data structure logged (full data suppressed)', + ); } } debugPrint('===== END SSE REQUEST DEBUG ====='); @@ -120,17 +125,8 @@ class ApiService { ), ); - // 5. Standard logging interceptor - _dio.interceptors.add( - LogInterceptor( - requestBody: true, - responseBody: false, // Don't log response bodies to reduce noise - requestHeader: true, - responseHeader: false, - error: true, - logPrint: (obj) => debugPrint('API: $obj'), - ), - ); + // LogInterceptor removed - was exposing sensitive data and creating verbose logs + // We now use custom interceptors with secure logging via DebugLogger } // Initialize validation interceptor asynchronously @@ -247,14 +243,13 @@ class ApiService { // User info Future getCurrentUser() async { final response = await _dio.get('/api/v1/auths/'); - debugPrint('DEBUG: /api/v1/auths/ response: ${jsonEncode(response.data)}'); + DebugLogger.log('User info retrieved successfully'); return User.fromJson(response.data); } // Models Future> getModels() async { final response = await _dio.get('/api/models'); - debugPrint('DEBUG: /api/models raw response: ${jsonEncode(response.data)}'); // Handle different response formats List models; @@ -265,11 +260,11 @@ class ApiService { // Response is a direct array models = response.data as List; } else { - debugPrint('ERROR: Unexpected models response format'); + DebugLogger.error('Unexpected models response format'); return []; } - debugPrint('DEBUG: Found ${models.length} models'); + DebugLogger.log('Found ${models.length} models'); return models.map((m) => Model.fromJson(m)).toList(); } @@ -279,7 +274,7 @@ class ApiService { debugPrint('DEBUG: Fetching default model from user settings'); final response = await _dio.get('/api/v1/users/user/settings'); - debugPrint('DEBUG: User settings response: ${jsonEncode(response.data)}'); + DebugLogger.log('User settings retrieved successfully'); final settings = response.data as Map; @@ -290,20 +285,20 @@ class ApiService { if (models != null && models.isNotEmpty) { // Return the first model in the user's preferred models list final defaultModel = models.first.toString(); - debugPrint( - 'DEBUG: Found default model from user settings: $defaultModel', + DebugLogger.log( + 'Found default model from user settings: $defaultModel', ); return defaultModel; } } - debugPrint('DEBUG: No default model found in user settings'); + DebugLogger.log('No default model found in user settings'); return null; } catch (e) { - debugPrint('DEBUG: Error fetching default model from user settings: $e'); + DebugLogger.error('Error fetching default model from user settings', e); // Fall back to trying the old endpoint try { - debugPrint('DEBUG: Falling back to configs/models endpoint'); + DebugLogger.log('Falling back to configs/models endpoint'); final response = await _dio.get('/api/v1/configs/models'); final config = response.data as Map; @@ -313,11 +308,11 @@ class ApiService { config['default_model'] as String?; if (defaultModel != null && defaultModel.isNotEmpty) { - debugPrint('DEBUG: Found default model from fallback: $defaultModel'); + DebugLogger.log('Found default model from fallback: $defaultModel'); return defaultModel; } } catch (fallbackError) { - debugPrint('DEBUG: Fallback also failed: $fallbackError'); + DebugLogger.error('Fallback also failed', fallbackError); } return null; @@ -572,10 +567,10 @@ class ApiService { } Future getConversation(String id) async { - debugPrint('DEBUG: Fetching individual chat: $id'); + DebugLogger.log('Fetching individual chat: $id'); final response = await _dio.get('/api/v1/chats/$id'); - debugPrint('DEBUG: Chat response: ${response.data}'); + DebugLogger.log('Chat response received successfully'); // Parse OpenWebUI ChatResponse format final chatData = response.data as Map; @@ -820,10 +815,10 @@ class ApiService { final response = await _dio.post('/api/v1/chats/new', data: chatData); - debugPrint( - 'DEBUG: Create conversation response status: ${response.statusCode}', + DebugLogger.log( + 'Create conversation response status: ${response.statusCode}', ); - debugPrint('DEBUG: Create conversation response data: ${response.data}'); + DebugLogger.log('Create conversation response received successfully'); // Parse the response final responseData = response.data as Map; @@ -914,12 +909,9 @@ class ApiService { debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST'); // OpenWebUI uses POST not PUT for updating chats - final response = await _dio.post( - '/api/v1/chats/$conversationId', - data: chatData, - ); + await _dio.post('/api/v1/chats/$conversationId', data: chatData); - debugPrint('DEBUG: Update conversation response: ${response.data}'); + DebugLogger.log('Update conversation response received successfully'); } Future updateConversation( @@ -1012,15 +1004,15 @@ class ApiService { try { debugPrint('DEBUG: Fetching folders from /api/v1/folders/'); final response = await _dio.get('/api/v1/folders/'); - debugPrint('DEBUG: Folders response status: ${response.statusCode}'); - debugPrint('DEBUG: Folders response data: ${response.data}'); + DebugLogger.log('Folders response status: ${response.statusCode}'); + DebugLogger.log('Folders response received successfully'); final data = response.data; if (data is List) { debugPrint('DEBUG: Found ${data.length} folders'); return data.cast>(); } else { - debugPrint('DEBUG: Response data is not a list: ${data.runtimeType}'); + DebugLogger.log('Response data is not a list: ${data.runtimeType}'); return []; } } catch (e) { @@ -1379,17 +1371,17 @@ class ApiService { data: {'queries': queries}, ); - debugPrint('DEBUG: Web search response status: ${response.statusCode}'); - debugPrint( - 'DEBUG: Web search response type: ${response.data.runtimeType}', - ); - debugPrint('DEBUG: Web search response data: ${response.data}'); + DebugLogger.log('Web search response status: ${response.statusCode}'); + DebugLogger.log('Web search response type: ${response.data.runtimeType}'); + DebugLogger.log('Web search response received successfully'); return response.data as Map; } catch (e) { debugPrint('DEBUG: Web search API error: $e'); if (e is DioException) { - debugPrint('DEBUG: Web search error response: ${e.response?.data}'); + DebugLogger.error( + 'Web search error response available (truncated for security)', + ); debugPrint('DEBUG: Web search error status: ${e.response?.statusCode}'); } rethrow; @@ -1406,7 +1398,7 @@ class ApiService { if (response.statusCode == 200 && response.data != null) { final modelData = response.data as Map; - debugPrint('DEBUG: Model details for $modelId: $modelData'); + DebugLogger.log('Model details for $modelId retrieved successfully'); return modelData; } } catch (e) { @@ -1430,7 +1422,7 @@ class ApiService { ); if (response.statusCode == 200 && response.data != null) { - debugPrint('DEBUG: Raw title response: ${response.data}'); + DebugLogger.log('Raw title response received successfully'); // Parse the complex response structure String? extractedTitle; @@ -1604,7 +1596,7 @@ class ApiService { debugPrint( 'DEBUG: Collection query response type: ${response.data.runtimeType}', ); - debugPrint('DEBUG: Collection query response data: ${response.data}'); + DebugLogger.log('Collection query response received successfully'); if (response.data is List) { return response.data as List; @@ -1644,7 +1636,7 @@ class ApiService { debugPrint( 'DEBUG: Retrieval config response status: ${response.statusCode}', ); - debugPrint('DEBUG: Retrieval config response data: ${response.data}'); + DebugLogger.log('Retrieval config response received successfully'); return response.data as Map; } catch (e) { @@ -1730,7 +1722,7 @@ class ApiService { debugPrint( 'DEBUG: Transcription response status: ${response.statusCode}', ); - debugPrint('DEBUG: Transcription response data: $data'); + DebugLogger.log('Transcription response received successfully'); if (data is String) return data; if (data is Map) { final text = data['text'] ?? data['transcription'] ?? data['result']; @@ -1760,7 +1752,9 @@ class ApiService { debugPrint( 'DEBUG: Transcription retry status: ${retryResponse.statusCode}', ); - debugPrint('DEBUG: Transcription retry data: $rdata'); + DebugLogger.log( + 'Transcription retry response received successfully', + ); if (rdata is String) return rdata; if (rdata is Map) { final text = @@ -2603,7 +2597,7 @@ class ApiService { ), ); - debugPrint('DEBUG: Sending SSE request with data: ${jsonEncode(data)}'); + DebugLogger.log('Sending SSE request with data structure logged'); final response = await streamDio.post( '/api/chat/completions', @@ -2678,8 +2672,8 @@ class ApiService { // Log what we got if we couldn't extract content if (!streamController.isClosed) { - debugPrint('DEBUG: JSON response structure: ${json.keys}'); - debugPrint('DEBUG: Full JSON response: $json'); + DebugLogger.log('JSON response structure: ${json.keys}'); + DebugLogger.log('JSON response received (full data suppressed)'); // Check if it's a task-based response if (json is Map && json.containsKey('task_id')) { @@ -3020,8 +3014,8 @@ class ApiService { debugPrint('DEBUG: Uploading to /api/v1/files/'); final response = await _dio.post('/api/v1/files/', data: formData); - debugPrint('DEBUG: Upload response status: ${response.statusCode}'); - debugPrint('DEBUG: Upload response data: ${response.data}'); + DebugLogger.log('Upload response status: ${response.statusCode}'); + DebugLogger.log('Upload response received successfully'); if (response.data is Map && response.data['id'] != null) { final fileId = response.data['id'] as String; @@ -3065,13 +3059,13 @@ class ApiService { debugPrint('Testing endpoint: $endpoint'); final response = await _dio.get(endpoint); debugPrint('✅ $endpoint - Status: ${response.statusCode}'); - debugPrint(' Response type: ${response.data.runtimeType}'); + DebugLogger.log(' Response type: ${response.data.runtimeType}'); if (response.data is List) { - debugPrint(' Array length: ${(response.data as List).length}'); + DebugLogger.log(' Array length: ${(response.data as List).length}'); } else if (response.data is Map) { - debugPrint(' Object keys: ${(response.data as Map).keys}'); + DebugLogger.log(' Object keys: ${(response.data as Map).keys}'); } - debugPrint( + DebugLogger.log( ' Sample data: ${response.data.toString().substring(0, 200)}...', ); } catch (e) { diff --git a/lib/core/services/background_streaming_handler.dart b/lib/core/services/background_streaming_handler.dart index 9b5036d..3abf022 100644 --- a/lib/core/services/background_streaming_handler.dart +++ b/lib/core/services/background_streaming_handler.dart @@ -1,93 +1,105 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import '../utils/debug_logger.dart'; /// Handles background streaming continuation for iOS and Android -/// +/// /// On iOS: Uses background tasks to keep streams alive for ~30 seconds /// On Android: Uses foreground service notifications class BackgroundStreamingHandler { - static const MethodChannel _channel = MethodChannel('conduit/background_streaming'); - + static const MethodChannel _channel = MethodChannel( + 'conduit/background_streaming', + ); + static BackgroundStreamingHandler? _instance; - static BackgroundStreamingHandler get instance => _instance ??= BackgroundStreamingHandler._(); - + static BackgroundStreamingHandler get instance => + _instance ??= BackgroundStreamingHandler._(); + BackgroundStreamingHandler._() { _setupMethodCallHandler(); } - + final Set _activeStreamIds = {}; final Map _streamStates = {}; - + // Callbacks for platform-specific events void Function(List streamIds)? onStreamsSuspending; void Function()? onBackgroundTaskExpiring; bool Function()? shouldContinueInBackground; - + void _setupMethodCallHandler() { _channel.setMethodCallHandler((call) async { switch (call.method) { case 'checkStreams': return _activeStreamIds.length; - + case 'streamsSuspending': - final Map args = call.arguments as Map; - final List streamIds = (args['streamIds'] as List).cast(); + final Map args = + call.arguments as Map; + final List streamIds = (args['streamIds'] as List) + .cast(); final String reason = args['reason'] as String; - - debugPrint('Background: Streams suspending - $streamIds (reason: $reason)'); + + DebugLogger.stream( + 'Background: Streams suspending - $streamIds (reason: $reason)', + ); onStreamsSuspending?.call(streamIds); - + // Save stream states for recovery await _saveStreamStatesForRecovery(streamIds, reason); break; - + case 'backgroundTaskExpiring': - debugPrint('Background: Background task expiring'); + DebugLogger.stream('Background: Background task expiring'); onBackgroundTaskExpiring?.call(); break; } }); } - + /// Start background execution for given stream IDs Future startBackgroundExecution(List streamIds) async { if (!Platform.isIOS && !Platform.isAndroid) return; - + _activeStreamIds.addAll(streamIds); - + try { await _channel.invokeMethod('startBackgroundExecution', { 'streamIds': streamIds, }); - - debugPrint('Background: Started background execution for ${streamIds.length} streams'); + + DebugLogger.stream( + 'Background: Started background execution for ${streamIds.length} streams', + ); } catch (e) { - debugPrint('Background: Failed to start background execution: $e'); + DebugLogger.error('Background: Failed to start background execution', e); } } - + /// Stop background execution for given stream IDs Future stopBackgroundExecution(List streamIds) async { if (!Platform.isIOS && !Platform.isAndroid) return; - + _activeStreamIds.removeAll(streamIds); streamIds.forEach(_streamStates.remove); - + try { await _channel.invokeMethod('stopBackgroundExecution', { 'streamIds': streamIds, }); - - debugPrint('Background: Stopped background execution for ${streamIds.length} streams'); + + DebugLogger.stream( + 'Background: Stopped background execution for ${streamIds.length} streams', + ); } catch (e) { - debugPrint('Background: Failed to stop background execution: $e'); + DebugLogger.error('Background: Failed to stop background execution', e); } } - + /// Register a stream with its current state - void registerStream(String streamId, { + void registerStream( + String streamId, { required String conversationId, required String messageId, String? sessionId, @@ -103,58 +115,61 @@ class BackgroundStreamingHandler { lastContent: lastContent ?? '', timestamp: DateTime.now(), ); - + _activeStreamIds.add(streamId); } - + /// Update stream state with new chunk - void updateStreamState(String streamId, { + void updateStreamState( + String streamId, { int? chunkSequence, String? content, String? appendedContent, }) { final state = _streamStates[streamId]; if (state == null) return; - + _streamStates[streamId] = state.copyWith( lastChunkSequence: chunkSequence ?? state.lastChunkSequence, - lastContent: appendedContent != null + lastContent: appendedContent != null ? (state.lastContent + appendedContent) : (content ?? state.lastContent), timestamp: DateTime.now(), ); } - + /// Unregister a stream when it completes void unregisterStream(String streamId) { _activeStreamIds.remove(streamId); _streamStates.remove(streamId); } - + /// Get current stream state for recovery StreamState? getStreamState(String streamId) { return _streamStates[streamId]; } - + /// Keep alive the background task (iOS only) Future keepAlive() async { if (!Platform.isIOS) return; - + try { await _channel.invokeMethod('keepAlive'); } catch (e) { - debugPrint('Background: Failed to keep alive: $e'); + DebugLogger.error('Background: Failed to keep alive', e); } } - + /// Recover stream states from previous app session Future> recoverStreamStates() async { if (!Platform.isIOS && !Platform.isAndroid) return []; - + try { - final List? states = await _channel.invokeMethod('recoverStreamStates'); + final List? states = await _channel.invokeMethod( + 'recoverStreamStates', + ); if (states == null) return []; - + final recovered = []; for (final stateData in states) { final map = stateData as Map; @@ -164,39 +179,44 @@ class BackgroundStreamingHandler { _streamStates[state.streamId] = state; } } - - debugPrint('Background: Recovered ${recovered.length} stream states'); + + DebugLogger.stream( + 'Background: Recovered ${recovered.length} stream states', + ); return recovered; } catch (e) { - debugPrint('Background: Failed to recover stream states: $e'); + DebugLogger.error('Background: Failed to recover stream states', e); return []; } } - + /// Save stream states for recovery after app restart - Future _saveStreamStatesForRecovery(List streamIds, String reason) async { + Future _saveStreamStatesForRecovery( + List streamIds, + String reason, + ) async { final statesToSave = streamIds .map((id) => _streamStates[id]) .where((state) => state != null) .map((state) => state!.toMap()) .toList(); - + try { await _channel.invokeMethod('saveStreamStates', { 'states': statesToSave, 'reason': reason, }); } catch (e) { - debugPrint('Background: Failed to save stream states: $e'); + DebugLogger.error('Background: Failed to save stream states', e); } } - + /// Check if any streams are currently active bool get hasActiveStreams => _activeStreamIds.isNotEmpty; - + /// Get list of active stream IDs List get activeStreamIds => _activeStreamIds.toList(); - + /// Clear all stream data (usually on app termination) void clearAll() { _activeStreamIds.clear(); @@ -213,7 +233,7 @@ class StreamState { final int lastChunkSequence; final String lastContent; final DateTime timestamp; - + const StreamState({ required this.streamId, required this.conversationId, @@ -223,7 +243,7 @@ class StreamState { required this.lastContent, required this.timestamp, }); - + StreamState copyWith({ String? streamId, String? conversationId, @@ -243,7 +263,7 @@ class StreamState { timestamp: timestamp ?? this.timestamp, ); } - + Map toMap() { return { 'streamId': streamId, @@ -255,7 +275,7 @@ class StreamState { 'timestamp': timestamp.millisecondsSinceEpoch, }; } - + static StreamState? fromMap(Map map) { try { return StreamState( @@ -270,20 +290,20 @@ class StreamState { ), ); } catch (e) { - debugPrint('Failed to parse StreamState from map: $e'); + DebugLogger.error('Failed to parse StreamState from map', e); return null; } } - + /// Check if this state is stale (older than threshold) bool isStale({Duration threshold = const Duration(minutes: 5)}) { return DateTime.now().difference(timestamp) > threshold; } - + @override String toString() { return 'StreamState(streamId: $streamId, conversationId: $conversationId, ' - 'messageId: $messageId, sequence: $lastChunkSequence, ' - 'contentLength: ${lastContent.length}, timestamp: $timestamp)'; + 'messageId: $messageId, sequence: $lastChunkSequence, ' + 'contentLength: ${lastContent.length}, timestamp: $timestamp)'; } -} \ No newline at end of file +} diff --git a/lib/core/services/navigation_state_service.dart b/lib/core/services/navigation_state_service.dart index c96b7a8..f9a3ac5 100644 --- a/lib/core/services/navigation_state_service.dart +++ b/lib/core/services/navigation_state_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/debug_logger.dart'; /// Navigation state data model class NavigationState { @@ -58,9 +59,9 @@ class NavigationStateService { try { _prefs = await SharedPreferences.getInstance(); await _loadNavigationState(); - debugPrint('DEBUG: NavigationStateService initialized'); + DebugLogger.navigation('NavigationStateService initialized'); } catch (e) { - debugPrint('ERROR: Failed to initialize NavigationStateService: $e'); + DebugLogger.error('Failed to initialize NavigationStateService', e); } } @@ -95,9 +96,9 @@ class NavigationStateService { await _saveNavigationState(); - debugPrint('DEBUG: Navigation state pushed - ${state.routeName}'); + DebugLogger.navigation('Navigation state pushed - ${state.routeName}'); } catch (e) { - debugPrint('ERROR: Failed to push navigation state: $e'); + DebugLogger.error('Failed to push navigation state', e); } } @@ -114,10 +115,12 @@ class NavigationStateService { await _saveNavigationState(); - debugPrint('DEBUG: Navigation state popped - ${poppedState.routeName}'); + DebugLogger.navigation( + 'Navigation state popped - ${poppedState.routeName}', + ); return poppedState; } catch (e) { - debugPrint('ERROR: Failed to pop navigation state: $e'); + DebugLogger.error('Failed to pop navigation state', e); return null; } } @@ -153,9 +156,9 @@ class NavigationStateService { _stateNotifier.value = updatedState; await _saveNavigationState(); - debugPrint('DEBUG: Navigation state updated'); + DebugLogger.navigation('Navigation state updated'); } catch (e) { - debugPrint('ERROR: Failed to update navigation state: $e'); + DebugLogger.error('Failed to update navigation state', e); } } @@ -167,9 +170,9 @@ class NavigationStateService { _navigationStack.add(_currentState!); } await _saveNavigationState(); - debugPrint('DEBUG: Navigation stack cleared'); + DebugLogger.navigation('Navigation stack cleared'); } catch (e) { - debugPrint('ERROR: Failed to clear navigation stack: $e'); + DebugLogger.error('Failed to clear navigation stack', e); } } @@ -182,11 +185,11 @@ class NavigationStateService { _stateNotifier.value = _currentState; await _saveNavigationState(); - debugPrint( - 'DEBUG: Navigation stack replaced with ${newStack.length} states', + DebugLogger.navigation( + 'Navigation stack replaced with ${newStack.length} states', ); } catch (e) { - debugPrint('ERROR: Failed to replace navigation stack: $e'); + DebugLogger.error('Failed to replace navigation stack', e); } } @@ -219,9 +222,9 @@ class NavigationStateService { await replaceStack([deepLinkState]); } - debugPrint('DEBUG: Deep link handled - $routeName'); + DebugLogger.navigation('Deep link handled - $routeName'); } catch (e) { - debugPrint('ERROR: Failed to handle deep link: $e'); + DebugLogger.error('Failed to handle deep link', e); } } @@ -275,8 +278,8 @@ class NavigationStateService { if (_currentState != null) { // Attempt to restore to the last known state - debugPrint( - 'DEBUG: Restoring navigation to ${_currentState!.routeName}', + DebugLogger.navigation( + 'Restoring navigation to ${_currentState!.routeName}', ); // This would need to be implemented based on your routing setup @@ -287,7 +290,7 @@ class NavigationStateService { // ); } } catch (e) { - debugPrint('ERROR: Failed to restore navigation state: $e'); + DebugLogger.error('Failed to restore navigation state', e); } } @@ -302,9 +305,9 @@ class NavigationStateService { await _prefs?.remove(_currentStateKey); await _prefs?.remove(_deepLinkStateKey); - debugPrint('DEBUG: All navigation state cleared'); + DebugLogger.navigation('All navigation state cleared'); } catch (e) { - debugPrint('ERROR: Failed to clear navigation state: $e'); + DebugLogger.error('Failed to clear navigation state', e); } } @@ -329,7 +332,7 @@ class NavigationStateService { await _prefs!.remove(_currentStateKey); } } catch (e) { - debugPrint('ERROR: Failed to save navigation state: $e'); + DebugLogger.error('Failed to save navigation state', e); } } @@ -359,11 +362,11 @@ class NavigationStateService { _stateNotifier.value = _currentState; } - debugPrint( - 'DEBUG: Navigation state loaded - ${_navigationStack.length} states', + DebugLogger.navigation( + 'Navigation state loaded - ${_navigationStack.length} states', ); } catch (e) { - debugPrint('ERROR: Failed to load navigation state: $e'); + DebugLogger.error('Failed to load navigation state', e); // Clear corrupted state await clearAll(); } @@ -376,7 +379,7 @@ class NavigationStateService { try { await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson())); } catch (e) { - debugPrint('ERROR: Failed to save deep link state: $e'); + DebugLogger.error('Failed to save deep link state', e); } } diff --git a/lib/core/services/persistent_streaming_service.dart b/lib/core/services/persistent_streaming_service.dart index 4a0fc34..58bdf34 100644 --- a/lib/core/services/persistent_streaming_service.dart +++ b/lib/core/services/persistent_streaming_service.dart @@ -4,9 +4,11 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:dio/dio.dart'; import 'background_streaming_handler.dart'; import 'connectivity_service.dart'; +import '../utils/debug_logger.dart'; class PersistentStreamingService with WidgetsBindingObserver { - static final PersistentStreamingService _instance = PersistentStreamingService._internal(); + static final PersistentStreamingService _instance = + PersistentStreamingService._internal(); factory PersistentStreamingService() => _instance; PersistentStreamingService._internal() { _initialize(); @@ -17,25 +19,25 @@ class PersistentStreamingService with WidgetsBindingObserver { final Map _streamControllers = {}; final Map _streamRecoveryCallbacks = {}; final Map> _streamMetadata = {}; - + // App lifecycle state // AppLifecycleState? _lastLifecycleState; // Removed as it's unused bool _isInBackground = false; Timer? _backgroundTimer; Timer? _heartbeatTimer; - + // Background streaming handler late final BackgroundStreamingHandler _backgroundHandler; - + // Connectivity monitoring StreamSubscription? _connectivitySubscription; bool _hasConnectivity = true; - + // Recovery state final Map _retryAttempts = {}; static const int _maxRetryAttempts = 3; static const Duration _retryDelay = Duration(seconds: 2); - + void _initialize() { WidgetsBinding.instance.addObserver(this); _backgroundHandler = BackgroundStreamingHandler.instance; @@ -43,48 +45,56 @@ class PersistentStreamingService with WidgetsBindingObserver { _setupConnectivityMonitoring(); _startHeartbeat(); } - + void _setupBackgroundHandlerCallbacks() { _backgroundHandler.onStreamsSuspending = (streamIds) { - debugPrint('PersistentStreaming: Streams suspending - $streamIds'); + DebugLogger.stream( + 'PersistentStreaming: Streams suspending - $streamIds', + ); // Mark streams as suspended but don't close them yet for (final streamId in streamIds) { _markStreamAsSuspended(streamId); } }; - + _backgroundHandler.onBackgroundTaskExpiring = () { - debugPrint('PersistentStreaming: Background task expiring'); + DebugLogger.stream('PersistentStreaming: Background task expiring'); // Save states and prepare for recovery _saveStreamStatesForRecovery(); }; - + _backgroundHandler.shouldContinueInBackground = () { return _activeStreams.isNotEmpty; }; } - + void _setupConnectivityMonitoring() { // Create a connectivity service instance - this would normally be injected // For now, create a temporary instance just for monitoring final connectivityService = ConnectivityService(Dio()); - - _connectivitySubscription = connectivityService.isConnected.listen((connected) { + + _connectivitySubscription = connectivityService.isConnected.listen(( + connected, + ) { final wasConnected = _hasConnectivity; _hasConnectivity = connected; - + if (!wasConnected && connected) { // Connectivity restored - try to recover streams - debugPrint('PersistentStreaming: Connectivity restored, recovering streams'); + DebugLogger.stream( + 'PersistentStreaming: Connectivity restored, recovering streams', + ); _recoverActiveStreams(); } else if (wasConnected && !connected) { // Connectivity lost - mark streams as suspended - debugPrint('PersistentStreaming: Connectivity lost, suspending streams'); + DebugLogger.stream( + 'PersistentStreaming: Connectivity lost, suspending streams', + ); _suspendAllStreams(); } }); } - + void _startHeartbeat() { _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) { if (_activeStreams.isNotEmpty && _isInBackground) { @@ -92,11 +102,11 @@ class PersistentStreamingService with WidgetsBindingObserver { } }); } - + @override void didChangeAppLifecycleState(AppLifecycleState state) { // _lastLifecycleState = state; // Removed as it's unused - + switch (state) { case AppLifecycleState.paused: case AppLifecycleState.inactive: @@ -112,47 +122,49 @@ class PersistentStreamingService with WidgetsBindingObserver { break; } } - + void _onAppBackground() { - debugPrint('PersistentStreamingService: App went to background'); + DebugLogger.stream('PersistentStreamingService: App went to background'); _isInBackground = true; - + // Enable wake lock to prevent device sleep during streaming if (_activeStreams.isNotEmpty) { _enableWakeLock(); _startBackgroundExecution(); } } - + void _onAppForeground() { - debugPrint('PersistentStreamingService: App returned to foreground'); + DebugLogger.stream( + 'PersistentStreamingService: App returned to foreground', + ); _isInBackground = false; - + // Cancel background timer _backgroundTimer?.cancel(); _backgroundTimer = null; - + // Disable wake lock if no active streams if (_activeStreams.isEmpty) { _disableWakeLock(); } - + // Check and recover any interrupted streams _recoverActiveStreams(); } - + void _onAppDetached() { - debugPrint('PersistentStreamingService: App detached'); - + DebugLogger.stream('PersistentStreamingService: App detached'); + // Save stream states for recovery _saveStreamStatesForRecovery(); - + // Clean up _backgroundTimer?.cancel(); _heartbeatTimer?.cancel(); _disableWakeLock(); } - + // Register a stream for persistent handling String registerStream({ required StreamSubscription subscription, @@ -161,17 +173,17 @@ class PersistentStreamingService with WidgetsBindingObserver { Map? metadata, }) { final streamId = DateTime.now().millisecondsSinceEpoch.toString(); - + _activeStreams[streamId] = subscription; _streamControllers[streamId] = controller; if (recoveryCallback != null) { _streamRecoveryCallbacks[streamId] = recoveryCallback; } - + // Store metadata for recovery if (metadata != null) { _streamMetadata[streamId] = metadata; - + // Register with background handler _backgroundHandler.registerStream( streamId, @@ -182,22 +194,24 @@ class PersistentStreamingService with WidgetsBindingObserver { lastContent: metadata['lastContent'], ); } - + // Enable wake lock when streaming starts if (_activeStreams.length == 1) { _enableWakeLock(); } - + // Start background execution if app is backgrounded if (_isInBackground) { _startBackgroundExecution(); } - - debugPrint('PersistentStreamingService: Registered stream $streamId'); - + + DebugLogger.stream( + 'PersistentStreamingService: Registered stream $streamId', + ); + return streamId; } - + // Unregister a stream void unregisterStream(String streamId) { _activeStreams.remove(streamId); @@ -205,31 +219,35 @@ class PersistentStreamingService with WidgetsBindingObserver { _streamRecoveryCallbacks.remove(streamId); _streamMetadata.remove(streamId); _retryAttempts.remove(streamId); - + // Unregister from background handler _backgroundHandler.unregisterStream(streamId); - + // Stop background execution if no more streams if (_activeStreams.isEmpty) { _backgroundHandler.stopBackgroundExecution([streamId]); _disableWakeLock(); } - - debugPrint('PersistentStreamingService: Unregistered stream $streamId'); + + DebugLogger.stream( + 'PersistentStreamingService: Unregistered stream $streamId', + ); } - + // Check if a stream is still active bool isStreamActive(String streamId) { return _activeStreams.containsKey(streamId); } - + // Recover interrupted streams Future _recoverActiveStreams() async { if (!_hasConnectivity) { - debugPrint('PersistentStreaming: No connectivity, skipping recovery'); + DebugLogger.stream( + 'PersistentStreaming: No connectivity, skipping recovery', + ); return; } - + // First, try to recover from background handler saved states final savedStates = await _backgroundHandler.recoverStreamStates(); for (final state in savedStates) { @@ -237,12 +255,12 @@ class PersistentStreamingService with WidgetsBindingObserver { await _recoverStreamFromState(state); } } - + // Then check active streams for recovery for (final entry in _streamRecoveryCallbacks.entries) { final streamId = entry.key; final recoveryCallback = entry.value; - + // Check if stream was interrupted or needs recovery final subscription = _activeStreams[streamId]; if (subscription == null || _needsRecovery(streamId)) { @@ -250,69 +268,84 @@ class PersistentStreamingService with WidgetsBindingObserver { } } } - + Future _recoverStreamFromState(StreamState state) async { final recoveryCallback = _streamRecoveryCallbacks[state.streamId]; if (recoveryCallback != null) { - debugPrint('PersistentStreaming: Recovering stream from saved state: ${state.streamId}'); + DebugLogger.stream( + 'PersistentStreaming: Recovering stream from saved state: ${state.streamId}', + ); await _attemptStreamRecovery(state.streamId, recoveryCallback); } } - - Future _attemptStreamRecovery(String streamId, Function recoveryCallback) async { + + Future _attemptStreamRecovery( + String streamId, + Function recoveryCallback, + ) async { final attempts = _retryAttempts[streamId] ?? 0; if (attempts >= _maxRetryAttempts) { - debugPrint('PersistentStreaming: Max retry attempts reached for stream $streamId'); + DebugLogger.warning( + 'PersistentStreaming: Max retry attempts reached for stream $streamId', + ); return; } - - debugPrint('PersistentStreaming: Recovering stream $streamId (attempt ${attempts + 1})'); - + + DebugLogger.stream( + 'PersistentStreaming: Recovering stream $streamId (attempt ${attempts + 1})', + ); + try { _retryAttempts[streamId] = attempts + 1; - + // Add exponential backoff delay if (attempts > 0) { final delay = _retryDelay * (1 << (attempts - 1)); // 2s, 4s, 8s... await Future.delayed(delay); } - + // Call recovery callback to restart the stream await recoveryCallback(); - + // Reset retry count on success _retryAttempts.remove(streamId); } catch (e) { - debugPrint('PersistentStreaming: Failed to recover stream $streamId: $e'); - + DebugLogger.error( + 'PersistentStreaming: Failed to recover stream $streamId', + e, + ); + // Schedule next retry if under limit if (_retryAttempts[streamId]! < _maxRetryAttempts) { - Timer(_retryDelay, () => _attemptStreamRecovery(streamId, recoveryCallback)); + Timer( + _retryDelay, + () => _attemptStreamRecovery(streamId, recoveryCallback), + ); } } } - + bool _needsRecovery(String streamId) { final metadata = _streamMetadata[streamId]; if (metadata == null) return false; - + // Check if stream has been inactive for too long final lastUpdate = metadata['lastUpdate'] as DateTime?; if (lastUpdate != null) { final timeSinceUpdate = DateTime.now().difference(lastUpdate); return timeSinceUpdate > const Duration(minutes: 1); } - + return false; } - + // Platform-specific background execution void _startBackgroundExecution() { if (_activeStreams.isNotEmpty) { _backgroundHandler.startBackgroundExecution(_activeStreams.keys.toList()); } } - + void _markStreamAsSuspended(String streamId) { final metadata = _streamMetadata[streamId]; if (metadata != null) { @@ -320,20 +353,23 @@ class PersistentStreamingService with WidgetsBindingObserver { metadata['suspendedAt'] = DateTime.now(); } } - + void _suspendAllStreams() { for (final streamId in _activeStreams.keys) { _markStreamAsSuspended(streamId); } } - + void _saveStreamStatesForRecovery() { // The background handler will handle the actual saving - debugPrint('PersistentStreaming: Saving ${_activeStreams.length} stream states for recovery'); + DebugLogger.stream( + 'PersistentStreaming: Saving ${_activeStreams.length} stream states for recovery', + ); } - + // Update stream metadata when chunks are received - void updateStreamProgress(String streamId, { + void updateStreamProgress( + String streamId, { int? chunkSequence, String? content, String? appendedContent, @@ -345,54 +381,62 @@ class PersistentStreamingService with WidgetsBindingObserver { content: content, appendedContent: appendedContent, ); - + // Update local metadata final metadata = _streamMetadata[streamId]; if (metadata != null) { metadata['lastUpdate'] = DateTime.now(); - metadata['lastChunkSequence'] = chunkSequence ?? metadata['lastChunkSequence']; + metadata['lastChunkSequence'] = + chunkSequence ?? metadata['lastChunkSequence']; if (appendedContent != null) { - metadata['lastContent'] = (metadata['lastContent'] ?? '') + appendedContent; + metadata['lastContent'] = + (metadata['lastContent'] ?? '') + appendedContent; } else if (content != null) { metadata['lastContent'] = content; } metadata['suspended'] = false; // Mark as active } } - + // Wake lock management void _enableWakeLock() async { try { await WakelockPlus.enable(); - debugPrint('PersistentStreamingService: Wake lock enabled'); + DebugLogger.stream('PersistentStreamingService: Wake lock enabled'); } catch (e) { - debugPrint('PersistentStreamingService: Failed to enable wake lock: $e'); + DebugLogger.error( + 'PersistentStreamingService: Failed to enable wake lock', + e, + ); } } - + void _disableWakeLock() async { try { await WakelockPlus.disable(); - debugPrint('PersistentStreamingService: Wake lock disabled'); + DebugLogger.stream('PersistentStreamingService: Wake lock disabled'); } catch (e) { - debugPrint('PersistentStreamingService: Failed to disable wake lock: $e'); + DebugLogger.error( + 'PersistentStreamingService: Failed to disable wake lock', + e, + ); } } - + // Get active stream count int get activeStreamCount => _activeStreams.length; - + // Get stream metadata Map? getStreamMetadata(String streamId) { return _streamMetadata[streamId]; } - + // Check if stream is suspended bool isStreamSuspended(String streamId) { final metadata = _streamMetadata[streamId]; return metadata?['suspended'] == true; } - + // Force recovery of a specific stream Future forceRecoverStream(String streamId) async { final recoveryCallback = _streamRecoveryCallbacks[streamId]; @@ -401,7 +445,7 @@ class PersistentStreamingService with WidgetsBindingObserver { await _attemptStreamRecovery(streamId, recoveryCallback); } } - + // Cleanup void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -409,18 +453,18 @@ class PersistentStreamingService with WidgetsBindingObserver { _heartbeatTimer?.cancel(); _connectivitySubscription?.cancel(); _disableWakeLock(); - + // Stop all background execution if (_activeStreams.isNotEmpty) { _backgroundHandler.stopBackgroundExecution(_activeStreams.keys.toList()); } - + // Cancel all active streams for (final subscription in _activeStreams.values) { subscription.cancel(); } _activeStreams.clear(); - + // Close all controllers for (final controller in _streamControllers.values) { if (!controller.isClosed) { @@ -428,13 +472,13 @@ class PersistentStreamingService with WidgetsBindingObserver { } } _streamControllers.clear(); - + // Clear all metadata _streamMetadata.clear(); _streamRecoveryCallbacks.clear(); _retryAttempts.clear(); - + // Clear background handler _backgroundHandler.clearAll(); } -} \ No newline at end of file +} diff --git a/lib/core/services/secure_credential_storage.dart b/lib/core/services/secure_credential_storage.dart index 2839f25..799e9d9 100644 --- a/lib/core/services/secure_credential_storage.dart +++ b/lib/core/services/secure_credential_storage.dart @@ -1,8 +1,8 @@ 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'; +import '../utils/debug_logger.dart'; /// Enhanced secure credential storage with platform-specific optimizations class SecureCredentialStorage { @@ -73,9 +73,9 @@ class SecureCredentialStorage { ); } - debugPrint('DEBUG: Credentials saved and verified securely'); + DebugLogger.storage('Credentials saved and verified securely'); } catch (e) { - debugPrint('ERROR: Failed to save credentials: $e'); + DebugLogger.error('Failed to save credentials', e); rethrow; } } @@ -92,7 +92,7 @@ class SecureCredentialStorage { final decoded = jsonDecode(jsonString); if (decoded is! Map) { - debugPrint('Warning: Invalid credentials format'); + DebugLogger.warning('Invalid credentials format'); await deleteSavedCredentials(); return null; } @@ -103,8 +103,8 @@ class SecureCredentialStorage { final currentDeviceId = await _getDeviceFingerprint(); if (savedDeviceId != currentDeviceId) { - debugPrint( - 'Info: Device fingerprint changed, but allowing credential access for better UX', + DebugLogger.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 @@ -115,8 +115,8 @@ class SecureCredentialStorage { if (!decoded.containsKey('serverId') || !decoded.containsKey('username') || !decoded.containsKey('password')) { - debugPrint( - 'Warning: Invalid saved credentials format - missing required fields', + DebugLogger.warning( + 'Invalid saved credentials format - missing required fields', ); await deleteSavedCredentials(); return null; @@ -132,12 +132,12 @@ class SecureCredentialStorage { // Warn if credentials are very old (but don't delete them) if (daysSinceCreated > 90) { - debugPrint( - 'Info: Saved credentials are $daysSinceCreated days old', + DebugLogger.info( + 'Saved credentials are $daysSinceCreated days old', ); } } catch (e) { - debugPrint('Warning: Could not parse savedAt timestamp: $e'); + DebugLogger.warning('Could not parse savedAt timestamp: $e'); } } @@ -148,7 +148,7 @@ class SecureCredentialStorage { 'savedAt': decoded['savedAt']?.toString() ?? '', }; } catch (e) { - debugPrint('ERROR: Failed to retrieve credentials: $e'); + DebugLogger.error('Failed to retrieve credentials', e); // Don't delete credentials on retrieval errors - they might be recoverable return null; } @@ -158,9 +158,9 @@ class SecureCredentialStorage { Future deleteSavedCredentials() async { try { await _secureStorage.delete(key: _credentialsKey); - debugPrint('DEBUG: Credentials deleted'); + DebugLogger.storage('Credentials deleted'); } catch (e) { - debugPrint('ERROR: Failed to delete credentials: $e'); + DebugLogger.error('Failed to delete credentials', e); } } @@ -170,7 +170,7 @@ class SecureCredentialStorage { final encryptedToken = await _encryptData(token); await _secureStorage.write(key: _authTokenKey, value: encryptedToken); } catch (e) { - debugPrint('ERROR: Failed to save auth token: $e'); + DebugLogger.error('Failed to save auth token', e); rethrow; } } @@ -183,7 +183,7 @@ class SecureCredentialStorage { return await _decryptData(encryptedToken); } catch (e) { - debugPrint('ERROR: Failed to retrieve auth token: $e'); + DebugLogger.error('Failed to retrieve auth token', e); return null; } } @@ -193,7 +193,7 @@ class SecureCredentialStorage { try { await _secureStorage.delete(key: _authTokenKey); } catch (e) { - debugPrint('ERROR: Failed to delete auth token: $e'); + DebugLogger.error('Failed to delete auth token', e); } } @@ -206,7 +206,7 @@ class SecureCredentialStorage { value: encryptedConfigs, ); } catch (e) { - debugPrint('ERROR: Failed to save server configs: $e'); + DebugLogger.error('Failed to save server configs', e); rethrow; } } @@ -221,7 +221,7 @@ class SecureCredentialStorage { return await _decryptData(encryptedConfigs); } catch (e) { - debugPrint('ERROR: Failed to retrieve server configs: $e'); + DebugLogger.error('Failed to retrieve server configs', e); return null; } } @@ -239,7 +239,7 @@ class SecureCredentialStorage { return result == testValue; } catch (e) { - debugPrint('WARNING: Secure storage not available: $e'); + DebugLogger.warning('Secure storage not available: $e'); return false; } } @@ -248,9 +248,9 @@ class SecureCredentialStorage { Future clearAll() async { try { await _secureStorage.deleteAll(); - debugPrint('DEBUG: All secure data cleared'); + DebugLogger.storage('All secure data cleared'); } catch (e) { - debugPrint('ERROR: Failed to clear secure data: $e'); + DebugLogger.error('Failed to clear secure data', e); } } @@ -261,7 +261,7 @@ class SecureCredentialStorage { // 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'); + DebugLogger.error('Failed to encrypt data', e); rethrow; } } @@ -273,7 +273,7 @@ class SecureCredentialStorage { // This matches the encryption method above return encryptedData; } catch (e) { - debugPrint('ERROR: Failed to decrypt data: $e'); + DebugLogger.error('Failed to decrypt data', e); rethrow; } } @@ -297,7 +297,7 @@ class SecureCredentialStorage { return digest.toString(); } catch (e) { - debugPrint('WARNING: Failed to generate device fingerprint: $e'); + DebugLogger.warning('Failed to generate device fingerprint: $e'); // Return a consistent fallback fingerprint return 'stable_fallback_device_id'; } @@ -315,11 +315,11 @@ class SecureCredentialStorage { username: oldCredentials['username'] ?? '', password: oldCredentials['password'] ?? '', ); - debugPrint( - 'DEBUG: Successfully migrated credentials to new secure format', + DebugLogger.storage( + 'Successfully migrated credentials to new secure format', ); } catch (e) { - debugPrint('ERROR: Failed to migrate credentials: $e'); + DebugLogger.error('Failed to migrate credentials', e); } } } diff --git a/lib/core/utils/debug_logger.dart b/lib/core/utils/debug_logger.dart new file mode 100644 index 0000000..970449c --- /dev/null +++ b/lib/core/utils/debug_logger.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; + +/// Centralized debug logging utility for the entire app +class DebugLogger { + static const bool _enabled = kDebugMode; + + /// Log debug information + static void log(String message) { + if (_enabled) { + debugPrint('🔍 $message'); + } + } + + /// Log errors + static void error(String message, [Object? error]) { + if (_enabled) { + if (error != null) { + debugPrint('❌ $message: $error'); + } else { + debugPrint('❌ $message'); + } + } + } + + /// Log warnings + static void warning(String message) { + if (_enabled) { + debugPrint('⚠️ $message'); + } + } + + /// Log success/info messages + static void info(String message) { + if (_enabled) { + debugPrint('ℹ️ $message'); + } + } + + /// Log navigation events + static void navigation(String message) { + if (_enabled) { + debugPrint('🧭 $message'); + } + } + + /// Log authentication events + static void auth(String message) { + if (_enabled) { + debugPrint('🔐 $message'); + } + } + + /// Log streaming events + static void stream(String message) { + if (_enabled) { + debugPrint('📡 $message'); + } + } + + /// Log validation events + static void validation(String message) { + if (_enabled) { + debugPrint('✅ $message'); + } + } + + /// Log storage events + static void storage(String message) { + if (_enabled) { + debugPrint('💾 $message'); + } + } +} diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart index ec553a9..57dc3a4 100644 --- a/lib/core/utils/reasoning_parser.dart +++ b/lib/core/utils/reasoning_parser.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'debug_logger.dart'; /// Utility class for parsing and extracting reasoning/thinking content from messages class ReasoningParser { @@ -7,21 +8,21 @@ class ReasoningParser { if (content.isEmpty) return null; if (kDebugMode) { - debugPrint( - 'DEBUG: Parsing content: ${content.substring(0, content.length > 200 ? 200 : content.length)}...', + DebugLogger.log( + 'Parsing content: ${content.substring(0, content.length > 200 ? 200 : content.length)}...', ); } // Check if content contains reasoning if (!content.contains('
tag with type="reasoning" @@ -45,18 +46,18 @@ class ReasoningParser { final flexMatch = flexRegex.firstMatch(content); if (flexMatch != null) { if (kDebugMode) { - debugPrint('DEBUG: Found flexible match: ${flexMatch.group(0)}'); + DebugLogger.log('Found flexible match: ${flexMatch.group(0)}'); } } else { if (kDebugMode) { - debugPrint('DEBUG: No flexible match found either'); + DebugLogger.log('No flexible match found either'); } } return null; } if (kDebugMode) { - debugPrint('DEBUG: Regex matched successfully'); + DebugLogger.log('Regex matched successfully'); } final isDone = match.group(1) == 'true'; @@ -65,10 +66,10 @@ class ReasoningParser { final reasoning = match.group(4)?.trim() ?? ''; if (kDebugMode) { - debugPrint( - 'DEBUG: Parsed values - isDone: $isDone, duration: $duration, summary: $summary', + DebugLogger.log( + 'Parsed values - isDone: $isDone, duration: $duration, summary: $summary', ); - debugPrint('DEBUG: Reasoning content length: ${reasoning.length}'); + DebugLogger.log('Reasoning content length: ${reasoning.length}'); } // Remove the reasoning section from the main content diff --git a/lib/core/validation/api_validator.dart b/lib/core/validation/api_validator.dart index e76f9ee..07320b5 100644 --- a/lib/core/validation/api_validator.dart +++ b/lib/core/validation/api_validator.dart @@ -1,7 +1,7 @@ -import 'package:flutter/foundation.dart'; import 'schema_registry.dart'; import 'validation_result.dart'; import 'field_mapper.dart'; +import '../utils/debug_logger.dart'; /// Comprehensive API request and response validator /// Validates against OpenAPI specification schemas @@ -24,9 +24,9 @@ class ApiValidator { try { await _schemaRegistry.loadSchemas(); _initialized = true; - debugPrint('ApiValidator: Successfully initialized with schemas'); + DebugLogger.validation('Successfully initialized with schemas'); } catch (e) { - debugPrint('ApiValidator: Failed to initialize: $e'); + DebugLogger.error('Failed to initialize', e); // Continue without validation if schemas can't be loaded } } diff --git a/lib/core/validation/field_mapper.dart b/lib/core/validation/field_mapper.dart index 2dcffce..4d9446b 100644 --- a/lib/core/validation/field_mapper.dart +++ b/lib/core/validation/field_mapper.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import '../utils/debug_logger.dart'; /// Handles field name transformations between API and client formats /// Converts between snake_case (API) and camelCase (client) @@ -224,7 +224,7 @@ class FieldMapper { void clearCache() { _toCamelCaseCache.clear(); _toSnakeCaseCache.clear(); - debugPrint('FieldMapper: Cleared transformation caches'); + DebugLogger.validation('Cleared transformation caches'); } /// Add custom field mapping @@ -236,7 +236,7 @@ class FieldMapper { _toCamelCaseCache.remove(apiField); _toSnakeCaseCache.remove(clientField); - debugPrint('FieldMapper: Added custom mapping: $apiField <-> $clientField'); + DebugLogger.validation('Added custom mapping: $apiField <-> $clientField'); } /// Validate that field transformations are reversible @@ -266,14 +266,14 @@ class FieldMapper { } if (errors.isNotEmpty) { - debugPrint('FieldMapper: Transformation validation errors:'); + DebugLogger.error('Transformation validation errors:'); for (final error in errors) { - debugPrint(' $error'); + DebugLogger.error(' $error'); } return false; } - debugPrint('FieldMapper: All transformations validated successfully'); + DebugLogger.validation('All transformations validated successfully'); return true; } } diff --git a/lib/core/validation/schema_registry.dart b/lib/core/validation/schema_registry.dart index 12c4536..be48a0f 100644 --- a/lib/core/validation/schema_registry.dart +++ b/lib/core/validation/schema_registry.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import '../utils/debug_logger.dart'; /// Registry for OpenAPI schemas /// Loads and provides access to request/response schemas for validation @@ -19,15 +19,15 @@ class SchemaRegistry { /// Load schemas from OpenAPI specification Future loadSchemas() async { try { - debugPrint('SchemaRegistry: Loading OpenAPI specification...'); + DebugLogger.validation('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...', + DebugLogger.warning( + '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'); @@ -35,14 +35,14 @@ class SchemaRegistry { _openApiSpec = jsonDecode(openApiContent) as Map; - debugPrint( - 'SchemaRegistry: Successfully loaded OpenAPI spec with ${_getPaths().length} paths', + DebugLogger.validation( + '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'); + DebugLogger.error('Failed to load schemas', e); rethrow; } } @@ -86,8 +86,9 @@ class SchemaRegistry { return schema; } catch (e) { - debugPrint( - 'SchemaRegistry: Error getting request schema for $method $endpoint: $e', + DebugLogger.error( + 'Error getting request schema for $method $endpoint', + e, ); return null; } @@ -146,8 +147,9 @@ class SchemaRegistry { return schema; } catch (e) { - debugPrint( - 'SchemaRegistry: Error getting response schema for $method $endpoint ($code): $e', + DebugLogger.error( + 'Error getting response schema for $method $endpoint ($code)', + e, ); return null; } @@ -240,7 +242,7 @@ class SchemaRegistry { /// Resolve $ref reference Map? _resolveReference(String ref) { if (!ref.startsWith('#/')) { - debugPrint('SchemaRegistry: External references not supported: $ref'); + DebugLogger.warning('External references not supported: $ref'); return null; } @@ -251,7 +253,7 @@ class SchemaRegistry { if (current is Map && current.containsKey(segment)) { current = current[segment]; } else { - debugPrint('SchemaRegistry: Could not resolve reference: $ref'); + DebugLogger.warning('Could not resolve reference: $ref'); return null; } } @@ -329,9 +331,7 @@ class SchemaRegistry { } } - debugPrint( - 'SchemaRegistry: Pre-cached schemas for $cachedCount operations', - ); + DebugLogger.validation('Pre-cached schemas for $cachedCount operations'); } /// Get all available endpoints diff --git a/lib/core/validation/validation_interceptor.dart b/lib/core/validation/validation_interceptor.dart index fb621db..266aa25 100644 --- a/lib/core/validation/validation_interceptor.dart +++ b/lib/core/validation/validation_interceptor.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'api_validator.dart'; import 'validation_result.dart'; +import '../utils/debug_logger.dart'; /// Dio interceptor for automatic API validation /// Validates requests and responses against OpenAPI schemas @@ -61,7 +62,7 @@ class ValidationInterceptor extends Interceptor { ); return; } else { - debugPrint('ValidationInterceptor: Request validation error: $e'); + DebugLogger.error('Request validation error', e); } } } @@ -120,7 +121,7 @@ class ValidationInterceptor extends Interceptor { ); return; } else { - debugPrint('ValidationInterceptor: Response validation error: $e'); + DebugLogger.error('Response validation error', e); } } } @@ -165,9 +166,7 @@ class ValidationInterceptor extends Interceptor { err.response!.extra['validationResult'] = result; } } catch (e) { - debugPrint( - 'ValidationInterceptor: Error response validation failed: $e', - ); + DebugLogger.error('Error response validation failed', e); } } @@ -187,21 +186,21 @@ class ValidationInterceptor extends Interceptor { final statusText = statusCode != null ? ' ($statusCode)' : ''; final icon = result.isValid ? '✅' : '❌'; - debugPrint( + DebugLogger.validation( '$icon Validation $type: ${method.toUpperCase()} $path$statusText - ${result.status.name}', ); if (result.hasErrors) { - debugPrint(' Errors: ${result.errors.join(', ')}'); + DebugLogger.error(' Errors: ${result.errors.join(', ')}'); } if (result.hasWarnings) { - debugPrint(' Warnings: ${result.warnings.join(', ')}'); + DebugLogger.warning(' Warnings: ${result.warnings.join(', ')}'); } if (result.message.isNotEmpty && result.status != ValidationStatus.success) { - debugPrint(' Message: ${result.message}'); + DebugLogger.info(' Message: ${result.message}'); } } diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 12b242a..d951344 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - import '../../../core/models/server_config.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/input_validation_service.dart'; @@ -15,16 +14,12 @@ import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../core/auth/auth_state_manager.dart'; - - +import '../../../core/utils/debug_logger.dart'; class AuthenticationPage extends ConsumerStatefulWidget { final ServerConfig serverConfig; - - const AuthenticationPage({ - super.key, - required this.serverConfig, - }); + + const AuthenticationPage({super.key, required this.serverConfig}); @override ConsumerState createState() => _AuthenticationPageState(); @@ -35,7 +30,7 @@ class _AuthenticationPageState extends ConsumerState { final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController(); - + bool _obscurePassword = true; bool _useApiKey = false; String? _loginError; @@ -67,7 +62,7 @@ class _AuthenticationPageState extends ConsumerState { Future _signIn() async { if (!_formKey.currentState!.validate()) return; - + setState(() { _isSigningIn = true; _loginError = null; @@ -94,7 +89,7 @@ class _AuthenticationPageState extends ConsumerState { final authState = ref.read(authStateManagerProvider); throw Exception(authState.error ?? 'Login failed'); } - + // Success - navigation will be handled by auth state change } catch (e) { setState(() { @@ -109,10 +104,6 @@ class _AuthenticationPageState extends ConsumerState { } } - - - - String _formatLoginError(String error) { if (error.contains('401') || error.contains('Unauthorized')) { return 'Invalid username or password. Please try again.'; @@ -130,13 +121,17 @@ class _AuthenticationPageState extends ConsumerState { Widget build(BuildContext context) { // Listen for auth state changes to navigate on successful login ref.listen(authStateManagerProvider, (previous, next) { - if (mounted && next.isAuthenticated && previous?.isAuthenticated != true) { - debugPrint('DEBUG: Authentication successful, initializing background resources'); - + if (mounted && + next.isAuthenticated && + previous?.isAuthenticated != true) { + DebugLogger.auth( + 'Authentication successful, initializing background resources', + ); + // Model selection and onboarding will be handled by the chat page // to avoid widget disposal issues - - debugPrint('DEBUG: Navigating to chat page'); + + DebugLogger.auth('Navigating to chat page'); // Navigate directly to chat page on successful authentication Navigator.of(context).pushNamedAndRemoveUntil( Routes.chat, @@ -144,7 +139,7 @@ class _AuthenticationPageState extends ConsumerState { ); } }); - + return ErrorBoundary( child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, @@ -158,9 +153,9 @@ class _AuthenticationPageState extends ConsumerState { children: [ // Header with progress indicator _buildHeader(), - + const SizedBox(height: Spacing.extraLarge), - + // Main content Expanded( child: SingleChildScrollView( @@ -189,7 +184,7 @@ class _AuthenticationPageState extends ConsumerState { ), ), ), - + // Bottom action button _buildSignInButton(), ], @@ -255,7 +250,9 @@ class _AuthenticationPageState extends ConsumerState { ), ), child: Icon( - Platform.isIOS ? CupertinoIcons.checkmark_circle_fill : Icons.check_circle, + Platform.isIOS + ? CupertinoIcons.checkmark_circle_fill + : Icons.check_circle, color: context.conduitTheme.success, size: IconSize.medium, ), @@ -340,12 +337,12 @@ class _AuthenticationPageState extends ConsumerState { children: [ // Authentication mode toggle _buildAuthModeToggle(), - + const SizedBox(height: Spacing.lg), - + // Authentication form fields _buildAuthFields(), - + if (_loginError != null) ...[ const SizedBox(height: Spacing.md), _buildErrorMessage(_loginError!), @@ -370,7 +367,9 @@ class _AuthenticationPageState extends ConsumerState { children: [ Expanded( child: _buildAuthToggleOption( - icon: Platform.isIOS ? CupertinoIcons.person_circle : Icons.account_circle_outlined, + icon: Platform.isIOS + ? CupertinoIcons.person_circle + : Icons.account_circle_outlined, label: 'Credentials', isSelected: !_useApiKey, onTap: () => setState(() => _useApiKey = false), @@ -378,7 +377,9 @@ class _AuthenticationPageState extends ConsumerState { ), Expanded( child: _buildAuthToggleOption( - icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, + icon: Platform.isIOS + ? CupertinoIcons.lock_shield + : Icons.vpn_key_outlined, label: 'API Key', isSelected: _useApiKey, onTap: () => setState(() => _useApiKey = true), @@ -482,17 +483,22 @@ class _AuthenticationPageState extends ConsumerState { obscureText: _obscurePassword, semanticLabel: 'Enter your API key', prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, + Platform.isIOS + ? CupertinoIcons.lock_shield + : Icons.vpn_key_outlined, color: context.conduitTheme.iconSecondary, ), suffixIcon: IconButton( icon: Icon( _obscurePassword - ? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off) + ? (Platform.isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), color: context.conduitTheme.iconSecondary, ), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), ), onSubmitted: (_) => _signIn(), isRequired: true, @@ -520,10 +526,7 @@ class _AuthenticationPageState extends ConsumerState { Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, color: context.conduitTheme.iconSecondary, ), - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], + autofillHints: const [AutofillHints.username, AutofillHints.email], isRequired: true, ), const SizedBox(height: Spacing.lg), @@ -548,11 +551,14 @@ class _AuthenticationPageState extends ConsumerState { suffixIcon: IconButton( icon: Icon( _obscurePassword - ? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off) + ? (Platform.isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), color: context.conduitTheme.iconSecondary, ), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), ), onSubmitted: (_) => _signIn(), autofillHints: const [AutofillHints.password], @@ -565,22 +571,25 @@ class _AuthenticationPageState extends ConsumerState { Widget _buildSignInButton() { return Padding( padding: const EdgeInsets.only(top: Spacing.lg), - child: ConduitButton( - text: _isSigningIn - ? 'Signing in...' - : _useApiKey - ? 'Sign in with API Key' + child: + ConduitButton( + text: _isSigningIn + ? 'Signing in...' + : _useApiKey + ? 'Sign in with API Key' : 'Sign In', - icon: _isSigningIn - ? null - : (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward), - onPressed: _isSigningIn ? null : _signIn, - isLoading: _isSigningIn, - isFullWidth: true, - ).animate().fadeIn( - duration: AnimationDuration.pageTransition, - delay: AnimationDuration.fast, - ), + icon: _isSigningIn + ? null + : (Platform.isIOS + ? CupertinoIcons.arrow_right + : Icons.arrow_forward), + onPressed: _isSigningIn ? null : _signIn, + isLoading: _isSigningIn, + isFullWidth: true, + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), ); } @@ -598,8 +607,8 @@ class _AuthenticationPageState extends ConsumerState { child: Row( children: [ Icon( - Platform.isIOS - ? CupertinoIcons.exclamationmark_circle_fill + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill : Icons.error_outline, color: context.conduitTheme.error, size: IconSize.medium, @@ -621,6 +630,4 @@ class _AuthenticationPageState extends ConsumerState { curve: Curves.easeOutCubic, ); } - - -} \ No newline at end of file +} diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 76376b0..7322c9b 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -12,10 +12,11 @@ import 'dart:async'; import 'package:path/path.dart' as path; import '../../../core/providers/app_providers.dart'; import '../providers/chat_providers.dart'; +import '../../../core/utils/debug_logger.dart'; import '../widgets/modern_chat_input.dart'; -import '../widgets/modern_message_bubble.dart'; -import '../widgets/documentation_message_widget.dart'; +import '../widgets/user_message_bubble.dart'; +import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/file_attachment_widget.dart'; import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; @@ -72,7 +73,7 @@ class _ChatPageState extends ConsumerState { // Clear current conversation ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(activeConversationProvider.notifier).state = null; - + // Scroll to top if (_scrollController.hasClients) { _scrollController.jumpTo(0); @@ -83,45 +84,47 @@ class _ChatPageState extends ConsumerState { // Check if a model is already selected final selectedModel = ref.read(selectedModelProvider); if (selectedModel != null) { - debugPrint('DEBUG: Model already selected: ${selectedModel.name}'); + DebugLogger.log('Model already selected: ${selectedModel.name}'); return; } - - debugPrint('DEBUG: No model selected, attempting auto-selection'); - + + DebugLogger.log('No model selected, attempting auto-selection'); + try { // First ensure models are loaded final modelsAsync = ref.read(modelsProvider); List models; - + if (modelsAsync.hasValue) { models = modelsAsync.value!; } else { - debugPrint('DEBUG: Models not loaded yet, fetching...'); + DebugLogger.log('Models not loaded yet, fetching...'); models = await ref.read(modelsProvider.future); } - - debugPrint('DEBUG: Found ${models.length} models available'); - + + DebugLogger.log('Found ${models.length} models available'); + if (models.isEmpty) { - debugPrint('DEBUG: No models available for selection'); + DebugLogger.log('No models available for selection'); return; } - + // Try to use the default model provider try { final Model? model = await ref.read(defaultModelProvider.future); if (model != null) { - debugPrint('DEBUG: Model auto-selected via provider: ${model.name}'); + DebugLogger.log('Model auto-selected via provider: ${model.name}'); } } catch (e) { - debugPrint('DEBUG: Default provider failed, selecting first model directly'); + DebugLogger.log( + 'Default provider failed, selecting first model directly', + ); // Fallback: select the first available model ref.read(selectedModelProvider.notifier).state = models.first; - debugPrint('DEBUG: Fallback model selected: ${models.first.name}'); + DebugLogger.log('Fallback model selected: ${models.first.name}'); } } catch (e) { - debugPrint('DEBUG: Failed to auto-select model: $e'); + DebugLogger.error('Failed to auto-select model', e); } } @@ -130,20 +133,20 @@ class _ChatPageState extends ConsumerState { // Check if onboarding has been seen final storage = ref.read(optimizedStorageServiceProvider); final seen = await storage.getOnboardingSeen(); - debugPrint('DEBUG: Chat page - Onboarding seen status: $seen'); - + DebugLogger.log('Chat page - Onboarding seen status: $seen'); + if (!seen && mounted) { // Small delay to ensure navigation has settled await Future.delayed(const Duration(milliseconds: 500)); if (!mounted) return; - - debugPrint('DEBUG: Showing onboarding from chat page'); + + DebugLogger.log('Showing onboarding from chat page'); _showOnboarding(); await storage.setOnboardingSeen(true); - debugPrint('DEBUG: Onboarding marked as seen'); + DebugLogger.log('Onboarding marked as seen'); } } catch (e) { - debugPrint('DEBUG: Error checking onboarding status: $e'); + DebugLogger.error('Error checking onboarding status', e); } } @@ -168,43 +171,45 @@ class _ChatPageState extends ConsumerState { Future _checkAndLoadDemoConversation() async { final isReviewerMode = ref.read(reviewerModeProvider); if (!isReviewerMode) return; - + // Check if there's already an active conversation final activeConversation = ref.read(activeConversationProvider); if (activeConversation != null) { - debugPrint('Conversation already active: ${activeConversation.title}'); + DebugLogger.log( + 'Conversation already active: ${activeConversation.title}', + ); return; } - + // Force refresh conversations provider to ensure we get the demo conversations ref.invalidate(conversationsProvider); - + // Try to load demo conversation for (int i = 0; i < 10; i++) { final conversationsAsync = ref.read(conversationsProvider); - + if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) { // Find and load the welcome conversation final welcomeConv = conversationsAsync.value!.firstWhere( (conv) => conv.id == 'demo-conv-1', orElse: () => conversationsAsync.value!.first, ); - + ref.read(activeConversationProvider.notifier).state = welcomeConv; debugPrint('Auto-loaded demo conversation: ${welcomeConv.title}'); return; } - + // If conversations are still loading, wait a bit and retry if (conversationsAsync.isLoading || i == 0) { await Future.delayed(const Duration(milliseconds: 200)); continue; } - + // If there was an error or no conversations, break break; } - + debugPrint('Failed to auto-load demo conversation'); } @@ -214,15 +219,15 @@ class _ChatPageState extends ConsumerState { // Listen to scroll events to show/hide scroll to bottom button _scrollController.addListener(_onScroll); - + // Initialize chat page components WidgetsBinding.instance.addPostFrameCallback((_) async { // First, ensure a model is selected await _checkAndAutoSelectModel(); - + // Then check for demo conversation in reviewer mode await _checkAndLoadDemoConversation(); - + // Finally, show onboarding if needed await _checkAndShowOnboarding(); }); @@ -252,7 +257,9 @@ class _ChatPageState extends ConsumerState { final isOnline = ref.read(isOnlineProvider); final isReviewerMode = ref.read(reviewerModeProvider); - debugPrint('DEBUG: Online status: $isOnline, Reviewer mode: $isReviewerMode'); + debugPrint( + 'DEBUG: Online status: $isOnline, Reviewer mode: $isReviewerMode', + ); if (!isOnline && !isReviewerMode) { debugPrint('DEBUG: Offline - cannot send message'); if (mounted) { @@ -324,7 +331,9 @@ class _ChatPageState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Message failed to send. Check your connection and try again.'), + content: const Text( + 'Message failed to send. Check your connection and try again.', + ), backgroundColor: context.conduitTheme.error, action: SnackBarAction( label: 'Retry', @@ -852,7 +861,7 @@ class _ChatPageState extends ConsumerState { controller: _scrollController, padding: const EdgeInsets.fromLTRB( Spacing.lg, - Spacing.xl, + Spacing.md, Spacing.lg, Spacing.lg, ), @@ -928,7 +937,7 @@ class _ChatPageState extends ConsumerState { items: messages, padding: const EdgeInsets.fromLTRB( Spacing.lg, - Spacing.xl, + Spacing.md, Spacing.lg, Spacing.lg, ), @@ -943,7 +952,7 @@ class _ChatPageState extends ConsumerState { // Use documentation style for assistant messages, bubble for user messages if (isUser) { - messageWidget = ModernMessageBubble( + messageWidget = UserMessageBubble( key: ValueKey('user-${message.id}'), message: message, isUser: isUser, @@ -956,14 +965,12 @@ class _ChatPageState extends ConsumerState { onDislike: () => _dislikeMessage(message), ); } else { - messageWidget = DocumentationMessageWidget( + messageWidget = assistant.AssistantMessageWidget( key: ValueKey('assistant-${message.id}'), message: message, - isUser: isUser, isStreaming: isStreaming, modelName: message.model, onCopy: () => _copyMessage(message.content), - onEdit: () => _editMessage(message), onRegenerate: () => _regenerateMessage(message), onLike: () => _likeMessage(message), onDislike: () => _dislikeMessage(message), @@ -1032,7 +1039,11 @@ class _ChatPageState extends ConsumerState { // Regenerate response for the previous user message (without duplicating it) final userMessage = messages[messageIndex - 1]; - await regenerateMessage(ref, userMessage.content, userMessage.attachmentIds); + await regenerateMessage( + ref, + userMessage.content, + userMessage.attachmentIds, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1046,7 +1057,9 @@ class _ChatPageState extends ConsumerState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to regenerate message. Try again or check your connection.'), + content: Text( + 'Failed to regenerate message. Try again or check your connection.', + ), backgroundColor: context.conduitTheme.error, action: SnackBarAction( label: 'Retry', @@ -1245,10 +1258,10 @@ class _ChatPageState extends ConsumerState { final selectedModel = ref.watch( selectedModelProvider.select((model) => model), ); - + // Watch reviewer mode and auto-select model if needed final isReviewerMode = ref.watch(reviewerModeProvider); - + // Auto-select model when in reviewer mode with no selection if (isReviewerMode && selectedModel == null) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1267,12 +1280,12 @@ class _ChatPageState extends ConsumerState { if (messages.isNotEmpty) { // Check if currently streaming final isStreaming = messages.any((msg) => msg.isStreaming); - + final shouldPop = await NavigationService.confirmNavigation( title: 'Leave Chat?', - message: isStreaming - ? 'The AI is still responding. Leave anyway?' - : 'Your conversation will be saved.', + message: isStreaming + ? 'The AI is still responding. Leave anyway?' + : 'Your conversation will be saved.', confirmText: 'Leave', cancelText: 'Stay', ); @@ -1281,10 +1294,10 @@ class _ChatPageState extends ConsumerState { if (isStreaming) { ref.read(chatMessagesProvider.notifier).finishStreaming(); } - + // Save the conversation before leaving await _saveConversationBeforeLeaving(ref); - + if (context.mounted) { final canPopNavigator = Navigator.of(context).canPop(); if (canPopNavigator) { @@ -1365,10 +1378,11 @@ class _ChatPageState extends ConsumerState { Flexible( child: Text( _formatModelDisplayName(selectedModel.name), - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w400, - ), + style: AppTypography.headlineSmallStyle + .copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1409,10 +1423,15 @@ class _ChatPageState extends ConsumerState { vertical: 1.0, ), decoration: BoxDecoration( - color: context.conduitTheme.success.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppBorderRadius.badge), + color: context.conduitTheme.success.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), border: Border.all( - color: context.conduitTheme.success.withValues(alpha: 0.3), + color: context.conduitTheme.success + .withValues(alpha: 0.3), width: BorderWidth.thin, ), ), @@ -1446,10 +1465,11 @@ class _ChatPageState extends ConsumerState { Flexible( child: Text( 'Choose Model', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w400, - ), + style: AppTypography.headlineSmallStyle + .copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -1491,10 +1511,15 @@ class _ChatPageState extends ConsumerState { vertical: 1.0, ), decoration: BoxDecoration( - color: context.conduitTheme.success.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppBorderRadius.badge), + color: context.conduitTheme.success.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.badge, + ), border: Border.all( - color: context.conduitTheme.success.withValues(alpha: 0.3), + color: context.conduitTheme.success + .withValues(alpha: 0.3), width: BorderWidth.thin, ), ), @@ -1540,8 +1565,6 @@ class _ChatPageState extends ConsumerState { children: [ Column( children: [ - - // Messages Area with pull-to-refresh Expanded( child: ConduitRefreshIndicator( @@ -1557,7 +1580,9 @@ class _ChatPageState extends ConsumerState { .state = full; } catch (e) { - debugPrint('DEBUG: Failed to refresh conversation: $e'); + debugPrint( + 'DEBUG: Failed to refresh conversation: $e', + ); // Could show a snackbar here if needed } } @@ -1581,7 +1606,9 @@ class _ChatPageState extends ConsumerState { // Modern Input (root matches input background including safe area) ModernChatInput( - enabled: selectedModel != null && (isOnline || ref.watch(reviewerModeProvider)), + enabled: + selectedModel != null && + (isOnline || ref.watch(reviewerModeProvider)), onSendMessage: (text) => _handleMessageSend(text, selectedModel), onVoiceInput: _handleVoiceInput, @@ -1643,7 +1670,8 @@ class _ChatPageState extends ConsumerState { // Check if the last message (assistant) has content final lastMessage = messages.last; - if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) { + if (lastMessage.role == 'assistant' && + lastMessage.content.trim().isEmpty) { // Remove empty assistant message before saving messages.removeLast(); if (messages.isEmpty) return; @@ -1836,8 +1864,6 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> { ), ), - - // Search field Padding( padding: const EdgeInsets.only(bottom: Spacing.md), @@ -2691,6 +2717,4 @@ class _SelectableMessageWrapper extends StatelessWidget { } // Extension on _ChatPageState for utility methods -extension on _ChatPageState { - -} +extension on _ChatPageState {} diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart new file mode 100644 index 0000000..d91cda7 --- /dev/null +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:async'; +import 'dart:io' show Platform; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; +import '../../../core/utils/reasoning_parser.dart'; +import 'enhanced_image_attachment.dart'; + +class AssistantMessageWidget extends ConsumerStatefulWidget { + final dynamic message; + final bool isStreaming; + final String? modelName; + final VoidCallback? onCopy; + final VoidCallback? onRegenerate; + final VoidCallback? onLike; + final VoidCallback? onDislike; + + const AssistantMessageWidget({ + super.key, + required this.message, + this.isStreaming = false, + this.modelName, + this.onCopy, + this.onRegenerate, + this.onLike, + this.onDislike, + }); + + @override + ConsumerState createState() => + _AssistantMessageWidgetState(); +} + +class _AssistantMessageWidgetState extends ConsumerState + with TickerProviderStateMixin { + bool _showReasoning = false; + late AnimationController _fadeController; + late AnimationController _slideController; + ReasoningContent? _reasoningContent; + String _renderedContent = ''; + Timer? _throttleTimer; + String? _pendingContent; + Widget? _cachedAvatar; + + @override + void initState() { + super.initState(); + _renderedContent = widget.message.content ?? ''; + _fadeController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _slideController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + // Parse reasoning content if present + _updateReasoningContent(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Build cached avatar when theme context is available + _buildCachedAvatar(); + } + + @override + void didUpdateWidget(AssistantMessageWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + // Re-parse reasoning content when message content changes + if (oldWidget.message.content != widget.message.content) { + // Throttle markdown re-rendering for smoother streaming + _scheduleRenderUpdate(widget.message.content ?? ''); + _updateReasoningContent(); + } + + // Rebuild cached avatar if model name changes + if (oldWidget.modelName != widget.modelName) { + _buildCachedAvatar(); + } + } + + void _updateReasoningContent() { + if (widget.message.content != null) { + final newReasoningContent = ReasoningParser.parseReasoningContent( + widget.message.content!, + ); + if (newReasoningContent != _reasoningContent) { + setState(() { + _reasoningContent = newReasoningContent; + }); + } + } + } + + void _scheduleRenderUpdate(String rawContent) { + final safe = _safeForStreaming(rawContent); + if (_throttleTimer != null && _throttleTimer!.isActive) { + _pendingContent = safe; + return; + } + if (mounted) { + setState(() => _renderedContent = safe); + } else { + _renderedContent = safe; + } + _throttleTimer = Timer(const Duration(milliseconds: 80), () { + if (!mounted) return; + if (_pendingContent != null) { + setState(() { + _renderedContent = _pendingContent!; + _pendingContent = null; + }); + } + }); + } + + String _safeForStreaming(String content) { + if (content.isEmpty) return content; + // Auto-close an unbalanced triple backtick fence during streaming so markdown stays valid + final fenceCount = '```'.allMatches(content).length; + if (fenceCount.isOdd) { + return '$content\n```'; + } + return content; + } + + void _buildCachedAvatar() { + _cachedAvatar = Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.small), + ), + child: Icon( + Icons.auto_awesome, + color: context.conduitTheme.buttonPrimaryText, + size: 12, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + widget.modelName ?? 'Assistant', + style: TextStyle( + color: context.conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + _throttleTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _buildDocumentationMessage(); + } + + Widget _buildDocumentationMessage() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16, left: 12, right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cached AI Name and Avatar to prevent flashing + _cachedAvatar ?? const SizedBox.shrink(), + + // Reasoning Section (if present) + if (_reasoningContent != null) ...[ + InkWell( + onTap: () => setState(() => _showReasoning = !_showReasoning), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showReasoning + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, + color: context.conduitTheme.textSecondary, + ), + const SizedBox(width: Spacing.xs), + Icon( + Icons.psychology_outlined, + size: 14, + color: context.conduitTheme.buttonPrimary, + ), + const SizedBox(width: Spacing.xs), + Text( + _reasoningContent!.summary.isNotEmpty + ? _reasoningContent!.summary + : 'Thought for ${_reasoningContent!.formattedDuration}', + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + + // Expandable reasoning content + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.sm), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: SelectableText( + _reasoningContent!.cleanedReasoning, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontFamily: 'monospace', + height: 1.4, + ), + ), + ), + crossFadeState: _showReasoning + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + + const SizedBox(height: Spacing.md), + ], + + // Documentation-style content without heavy bubble; premium markdown + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Display attachment images if any (for user uploaded images) + if (widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty) ...[ + _buildAttachmentImages(), + const SizedBox(height: Spacing.md), + ], + + if (widget.isStreaming && + (widget.message.content.trim().isEmpty || + widget.message.content == '[TYPING_INDICATOR]')) + _buildTypingIndicator() + else if (widget.isStreaming && + widget.message.content.isNotEmpty && + widget.message.content != '[TYPING_INDICATOR]') + // While streaming, render markdown with throttling and safety fixes + _buildEnhancedMarkdownContent(_renderedContent) + else + // After streaming finishes (or static content), render full markdown + _buildEnhancedMarkdownContent( + _reasoningContent?.mainContent ?? + widget.message.content, + ), + ], + ), + ), + + // Action buttons below the message content (always visible) + const SizedBox(height: Spacing.sm), + _buildActionButtons(), + ], + ), + ) + .animate() + .fadeIn(duration: const Duration(milliseconds: 300)) + .slideY( + begin: 0.1, + end: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + + Widget _buildEnhancedMarkdownContent(String content) { + if (content.trim().isEmpty) { + return const SizedBox.shrink(); + } + + // Process content to ensure proper image rendering + final processedContent = _processContentForImages(content); + + return StreamingMarkdownWidget( + staticContent: processedContent, + isStreaming: widget.isStreaming, + ); + } + + String _processContentForImages(String content) { + // Check if content contains image markdown or base64 data URLs + // This ensures images generated by AI are properly formatted + + // Pattern to detect base64 images that might not be in markdown format + final base64Pattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); + + // If we find base64 images not wrapped in markdown, wrap them + if (base64Pattern.hasMatch(content) && !content.contains('![')) { + content = content.replaceAllMapped(base64Pattern, (match) { + final imageData = match.group(0)!; + // Check if this image is already in markdown format + final markdownCheck = RegExp( + r'!\[.*?\]\(' + RegExp.escape(imageData) + r'\)', + ); + if (!markdownCheck.hasMatch(content)) { + return '\n![Generated Image]($imageData)\n'; + } + return imageData; + }); + } + + return content; + } + + Widget _buildAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } + + final imageCount = widget.message.attachmentIds!.length; + + // Display images in a clean, modern layout for assistant messages + if (imageCount == 1) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isMarkdownFormat: true, + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), + ), + ); + } else { + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isMarkdownFormat: true, + constraints: BoxConstraints( + maxWidth: imageCount == 2 ? 245 : 160, + maxHeight: imageCount == 2 ? 245 : 160, + ), + ), + ); + }).toList(), + ); + } + } + + Widget _buildTypingIndicator() { + return Consumer( + builder: (context, ref, child) { + const statusText = 'Thinking about your question...'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusText, + style: TextStyle( + color: context.conduitTheme.textSecondary.withValues( + alpha: 0.7, + ), + fontSize: AppTypography.bodyMedium, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: Spacing.xs), + Row( + children: [ + _buildTypingDot(0), + const SizedBox(width: Spacing.xs), + _buildTypingDot(200), + const SizedBox(width: Spacing.xs), + _buildTypingDot(400), + ], + ), + ], + ); + }, + ); + } + + Widget _buildTypingDot(int delay) { + return Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: context.conduitTheme.textSecondary.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ) + .animate(onPlay: (controller) => controller.repeat()) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1, 1), + end: const Offset(1.3, 1.3), + ) + .then(delay: Duration(milliseconds: delay)) + .scale( + duration: const Duration(milliseconds: 1000), + begin: const Offset(1.3, 1.3), + end: const Offset(1, 1), + ); + } + + Widget _buildActionButtons() { + final isErrorMessage = + widget.message.content.contains('⚠️') || + widget.message.content.contains('Error') || + widget.message.content.contains('timeout') || + widget.message.content.contains('retry options'); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.content_copy, + label: 'Copy', + onTap: widget.onCopy, + ), + if (isErrorMessage) ...[ + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.arrow_clockwise + : Icons.refresh, + label: 'Retry', + onTap: widget.onRegenerate, + ), + ] else ...[ + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, + label: 'Regenerate', + onTap: widget.onRegenerate, + ), + ], + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.08), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: IconSize.sm, + color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: TextStyle( + fontSize: AppTypography.labelMedium, + color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/widgets/conversation_search_widget.dart b/lib/features/chat/widgets/conversation_search_widget.dart index 5a60157..a12171f 100644 --- a/lib/features/chat/widgets/conversation_search_widget.dart +++ b/lib/features/chat/widgets/conversation_search_widget.dart @@ -11,6 +11,7 @@ import '../../../shared/widgets/empty_states.dart'; import '../../../shared/utils/platform_utils.dart'; import '../services/conversation_search_service.dart'; import '../../../core/providers/app_providers.dart'; +import '../../../core/utils/debug_logger.dart'; /// Advanced conversation search widget with filters and results class ConversationSearchWidget extends ConsumerStatefulWidget { @@ -87,7 +88,7 @@ class _ConversationSearchWidgetState ref.read(conversationSearchResultsProvider.notifier).state = results; } catch (e) { - debugPrint('Search error: $e'); + DebugLogger.error('Search error', e); } finally { if (mounted) { setState(() { diff --git a/lib/features/chat/widgets/documentation_message_widget.dart b/lib/features/chat/widgets/documentation_message_widget.dart deleted file mode 100644 index 516d4c6..0000000 --- a/lib/features/chat/widgets/documentation_message_widget.dart +++ /dev/null @@ -1,752 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'dart:async'; -import 'dart:io' show Platform; -import '../../../shared/theme/theme_extensions.dart'; -import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; -import '../../../core/utils/reasoning_parser.dart'; -import 'enhanced_image_attachment.dart'; - -class DocumentationMessageWidget extends ConsumerStatefulWidget { - final dynamic message; - final bool isUser; - final bool isStreaming; - final String? modelName; - final VoidCallback? onCopy; - final VoidCallback? onEdit; - final VoidCallback? onRegenerate; - final VoidCallback? onLike; - final VoidCallback? onDislike; - - const DocumentationMessageWidget({ - super.key, - required this.message, - required this.isUser, - this.isStreaming = false, - this.modelName, - this.onCopy, - this.onEdit, - this.onRegenerate, - this.onLike, - this.onDislike, - }); - - @override - ConsumerState createState() => - _DocumentationMessageWidgetState(); -} - -class _DocumentationMessageWidgetState - extends ConsumerState - with TickerProviderStateMixin { - bool _showActions = false; - bool _showReasoning = false; - late AnimationController _fadeController; - late AnimationController _slideController; - ReasoningContent? _reasoningContent; - String _renderedContent = ''; - Timer? _throttleTimer; - String? _pendingContent; - Widget? _cachedAvatar; - - @override - void initState() { - super.initState(); - _renderedContent = widget.message.content ?? ''; - _fadeController = AnimationController( - duration: const Duration(milliseconds: 200), - vsync: this, - ); - _slideController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - // Parse reasoning content if present - _updateReasoningContent(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Build cached avatar when theme context is available - _buildCachedAvatar(); - } - - @override - void didUpdateWidget(DocumentationMessageWidget oldWidget) { - super.didUpdateWidget(oldWidget); - - // Re-parse reasoning content when message content changes - if (oldWidget.message.content != widget.message.content) { - // Throttle markdown re-rendering for smoother streaming - _scheduleRenderUpdate(widget.message.content ?? ''); - _updateReasoningContent(); - } - - // Rebuild cached avatar if model name changes - if (oldWidget.modelName != widget.modelName) { - _buildCachedAvatar(); - } - } - - void _updateReasoningContent() { - if (!widget.isUser && widget.message.content != null) { - final newReasoningContent = ReasoningParser.parseReasoningContent( - widget.message.content!, - ); - if (newReasoningContent != _reasoningContent) { - setState(() { - _reasoningContent = newReasoningContent; - }); - } - } - } - - void _scheduleRenderUpdate(String rawContent) { - final safe = _safeForStreaming(rawContent); - if (_throttleTimer != null && _throttleTimer!.isActive) { - _pendingContent = safe; - return; - } - if (mounted) { - setState(() => _renderedContent = safe); - } else { - _renderedContent = safe; - } - _throttleTimer = Timer(const Duration(milliseconds: 80), () { - if (!mounted) return; - if (_pendingContent != null) { - setState(() { - _renderedContent = _pendingContent!; - _pendingContent = null; - }); - } - }); - } - - String _safeForStreaming(String content) { - if (content.isEmpty) return content; - // Auto-close an unbalanced triple backtick fence during streaming so markdown stays valid - final fenceCount = '```'.allMatches(content).length; - if (fenceCount.isOdd) { - return '$content\n```'; - } - return content; - } - - void _buildCachedAvatar() { - _cachedAvatar = Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: context.conduitTheme.buttonPrimary, - borderRadius: BorderRadius.circular( - AppBorderRadius.small, - ), - ), - child: Icon( - Icons.auto_awesome, - color: context.conduitTheme.buttonPrimaryText, - size: 12, - ), - ), - const SizedBox(width: Spacing.xs), - Text( - widget.modelName ?? 'Assistant', - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodySmall, - fontWeight: FontWeight.w500, - letterSpacing: 0.1, - ), - ), - ], - ), - ); - } - - @override - void dispose() { - _fadeController.dispose(); - _slideController.dispose(); - _throttleTimer?.cancel(); - super.dispose(); - } - - void _toggleActions() { - setState(() { - _showActions = !_showActions; - }); - - if (_showActions) { - _fadeController.forward(); - _slideController.forward(); - } else { - _fadeController.reverse(); - _slideController.reverse(); - } - } - - @override - Widget build(BuildContext context) { - if (widget.isUser) { - return _buildUserMessage(); - } else { - return _buildDocumentationMessage(); - } - } - - Widget _buildUserMessage() { - final hasImages = widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty; - final hasText = widget.message.content.isNotEmpty; - - return GestureDetector( - onLongPress: () => _toggleActions(), - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12, left: 50, right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Display images outside and above the text bubble - if (hasImages) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: _buildUserAttachmentImages(), - ), - ], - ), - if (hasText) const SizedBox(height: Spacing.xs), - ], - - // Display text bubble if there's text content - if (hasText) - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: context.conduitTheme.chatBubbleUser, - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - border: Border.all( - color: context.conduitTheme.chatBubbleUserBorder, - width: BorderWidth.regular, - ), - ), - child: Text( - widget.message.content, - style: TextStyle( - color: context.conduitTheme.chatBubbleUserText, - fontSize: AppTypography.bodyMedium, - height: 1.3, - letterSpacing: 0.1, - ), - ), - ), - ), - ], - ), - - // Action buttons below the message bubble - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildUserActionButtons(), - ], - ], - ), - ), - ) - .animate() - .fadeIn(duration: const Duration(milliseconds: 400)) - .slideX( - begin: 0.2, - end: 0, - duration: const Duration(milliseconds: 400), - curve: Curves.easeOutCubic, - ); - } - - Widget _buildDocumentationMessage() { - return GestureDetector( - onLongPress: () => _toggleActions(), - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 16, left: 12, right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Cached AI Name and Avatar to prevent flashing - _cachedAvatar ?? const SizedBox.shrink(), - - // Reasoning Section (if present) - if (_reasoningContent != null) ...[ - InkWell( - onTap: () => setState(() => _showReasoning = !_showReasoning), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer.withValues( - alpha: 0.5, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _showReasoning - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: context.conduitTheme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - Icons.psychology_outlined, - size: 14, - color: context.conduitTheme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Text( - _reasoningContent!.summary.isNotEmpty - ? _reasoningContent!.summary - : 'Thought for ${_reasoningContent!.formattedDuration}', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - - // Expandable reasoning content - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: SelectableText( - _reasoningContent!.cleanedReasoning, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: context.conduitTheme.textSecondary, - fontFamily: 'monospace', - height: 1.4, - ), - ), - ), - crossFadeState: _showReasoning - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), - - const SizedBox(height: Spacing.md), - ], - - // Documentation-style content without heavy bubble; premium markdown - SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Display attachment images if any (for user uploaded images) - if (widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty) ...[ - _buildAttachmentImages(), - const SizedBox(height: Spacing.md), - ], - - if (widget.isStreaming && - (widget.message.content.trim().isEmpty || - widget.message.content == '[TYPING_INDICATOR]')) - _buildTypingIndicator() - else if (widget.isStreaming && - widget.message.content.isNotEmpty && - widget.message.content != '[TYPING_INDICATOR]') - // While streaming, render markdown with throttling and safety fixes - _buildEnhancedMarkdownContent(_renderedContent) - else - // After streaming finishes (or static content), render full markdown - _buildEnhancedMarkdownContent( - _reasoningContent?.mainContent ?? - widget.message.content, - ), - ], - ), - ), - - // Action buttons below the message content - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildActionButtons(), - ], - ], - ), - ), - ) - .animate() - .fadeIn(duration: const Duration(milliseconds: 300)) - .slideY( - begin: 0.1, - end: 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - ); - } - - Widget _buildEnhancedMarkdownContent(String content) { - if (content.trim().isEmpty) { - return const SizedBox.shrink(); - } - - // Process content to ensure proper image rendering - final processedContent = _processContentForImages(content); - - return StreamingMarkdownWidget( - staticContent: processedContent, - isStreaming: widget.isStreaming, - ); - } - - String _processContentForImages(String content) { - // Check if content contains image markdown or base64 data URLs - // This ensures images generated by AI are properly formatted - - // Pattern to detect base64 images that might not be in markdown format - final base64Pattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); - - // If we find base64 images not wrapped in markdown, wrap them - if (base64Pattern.hasMatch(content) && !content.contains('![')) { - content = content.replaceAllMapped(base64Pattern, (match) { - final imageData = match.group(0)!; - // Check if this image is already in markdown format - final markdownCheck = RegExp(r'!\[.*?\]\(' + RegExp.escape(imageData) + r'\)'); - if (!markdownCheck.hasMatch(content)) { - return '\n![Generated Image]($imageData)\n'; - } - return imageData; - }); - } - - return content; - } - - Widget _buildUserAttachmentImages() { - if (widget.message.attachmentIds == null || - widget.message.attachmentIds!.isEmpty) { - return const SizedBox.shrink(); - } - - final imageCount = widget.message.attachmentIds!.length; - - // Similar to iMessage style but adapted for documentation widget - if (imageCount == 1) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - child: EnhancedImageAttachment( - attachmentId: widget.message.attachmentIds![0], - isUserMessage: true, - constraints: const BoxConstraints( - maxWidth: 280, - maxHeight: 350, - ), - ), - ); - } else if (imageCount == 2) { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: widget.message.attachmentIds!.map((attachmentId) { - return Padding( - padding: EdgeInsets.only( - left: attachmentId == widget.message.attachmentIds!.first - ? 0 - : Spacing.xs, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: const BoxConstraints( - maxWidth: 135, - maxHeight: 180, - ), - ), - ), - ); - }).toList(), - ); - } else { - return Container( - constraints: const BoxConstraints(maxWidth: 280), - child: Wrap( - alignment: WrapAlignment.end, - spacing: Spacing.xs, - runSpacing: Spacing.xs, - children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: BoxConstraints( - maxWidth: imageCount == 3 ? 135 : 90, - maxHeight: imageCount == 3 ? 135 : 90, - ), - ), - ); - }).toList(), - ), - ); - } - } - - Widget _buildAttachmentImages() { - if (widget.message.attachmentIds == null || - widget.message.attachmentIds!.isEmpty) { - return const SizedBox.shrink(); - } - - final imageCount = widget.message.attachmentIds!.length; - - // Display images in a clean, modern layout for assistant messages - if (imageCount == 1) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: widget.message.attachmentIds![0], - isMarkdownFormat: true, - constraints: const BoxConstraints( - maxWidth: 500, - maxHeight: 400, - ), - ), - ); - } else { - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isMarkdownFormat: true, - constraints: BoxConstraints( - maxWidth: imageCount == 2 ? 245 : 160, - maxHeight: imageCount == 2 ? 245 : 160, - ), - ), - ); - }).toList(), - ); - } - } - - Widget _buildTypingIndicator() { - return Consumer( - builder: (context, ref, child) { - const statusText = 'Thinking about your question...'; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - statusText, - style: TextStyle( - color: context.conduitTheme.textSecondary.withValues( - alpha: 0.7, - ), - fontSize: AppTypography.bodyMedium, - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: Spacing.xs), - Row( - children: [ - _buildTypingDot(0), - const SizedBox(width: Spacing.xs), - _buildTypingDot(200), - const SizedBox(width: Spacing.xs), - _buildTypingDot(400), - ], - ), - ], - ); - }, - ); - } - - Widget _buildTypingDot(int delay) { - return Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: context.conduitTheme.textSecondary.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ) - .animate(onPlay: (controller) => controller.repeat()) - .scale( - duration: const Duration(milliseconds: 1000), - begin: const Offset(1, 1), - end: const Offset(1.3, 1.3), - ) - .then(delay: Duration(milliseconds: delay)) - .scale( - duration: const Duration(milliseconds: 1000), - begin: const Offset(1.3, 1.3), - end: const Offset(1, 1), - ); - } - - Widget _buildActionButtons() { - final isErrorMessage = widget.message.content.contains('⚠️') || - widget.message.content.contains('Error') || - widget.message.content.contains('timeout') || - widget.message.content.contains('retry options'); - - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.content_copy, - label: 'Copy', - onTap: widget.onCopy, - ), - if (isErrorMessage) ...[ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.arrow_clockwise : Icons.refresh, - label: 'Retry', - onTap: widget.onRegenerate, - ), - ] else ...[ - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.hand_thumbsup - : Icons.thumb_up_outlined, - label: 'Like', - onTap: widget.onLike, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.hand_thumbsdown - : Icons.thumb_down_outlined, - label: 'Dislike', - onTap: widget.onDislike, - ), - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, - label: 'Regenerate', - onTap: widget.onRegenerate, - ), - ], - ], - ); - } - - Widget _buildActionButton({ - required IconData icon, - required String label, - VoidCallback? onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: context.conduitTheme.textPrimary.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues(alpha: 0.08), - width: BorderWidth.regular, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: IconSize.sm, - color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), - ), - const SizedBox(width: Spacing.xs), - Text( - label, - style: TextStyle( - fontSize: AppTypography.labelMedium, - color: context.conduitTheme.textPrimary.withValues(alpha: 0.8), - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - ), - ), - ], - ), - ), - ); - } - - Widget _buildUserActionButtons() { - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, - label: 'Edit', - onTap: widget.onEdit, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.content_copy, - label: 'Copy', - onTap: widget.onCopy, - ), - ], - ); - } -} \ No newline at end of file diff --git a/lib/features/chat/widgets/modern_message_bubble.dart b/lib/features/chat/widgets/modern_message_bubble.dart deleted file mode 100644 index 9495392..0000000 --- a/lib/features/chat/widgets/modern_message_bubble.dart +++ /dev/null @@ -1,621 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../shared/theme/theme_extensions.dart'; -import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; -import 'enhanced_image_attachment.dart'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'dart:io' show Platform; - -class ModernMessageBubble extends ConsumerStatefulWidget { - final dynamic message; - final bool isUser; - final bool isStreaming; - final String? modelName; - final VoidCallback? onCopy; - final VoidCallback? onEdit; - final VoidCallback? onRegenerate; - final VoidCallback? onLike; - final VoidCallback? onDislike; - - const ModernMessageBubble({ - super.key, - required this.message, - required this.isUser, - this.isStreaming = false, - this.modelName, - this.onCopy, - this.onEdit, - this.onRegenerate, - this.onLike, - this.onDislike, - }); - - @override - ConsumerState createState() => - _ModernMessageBubbleState(); -} - -class _ModernMessageBubbleState extends ConsumerState - with TickerProviderStateMixin { - bool _showActions = false; - late AnimationController _fadeController; - late AnimationController _slideController; - - @override - void initState() { - super.initState(); - _fadeController = AnimationController( - duration: AnimationDuration.microInteraction, - vsync: this, - ); - _slideController = AnimationController( - duration: AnimationDuration.messageSlide, - vsync: this, - ); - } - - - - Widget _buildUserAttachmentImages() { - if (widget.message.attachmentIds == null || - widget.message.attachmentIds!.isEmpty) { - return const SizedBox.shrink(); - } - - final imageCount = widget.message.attachmentIds!.length; - - // iMessage-style image layout - if (imageCount == 1) { - // Single image - larger display - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), - child: EnhancedImageAttachment( - attachmentId: widget.message.attachmentIds![0], - isUserMessage: true, - constraints: const BoxConstraints( - maxWidth: 280, - maxHeight: 350, - ), - ), - ), - ], - ); - } else if (imageCount == 2) { - // Two images side by side - return Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: widget.message.attachmentIds!.map((attachmentId) { - return Padding( - padding: EdgeInsets.only( - left: attachmentId == widget.message.attachmentIds!.first - ? 0 - : Spacing.xs, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: const BoxConstraints( - maxWidth: 135, - maxHeight: 180, - ), - ), - ), - ); - }).toList(), - ), - ), - ], - ); - } else { - // Grid layout for 3+ images - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 280), - child: Wrap( - alignment: WrapAlignment.end, - spacing: Spacing.xs, - runSpacing: Spacing.xs, - children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: BoxConstraints( - maxWidth: imageCount == 3 ? 135 : 90, - maxHeight: imageCount == 3 ? 135 : 90, - ), - ), - ); - }).toList(), - ), - ), - ), - ], - ); - } - } - - Widget _buildAssistantAttachmentImages() { - if (widget.message.attachmentIds == null || - widget.message.attachmentIds!.isEmpty) { - return const SizedBox.shrink(); - } - - // Assistant images - similar style but left-aligned - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - constraints: const BoxConstraints( - maxWidth: 300, - maxHeight: 350, - ), - ), - ); - }).toList(), - ); - } - - @override - void dispose() { - _fadeController.dispose(); - _slideController.dispose(); - super.dispose(); - } - - void _toggleActions() { - setState(() { - _showActions = !_showActions; - }); - - if (_showActions) { - _fadeController.forward(); - _slideController.forward(); - } else { - _fadeController.reverse(); - _slideController.reverse(); - } - } - - @override - Widget build(BuildContext context) { - if (widget.isUser) { - return _buildUserMessage(); - } else { - return _buildAssistantMessage(); - } - } - - Widget _buildUserMessage() { - final hasImages = widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty; - final hasText = widget.message.content.isNotEmpty; - - return GestureDetector( - onLongPress: () => _toggleActions(), - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - margin: const EdgeInsets.only( - bottom: Spacing.sm, - left: Spacing.xxxl, - right: Spacing.xs, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Display images outside and above the text bubble (iMessage style) - if (hasImages) ...[ - _buildUserAttachmentImages(), - if (hasText) const SizedBox(height: Spacing.xs), - ], - - // Display text bubble if there's text content - if (hasText) - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.messagePadding, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - context.conduitTheme.chatBubbleUser.withValues( - alpha: 0.95, - ), - context.conduitTheme.chatBubbleUser, - ], - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, - ), - border: Border.all( - color: context.conduitTheme.chatBubbleUserBorder, - width: BorderWidth.regular, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: _buildCustomText( - widget.message.content, - context.conduitTheme.chatBubbleUserText, - ), - ), - ), - ], - ), - - // Action buttons below the message - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildUserActionButtons(), - ], - ], - ), - ), - ) - .animate() - .fadeIn(duration: AnimationDuration.messageAppear) - .slideX( - begin: AnimationValues.messageSlideDistance, - end: 0, - duration: AnimationDuration.messageSlide, - curve: AnimationCurves.messageSlide, - ); - } - - Widget _buildAssistantMessage() { - final hasImages = widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty; - final hasContent = widget.message.content.isNotEmpty && - widget.message.content != '[TYPING_INDICATOR]'; - final showTyping = (widget.message.content.isEmpty || - widget.message.content == '[TYPING_INDICATOR]') && - widget.isStreaming; - - return GestureDetector( - onLongPress: () => _toggleActions(), - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - margin: const EdgeInsets.only( - bottom: Spacing.md, - left: Spacing.xs, - right: Spacing.xxxl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Simplified AI Name and Avatar - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - context.conduitTheme.buttonPrimary.withValues( - alpha: 0.9, - ), - context.conduitTheme.buttonPrimary, - ], - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.small, - ), - ), - child: Icon( - Icons.auto_awesome, - color: context.conduitTheme.buttonPrimaryText, - size: 12, - ), - ), - const SizedBox(width: Spacing.xs), - Text( - widget.modelName ?? 'Assistant', - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodySmall, - fontWeight: FontWeight.w500, - letterSpacing: 0.1, - ), - ), - ], - ), - ), - - // Display images outside the bubble if any - if (hasImages) ...[ - _buildAssistantAttachmentImages(), - if (hasContent || showTyping) const SizedBox(height: Spacing.xs), - ], - - // Message Content Bubble - if (hasContent || showTyping) - Container( - padding: EdgeInsets.symmetric( - horizontal: Spacing.messagePadding, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.chatBubbleAssistant, - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, - ), - border: Border.all( - color: context.conduitTheme.chatBubbleAssistantBorder, - width: BorderWidth.regular, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: showTyping - ? _buildTypingIndicator() - : _buildCustomText( - widget.message.content, - context.conduitTheme.chatBubbleAssistantText, - ), - ), - - // Action buttons below the message content - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildActionButtons(), - ], - ], - ), - ), - ) - .animate() - .fadeIn(duration: AnimationDuration.messageAppear) - .slideX( - begin: -AnimationValues.messageSlideDistance, - end: 0, - duration: AnimationDuration.messageSlide, - curve: AnimationCurves.messageSlide, - ); - } - - - - - - Widget _buildCustomText(String text, [Color? textColor]) { - // Use the new markdown widget for rich text rendering - return StreamingMarkdownWidget( - staticContent: text, - isStreaming: widget.isStreaming, - ); - } - - - - - - Widget _buildTypingIndicator() { - return Consumer( - builder: (context, ref, child) { - // Show only animated dots, no text - return _buildTypingDots(); - }, - ); - } - - Widget _buildTypingDots() { - return Row( - children: List.generate(3, (index) { - return Container( - margin: EdgeInsets.only(right: index < 2 ? Spacing.xs : 0), - width: 6, - height: 6, - decoration: BoxDecoration( - color: context.conduitTheme.loadingIndicator, - borderRadius: BorderRadius.circular(3), - ), - ) - .animate(onPlay: (controller) => controller.repeat()) - .scale( - duration: AnimationDuration.typingIndicator, - begin: const Offset( - AnimationValues.typingIndicatorScale, - AnimationValues.typingIndicatorScale, - ), - end: const Offset(1.0, 1.0), - curve: AnimationCurves.typingIndicator, - delay: Duration( - milliseconds: index * 200, - ), // Stagger the animation - ); - }), - ); - } - - Widget _buildActionButtons() { - final isErrorMessage = widget.message.content.contains('⚠️') || - widget.message.content.contains('Error') || - widget.message.content.contains('timeout') || - widget.message.content.contains('retry options'); - - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.content_copy, - label: 'Copy', - onTap: widget.onCopy, - ), - if (isErrorMessage) ...[ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.arrow_clockwise : Icons.refresh, - label: 'Retry', - onTap: widget.onRegenerate, - ), - ] else ...[ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, - label: 'Edit', - onTap: widget.onEdit, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.speaker_1 - : Icons.volume_up_outlined, - label: 'Read', - onTap: () => _handleTextToSpeech(context), - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.hand_thumbsup - : Icons.thumb_up_outlined, - label: 'Like', - onTap: widget.onLike, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.hand_thumbsdown - : Icons.thumb_down_outlined, - label: 'Dislike', - onTap: widget.onDislike, - ), - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, - label: 'Regenerate', - onTap: widget.onRegenerate, - ), - ], - ], - ); - } - - Widget _buildActionButton({ - required IconData icon, - required String label, - VoidCallback? onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.actionButtonPadding, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground.withValues( - alpha: Alpha.buttonHover, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.actionButton), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.subtle, - ), - width: BorderWidth.regular, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: IconSize.small, - color: context.conduitTheme.iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Text( - label, - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - ).animate().scale( - duration: AnimationDuration.buttonPress, - curve: AnimationCurves.buttonPress, - ); - } - - Widget _buildUserActionButtons() { - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, - label: 'Edit', - onTap: widget.onEdit, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.content_copy, - label: 'Copy', - onTap: widget.onCopy, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.speaker_1 - : Icons.volume_up_outlined, - label: 'Read', - onTap: () => _handleTextToSpeech(context), - ), - ], - ); - } - - void _handleTextToSpeech(BuildContext context) { - // Implementation for text-to-speech functionality - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Text-to-speech feature coming soon!'), - backgroundColor: context.conduitTheme.info, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.snackbar), - ), - ), - ); - } -} diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart new file mode 100644 index 0000000..613066c --- /dev/null +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import 'enhanced_image_attachment.dart'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'dart:io' show Platform; + +class UserMessageBubble extends ConsumerStatefulWidget { + final dynamic message; + final bool isUser; + final bool isStreaming; + final String? modelName; + final VoidCallback? onCopy; + final VoidCallback? onEdit; + final VoidCallback? onRegenerate; + final VoidCallback? onLike; + final VoidCallback? onDislike; + + const UserMessageBubble({ + super.key, + required this.message, + required this.isUser, + this.isStreaming = false, + this.modelName, + this.onCopy, + this.onEdit, + this.onRegenerate, + this.onLike, + this.onDislike, + }); + + @override + ConsumerState createState() => _UserMessageBubbleState(); +} + +class _UserMessageBubbleState extends ConsumerState + with TickerProviderStateMixin { + bool _showActions = false; + late AnimationController _fadeController; + late AnimationController _slideController; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController( + duration: AnimationDuration.microInteraction, + vsync: this, + ); + _slideController = AnimationController( + duration: AnimationDuration.messageSlide, + vsync: this, + ); + } + + Widget _buildUserAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } + + final imageCount = widget.message.attachmentIds!.length; + + // iMessage-style image layout + if (imageCount == 1) { + // Single image - larger display + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isUserMessage: true, + constraints: const BoxConstraints(maxWidth: 280, maxHeight: 350), + ), + ), + ], + ); + } else if (imageCount == 2) { + // Two images side by side + return Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: widget.message.attachmentIds!.map((attachmentId) { + return Padding( + padding: EdgeInsets.only( + left: attachmentId == widget.message.attachmentIds!.first + ? 0 + : Spacing.xs, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 135, + maxHeight: 180, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } else { + // Grid layout for 3+ images + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 280), + child: Wrap( + alignment: WrapAlignment.end, + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: BoxConstraints( + maxWidth: imageCount == 3 ? 135 : 90, + maxHeight: imageCount == 3 ? 135 : 90, + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ); + } + } + + // Assistant-only helpers removed; this widget renders only user bubbles. + + @override + void dispose() { + _fadeController.dispose(); + _slideController.dispose(); + super.dispose(); + } + + void _toggleActions() { + setState(() { + _showActions = !_showActions; + }); + + if (_showActions) { + _fadeController.forward(); + _slideController.forward(); + } else { + _fadeController.reverse(); + _slideController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return _buildUserMessage(); + } + + Widget _buildUserMessage() { + final hasImages = + widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty; + final hasText = widget.message.content.isNotEmpty; + + return GestureDetector( + onLongPress: () => _toggleActions(), + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only( + bottom: Spacing.sm, + left: Spacing.xxxl, + right: Spacing.xs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Display images outside and above the text bubble (iMessage style) + if (hasImages) ...[_buildUserAttachmentImages()], + + // Display text bubble if there's text content + if (hasText) const SizedBox(height: Spacing.xs), + if (hasText) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.chatBubblePadding, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.conduitTheme.chatBubbleUser.withValues( + alpha: 0.95, + ), + context.conduitTheme.chatBubbleUser, + ], + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.chatBubbleUserBorder, + width: BorderWidth.regular, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + widget.message.content, + style: AppTypography.chatMessageStyle.copyWith( + color: context.conduitTheme.chatBubbleUserText, + ), + softWrap: true, + ), + ), + ), + ], + ), + if (hasText) const SizedBox(height: Spacing.xs), + + // Action buttons below the message + if (_showActions) ...[ + const SizedBox(height: Spacing.sm), + _buildUserActionButtons(), + ], + ], + ), + ), + ) + .animate() + .fadeIn(duration: AnimationDuration.messageAppear) + .slideX( + begin: AnimationValues.messageSlideDistance, + end: 0, + duration: AnimationDuration.messageSlide, + curve: AnimationCurves.messageSlide, + ); + } + + // Assistant-only message renderer removed. + + // Markdown rendering and typing indicator helpers removed. + + // Removed unused assistant action buttons builder. + + Widget _buildActionButton({ + required IconData icon, + required String label, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.actionButtonPadding, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues( + alpha: Alpha.buttonHover, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.actionButton), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues( + alpha: Alpha.subtle, + ), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: IconSize.small, + color: context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ).animate().scale( + duration: AnimationDuration.buttonPress, + curve: AnimationCurves.buttonPress, + ); + } + + Widget _buildUserActionButtons() { + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, + label: 'Edit', + onTap: widget.onEdit, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.content_copy, + label: 'Copy', + onTap: widget.onCopy, + ), + ], + ); + } +} diff --git a/lib/features/navigation/views/chats_list_page.dart b/lib/features/navigation/views/chats_list_page.dart index 3d5dc6f..feaec95 100644 --- a/lib/features/navigation/views/chats_list_page.dart +++ b/lib/features/navigation/views/chats_list_page.dart @@ -14,7 +14,7 @@ import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../chat/providers/chat_providers.dart'; import '../../chat/views/chat_page_helpers.dart'; - +import '../../../core/utils/debug_logger.dart'; /// Optimized conversation list page with Conduit design aesthetics class ChatsListPage extends ConsumerStatefulWidget { @@ -40,10 +40,12 @@ class _ChatsListPageState extends ConsumerState // Provider for archived section visibility static final _showArchivedProvider = StateProvider((ref) => false); - + // Provider for folder expansion state (Map) // Start with folders expanded by default for better discoverability - static final _expandedFoldersProvider = StateProvider>((ref) => {}); + static final _expandedFoldersProvider = StateProvider>( + (ref) => {}, + ); @override bool get wantKeepAlive => true; // Keep state alive for better performance @@ -195,7 +197,9 @@ class _ChatsListPageState extends ConsumerState decoration: InputDecoration( hintText: 'Search conversations...', hintStyle: TextStyle( - color: context.conduitTheme.inputPlaceholder.withValues(alpha: 0.8), + color: context.conduitTheme.inputPlaceholder.withValues( + alpha: 0.8, + ), fontSize: AppTypography.bodyMedium, ), prefixIcon: Icon( @@ -257,33 +261,49 @@ class _ChatsListPageState extends ConsumerState for (final conv in filteredConversations) { deduplicatedConversations[conv.id] = conv; } - final uniqueConversations = deduplicatedConversations.values.toList(); + final uniqueConversations = deduplicatedConversations.values + .toList(); // Separate conversations by status and folder final pinnedConversations = uniqueConversations .where((c) => c.pinned == true) .toList(); final regularConversations = uniqueConversations - .where((c) => c.pinned != true && c.archived != true && (c.folderId == null || c.folderId!.isEmpty)) + .where( + (c) => + c.pinned != true && + c.archived != true && + (c.folderId == null || c.folderId!.isEmpty), + ) .toList(); final folderConversations = uniqueConversations - .where((c) => c.pinned != true && c.archived != true && c.folderId != null && c.folderId!.isNotEmpty) + .where( + (c) => + c.pinned != true && + c.archived != true && + c.folderId != null && + c.folderId!.isNotEmpty, + ) .toList(); final archivedConversations = uniqueConversations .where((c) => c.archived == true) .toList(); // Debug logging - debugPrint('DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})'); - debugPrint('DEBUG: Pinned: ${pinnedConversations.length}'); - debugPrint('DEBUG: Regular: ${regularConversations.length}'); - debugPrint('DEBUG: Folder: ${folderConversations.length}'); - debugPrint('DEBUG: Archived: ${archivedConversations.length}'); - + DebugLogger.log( + 'Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})', + ); + DebugLogger.log('Pinned: ${pinnedConversations.length}'); + DebugLogger.log('Regular: ${regularConversations.length}'); + DebugLogger.log('Folder: ${folderConversations.length}'); + DebugLogger.log('Archived: ${archivedConversations.length}'); + // Check first few conversations for folder IDs for (int i = 0; i < uniqueConversations.take(5).length; i++) { final conv = uniqueConversations[i]; - debugPrint('DEBUG: Conv $i: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}'); + DebugLogger.log( + 'Conv $i: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}', + ); } return ListView( @@ -305,39 +325,63 @@ class _ChatsListPageState extends ConsumerState // Folder conversations sections (after pinned, before recent) if (folderConversations.isNotEmpty) ...[ - ...ref.watch(foldersProvider).when( - data: (folders) { - // Group conversations by folder - final groupedByFolder = >{}; - for (final conv in folderConversations) { - if (conv.folderId != null) { - groupedByFolder.putIfAbsent(conv.folderId!, () => []).add(conv); - } - } + ...ref + .watch(foldersProvider) + .when( + data: (folders) { + // Group conversations by folder + final groupedByFolder = >{}; + for (final conv in folderConversations) { + if (conv.folderId != null) { + groupedByFolder + .putIfAbsent(conv.folderId!, () => []) + .add(conv); + } + } - // Build folder sections - return folders.where((folder) => groupedByFolder.containsKey(folder.id)).map((folder) { - final conversations = groupedByFolder[folder.id]!; - final expandedFolders = ref.watch(_expandedFoldersProvider); - final isExpanded = expandedFolders[folder.id] ?? false; - - return Column( - children: [ - _buildFolderHeader(folder.id, folder.name, conversations.length), - // Only show conversations if folder is expanded - if (isExpanded) ...[ - ...conversations.asMap().entries.map((entry) { - return _buildConversationTile(entry.value, entry.key, inFolder: true); - }), - ], - const SizedBox(height: Spacing.md), - ], - ); - }).toList(); - }, - loading: () => [const SizedBox.shrink()], - error: (_, stackTrace) => [const SizedBox.shrink()], - ), + // Build folder sections + return folders + .where( + (folder) => + groupedByFolder.containsKey(folder.id), + ) + .map((folder) { + final conversations = + groupedByFolder[folder.id]!; + final expandedFolders = ref.watch( + _expandedFoldersProvider, + ); + final isExpanded = + expandedFolders[folder.id] ?? false; + + return Column( + children: [ + _buildFolderHeader( + folder.id, + folder.name, + conversations.length, + ), + // Only show conversations if folder is expanded + if (isExpanded) ...[ + ...conversations.asMap().entries.map(( + entry, + ) { + return _buildConversationTile( + entry.value, + entry.key, + inFolder: true, + ); + }), + ], + const SizedBox(height: Spacing.md), + ], + ); + }) + .toList(); + }, + loading: () => [const SizedBox.shrink()], + error: (_, stackTrace) => [const SizedBox.shrink()], + ), ], // Regular conversations section @@ -815,8 +859,6 @@ class _ChatsListPageState extends ConsumerState }).toList(); } - - String _formatConversationDate(DateTime? date) { if (date == null) return ''; @@ -944,10 +986,10 @@ class _ChatsListPageState extends ConsumerState // Load the full conversation with messages final api = ref.read(apiServiceProvider); if (api != null) { - debugPrint('DEBUG: Loading full conversation: ${conversation.id}'); + DebugLogger.log('Loading full conversation: ${conversation.id}'); final fullConversation = await api.getConversation(conversation.id); - debugPrint( - 'DEBUG: Loaded conversation with ${fullConversation.messages.length} messages', + DebugLogger.log( + 'Loaded conversation with ${fullConversation.messages.length} messages', ); // Set the full conversation as active @@ -971,7 +1013,7 @@ class _ChatsListPageState extends ConsumerState } }); } catch (e) { - debugPrint('DEBUG: Error loading conversation: $e'); + DebugLogger.error('Error loading conversation', e); // Fallback to the conversation from the list ref.read(activeConversationProvider.notifier).state = conversation; // Ensure global loading is cleared even on error @@ -1163,11 +1205,15 @@ class _ChatsListPageState extends ConsumerState width: 40, height: 40, decoration: BoxDecoration( - color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), + color: context.conduitTheme.buttonPrimary.withValues( + alpha: 0.1, + ), borderRadius: BorderRadius.circular(AppBorderRadius.lg), ), child: Icon( - Platform.isIOS ? CupertinoIcons.chat_bubble : Icons.chat_rounded, + Platform.isIOS + ? CupertinoIcons.chat_bubble + : Icons.chat_rounded, color: context.conduitTheme.buttonPrimary, size: IconSize.medium, ), @@ -1200,7 +1246,9 @@ class _ChatsListPageState extends ConsumerState borderRadius: BorderRadius.circular(AppBorderRadius.lg), ), child: Icon( - Platform.isIOS ? CupertinoIcons.folder_badge_plus : Icons.create_new_folder_rounded, + Platform.isIOS + ? CupertinoIcons.folder_badge_plus + : Icons.create_new_folder_rounded, color: context.conduitTheme.info, size: IconSize.medium, ), @@ -1231,8 +1279,6 @@ class _ChatsListPageState extends ConsumerState ); } - - void _showCreateFolderDialog() { final nameController = TextEditingController(); @@ -1245,14 +1291,17 @@ class _ChatsListPageState extends ConsumerState ); } - Future _createFolderFromDialog(String name, BuildContext dialogContext) async { + Future _createFolderFromDialog( + String name, + BuildContext dialogContext, + ) async { // Store theme values and messenger before async operation final theme = context.conduitTheme; final textInverseColor = theme.textInverse; final successColor = theme.success; final errorColor = theme.error; final messenger = ScaffoldMessenger.of(context); - + try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service available'); @@ -1318,7 +1367,7 @@ class _ChatsListPageState extends ConsumerState } } } catch (e) { - debugPrint('DEBUG: Error toggling pin: $e'); + DebugLogger.error('Error toggling pin', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1335,8 +1384,6 @@ class _ChatsListPageState extends ConsumerState } } - - void _archiveConversation(dynamic conversation) async { try { final api = ref.read(apiServiceProvider); @@ -1361,7 +1408,7 @@ class _ChatsListPageState extends ConsumerState } } } catch (e) { - debugPrint('DEBUG: Error archiving conversation: $e'); + DebugLogger.error('Error archiving conversation', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1414,7 +1461,7 @@ class _ChatsListPageState extends ConsumerState } } } catch (e) { - debugPrint('DEBUG: Error deleting conversation: $e'); + DebugLogger.error('Error deleting conversation', e); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1490,7 +1537,7 @@ class _ChatsListPageState extends ConsumerState Widget _buildFolderHeader(String folderId, String folderName, int count) { final expandedFolders = ref.watch(_expandedFoldersProvider); final isExpanded = expandedFolders[folderId] ?? false; - + return InkWell( onTap: () { final currentState = ref.read(_expandedFoldersProvider); @@ -1507,15 +1554,21 @@ class _ChatsListPageState extends ConsumerState child: Row( children: [ Icon( - isExpanded - ? (Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more_rounded) - : (Platform.isIOS ? CupertinoIcons.chevron_right : Icons.chevron_right_rounded), + isExpanded + ? (Platform.isIOS + ? CupertinoIcons.chevron_down + : Icons.expand_more_rounded) + : (Platform.isIOS + ? CupertinoIcons.chevron_right + : Icons.chevron_right_rounded), size: IconSize.small, color: context.conduitTheme.textSecondary, ), const SizedBox(width: Spacing.sm), Icon( - Platform.isIOS ? CupertinoIcons.folder_fill : Icons.folder_rounded, + Platform.isIOS + ? CupertinoIcons.folder_fill + : Icons.folder_rounded, size: IconSize.small, color: context.conduitTheme.textSecondary, ), @@ -1664,213 +1717,263 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> { textDirection: TextDirection.ltr, child: Dialog( backgroundColor: Colors.transparent, - child: Container( - width: 400, - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground, - borderRadius: BorderRadius.circular(AppBorderRadius.modal), - border: Border.all( - color: context.conduitTheme.cardBorder.withValues(alpha: 0.2), - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.modal, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Container( - padding: const EdgeInsets.all(Spacing.xl), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppBorderRadius.modal), - ), - border: Border( - bottom: BorderSide( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.1), + child: + Container( + width: 400, + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.modal), + border: Border.all( + color: context.conduitTheme.cardBorder.withValues( + alpha: 0.2, + ), width: BorderWidth.regular, ), + boxShadow: ConduitShadows.modal, ), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.conduitTheme.info.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(AppBorderRadius.lg), - ), - child: Icon( - Platform.isIOS ? CupertinoIcons.folder_badge_plus : Icons.create_new_folder_rounded, - color: context.conduitTheme.info, - size: IconSize.medium, - ), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Create New Folder', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.all(Spacing.xl), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + border: Border( + bottom: BorderSide( + color: context.conduitTheme.dividerColor + .withValues(alpha: 0.1), + width: BorderWidth.regular, ), ), - const SizedBox(height: Spacing.xs), - Text( - 'Enter a name for your folder', - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - ConduitIconButton( - icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded, - onPressed: isCreating ? null : () => Navigator.pop(context), - ), - ], - ), - ), - - // Content - Padding( - padding: const EdgeInsets.all(Spacing.xl), - child: TextField( - controller: widget.nameController, - autofocus: true, - enabled: !isCreating, - decoration: InputDecoration( - labelText: 'Folder Name', - hintText: 'Enter folder name', - prefixIcon: Icon( - Platform.isIOS ? CupertinoIcons.folder : Icons.folder_outlined, - color: context.conduitTheme.iconSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.input), - borderSide: BorderSide( - color: context.conduitTheme.inputBorder, - width: BorderWidth.regular, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.input), - borderSide: BorderSide( - color: context.conduitTheme.buttonPrimary, - width: BorderWidth.regular, - ), - ), - labelStyle: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textSecondary, - ), - hintStyle: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.inputPlaceholder, - ), - ), - style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textPrimary, - ), - onSubmitted: (value) { - if (value.trim().isNotEmpty && !isCreating) { - _createFolder(); - } - }, - ), - ), - - // Actions - Container( - padding: const EdgeInsets.all(Spacing.xl), - decoration: BoxDecoration( - color: context.conduitTheme.cardBackground, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(AppBorderRadius.modal), - ), - border: Border( - top: BorderSide( - color: context.conduitTheme.dividerColor.withValues(alpha: 0.1), - width: BorderWidth.regular, - ), - ), - ), - child: Row( - children: [ - Expanded( - child: TextButton( - onPressed: isCreating ? null : () => Navigator.pop(context), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: Spacing.md), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.button), - ), ), - child: Text( - 'Cancel', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: ElevatedButton( - onPressed: isCreating ? null : () { - final name = widget.nameController.text.trim(); - if (name.isNotEmpty) { - _createFolder(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - padding: const EdgeInsets.symmetric(vertical: Spacing.md), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.button), - ), - elevation: Elevation.none, - ), - child: isCreating - ? SizedBox( - width: IconSize.medium, - height: IconSize.medium, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - context.conduitTheme.buttonPrimaryText, - ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.conduitTheme.info.withValues( + alpha: 0.1, ), - ) - : Text( - 'Create', - style: AppTypography.labelStyle.copyWith( - color: context.conduitTheme.buttonPrimaryText, - fontWeight: FontWeight.w600, + borderRadius: BorderRadius.circular( + AppBorderRadius.lg, ), ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.folder_badge_plus + : Icons.create_new_folder_rounded, + color: context.conduitTheme.info, + size: IconSize.medium, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Create New Folder', + style: AppTypography.headlineSmallStyle + .copyWith( + color: + context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + 'Enter a name for your folder', + style: AppTypography.bodyMediumStyle + .copyWith( + color: context + .conduitTheme + .textSecondary, + ), + ), + ], + ), + ), + ConduitIconButton( + icon: Platform.isIOS + ? CupertinoIcons.xmark + : Icons.close_rounded, + onPressed: isCreating + ? null + : () => Navigator.pop(context), + ), + ], + ), ), - ), - ], + + // Content + Padding( + padding: const EdgeInsets.all(Spacing.xl), + child: TextField( + controller: widget.nameController, + autofocus: true, + enabled: !isCreating, + decoration: InputDecoration( + labelText: 'Folder Name', + hintText: 'Enter folder name', + prefixIcon: Icon( + Platform.isIOS + ? CupertinoIcons.folder + : Icons.folder_outlined, + color: context.conduitTheme.iconSecondary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.input, + ), + borderSide: BorderSide( + color: context.conduitTheme.inputBorder, + width: BorderWidth.regular, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.input, + ), + borderSide: BorderSide( + color: context.conduitTheme.buttonPrimary, + width: BorderWidth.regular, + ), + ), + labelStyle: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textSecondary, + ), + hintStyle: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.inputPlaceholder, + ), + ), + style: AppTypography.bodyMediumStyle.copyWith( + color: context.conduitTheme.textPrimary, + ), + onSubmitted: (value) { + if (value.trim().isNotEmpty && !isCreating) { + _createFolder(); + } + }, + ), + ), + + // Actions + Container( + padding: const EdgeInsets.all(Spacing.xl), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(AppBorderRadius.modal), + ), + border: Border( + top: BorderSide( + color: context.conduitTheme.dividerColor + .withValues(alpha: 0.1), + width: BorderWidth.regular, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: isCreating + ? null + : () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: Spacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.button, + ), + ), + ), + child: Text( + 'Cancel', + style: AppTypography.labelStyle.copyWith( + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: ElevatedButton( + onPressed: isCreating + ? null + : () { + final name = widget.nameController.text + .trim(); + if (name.isNotEmpty) { + _createFolder(); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + context.conduitTheme.buttonPrimary, + foregroundColor: + context.conduitTheme.buttonPrimaryText, + padding: const EdgeInsets.symmetric( + vertical: Spacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.button, + ), + ), + elevation: Elevation.none, + ), + child: isCreating + ? SizedBox( + width: IconSize.medium, + height: IconSize.medium, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + context + .conduitTheme + .buttonPrimaryText, + ), + ), + ) + : Text( + 'Create', + style: AppTypography.labelStyle + .copyWith( + color: context + .conduitTheme + .buttonPrimaryText, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ) + .animate() + .slideY( + begin: 0.1, + duration: AnimationDuration.modalPresentation, + curve: AnimationCurves.modalPresentation, + ) + .fadeIn( + duration: AnimationDuration.modalPresentation, + curve: AnimationCurves.easeOut, ), - ), - ], - ), - ).animate().slideY( - begin: 0.1, - duration: AnimationDuration.modalPresentation, - curve: AnimationCurves.modalPresentation, - ).fadeIn( - duration: AnimationDuration.modalPresentation, - curve: AnimationCurves.easeOut, - ), ), ); } diff --git a/lib/main.dart b/lib/main.dart index 098ecb6..3877183 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'shared/widgets/offline_indicator.dart'; import 'features/auth/views/connect_signin_page.dart'; import 'features/auth/providers/unified_auth_providers.dart'; import 'core/auth/auth_state_manager.dart'; +import 'core/utils/debug_logger.dart'; import 'features/onboarding/views/onboarding_sheet.dart'; import 'features/chat/views/chat_page.dart'; @@ -57,7 +58,7 @@ class _ConduitAppState extends ConsumerState { void _initializeAppState() { // Initialize unified auth state manager and API integration synchronously // This ensures auth state is loaded before first widget build - debugPrint('DEBUG: Initializing unified auth system'); + DebugLogger.auth('Initializing unified auth system'); // Initialize auth state manager (will handle token validation automatically) ref.read(authStateManagerProvider); @@ -172,7 +173,7 @@ class _ConduitAppState extends ConsumerState { }, loading: () => _buildInitialLoadingSkeleton(context), error: (error, stackTrace) { - debugPrint('DEBUG: Server provider error: $error'); + DebugLogger.error('Server provider error', error); return _buildErrorState('Server connection failed: $error'); }, ); @@ -187,8 +188,8 @@ class _ConduitAppState extends ConsumerState { // Get the API service final api = ref.read(apiServiceProvider); if (api == null) { - debugPrint( - 'DEBUG: API service not available for background initialization', + DebugLogger.warning( + 'API service not available for background initialization', ); return; } @@ -197,9 +198,9 @@ class _ConduitAppState extends ConsumerState { final authToken = ref.read(authTokenProvider3); if (authToken != null && authToken.isNotEmpty) { api.updateAuthToken(authToken); - debugPrint('DEBUG: Background - Set auth token on API service'); + DebugLogger.auth('Background: Set auth token on API service'); } else { - debugPrint('DEBUG: Background - No auth token available yet'); + DebugLogger.warning('Background: No auth token available yet'); return; } @@ -208,26 +209,26 @@ class _ConduitAppState extends ConsumerState { // Load models and set default in background await ref.read(defaultModelProvider.future); - debugPrint('DEBUG: Background initialization completed'); + DebugLogger.info('Background initialization completed'); // Onboarding: show once if not seen final storage = ref.read(optimizedStorageServiceProvider); final seen = await storage.getOnboardingSeen(); - + if (!seen && mounted) { await Future.delayed(const Duration(milliseconds: 300)); if (!mounted) return; - + WidgetsBinding.instance.addPostFrameCallback((_) async { final navContext = NavigationService.navigatorKey.currentContext; if (!mounted || navContext == null) return; - + _showOnboarding(navContext); await storage.setOnboardingSeen(true); }); } } catch (e) { - debugPrint('DEBUG: Background initialization failed: $e'); + DebugLogger.error('Background initialization failed', e); // Don't throw - this is background initialization } }); @@ -305,12 +306,12 @@ class _NavigationObserver extends NavigatorObserver { void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); // Log navigation for debugging and analytics - debugPrint('DEBUG: Navigation - Pushed: ${route.settings.name}'); + DebugLogger.navigation('Pushed: ${route.settings.name}'); } @override void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); - debugPrint('DEBUG: Navigation - Popped: ${route.settings.name}'); + DebugLogger.navigation('Popped: ${route.settings.name}'); } } diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 21472f6..ae28422 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -5,7 +5,6 @@ import 'package:flutter_highlight/themes/atom-one-dark.dart'; import 'package:flutter_highlight/themes/atom-one-light.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:url_launcher/url_launcher_string.dart'; -import 'package:conduit/shared/theme/app_theme.dart'; import 'package:conduit/shared/theme/theme_extensions.dart'; class ConduitMarkdownConfig { @@ -96,10 +95,7 @@ class ConduitMarkdownConfig { const SizedBox(height: Spacing.xs), Text( 'Failed to load image', - style: TextStyle( - color: theme.error, - fontSize: 12, - ), + style: TextStyle(color: theme.error, fontSize: 12), ), ], ), @@ -162,16 +158,13 @@ class ConduitMarkdownConfig { if (commaIndex == -1) { throw Exception('Invalid data URL format'); } - + final base64String = dataUrl.substring(commaIndex + 1); final imageBytes = base64.decode(base64String); - + return Container( margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - constraints: const BoxConstraints( - maxWidth: 500, - maxHeight: 500, - ), + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 500), child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.md), child: Image.memory( @@ -191,18 +184,11 @@ class ConduitMarkdownConfig { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline, - color: theme.error, - size: 32, - ), + Icon(Icons.error_outline, color: theme.error, size: 32), const SizedBox(height: Spacing.xs), Text( 'Invalid image data', - style: TextStyle( - color: theme.error, - fontSize: 12, - ), + style: TextStyle(color: theme.error, fontSize: 12), ), ], ), @@ -221,10 +207,7 @@ class ConduitMarkdownConfig { child: Center( child: Text( 'Invalid image format', - style: TextStyle( - color: theme.error, - fontSize: 12, - ), + style: TextStyle(color: theme.error, fontSize: 12), ), ), ); @@ -302,4 +285,4 @@ class CodeBlockWrapper extends StatelessWidget { ], ); } -} \ No newline at end of file +}