refactor: debug logs
This commit is contained in:
@@ -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) {
|
||||
@@ -114,7 +114,9 @@ class ApiAuthInterceptor extends Interceptor {
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AuthState> {
|
||||
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<AuthState> {
|
||||
_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,
|
||||
@@ -191,7 +192,8 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
// 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<AuthState> {
|
||||
// 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<AuthState> {
|
||||
// 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<AuthState> {
|
||||
|
||||
/// Handle token invalidation (called by API service)
|
||||
Future<void> 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<AuthState> {
|
||||
// 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<AuthState> {
|
||||
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<AuthState> {
|
||||
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<AuthState> {
|
||||
|
||||
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<AuthState> {
|
||||
// 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<AuthState> {
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharedPreferences>((ref) {
|
||||
@@ -236,11 +237,9 @@ final modelsProvider = FutureProvider<List<Model>>((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<List<Model>>((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<List<Conversation>>((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<List<Conversation>>((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
|
||||
@@ -910,7 +907,7 @@ final foldersProvider = FutureProvider<List<Folder>>((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();
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
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<User> 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<List<Model>> getModels() async {
|
||||
final response = await _dio.get('/api/models');
|
||||
debugPrint('DEBUG: /api/models raw response: ${jsonEncode(response.data)}');
|
||||
|
||||
// Handle different response formats
|
||||
List<dynamic> 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<String, dynamic>;
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
|
||||
@@ -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<Conversation> 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<String, dynamic>;
|
||||
@@ -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<String, dynamic>;
|
||||
@@ -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<void> 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<Map<String, dynamic>>();
|
||||
} 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<String, dynamic>;
|
||||
} 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<String, dynamic>;
|
||||
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<dynamic>;
|
||||
@@ -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<String, dynamic>;
|
||||
} 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<String, dynamic>) {
|
||||
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<String, dynamic>) {
|
||||
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) {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
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();
|
||||
@@ -32,11 +35,15 @@ class BackgroundStreamingHandler {
|
||||
return _activeStreamIds.length;
|
||||
|
||||
case 'streamsSuspending':
|
||||
final Map<String, dynamic> args = call.arguments as Map<String, dynamic>;
|
||||
final List<String> streamIds = (args['streamIds'] as List).cast<String>();
|
||||
final Map<String, dynamic> args =
|
||||
call.arguments as Map<String, dynamic>;
|
||||
final List<String> streamIds = (args['streamIds'] as List)
|
||||
.cast<String>();
|
||||
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
|
||||
@@ -44,7 +51,7 @@ class BackgroundStreamingHandler {
|
||||
break;
|
||||
|
||||
case 'backgroundTaskExpiring':
|
||||
debugPrint('Background: Background task expiring');
|
||||
DebugLogger.stream('Background: Background task expiring');
|
||||
onBackgroundTaskExpiring?.call();
|
||||
break;
|
||||
}
|
||||
@@ -62,9 +69,11 @@ class BackgroundStreamingHandler {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +89,17 @@ class BackgroundStreamingHandler {
|
||||
'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,
|
||||
@@ -108,7 +120,8 @@ class BackgroundStreamingHandler {
|
||||
}
|
||||
|
||||
/// Update stream state with new chunk
|
||||
void updateStreamState(String streamId, {
|
||||
void updateStreamState(
|
||||
String streamId, {
|
||||
int? chunkSequence,
|
||||
String? content,
|
||||
String? appendedContent,
|
||||
@@ -143,7 +156,7 @@ class BackgroundStreamingHandler {
|
||||
try {
|
||||
await _channel.invokeMethod('keepAlive');
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to keep alive: $e');
|
||||
DebugLogger.error('Background: Failed to keep alive', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +165,9 @@ class BackgroundStreamingHandler {
|
||||
if (!Platform.isIOS && !Platform.isAndroid) return [];
|
||||
|
||||
try {
|
||||
final List<dynamic>? states = await _channel.invokeMethod('recoverStreamStates');
|
||||
final List<dynamic>? states = await _channel.invokeMethod(
|
||||
'recoverStreamStates',
|
||||
);
|
||||
if (states == null) return [];
|
||||
|
||||
final recovered = <StreamState>[];
|
||||
@@ -165,16 +180,21 @@ class BackgroundStreamingHandler {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> _saveStreamStatesForRecovery(List<String> streamIds, String reason) async {
|
||||
Future<void> _saveStreamStatesForRecovery(
|
||||
List<String> streamIds,
|
||||
String reason,
|
||||
) async {
|
||||
final statesToSave = streamIds
|
||||
.map((id) => _streamStates[id])
|
||||
.where((state) => state != null)
|
||||
@@ -187,7 +207,7 @@ class BackgroundStreamingHandler {
|
||||
'reason': reason,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Background: Failed to save stream states: $e');
|
||||
DebugLogger.error('Background: Failed to save stream states', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +290,7 @@ class StreamState {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse StreamState from map: $e');
|
||||
DebugLogger.error('Failed to parse StreamState from map', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -283,7 +303,7 @@ class StreamState {
|
||||
@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)';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -46,7 +48,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
|
||||
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);
|
||||
@@ -54,7 +58,7 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
};
|
||||
|
||||
_backgroundHandler.onBackgroundTaskExpiring = () {
|
||||
debugPrint('PersistentStreaming: Background task expiring');
|
||||
DebugLogger.stream('PersistentStreaming: Background task expiring');
|
||||
// Save states and prepare for recovery
|
||||
_saveStreamStatesForRecovery();
|
||||
};
|
||||
@@ -69,17 +73,23 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
@@ -114,7 +124,7 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -125,7 +135,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void _onAppForeground() {
|
||||
debugPrint('PersistentStreamingService: App returned to foreground');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: App returned to foreground',
|
||||
);
|
||||
_isInBackground = false;
|
||||
|
||||
// Cancel background timer
|
||||
@@ -142,7 +154,7 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void _onAppDetached() {
|
||||
debugPrint('PersistentStreamingService: App detached');
|
||||
DebugLogger.stream('PersistentStreamingService: App detached');
|
||||
|
||||
// Save stream states for recovery
|
||||
_saveStreamStatesForRecovery();
|
||||
@@ -193,7 +205,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_startBackgroundExecution();
|
||||
}
|
||||
|
||||
debugPrint('PersistentStreamingService: Registered stream $streamId');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: Registered stream $streamId',
|
||||
);
|
||||
|
||||
return streamId;
|
||||
}
|
||||
@@ -215,7 +229,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
_disableWakeLock();
|
||||
}
|
||||
|
||||
debugPrint('PersistentStreamingService: Unregistered stream $streamId');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreamingService: Unregistered stream $streamId',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a stream is still active
|
||||
@@ -226,7 +242,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
// Recover interrupted streams
|
||||
Future<void> _recoverActiveStreams() async {
|
||||
if (!_hasConnectivity) {
|
||||
debugPrint('PersistentStreaming: No connectivity, skipping recovery');
|
||||
DebugLogger.stream(
|
||||
'PersistentStreaming: No connectivity, skipping recovery',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,19 +272,28 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
Future<void> _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<void> _attemptStreamRecovery(String streamId, Function recoveryCallback) async {
|
||||
Future<void> _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;
|
||||
@@ -283,11 +310,17 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,11 +362,14 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
|
||||
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,
|
||||
@@ -350,9 +386,11 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
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;
|
||||
}
|
||||
@@ -364,18 +402,24 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, dynamic>) {
|
||||
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<void> 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<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
lib/core/utils/debug_logger.dart
Normal file
73
lib/core/utils/debug_logger.dart
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('<details type="reasoning"')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: No reasoning content found in text');
|
||||
DebugLogger.log('No reasoning content found in text');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('DEBUG: Found reasoning tags in content');
|
||||
DebugLogger.log('Found reasoning tags in content');
|
||||
}
|
||||
|
||||
// Match the <details> 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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<String, dynamic>;
|
||||
|
||||
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<String, dynamic>? _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<String, dynamic> && 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
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthenticationPage> createState() => _AuthenticationPageState();
|
||||
@@ -109,10 +104,6 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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<AuthenticationPage> {
|
||||
Widget build(BuildContext context) {
|
||||
// Listen for auth state changes to navigate on successful login
|
||||
ref.listen<AuthState>(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,
|
||||
@@ -255,7 +250,9 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
@@ -370,7 +367,9 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
),
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
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<AuthenticationPage> {
|
||||
Widget _buildSignInButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Spacing.lg),
|
||||
child: ConduitButton(
|
||||
text: _isSigningIn
|
||||
? 'Signing in...'
|
||||
: _useApiKey
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -621,6 +630,4 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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';
|
||||
@@ -83,11 +84,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// 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
|
||||
@@ -97,14 +98,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -112,16 +113,18 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
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<ChatPage> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +175,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -252,7 +257,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.lg,
|
||||
Spacing.xl,
|
||||
Spacing.md,
|
||||
Spacing.lg,
|
||||
Spacing.lg,
|
||||
),
|
||||
@@ -928,7 +937,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
items: messages,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.lg,
|
||||
Spacing.xl,
|
||||
Spacing.md,
|
||||
Spacing.lg,
|
||||
Spacing.lg,
|
||||
),
|
||||
@@ -943,7 +952,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
// 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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
|
||||
// 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<ChatPage> {
|
||||
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',
|
||||
@@ -1271,8 +1284,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final shouldPop = await NavigationService.confirmNavigation(
|
||||
title: 'Leave Chat?',
|
||||
message: isStreaming
|
||||
? 'The AI is still responding. Leave anyway?'
|
||||
: 'Your conversation will be saved.',
|
||||
? 'The AI is still responding. Leave anyway?'
|
||||
: 'Your conversation will be saved.',
|
||||
confirmText: 'Leave',
|
||||
cancelText: 'Stay',
|
||||
);
|
||||
@@ -1365,10 +1378,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
|
||||
|
||||
// Messages Area with pull-to-refresh
|
||||
Expanded(
|
||||
child: ConduitRefreshIndicator(
|
||||
@@ -1557,7 +1580,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
.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<ChatPage> {
|
||||
|
||||
// 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<ChatPage> {
|
||||
|
||||
// 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 {}
|
||||
|
||||
537
lib/features/chat/widgets/assistant_message_widget.dart
Normal file
537
lib/features/chat/widgets/assistant_message_widget.dart
Normal file
@@ -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<AssistantMessageWidget> createState() =>
|
||||
_AssistantMessageWidgetState();
|
||||
}
|
||||
|
||||
class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
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\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<Widget>((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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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<DocumentationMessageWidget> createState() =>
|
||||
_DocumentationMessageWidgetState();
|
||||
}
|
||||
|
||||
class _DocumentationMessageWidgetState
|
||||
extends ConsumerState<DocumentationMessageWidget>
|
||||
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\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<Widget>((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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ModernMessageBubble> createState() =>
|
||||
_ModernMessageBubbleState();
|
||||
}
|
||||
|
||||
class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
|
||||
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<Widget>((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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
346
lib/features/chat/widgets/user_message_bubble.dart
Normal file
346
lib/features/chat/widgets/user_message_bubble.dart
Normal file
@@ -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<UserMessageBubble> createState() => _UserMessageBubbleState();
|
||||
}
|
||||
|
||||
class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -43,7 +43,9 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
|
||||
// Provider for folder expansion state (Map<folderId, isExpanded>)
|
||||
// Start with folders expanded by default for better discoverability
|
||||
static final _expandedFoldersProvider = StateProvider<Map<String, bool>>((ref) => {});
|
||||
static final _expandedFoldersProvider = StateProvider<Map<String, bool>>(
|
||||
(ref) => {},
|
||||
);
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true; // Keep state alive for better performance
|
||||
@@ -195,7 +197,9 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
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<ChatsListPage>
|
||||
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<ChatsListPage>
|
||||
|
||||
// Folder conversations sections (after pinned, before recent)
|
||||
if (folderConversations.isNotEmpty) ...[
|
||||
...ref.watch(foldersProvider).when(
|
||||
data: (folders) {
|
||||
// Group conversations by folder
|
||||
final groupedByFolder = <String, List<dynamic>>{};
|
||||
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 = <String, List<dynamic>>{};
|
||||
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;
|
||||
// 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()],
|
||||
),
|
||||
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<ChatsListPage>
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
String _formatConversationDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
|
||||
@@ -944,10 +986,10 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
// 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<ChatsListPage>
|
||||
}
|
||||
});
|
||||
} 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<ChatsListPage>
|
||||
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<ChatsListPage>
|
||||
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<ChatsListPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _showCreateFolderDialog() {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
@@ -1245,7 +1291,10 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createFolderFromDialog(String name, BuildContext dialogContext) async {
|
||||
Future<void> _createFolderFromDialog(
|
||||
String name,
|
||||
BuildContext dialogContext,
|
||||
) async {
|
||||
// Store theme values and messenger before async operation
|
||||
final theme = context.conduitTheme;
|
||||
final textInverseColor = theme.textInverse;
|
||||
@@ -1318,7 +1367,7 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
}
|
||||
}
|
||||
} 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<ChatsListPage>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _archiveConversation(dynamic conversation) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
@@ -1361,7 +1408,7 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
}
|
||||
}
|
||||
} 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<ChatsListPage>
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error deleting conversation: $e');
|
||||
DebugLogger.error('Error deleting conversation', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -1508,14 +1555,20 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
children: [
|
||||
Icon(
|
||||
isExpanded
|
||||
? (Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more_rounded)
|
||||
: (Platform.isIOS ? CupertinoIcons.chevron_right : Icons.chevron_right_rounded),
|
||||
? (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<Color>(
|
||||
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<Color>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ConduitApp> {
|
||||
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<ConduitApp> {
|
||||
},
|
||||
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<ConduitApp> {
|
||||
// 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<ConduitApp> {
|
||||
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,7 +209,7 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
|
||||
|
||||
// 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);
|
||||
@@ -227,7 +228,7 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
|
||||
});
|
||||
}
|
||||
} 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<dynamic> route, Route<dynamic>? 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<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
debugPrint('DEBUG: Navigation - Popped: ${route.settings.name}');
|
||||
DebugLogger.navigation('Popped: ${route.settings.name}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -168,10 +164,7 @@ class ConduitMarkdownConfig {
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user