From 5bda59f7af375b858c2691471b1d55c90f079b03 Mon Sep 17 00:00:00 2001 From: 240405 <240405@epvc.pt> Date: Thu, 21 May 2026 11:49:56 +0100 Subject: [PATCH] historico --- lib/core/routing/app_router.dart | 19 ++ lib/core/services/chat_memory_service.dart | 303 +++++++++++++--- lib/core/services/rag_ai_service.dart | 42 ++- .../presentation/pages/chat_history_page.dart | 322 ++++++++++++++++++ .../pages/tutor_chat_page_simple.dart | 117 ++++++- 5 files changed, 742 insertions(+), 61 deletions(-) create mode 100644 lib/features/ai_tutor/presentation/pages/chat_history_page.dart diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8da2dca..2155308 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -9,6 +9,7 @@ import '../../features/auth/presentation/pages/signup_page.dart'; import '../../features/dashboard/presentation/pages/student_dashboard_page.dart'; import '../../features/dashboard/presentation/pages/teacher_dashboard_page.dart'; import '../../features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart'; +import '../../features/ai_tutor/presentation/pages/chat_history_page.dart'; import '../../features/quiz/presentation/pages/quiz_list_page.dart'; import '../../features/quiz/presentation/pages/quiz_page.dart'; import '../../features/quiz/presentation/pages/teacher_quiz_page.dart'; @@ -35,6 +36,7 @@ class AppRouter { static const String teacherAnalytics = '/teacher/analytics'; static const String studentAchievements = '/student/achievements'; static const String quizManagement = '/quiz-management'; + static const String chatHistory = '/chat-history'; // Nested route paths (without leading slash) static const String tutorNested = 'tutor'; @@ -147,6 +149,23 @@ class AppRouter { builder: (context, state) => const TutorChatPageSimple(), ), + // AI Tutor Route with conversation ID (resume conversation) + GoRoute( + path: '$tutor/:conversationId', + name: 'aiTutorConversation', + builder: (context, state) { + final conversationId = state.pathParameters['conversationId']!; + return TutorChatPageSimple(conversationId: conversationId); + }, + ), + + // Chat History Route + GoRoute( + path: chatHistory, + name: 'chatHistory', + builder: (context, state) => const ChatHistoryPage(), + ), + // Teacher Analytics Route GoRoute( path: teacherAnalytics, diff --git a/lib/core/services/chat_memory_service.dart b/lib/core/services/chat_memory_service.dart index 2f019fb..91981b1 100644 --- a/lib/core/services/chat_memory_service.dart +++ b/lib/core/services/chat_memory_service.dart @@ -3,34 +3,180 @@ import 'package:firebase_auth/firebase_auth.dart'; import '../utils/logger.dart'; /// Service for managing conversation history in Firestore -/// Structure: conversations/{conversationId}/messages/{messageId} +/// Structure: userChats/{userId}/conversations/{conversationId}/messages/{messageId} class ChatMemoryService { static final FirebaseFirestore _firestore = FirebaseFirestore.instance; static final FirebaseAuth _auth = FirebaseAuth.instance; - /// Get or create a conversation for the current user - static Future _getOrCreateConversationId() async { + /// Current active conversation ID + static String? _currentConversationId; + + /// Get current conversation ID + static String? get currentConversationId => _currentConversationId; + + /// Set current conversation ID + static void setCurrentConversationId(String? id) { + _currentConversationId = id; + } + + /// Create a new conversation + static Future createConversation({ + required List selectedMaterialIds, + String? title, + }) async { final user = _auth.currentUser; if (user == null) { throw Exception('User not authenticated'); } - // For simplicity, use user's UID as conversation ID - // In a multi-conversation system, this would create new conversation docs - return user.uid; + final conversationRef = await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .add({ + 'title': title ?? 'Nova conversa', + 'createdAt': FieldValue.serverTimestamp(), + 'updatedAt': FieldValue.serverTimestamp(), + 'selectedMaterialIds': selectedMaterialIds, + 'messageCount': 0, + }); + + _currentConversationId = conversationRef.id; + Logger.info('Created new conversation: ${conversationRef.id}'); + return conversationRef.id; + } + + /// Update conversation materials + static Future updateConversationMaterials({ + required String conversationId, + required List selectedMaterialIds, + }) async { + try { + await _firestore + .collection('userChats') + .doc(_auth.currentUser?.uid) + .collection('conversations') + .doc(conversationId) + .update({ + 'selectedMaterialIds': selectedMaterialIds, + 'updatedAt': FieldValue.serverTimestamp(), + }); + Logger.info('Updated materials for conversation: $conversationId'); + } catch (e) { + Logger.error('Error updating conversation materials: $e'); + } + } + + /// Update conversation title + static Future updateConversationTitle({ + required String conversationId, + required String title, + }) async { + try { + await _firestore + .collection('userChats') + .doc(_auth.currentUser?.uid) + .collection('conversations') + .doc(conversationId) + .update({'title': title, 'updatedAt': FieldValue.serverTimestamp()}); + Logger.info('Updated title for conversation: $conversationId'); + } catch (e) { + Logger.error('Error updating conversation title: $e'); + } + } + + /// Get all conversations for current user + static Future>> getConversations() async { + try { + final user = _auth.currentUser; + if (user == null) return []; + + final snapshot = await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .orderBy('updatedAt', descending: true) + .get(); + + final conversations = snapshot.docs.map((doc) { + final data = doc.data(); + return { + 'id': doc.id, + 'title': data['title'] as String? ?? 'Sem título', + 'createdAt': data['createdAt'] as Timestamp?, + 'updatedAt': data['updatedAt'] as Timestamp?, + 'selectedMaterialIds': + (data['selectedMaterialIds'] as List?)?.cast() ?? + [], + 'messageCount': data['messageCount'] as int? ?? 0, + }; + }).toList(); + + Logger.info('Retrieved ${conversations.length} conversations'); + return conversations; + } catch (e) { + Logger.error('Error getting conversations: $e'); + return []; + } + } + + /// Delete a conversation + static Future deleteConversation(String conversationId) async { + try { + final user = _auth.currentUser; + if (user == null) return; + + // Delete all messages in the conversation + final messagesSnapshot = await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .doc(conversationId) + .collection('messages') + .get(); + + final batch = _firestore.batch(); + for (final doc in messagesSnapshot.docs) { + batch.delete(doc.reference); + } + + // Delete the conversation document + batch.delete( + _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .doc(conversationId), + ); + + await batch.commit(); + + if (_currentConversationId == conversationId) { + _currentConversationId = null; + } + + Logger.info('Deleted conversation: $conversationId'); + } catch (e) { + Logger.error('Error deleting conversation: $e'); + } } /// Save a message to Firestore static Future saveMessage({ required String role, // 'user' or 'assistant' required String content, + String? conversationId, }) async { try { - final conversationId = await _getOrCreateConversationId(); final user = _auth.currentUser; - if (user == null) return; + final convId = conversationId ?? _currentConversationId; + if (convId == null) { + Logger.warning('No conversation ID, message not saved'); + return; + } + final messageData = { 'role': role, 'content': content, @@ -39,26 +185,45 @@ class ChatMemoryService { }; await _firestore + .collection('userChats') + .doc(user.uid) .collection('conversations') - .doc(conversationId) + .doc(convId) .collection('messages') .add(messageData); - Logger.info('Message saved to Firestore: role=$role'); + // Update conversation metadata + await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .doc(convId) + .update({ + 'updatedAt': FieldValue.serverTimestamp(), + 'messageCount': FieldValue.increment(1), + }); + + Logger.info( + 'Message saved to Firestore: role=$role, conversation=$convId', + ); } catch (e) { Logger.error('Error saving message: $e'); } } - /// Get the last N messages from conversation history + /// Get the last N messages from a specific conversation /// Returns list of messages sorted by createdAt ascending (oldest first) - static Future>> getRecentMessages({ + static Future>> getConversationMessages({ + required String conversationId, int limit = 20, }) async { try { - final conversationId = await _getOrCreateConversationId(); + final user = _auth.currentUser; + if (user == null) return []; final snapshot = await _firestore + .collection('userChats') + .doc(user.uid) .collection('conversations') .doc(conversationId) .collection('messages') @@ -68,64 +233,110 @@ class ChatMemoryService { // Convert to list and reverse to get ascending order (oldest first) final messages = snapshot.docs - .map((doc) => { - 'role': doc.data()['role'] as String, - 'content': doc.data()['content'] as String, - 'createdAt': doc.data()['createdAt'] as Timestamp?, - }) + .map( + (doc) => { + 'role': doc.data()['role'] as String, + 'content': doc.data()['content'] as String, + 'createdAt': doc.data()['createdAt'] as Timestamp?, + }, + ) .toList() .reversed .toList(); - // Log de confirmação de ordem - if (messages.isNotEmpty) { - Logger.info('History order fixed. First message: ${messages.first['role']} - ${(messages.first['content'] as String).substring(0, (messages.first['content'] as String).length > 30 ? 30 : (messages.first['content'] as String).length)}...'); - } - Logger.info('Retrieved ${messages.length} messages from history (oldest first)'); + Logger.info( + 'Retrieved ${messages.length} messages from conversation $conversationId', + ); return messages; } catch (e) { - Logger.error('Error getting recent messages: $e'); + Logger.error('Error getting conversation messages: $e'); return []; } } - /// Build messages array for API request + /// Get conversation details + static Future?> getConversation( + String conversationId, + ) async { + try { + final user = _auth.currentUser; + if (user == null) return null; + + final doc = await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .doc(conversationId) + .get(); + + if (!doc.exists) return null; + + final data = doc.data(); + return { + 'id': doc.id, + 'title': data?['title'] as String? ?? 'Sem título', + 'createdAt': data?['createdAt'] as Timestamp?, + 'updatedAt': data?['updatedAt'] as Timestamp?, + 'selectedMaterialIds': + (data?['selectedMaterialIds'] as List?)?.cast() ?? + [], + 'messageCount': data?['messageCount'] as int? ?? 0, + }; + } catch (e) { + Logger.error('Error getting conversation: $e'); + return null; + } + } + + /// Build messages array for API request from current conversation /// Returns list of message maps with 'role' and 'content' keys static Future>> buildMessagesForAPI({ required String currentUserMessage, + String? conversationId, int historyLimit = 20, }) async { final messages = >[]; - // 1. Get recent conversation history - final history = await getRecentMessages(limit: historyLimit); + final convId = conversationId ?? _currentConversationId; + if (convId != null) { + // Get recent conversation history + final history = await getConversationMessages( + conversationId: convId, + limit: historyLimit, + ); - // 2. Add historical messages - for (final msg in history) { - messages.add({ - 'role': msg['role'] as String, - 'content': msg['content'] as String, - }); + // Add historical messages + for (final msg in history) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } } - // 3. Add current user message - messages.add({ - 'role': 'user', - 'content': currentUserMessage, - }); + // Add current user message + messages.add({'role': 'user', 'content': currentUserMessage}); - Logger.info('Built messages array with ${messages.length} messages for API'); + Logger.info( + 'Built messages array with ${messages.length} messages for API', + ); return messages; } - /// Clear conversation history for current user + /// Clear current conversation history static Future clearHistory() async { try { - final conversationId = await _getOrCreateConversationId(); + final user = _auth.currentUser; + if (user == null) return; + + final convId = _currentConversationId; + if (convId == null) return; final snapshot = await _firestore + .collection('userChats') + .doc(user.uid) .collection('conversations') - .doc(conversationId) + .doc(convId) .collection('messages') .get(); @@ -136,6 +347,14 @@ class ChatMemoryService { } await batch.commit(); + // Reset message count + await _firestore + .collection('userChats') + .doc(user.uid) + .collection('conversations') + .doc(convId) + .update({'messageCount': 0}); + Logger.info('Conversation history cleared'); } catch (e) { Logger.error('Error clearing history: $e'); diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index 1acfed4..662787a 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -51,14 +51,19 @@ IMPORTANTE - RESPOSTAS COMPLETAS: }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore - final conversationHistory = await ChatMemoryService.getRecentMessages( - limit: 20, - ); - for (final msg in conversationHistory) { - messages.add({ - 'role': msg['role'] as String, - 'content': msg['content'] as String, - }); + final conversationId = ChatMemoryService.currentConversationId; + if (conversationId != null) { + final conversationHistory = + await ChatMemoryService.getConversationMessages( + conversationId: conversationId, + limit: 20, + ); + for (final msg in conversationHistory) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } } // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) @@ -905,14 +910,19 @@ IMPORTANTE - RESPOSTAS COMPLETAS: }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) - final conversationHistory = await ChatMemoryService.getRecentMessages( - limit: 4, - ); - for (final msg in conversationHistory) { - messages.add({ - 'role': msg['role'] as String, - 'content': msg['content'] as String, - }); + final conversationId = ChatMemoryService.currentConversationId; + if (conversationId != null) { + final conversationHistory = + await ChatMemoryService.getConversationMessages( + conversationId: conversationId, + limit: 4, + ); + for (final msg in conversationHistory) { + messages.add({ + 'role': msg['role'] as String, + 'content': msg['content'] as String, + }); + } } // Log de confirmação de ordem do histórico diff --git a/lib/features/ai_tutor/presentation/pages/chat_history_page.dart b/lib/features/ai_tutor/presentation/pages/chat_history_page.dart new file mode 100644 index 0000000..0c3f590 --- /dev/null +++ b/lib/features/ai_tutor/presentation/pages/chat_history_page.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:go_router/go_router.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../../../../core/services/chat_memory_service.dart'; +import '../../../../core/utils/logger.dart'; + +class ChatHistoryPage extends StatefulWidget { + const ChatHistoryPage({super.key}); + + @override + State createState() => _ChatHistoryPageState(); +} + +class _ChatHistoryPageState extends State { + List> _conversations = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadConversations(); + } + + Future _loadConversations() async { + setState(() => _isLoading = true); + try { + final conversations = await ChatMemoryService.getConversations(); + if (mounted) { + setState(() { + _conversations = conversations; + _isLoading = false; + }); + } + } catch (e) { + Logger.error('Error loading conversations: $e'); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _deleteConversation(String conversationId) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Eliminar conversa'), + content: const Text('Tem certeza que deseja eliminar esta conversa?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Eliminar'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + await ChatMemoryService.deleteConversation(conversationId); + await _loadConversations(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Conversa eliminada'))); + } + } catch (e) { + Logger.error('Error deleting conversation: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Erro ao eliminar conversa')), + ); + } + } + } + + String _formatDate(Timestamp? timestamp) { + if (timestamp == null) return ''; + final date = timestamp.toDate(); + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'Hoje ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } else if (difference.inDays == 1) { + return 'Ontem'; + } else if (difference.inDays < 7) { + return '${difference.inDays} dias atrás'; + } else { + return '${date.day}/${date.month}/${date.year}'; + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surfaceContainerLowest, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => context.go('/student-dashboard'), + ), + title: Text( + 'Histórico de Conversas', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + actions: [ + IconButton( + icon: Icon(Icons.refresh, color: cs.onSurface), + onPressed: _loadConversations, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _conversations.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 16), + Text( + 'Sem conversas ainda', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + 'Começa uma conversa com o Vico!', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 14, + ), + ), + ], + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _conversations.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final conversation = _conversations[index]; + return _buildConversationCard(conversation, cs); + }, + ), + ); + } + + Widget _buildConversationCard( + Map conversation, + ColorScheme cs, + ) { + return Dismissible( + key: Key(conversation['id']), + direction: DismissDirection.endToStart, + onDismissed: (_) => _deleteConversation(conversation['id']), + background: Container( + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + child: InkWell( + onTap: () { + ChatMemoryService.setCurrentConversationId(conversation['id']); + context.go('/ai-tutor/${conversation['id']}'); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.chat_bubble, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + conversation['title'], + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.access_time, + size: 12, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + _formatDate(conversation['updatedAt']), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(width: 12), + Icon( + Icons.message, + size: 12, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${conversation['messageCount']} msgs', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + if (conversation['selectedMaterialIds'] != null && + (conversation['selectedMaterialIds'] as List).isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: (conversation['selectedMaterialIds'] as List) + .take(3) + .map( + (id) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + id.length > 15 ? '${id.substring(0, 15)}...' : id, + style: TextStyle( + color: cs.primary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), + ).animate().slideX(duration: 300.ms).fadeIn(duration: 300.ms); + } +} diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart index 20ac503..4662354 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart @@ -12,7 +12,9 @@ import '../../../materials/presentation/pages/pdf_viewer_page.dart'; /// Simple AI Tutor chat interface page (for testing) class TutorChatPageSimple extends StatefulWidget { - const TutorChatPageSimple({super.key}); + final String? conversationId; + + const TutorChatPageSimple({super.key, this.conversationId}); @override State createState() => _TutorChatPageSimpleState(); @@ -36,6 +38,10 @@ class _TutorChatPageSimpleState extends State void initState() { super.initState(); _loadAvailableMaterials(); + if (widget.conversationId != null) { + ChatMemoryService.setCurrentConversationId(widget.conversationId); + _loadConversation(widget.conversationId!); + } } Future _loadAvailableMaterials() async { @@ -66,6 +72,46 @@ class _TutorChatPageSimpleState extends State } } + Future _loadConversation(String conversationId) async { + try { + final conversation = await ChatMemoryService.getConversation( + conversationId, + ); + if (conversation == null) return; + + final messages = await ChatMemoryService.getConversationMessages( + conversationId: conversationId, + limit: 50, + ); + + final materialIds = conversation['selectedMaterialIds'] as List?; + if (materialIds != null) { + setState(() { + _selectedMaterialIds = materialIds.cast().toSet(); + _materialsConfirmed = true; + }); + } + + // Load messages from Firestore + final loadedMessages = messages.map((msg) { + return { + 'content': msg['content'] as String, + 'isUser': msg['role'] == 'user', + 'timestamp': msg['createdAt'] as Timestamp? ?? DateTime.now(), + }; + }).toList(); + + if (mounted) { + setState(() { + _messages = loadedMessages; + }); + _scrollToBottom(); + } + } catch (e) { + Logger.error('Error loading conversation: $e'); + } + } + @override void dispose() { _messageController.dispose(); @@ -202,6 +248,14 @@ class _TutorChatPageSimpleState extends State tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), + IconButton( + onPressed: () => context.go('/chat-history'), + icon: const Icon( + Icons.history, + color: Colors.white, + size: 20, + ), + ), const SizedBox(width: 4), ], ), @@ -1190,11 +1244,48 @@ class _TutorChatPageSimpleState extends State ElevatedButton( onPressed: tempSelected.isEmpty ? null - : () { + : () async { final isFirst = !_materialsConfirmed; final selectionChanged = !tempSelected.containsAll(_selectedMaterialIds) || !_selectedMaterialIds.containsAll(tempSelected); + + // Create new conversation if materials changed or first time + if (isFirst || selectionChanged) { + final newConversationId = + await ChatMemoryService.createConversation( + selectedMaterialIds: tempSelected.toList(), + title: 'Nova conversa', + ); + ChatMemoryService.setCurrentConversationId( + newConversationId, + ); + + // Update title based on first message if exists + if (_messages.isNotEmpty && + _messages.first['isUser'] == true) { + final firstMessage = + _messages.first['content'] as String; + final title = firstMessage.length > 30 + ? '${firstMessage.substring(0, 30)}...' + : firstMessage; + await ChatMemoryService.updateConversationTitle( + conversationId: newConversationId, + title: title, + ); + } + } else { + // Update materials in existing conversation + final currentId = + ChatMemoryService.currentConversationId; + if (currentId != null) { + await ChatMemoryService.updateConversationMaterials( + conversationId: currentId, + selectedMaterialIds: tempSelected.toList(), + ); + } + } + setState(() { _selectedMaterialIds = tempSelected; _materialsConfirmed = true; @@ -1207,7 +1298,6 @@ class _TutorChatPageSimpleState extends State }); if (isFirst) _addWelcomeMessage(); if (selectionChanged) { - ChatMemoryService.clearHistory(); RAGAIService.clearLastContext(); } Navigator.of(dialogContext).pop(); @@ -1250,6 +1340,21 @@ class _TutorChatPageSimpleState extends State final userMessage = _messageController.text.trim(); + // Save user message to Firestore + await ChatMemoryService.saveMessage(role: 'user', content: userMessage); + + // Update conversation title if it's the first message + final currentId = ChatMemoryService.currentConversationId; + if (currentId != null && _messages.isEmpty) { + final title = userMessage.length > 30 + ? '${userMessage.substring(0, 30)}...' + : userMessage; + await ChatMemoryService.updateConversationTitle( + conversationId: currentId, + title: title, + ); + } + // Add user message setState(() { _messages.add({ @@ -1279,6 +1384,12 @@ class _TutorChatPageSimpleState extends State : replyText; Logger.info('Ollama response received: $preview...'); + // Save assistant message to Firestore + await ChatMemoryService.saveMessage( + role: 'assistant', + content: replyText, + ); + setState(() { _messages.add({ 'content': replyText,