diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 2155308..e21d7b3 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -142,14 +142,7 @@ class AppRouter { ], ), - // AI Tutor Route (independent) - GoRoute( - path: tutor, - name: 'aiTutor', - builder: (context, state) => const TutorChatPageSimple(), - ), - - // AI Tutor Route with conversation ID (resume conversation) + // AI Tutor Route with conversation ID (resume conversation) - MUST come before regular /ai-tutor route GoRoute( path: '$tutor/:conversationId', name: 'aiTutorConversation', @@ -159,6 +152,13 @@ class AppRouter { }, ), + // AI Tutor Route (independent - new conversation) + GoRoute( + path: tutor, + name: 'aiTutor', + builder: (context, state) => const TutorChatPageSimple(), + ), + // Chat History Route GoRoute( path: chatHistory, diff --git a/lib/core/services/chat_memory_service.dart b/lib/core/services/chat_memory_service.dart index 91981b1..b036abc 100644 --- a/lib/core/services/chat_memory_service.dart +++ b/lib/core/services/chat_memory_service.dart @@ -360,4 +360,114 @@ class ChatMemoryService { Logger.error('Error clearing history: $e'); } } + + /// Generate a smart title from the first message by extracting keywords + static String generateConversationTitle(String firstMessage) { + // Remove common Portuguese stopwords and greetings + final stopwords = { + 'olá', + 'oi', + 'bom dia', + 'boa tarde', + 'boa noite', + 'consegues', + 'podes', + 'pode', + 'poderia', + 'poderias', + 'explicar', + 'explica', + 'explicar-me', + 'explicar-lhe', + 'sobre', + 'acerca de', + 'a respeito de', + 'relativamente a', + 'o que é', + 'o que são', + 'qual é', + 'quais são', + 'como', + 'onde', + 'quando', + 'porquê', + 'por que', + 'quero', + 'quero que', + 'gostaria', + 'gostaria de', + 'preciso', + 'preciso de', + 'ajuda', + 'ajuda-me', + 'me', + 'te', + 'lhe', + 'nos', + 'vos', + 'um', + 'uma', + 'uns', + 'umas', + 'e', + 'ou', + 'mas', + 'porém', + 'todavia', + 'para', + 'de', + 'em', + 'a', + 'o', + 'as', + 'os', + 'que', + 'quem', + 'qual', + 'quais', + 'é', + 'são', + 'está', + 'estão', + 'foi', + 'foram', + '?', + '!', + '.', + ',', + ';', + ':', + }; + + // Convert to lowercase and remove punctuation + final cleaned = firstMessage + .toLowerCase() + .replaceAll(RegExp(r'[?!.;,]'), '') + .trim(); + + // Split into words and remove stopwords + final words = cleaned + .split(' ') + .where((word) => word.isNotEmpty && !stopwords.contains(word)) + .toList(); + + // Take the first 2-3 meaningful words as the title + if (words.isEmpty) { + return firstMessage.length > 30 + ? '${firstMessage.substring(0, 30)}...' + : firstMessage; + } + + final titleWords = words.take(3).join(' '); + // Capitalize first letter of each word + final title = titleWords + .split(' ') + .map((word) { + if (word.isEmpty) return ''; + return word[0].toUpperCase() + word.substring(1); + }) + .join(' '); + + return title.length > 40 ? '${title.substring(0, 40)}...' : title; + } } diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index 662787a..54184cc 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -860,11 +860,153 @@ ans é o índice (0-3) da opção correcta.'''; return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); } + /// Build dynamic system prompt based on selected materials and discipline + static String _buildSystemPrompt({ + List? selectedMaterialNames, + String? disciplineName, + bool isMathematics = false, + }) { + final buffer = StringBuffer(); + + buffer.writeln( + r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.''', + ); + buffer.writeln(); + buffer.writeln('Nunca referes o nome do modelo de linguagem.'); + buffer.writeln('Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.'); + buffer.writeln('Respondes sempre como o Vico.'); + buffer.writeln(); + buffer.writeln('Tens personalidade simpática, confiante e motivadora.'); + buffer.writeln( + 'Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.', + ); + buffer.writeln(); + + // Material context section + if (disciplineName != null && disciplineName.isNotEmpty) { + buffer.writeln('DISCIPLINA ATUAL: $disciplineName'); + } + if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) { + buffer.writeln( + 'MATERIAIS SELECIONADOS: ${selectedMaterialNames.join(", ")}', + ); + } + if (disciplineName != null || + (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty)) { + buffer.writeln(); + } + + // LaTeX prohibition (always) + buffer.writeln( + 'IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.', + ); + buffer.writeln( + 'Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).', + ); + buffer.writeln(); + + // Discipline-specific rules + if (isMathematics) { + buffer.writeln('REGRAS CRÍTICAS PARA MATEMÁTICA:'); + buffer.writeln( + '- O material fornecido serve como REFERÊNCIA de matéria, fórmulas e métodos.', + ); + buffer.writeln( + '- Podes CRIAR exercícios NOVOS que sigam a mesma lógica, fórmulas e métodos do material.', + ); + buffer.writeln( + '- NÃO copies exercícios diretamente do material — cria variações com valores diferentes.', + ); + buffer.writeln( + '- Se o aluno pedir exercícios, cria exercícios novos baseados nos CONCEITOS e FÓRMULAS do material.', + ); + buffer.writeln( + '- Se a resposta não estiver no contexto, usa o teu conhecimento matemático geral para ajudar, mas prioriza os métodos do material.', + ); + buffer.writeln( + '- SEMPRE fornece o passo a passo completo com o resultado final.', + ); + } else { + buffer.writeln('REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:'); + buffer.writeln( + '- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.', + ); + buffer.writeln( + '- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.', + ); + buffer.writeln( + '- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."', + ); + } + buffer.writeln( + '- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.', + ); + buffer.writeln(); + + // Material handling rules (always) + buffer.writeln('IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:'); + buffer.writeln( + '- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.', + ); + buffer.writeln( + '- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".', + ); + buffer.writeln( + '- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.', + ); + buffer.writeln( + '- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.', + ); + if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) { + buffer.writeln( + '- Se a pergunta do aluno for vaga (ex: "ajuda"), oferece ajuda específica sobre os materiais selecionados.', + ); + } + buffer.writeln(); + + // Greeting rules (always) + buffer.writeln('IMPORTANTE - NÃO REPITAS SAUDAÇÕES:'); + buffer.writeln( + '- NUNCA começes uma resposta com "Olá", "Olá de novo", "Oi", "Bom dia", etc., a menos que seja a PRIMEIRA mensagem da conversa.', + ); + buffer.writeln( + '- Nas respostas subsequentes, vai DIRETO ao assunto sem saudar novamente.', + ); + buffer.writeln( + '- Se já saudaste o utilizador uma vez na conversa, NUNCA o faças de novo.', + ); + buffer.writeln(); + + // Complete response rules (always) + buffer.writeln('IMPORTANTE - RESPOSTAS COMPLETAS:'); + buffer.writeln('- NUNCA termines respostas com dois pontos (:).'); + buffer.writeln( + '- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".', + ); + buffer.writeln( + '- SEMPRE completa as frases e fornece a resposta completa.', + ); + buffer.writeln( + '- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.', + ); + buffer.writeln( + '- Se precisares de definir algo, fornece a definição completa.', + ); + + return buffer.toString(); + } + /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno + /// [selectedMaterialNames] — nomes dos materiais selecionados (para contextualizar a IA) + /// [disciplineName] — nome da disciplina (ex: "Matemática A", "Português") + /// [isMathematics] — true se for matemática ou disciplina quantitativa static Future ask( String userQuery, { List? selectedMaterialIds, + List? selectedMaterialNames, + String? disciplineName, + bool isMathematics = false, }) async { Logger.info('USING RAG AI SERVICE'); @@ -872,50 +1014,21 @@ ans é o índice (0-3) da opção correcta.'''; List> messages = []; // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) - messages.add({ - 'role': 'system', - 'content': - r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa. + final systemPrompt = _buildSystemPrompt( + selectedMaterialNames: selectedMaterialNames, + disciplineName: disciplineName, + isMathematics: isMathematics, + ); + messages.add({'role': 'system', 'content': systemPrompt}); -Nunca referes o nome do modelo de linguagem. -Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo. -Respondes sempre como o Vico. - -Tens personalidade simpática, confiante e motivadora. -Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável. - -IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas. -Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √). - -REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS: -- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. -- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares. -- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." -- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade. - -IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS: -- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais. -- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver". -- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados. -- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados. -- Analisa os materiais selecionados e oferece ajuda proativamente: "Vejo que selecionaste [nome do material]. Como posso ajudar com este conteúdo?" -- Se a pergunta do aluno for vaga (ex: "ajuda"), usa os materiais selecionados para oferecer ajuda específica sobre o conteúdo. - -IMPORTANTE - RESPOSTAS COMPLETAS: -- NUNCA termines respostas com dois pontos (:). -- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ". -- SEMPRE completa as frases e fornece a resposta completa. -- Se precisares de explicar um cálculo, explica-o completamente com o resultado final. -- Se precisares de definir algo, fornece a definição completa.''', - }); - - // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) + // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 10 para manter contexto) final conversationId = ChatMemoryService.currentConversationId; + var lastHistoryMessageIsDuplicate = false; if (conversationId != null) { final conversationHistory = await ChatMemoryService.getConversationMessages( conversationId: conversationId, - limit: 4, + limit: 10, ); for (final msg in conversationHistory) { messages.add({ @@ -923,6 +1036,17 @@ IMPORTANTE - RESPOSTAS COMPLETAS: 'content': msg['content'] as String, }); } + // Verificar se a última mensagem do histórico já é a pergunta atual + // (evita duplicação quando a UI guardou a mensagem antes de chamar ask()) + if (conversationHistory.isNotEmpty) { + final lastMsg = conversationHistory.last; + if (lastMsg['role'] == 'user' && lastMsg['content'] == userQuery) { + lastHistoryMessageIsDuplicate = true; + Logger.info( + 'Last history message matches current query — skipping duplicate user message', + ); + } + } } // Log de confirmação de ordem do histórico @@ -934,8 +1058,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS: // Small talk: skip PDF lookup entirely and go straight to model if (_isSmallTalk(userQuery)) { Logger.info('Small talk detected — skipping PDF lookup'); - messages.add({'role': 'user', 'content': userQuery}); - await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + if (!lastHistoryMessageIsDuplicate) { + messages.add({'role': 'user', 'content': userQuery}); + } + // Only save to Firestore if not already saved by UI + if (!lastHistoryMessageIsDuplicate) { + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + } final response = await _callOllamaAPIWithMessages(messages); await ChatMemoryService.saveMessage(role: 'assistant', content: response); return response; @@ -963,8 +1092,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS: } if (pdfContext.isEmpty) { // Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo - messages.add({'role': 'user', 'content': userQuery}); - await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + if (!lastHistoryMessageIsDuplicate) { + messages.add({'role': 'user', 'content': userQuery}); + } + // Only save to Firestore if not already saved by UI + if (!lastHistoryMessageIsDuplicate) { + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + } final response = await _callOllamaAPIWithMessages(messages); await ChatMemoryService.saveMessage(role: 'assistant', content: response); return response; @@ -979,14 +1113,18 @@ $pdfContext Pergunta: $userQuery''' : userQuery; - messages.add({'role': 'user', 'content': userContent}); + if (!lastHistoryMessageIsDuplicate) { + messages.add({'role': 'user', 'content': userContent}); + } Logger.info( 'USING RAG AI SERVICE - Built messages array with ${messages.length} messages', ); - // Save user message to Firestore - await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + // Only save to Firestore if not already saved by UI + if (!lastHistoryMessageIsDuplicate) { + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + } // Call API final response = await _callOllamaAPIWithMessages(messages); diff --git a/lib/features/ai_tutor/presentation/pages/chat_history_page.dart b/lib/features/ai_tutor/presentation/pages/chat_history_page.dart index 0c3f590..1784463 100644 --- a/lib/features/ai_tutor/presentation/pages/chat_history_page.dart +++ b/lib/features/ai_tutor/presentation/pages/chat_history_page.dart @@ -14,12 +14,24 @@ class ChatHistoryPage extends StatefulWidget { class _ChatHistoryPageState extends State { List> _conversations = []; + List> _filteredConversations = []; bool _isLoading = true; + final TextEditingController _searchController = TextEditingController(); + String _selectedDateFilter = 'all'; // all, today, yesterday, week, month + final Set _selectedConversationIds = {}; + bool _isSelectionMode = false; @override void initState() { super.initState(); _loadConversations(); + _searchController.addListener(_filterConversations); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } Future _loadConversations() async { @@ -29,6 +41,7 @@ class _ChatHistoryPageState extends State { if (mounted) { setState(() { _conversations = conversations; + _filteredConversations = conversations; _isLoading = false; }); } @@ -40,6 +53,136 @@ class _ChatHistoryPageState extends State { } } + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedConversationIds.clear(); + } + }); + } + + void _toggleConversationSelection(String conversationId) { + setState(() { + if (_selectedConversationIds.contains(conversationId)) { + _selectedConversationIds.remove(conversationId); + } else { + _selectedConversationIds.add(conversationId); + } + }); + } + + Future _deleteSelectedConversations() async { + if (_selectedConversationIds.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Eliminar ${_selectedConversationIds.length} conversas'), + content: const Text('Tem certeza que deseja eliminar estas conversas?'), + 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 { + for (final id in _selectedConversationIds) { + await ChatMemoryService.deleteConversation(id); + } + await _loadConversations(); + setState(() { + _selectedConversationIds.clear(); + _isSelectionMode = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.delete_outline, color: Colors.white), + const SizedBox(width: 12), + const Text('Conversas eliminadas'), + ], + ), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } + } catch (e) { + Logger.error('Error deleting conversations: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + const Text('Erro ao eliminar conversas'), + ], + ), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } + } + } + + void _filterConversations() { + final searchQuery = _searchController.text.toLowerCase(); + setState(() { + _filteredConversations = _conversations.where((conv) { + // Filter by search query (title) + final title = (conv['title'] as String? ?? '').toLowerCase(); + final matchesSearch = title.contains(searchQuery); + + // Filter by date + final matchesDate = _matchesDateFilter(conv); + + return matchesSearch && matchesDate; + }).toList(); + }); + } + + bool _matchesDateFilter(Map conv) { + final updatedAt = conv['updatedAt'] as Timestamp?; + if (updatedAt == null) return true; + final date = updatedAt.toDate(); + final now = DateTime.now(); + final difference = now.difference(date); + + switch (_selectedDateFilter) { + case 'today': + return difference.inDays == 0; + case 'yesterday': + return difference.inDays == 1; + case 'week': + return difference.inDays < 7; + case 'month': + return difference.inDays < 30; + default: + return true; + } + } + Future _deleteConversation(String conversationId) async { final confirmed = await showDialog( context: context, @@ -66,15 +209,41 @@ class _ChatHistoryPageState extends State { await ChatMemoryService.deleteConversation(conversationId); await _loadConversations(); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Conversa eliminada'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.delete_outline, color: Colors.white), + const SizedBox(width: 12), + const Text('Conversa eliminada'), + ], + ), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); } } catch (e) { Logger.error('Error deleting conversation: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Erro ao eliminar conversa')), + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + const Text('Erro ao eliminar conversa'), + ], + ), + backgroundColor: Colors.orange, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ); } } @@ -111,7 +280,9 @@ class _ChatHistoryPageState extends State { onPressed: () => context.go('/student-dashboard'), ), title: Text( - 'Histórico de Conversas', + _isSelectionMode + ? '${_selectedConversationIds.length} selecionadas' + : 'Histórico de Conversas', style: TextStyle( color: cs.onSurface, fontSize: 18, @@ -119,55 +290,157 @@ class _ChatHistoryPageState extends State { ), ), actions: [ - IconButton( - icon: Icon(Icons.refresh, color: cs.onSurface), - onPressed: _loadConversations, + if (_isSelectionMode) + IconButton( + icon: Icon(Icons.delete, color: Colors.red), + onPressed: _selectedConversationIds.isEmpty + ? null + : _deleteSelectedConversations, + ) + else + IconButton( + icon: Icon(Icons.checklist, color: cs.onSurface), + onPressed: _toggleSelectionMode, + ), + if (!_isSelectionMode) + IconButton( + icon: Icon(Icons.refresh, color: cs.onSurface), + onPressed: _loadConversations, + ), + if (_isSelectionMode) + IconButton( + icon: Icon(Icons.close, color: cs.onSurface), + onPressed: _toggleSelectionMode, + ), + ], + ), + body: Column( + children: [ + // Search and filters + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Search bar + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Pesquisar conversas...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: cs.outline.withValues(alpha: 0.3), + ), + ), + filled: true, + fillColor: cs.surface, + ), + ), + const SizedBox(height: 12), + // Date filter chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildDateFilterChip('Todas', 'all', cs), + const SizedBox(width: 8), + _buildDateFilterChip('Hoje', 'today', cs), + const SizedBox(width: 8), + _buildDateFilterChip('Ontem', 'yesterday', cs), + const SizedBox(width: 8), + _buildDateFilterChip('Última semana', 'week', cs), + const SizedBox(width: 8), + _buildDateFilterChip('Último mês', 'month', cs), + ], + ), + ), + ], + ), + ), + // Conversation list + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredConversations.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( + _conversations.isEmpty + ? 'Sem conversas ainda' + : 'Nenhuma conversa encontrada', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + _conversations.isEmpty + ? 'Começa uma conversa com o Vico!' + : 'Tenta ajustar os filtros', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 14, + ), + ), + ], + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _filteredConversations.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final conversation = _filteredConversations[index]; + return _buildConversationCard(conversation, cs); + }, + ), ), ], ), - 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 _buildDateFilterChip(String label, String value, ColorScheme cs) { + final isSelected = _selectedDateFilter == value; + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedDateFilter = value; + }); + _filterConversations(); + }, + backgroundColor: cs.surface, + selectedColor: cs.primary.withValues(alpha: 0.2), + labelStyle: TextStyle( + color: isSelected ? cs.primary : cs.onSurface, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isSelected ? cs.primary : cs.outline.withValues(alpha: 0.3), + ), ); } @@ -175,10 +448,16 @@ class _ChatHistoryPageState extends State { Map conversation, ColorScheme cs, ) { + final isSelected = _selectedConversationIds.contains(conversation['id']); + return Dismissible( key: Key(conversation['id']), - direction: DismissDirection.endToStart, - onDismissed: (_) => _deleteConversation(conversation['id']), + direction: _isSelectionMode + ? DismissDirection.none + : DismissDirection.endToStart, + onDismissed: _isSelectionMode + ? null + : (_) => _deleteConversation(conversation['id']), background: Container( decoration: BoxDecoration( color: Colors.red, @@ -190,16 +469,24 @@ class _ChatHistoryPageState extends State { ), child: InkWell( onTap: () { - ChatMemoryService.setCurrentConversationId(conversation['id']); - context.go('/ai-tutor/${conversation['id']}'); + if (_isSelectionMode) { + _toggleConversationSelection(conversation['id']); + } else { + 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, + color: isSelected ? cs.primary.withValues(alpha: 0.1) : cs.surface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + border: Border.all( + color: isSelected + ? cs.primary + : cs.outline.withValues(alpha: 0.15), + ), boxShadow: [ BoxShadow( color: cs.shadow.withValues(alpha: 0.05), @@ -213,21 +500,34 @@ class _ChatHistoryPageState extends State { children: [ Row( children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], + if (_isSelectionMode) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Checkbox( + value: isSelected, + onChanged: (_) => + _toggleConversationSelection(conversation['id']), + ), + ) + else + 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, ), - borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.chat_bubble, - color: Colors.white, - size: 24, - ), - ), const SizedBox(width: 12), Expanded( child: Column( 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 4662354..06abc97 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 @@ -77,28 +77,55 @@ class _TutorChatPageSimpleState extends State final conversation = await ChatMemoryService.getConversation( conversationId, ); - if (conversation == null) return; + if (conversation == null) { + Logger.warning('Conversation not found: $conversationId'); + 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; - }); + // Safely extract material IDs with type checking + try { + final materialIdsRaw = conversation['selectedMaterialIds']; + if (materialIdsRaw != null && materialIdsRaw is List) { + final materialIds = (materialIdsRaw as List) + .where((item) => item is String) + .cast() + .toSet(); + if (mounted) { + setState(() { + _selectedMaterialIds = materialIds; + _materialsConfirmed = materialIds.isNotEmpty; + }); + } + } + } catch (e) { + Logger.error('Error loading material IDs: $e'); + // Continue without materials rather than crash } - // Load messages from Firestore + // Load messages from Firestore with safe casting final loadedMessages = messages.map((msg) { - return { - 'content': msg['content'] as String, - 'isUser': msg['role'] == 'user', - 'timestamp': msg['createdAt'] as Timestamp? ?? DateTime.now(), - }; + try { + return { + 'content': msg['content']?.toString() ?? '', + 'isUser': msg['role']?.toString() == 'user', + 'timestamp': msg['createdAt'] is Timestamp + ? (msg['createdAt'] as Timestamp).toDate() + : DateTime.now(), + }; + } catch (e) { + Logger.error('Error mapping message: $e'); + // Return a safe fallback message + return { + 'content': '[Mensagem indisponível]', + 'isUser': false, + 'timestamp': DateTime.now(), + }; + } }).toList(); if (mounted) { @@ -109,6 +136,12 @@ class _TutorChatPageSimpleState extends State } } catch (e) { Logger.error('Error loading conversation: $e'); + // Show error to user + if (mounted) { + setState(() { + _messages = []; + }); + } } } @@ -256,6 +289,42 @@ class _TutorChatPageSimpleState extends State size: 20, ), ), + IconButton( + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Nova conversa'), + content: const Text( + 'Isto vai limpar o chat atual e guardá-lo no histórico. Continuar?', + ), + 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('Limpar'), + ), + ], + ), + ); + if (confirmed == true) { + ChatMemoryService.setCurrentConversationId(null); + context.go('/ai-tutor'); + } + }, + icon: const Icon( + Icons.add_comment, + color: Colors.white, + size: 20, + ), + tooltip: 'Nova conversa', + ), const SizedBox(width: 4), ], ), @@ -1289,12 +1358,6 @@ class _TutorChatPageSimpleState extends State setState(() { _selectedMaterialIds = tempSelected; _materialsConfirmed = true; - if (!isFirst && selectionChanged) { - final welcome = _messages.isNotEmpty - ? [_messages.first] - : >[]; - _messages = welcome; - } }); if (isFirst) _addWelcomeMessage(); if (selectionChanged) { @@ -1319,9 +1382,10 @@ class _TutorChatPageSimpleState extends State } void _addWelcomeMessage() { + final welcomeText = + 'Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪'; final welcomeMessage = { - 'content': - '''Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪''', + 'content': welcomeText, 'isUser': false, 'timestamp': DateTime.now(), }; @@ -1330,29 +1394,81 @@ class _TutorChatPageSimpleState extends State _messages.add(welcomeMessage); }); + // Save welcome message to Firestore so the AI knows it already greeted the user + ChatMemoryService.saveMessage(role: 'assistant', content: welcomeText); + WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); } + /// Get names of selected materials + List _getSelectedMaterialNames() { + return _selectedMaterialIds.map((id) { + final material = _availableMaterials.firstWhere( + (m) => m['id'] == id, + orElse: () => {'id': id, 'name': 'Material'}, + ); + return material['name'] ?? 'Material'; + }).toList(); + } + + /// Determine discipline name from selected materials + String? _getDisciplineName() { + if (_selectedMaterialIds.isEmpty) return null; + // Find classId of first selected material + final firstId = _selectedMaterialIds.first; + final material = _availableMaterials.firstWhere( + (m) => m['id'] == firstId, + orElse: () => {}, + ); + final classId = material['classId']; + if (classId != null && _classNames.containsKey(classId)) { + return _classNames[classId]; + } + return null; + } + + /// Detect if current discipline is mathematics + bool _isMathematics() { + final discipline = _getDisciplineName(); + if (discipline == null) return false; + final lower = discipline.toLowerCase(); + final mathKeywords = [ + 'matemática', + 'matematica', + 'math', + 'álgebra', + 'algebra', + 'geometria', + 'cálculo', + 'calculo', + 'estatística', + 'estatistica', + 'física', + 'fisica', + 'química', + 'quimica', + ]; + return mathKeywords.any((k) => lower.contains(k)); + } + Future _handleSendMessage() async { if (_messageController.text.trim().isEmpty) return; 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 + // Update conversation title if it's still the default "Nova conversa" 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, - ); + if (currentId != null) { + final conversation = await ChatMemoryService.getConversation(currentId); + if (conversation != null && conversation['title'] == 'Nova conversa') { + final title = ChatMemoryService.generateConversationTitle(userMessage); + await ChatMemoryService.updateConversationTitle( + conversationId: currentId, + title: title, + ); + } } // Add user message @@ -1377,6 +1493,11 @@ class _TutorChatPageSimpleState extends State selectedMaterialIds: _selectedMaterialIds.isEmpty ? null : _selectedMaterialIds.toList(), + selectedMaterialNames: _selectedMaterialIds.isEmpty + ? null + : _getSelectedMaterialNames(), + disciplineName: _getDisciplineName(), + isMathematics: _isMathematics(), ); final preview = replyText.length > 50 @@ -1384,12 +1505,6 @@ 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,