diff --git a/lib/core/services/materials_rag_service.dart b/lib/core/services/materials_rag_service.dart index 28bff0b..4db4e84 100644 --- a/lib/core/services/materials_rag_service.dart +++ b/lib/core/services/materials_rag_service.dart @@ -86,11 +86,13 @@ class MaterialsRAGService { /// RAG CHUNK RETRIEVAL - Versão correta /// Busca chunks relevantes dos PDFs com base na query do usuário /// Se [selectedMaterialIds] for fornecido e não vazio, filtra apenas esses materiais + /// Se [filterTableData] for true, remove dados de tabelas/gráficos do conteúdo static Future getRelevantChunks({ required String userQuery, int maxMaterials = 5, int maxChunks = 5, List? selectedMaterialIds, + bool filterTableData = false, }) async { try { final user = _auth.currentUser; @@ -187,7 +189,7 @@ class MaterialsRAGService { // PDFs pequenos: enviar texto completo (formulários, notas, etc.) // PDFs grandes: keyword window search para não sobrecarregar o modelo - final String context; + String context; if (fullText.length <= 10000) { context = fullText; Logger.info( @@ -202,6 +204,13 @@ class MaterialsRAGService { context = windows.join('\n\n---\n\n'); Logger.info('Large PDF — keyword windows: ${windows.length}'); } + + // Filter table data if requested (for math subjects) + if (filterTableData) { + context = _filterTableData(context); + Logger.info('Filtered table data from content'); + } + if (context.isNotEmpty) { contextBuffer.writeln('\n[MATERIAL: $fileName]'); contextBuffer.writeln(context); @@ -409,11 +418,6 @@ class MaterialsRAGService { int maxWindows, { int windowSize = 1200, }) { - if (text.isEmpty || userQuery.isEmpty) { - // Sem query — devolver início do texto - return [text.length > windowSize ? text.substring(0, windowSize) : text]; - } - // Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars) // Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François") final properNouns = RegExp( @@ -645,4 +649,36 @@ class MaterialsRAGService { _chunksCache.clear(); Logger.info('Materials chunks cache cleared'); } + + /// Filter out table data from text (for math subjects) + /// Removes lines that look like tabular data with multiple numbers + static String _filterTableData(String text) { + final lines = text.split('\n'); + final filtered = []; + + for (final line in lines) { + final trimmed = line.trim(); + + // Skip lines that look like table data + // Pattern: multiple numbers separated by spaces/tabs + final numberPattern = RegExp(r'\d+\s+\d+'); + final matches = numberPattern.allMatches(trimmed); + + // If a line has 2+ number pairs separated by spaces, it's likely table data + if (matches.length >= 2) { + continue; + } + + // Skip lines with specific date patterns (table data) + if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) && + RegExp(r'\d+').allMatches(trimmed).length > 2) { + continue; + } + + // Keep the line + filtered.add(line); + } + + return filtered.join('\n'); + } } diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index ba6d9f9..1acfed4 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -29,7 +29,7 @@ class RAGAIService { // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', - 'content': '''Tu és "Vico", o Assistente IA oficial do Teach it. + 'content': r'''Tu és "Vico", o Assistente IA oficial do Learn It. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. @@ -37,7 +37,17 @@ Respondes sempre como o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. -Usas formatação Markdown clara e organizada.''', +Usas formatação Markdown clara e organizada. + +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², ³, ¹⁄², π, √). + +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 @@ -54,8 +64,8 @@ Usas formatação Markdown clara e organizada.''', // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) final pdfContext = await MaterialsRAGService.getRelevantChunks( userQuery: userQuery, - maxMaterials: 5, - maxChunks: 5, + maxMaterials: 10, + maxChunks: 20, ); if (pdfContext.isNotEmpty) { messages.add({ @@ -178,7 +188,7 @@ Usas formatação Markdown clara e organizada.''', /// System message for Vico identity (for legacy calls) static const String _systemMessage = - '''Tu és "Vico", o Assistente IA oficial do Teach it. + r'''Tu és "Vico", o Assistente IA oficial do Learn It. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. @@ -186,7 +196,17 @@ Respondes sempre como o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. -Usas formatação clara e organizada.'''; +Usas formatação clara e organizada. + +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², ³, ¹⁄², π, √). + +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.'''; /// Call Ollama API with complete messages array static Future _callOllamaAPIWithMessages( @@ -215,7 +235,10 @@ Usas formatação clara e organizada.'''; if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final message = responseData['message']; - final content = message?['content'] ?? ''; + var content = message?['content'] ?? ''; + + // Post-process to remove LaTeX symbols + content = _removeLaTeXSymbols(content); Logger.info('Ollama API response received'); return content.trim(); @@ -431,15 +454,8 @@ Usas formatação clara e organizada.'''; final response = await http.get(url).timeout(Duration(seconds: 10)); if (response.statusCode == 200) { - final responseData = jsonDecode(response.body); - final models = responseData['models'] as List? ?? []; - - final hasModel = models.any( - (model) => (model['name'] as String? ?? '').contains('qwen3-coder'), - ); - - Logger.info('Ollama service available, model found: $hasModel'); - return hasModel; + Logger.info('Ollama service available'); + return true; } else { Logger.warning( 'Ollama service returned status: ${response.statusCode}', @@ -477,18 +493,228 @@ Usas formatação clara e organizada.'''; } } + /// Remove LaTeX symbols from AI response + static String _removeLaTeXSymbols(String text) { + // Remove patterns like $$...$$ (display math) + var cleaned = text.replaceAll(RegExp(r'\$\$[^$]*\$\$'), ''); + + // Remove patterns like $...$ (inline math) - be more careful + // Only remove when properly closed + cleaned = cleaned.replaceAllMapped( + RegExp(r'\$[^$\n]+?\$'), + (match) => match.group(0)!.replaceAll(r'$', ''), + ); + + // Remove any remaining standalone $ symbols + cleaned = cleaned.replaceAll(RegExp(r'(? generateQuiz(String prompt) async { + static Future generateQuiz( + String prompt, { + bool isMathematics = false, + }) async { + final systemPrompt = isMathematics + ? _getMathematicsSystemPrompt() + : _getTextBasedSystemPrompt(); + final messages = >[ - { - 'role': 'system', - 'content': - 'És um assistente educativo especializado em criar quizzes pedagógicos. ' - 'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.', - }, + {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': prompt}, ]; - return await _callOllamaAPIWithMessages(messages); + final raw = await _callOllamaAPIWithMessages(messages); + + // Filter out table questions for mathematics + if (isMathematics) { + return _filterTableQuestions(raw); + } + + return raw; + } + + /// Filter out questions that reference tables, graphs, or specific dates + static String _filterTableQuestions(String json) { + try { + final List questions = jsonDecode(json); + final List> filtered = []; + + // Keywords that indicate table/graph dependence + final tableKeywords = [ + 'tabela', + 'gráfico', + 'dia 1/', + 'início de', + 'final de', + 'tendência', + 'evolução', + 'ao longo do tempo', + 'percentagem no início', + 'percentagem no final', + 'ano foi de', + 'dia específico', + 'data específica', + ]; + + for (final q in questions) { + if (q is Map) { + final questionText = (q['q'] as String? ?? '').toLowerCase(); + + // Check if question contains table keywords + final hasTableKeyword = tableKeywords.any( + (keyword) => questionText.contains(keyword.toLowerCase()), + ); + + // Skip questions with table keywords + if (!hasTableKeyword) { + filtered.add(q); + } + } + } + + // If filtered list is empty, return original to avoid empty response + if (filtered.isEmpty) { + Logger.warning( + 'All questions filtered out as table questions, returning original', + ); + return json; + } + + return jsonEncode(filtered); + } catch (e) { + Logger.error('Error filtering table questions: $e'); + return json; + } + } + + /// System prompt for mathematics quizzes + static String _getMathematicsSystemPrompt() { + return '''És um assistente educativo especializado em criar EXERCÍCIOS DE MATEMÁTICA. + +REGRAS CRÍTICAS: +1. ANALISA TODO O CONTEÚDO fornecido: + - Lê TODO o documento do início ao fim + - Identifica TODOS os tópicos e tipos de exercícios presentes + - NÃO te limites apenas aos primeiros exercícios + - Cria perguntas sobre TODOS os tópicos encontrados no documento + +2. MANTÉM o NÍVEL DE DIFICULDADE da ficha original: + - Analisa a complexidade dos exercícios na ficha + - Cria exercícios com o MESMO nível de dificuldade + - NÃO faças perguntas mais avançadas do que o que está na ficha + - Se a ficha tem exercícios simples, cria exercícios simples + - Se a ficha tem exercícios complexos, cria exercícios complexos + +3. ESTRICTAMENTE PROIBIDO criar perguntas que envolvam tabelas ou gráficos: + - NUNCA faças perguntas que dependam de dados de tabelas + - NUNCA faças perguntas que dependam de dados de gráficos + - NUNCA faças perguntas sobre "tendência" ou "evolução ao longo do tempo" + - NUNCA faças perguntas com datas específicas (ex: "dia 1/1/2017", "início de 2017") + - NUNCA faças perguntas sobre "percentagem no início" ou "percentagem no final" + - Se a ficha tem exercícios com tabelas, adapta-os para usar valores diretamente no texto + - Fornece os dados necessários diretamente no texto da pergunta + +4. Cria perguntas COMPLETAMENTE INDEPENDENTES: + - CADA pergunta deve ser respondida SEM depender de outras perguntas + - NUNCA faças referências à "pergunta anterior" ou "pergunta seguinte" + - NUNCA uses resultados de perguntas anteriores em novas perguntas + - CADA pergunta deve ter TODOS os dados necessários no seu enunciado + - O aluno deve conseguir responder a qualquer pergunta independentemente da ordem + +5. Usa VALORES DIFERENTES do conteúdo: + - Os valores numéricos nas perguntas podem ser DIFERENTES dos que estão no PDF + - Usa valores que mantenham o mesmo tipo de problema mas com números diferentes + - Exemplo: se o PDF tem "um prisma de 5cm", podes usar "um prisma de 7cm" + - O importante é manter a ESTRUTURA e TIPO de problema, não os valores exatos + +6. EXEMPLOS DE PERGUNTAS PROIBIDAS (NÃO faças estas): + - "Qual é a percentagem da área afetada pela vespa no início do dia 1/1/2017?" (PROIBIDO - depende de tabela) + - "Com o passar do tempo, a área afetada tende para:" (PROIBIDO - depende de gráfico/evolução) + - "Em que ano a produção foi de X toneladas?" (PROIBIDO - depende de tabela) + - "Usando o resultado da pergunta anterior, calcule..." (PROIBIDO - depende de pergunta anterior) + - "Considerando o valor calculado acima, determine..." (PROIBIDO - depende de pergunta anterior) + +7. EXEMPLOS DE PERGUNTAS PERMITIDAS (faz estas): + - "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" (PERMITIDO - dados diretos, independente) + - "Calcule a área de um círculo com raio 7cm." (PERMITIDO - dados diretos, independente) + - "Resolva a equação 2x + 5 = 15." (PERMITIDO - dados diretos, independente) + - "Uma esfera tem raio de 3 metros. Determine o seu volume." (PERMITIDO - dados diretos, independente) + +8. ANALISA OS EXEMPLOS DE EXERCÍCIOS no contexto fornecido: + - Identifica os TIPOS de problemas que aparecem na ficha (ex: determinar volume de sólidos, planos que decompõem prismas, equações, frações, etc.) + - Repara na COMPLEXIDADE e estrutura dos exercícios originais + - Gera exercícios NOVOS que seguem a MESMA estrutura e complexidade mas com VALORES DIFERENTES + +9. NÃO copies perguntas do conteúdo fornecido +10. Usa valores diferentes mas mantém o TIPO e ESTRUTURA do problema + +11. INCLUI TODOS OS DADOS NECESSÁRIOS no texto da pergunta: + - Fornece TODOS os valores numéricos necessários + - Exemplo: "Um prisma tem base quadrada com 5cm de lado e altura 12cm. Qual é o volume?" + - NUNCA faças perguntas que dependam de dados que não forneces no texto + +12. Na explicação, usa texto normal, SEM LaTeX: + - Escreve "a fração é 15/44" em vez de "\\frac{15}{44}" + - Escreve "raiz quadrada de 25" em vez de "\\sqrt{25}" + - Escreve "x ao quadrado" em vez de "x^2" + - Apenas usa LaTeX na própria pergunta se for estritamente necessário para a notação matemática + +13. Cria exercícios variados sobre TODOS os tópicos: + - Diferentes valores numéricos + - Diferentes contextos quando aplicável + - Mesmo TIPO e complexidade de problema matemático + - VARIADOS entre todos os tópicos do documento + +14. EXEMPLOS DE TIPOS DE EXERCÍCIOS DE MATEMÁTICA: + - Determinar volumes de sólidos (prismas, pirâmides, cilindros, cones, esferas) + - Planos que decompõem sólidos em partes geometricamente iguais + - Equações lineares e quadráticas + - Sistemas de equações + - Funções e gráficos + - Geometria analítica + - Trigonometria + - Probabilidade e estatística + - Cálculo de áreas e perímetros + - Frações e números racionais + - Potências e raízes + +FORMATO JSON: +[{"q":"Pergunta com dados completos incluídos","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação em texto normal sem LaTeX"}] +ans é o índice (0-3) da opção correcta.'''; + } + + /// System prompt for text-based subject quizzes + static String _getTextBasedSystemPrompt() { + return '''És um assistente educativo especializado em criar quizzes pedagógicos. + +REGRAS CRÍTICAS: +1. Cria perguntas de compreensão, análise e síntese +2. Baseia-te nos conceitos e temas do conteúdo +3. Evita perguntas de cópia direta +4. Foca em entender e aplicar os conceitos + +5. INCLUI CONTEXTO SUFICIENTE EM CADA PERGUNTA: + - Cada pergunta deve ser compreensível por si só + - Fornece contexto necessário sobre o assunto da pergunta + - Exemplo PROIBIDO: "Em que ano a vespa asiática afetou mais de 50% da área?" (sem contexto) + - Exemplo PERMITIDO: "De acordo com o estudo sobre a vespa asiática em Portugal, em que ano esta espécie afetou mais de 50% da área de distribuição?" + - Exemplo PROIBIDO: "Qual foi a principal causa?" (sem contexto) + - Exemplo PERMITIDO: "Qual foi a principal causa da extinção do dodo, segundo o texto?" + +6. EVITA perguntas que dependam de dados específicos não mencionados: + - NUNCA faças perguntas sobre "ano X" ou "data X" sem especificar de que ano/data se trata + - NUNCA faças perguntas sobre "porcentagem X" sem explicar o contexto + - NUNCA faças perguntas que dependam de tabelas ou gráficos + +7. EXEMPLOS DE PERGUNTAS BEM FORMULADAS: + - "De acordo com o texto sobre a vespa asiática, qual é o principal impacto desta espécie na biodiversidade portuguesa?" + - "O estudo sobre a mudança climática menciona que a temperatura média aumentou. Qual foi a principal causa mencionada?" + - "Segundo o documento sobre a história de Portugal, qual foi o resultado da Batalha de Aljubarrota?" + +FORMATO JSON: +[{"q":"Pergunta com contexto suficiente","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação"}] +ans é o índice (0-3) da opção correcta.'''; } /// Test the service with a simple query @@ -644,7 +870,7 @@ Usas formatação clara e organizada.'''; messages.add({ 'role': 'system', 'content': - '''Tu és "Vico", o Assistente IA oficial do Teach it — uma plataforma educativa portuguesa. + r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa. Nunca referes o nome do modelo de linguagem. Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo. @@ -653,11 +879,29 @@ 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.''', +- 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) @@ -696,8 +940,8 @@ REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS: } else { pdfContext = await MaterialsRAGService.getRelevantChunks( userQuery: userQuery, - maxMaterials: 5, - maxChunks: 5, + maxMaterials: 10, + maxChunks: 20, selectedMaterialIds: selectedMaterialIds, ); if (pdfContext.isNotEmpty) { diff --git a/lib/core/services/rag_service.dart b/lib/core/services/rag_service.dart index 3f4bde2..4b61279 100644 --- a/lib/core/services/rag_service.dart +++ b/lib/core/services/rag_service.dart @@ -63,7 +63,7 @@ class RAGService { /// System message for Vico identity - ALWAYS first in every conversation static const String _systemMessage = - '''Tu és "Vico", o Assistente IA oficial do Teach it. + '''Tu és "Vico", o Assistente IA oficial do Learn It. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. @@ -71,7 +71,17 @@ Respondes sempre como o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. -Usas formatação clara e organizada.'''; +Usas formatação clara e organizada. + +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², ³, ¹⁄², π, √). + +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.'''; /// Process a user query through RAG pipeline static Future processQuery({ @@ -89,6 +99,10 @@ Usas formatação clara e organizada.'''; 'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode', ); + // Detect if subject is math + final isMathSubject = _isMathSubject(subject); + Logger.info('Subject: $subject, Is math: $isMathSubject'); + // 1. Generate embedding for user query final queryEmbedding = VectorService.generateEmbedding(userQuery); @@ -109,8 +123,13 @@ Usas formatação clara e organizada.'''; return _createNoContentResponse(userQuery, mode); } - // 3. Build context window - final context = _buildContextWindow(relevantChunks, userQuery, mode); + // 3. Build context window with math-specific filtering + final context = _buildContextWindow( + relevantChunks, + userQuery, + mode, + isMathSubject: isMathSubject, + ); // 4. Generate response (this will be handled by RAGAIService) final response = await _generateResponse( @@ -134,8 +153,9 @@ Usas formatação clara e organizada.'''; static String _buildContextWindow( List chunks, String userQuery, - TutorMode mode, - ) { + TutorMode mode, { + bool isMathSubject = false, + }) { try { final contextBuilder = StringBuffer(); @@ -147,6 +167,13 @@ Usas formatação clara e organizada.'''; for (int i = 0; i < sortedChunks.length; i++) { final chunk = sortedChunks[i]; + var chunkText = chunk.text; + + // Filter table data for math subjects + if (isMathSubject) { + chunkText = _filterTableData(chunkText); + } + contextBuilder.writeln('--- Fonte ${i + 1} ---'); contextBuilder.writeln('Disciplina: ${chunk.subject}'); contextBuilder.writeln('Conceito: ${chunk.concept}'); @@ -158,12 +185,30 @@ Usas formatação clara e organizada.'''; if (chunk.pageNumber != null) { contextBuilder.writeln('Página: ${chunk.pageNumber}'); } - contextBuilder.writeln('\nConteúdo:\n${chunk.text}\n'); + contextBuilder.writeln('\nConteúdo:\n$chunkText\n'); } // Add mode-specific instructions contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ==='); contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}'); + + // Add math-specific instructions if applicable + if (isMathSubject) { + contextBuilder.writeln('\n=== INSTRUÇÕES PARA MATEMÁTICA ==='); + contextBuilder.writeln('Para notações matemáticas:'); + contextBuilder.writeln( + r'- NUNCA use LaTeX ou símbolos como $ ou $$ para fórmulas', + ); + contextBuilder.writeln( + '- Use apenas texto normal e caracteres Unicode (ex: x², ³, ¹⁄², π, √)', + ); + contextBuilder.writeln( + '- Preserve a notação matemática original quando possível', + ); + contextBuilder.writeln('- Explique passo a passo os cálculos'); + contextBuilder.writeln('- Use exemplos numéricos concretos'); + } + contextBuilder.writeln('Pergunta do Aluno: $userQuery\n'); final contextText = contextBuilder.toString(); @@ -241,7 +286,11 @@ Usas formatação clara e organizada.'''; if (response.statusCode == 200) { final responseData = jsonDecode(response.body); - final answer = responseData['message']['content'] as String; + var answer = responseData['message']['content'] as String; + + // Post-process to remove LaTeX symbols + answer = _removeLaTeXSymbols(answer); + final confidence = _calculateConfidence(sources); final relatedConcepts = _extractRelatedConcepts(sources); @@ -344,7 +393,9 @@ $query - Use linguagem clara e educacional - Adapte a resposta ao nível do aluno - Forneça exemplos quando possível -- Seja conciso mas completo'''; +- Seja conciso mas completo +- NUNCA use LaTeX ou símbolos como \$ ou \$\$ para fórmulas +- Use apenas texto normal e caracteres Unicode para símbolos matemáticos'''; } /// Calculate relevance score @@ -408,6 +459,77 @@ $query return concepts.toList()..sort(); } + /// Detect if subject is math-related + static bool _isMathSubject(String? subject) { + if (subject == null) return false; + final lowerSubject = subject.toLowerCase(); + + final mathKeywords = [ + 'matemática', + 'math', + 'matematica', + 'álgebra', + 'algebra', + 'geometria', + 'cálculo', + 'calculo', + 'estatística', + 'estatistica', + 'funções', + 'funcoes', + 'equações', + 'equacoes', + 'números', + 'numeros', + ]; + + return mathKeywords.any((keyword) => lowerSubject.contains(keyword)); + } + + /// Filter out table data from text (for math subjects) + /// Removes lines that look like tabular data with multiple numbers + static String _filterTableData(String text) { + final lines = text.split('\n'); + final filtered = []; + + for (final line in lines) { + final trimmed = line.trim(); + + // Skip lines that look like table data + // Pattern: multiple numbers separated by spaces/tabs + final numberPattern = RegExp(r'\d+\s+\d+'); + final matches = numberPattern.allMatches(trimmed); + + // If a line has 2+ number pairs separated by spaces, it's likely table data + if (matches.length >= 2) { + continue; + } + + // Skip lines with specific date patterns (table data) + if (RegExp(r'\d{1,2}/\d{1,2}/\d{4}').hasMatch(trimmed) && + RegExp(r'\d+').allMatches(trimmed).length > 2) { + continue; + } + + // Keep the line + filtered.add(line); + } + + return filtered.join('\n'); + } + + /// Remove LaTeX symbols from AI response + static String _removeLaTeXSymbols(String text) { + // Remove patterns like $...$ and $$...$$ + var cleaned = text.replaceAll(RegExp(r'\$\$[^$]+\$\$'), ''); + cleaned = cleaned.replaceAll(RegExp(r'\$[^$]+\$'), ''); + + // Also remove standalone $ symbols + cleaned = cleaned.replaceAll(r'$', r'\$'); + + return cleaned; + } + /// Create response for no content found static RAGResponse _createNoContentResponse(String query, TutorMode mode) { return RAGResponse( diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart index ab5f6f6..bdbb25e 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart @@ -328,7 +328,7 @@ class _TutorChatPageState extends State void _addWelcomeMessage() { final welcomeMessage = { 'content': - '''**Olá! Sou o Vico, o teu Assistente IA oficial do Teach it.** + '''**Olá! Sou o Vico, o teu Assistente IA oficial do Learn It.** Estou aqui para te ajudar a aprender de forma confiante e motivadora! diff --git a/lib/features/classes/presentation/pages/join_class_page.dart b/lib/features/classes/presentation/pages/join_class_page.dart index e0927e7..840f1c0 100644 --- a/lib/features/classes/presentation/pages/join_class_page.dart +++ b/lib/features/classes/presentation/pages/join_class_page.dart @@ -13,22 +13,30 @@ class JoinClassPage extends ConsumerStatefulWidget { class _JoinClassPageState extends ConsumerState { final _codeController = TextEditingController(); + final _nameController = TextEditingController(); bool _isLoading = false; @override void dispose() { _codeController.dispose(); + _nameController.dispose(); super.dispose(); } Future _joinClass() async { final code = _codeController.text.trim().toUpperCase(); + final customName = _nameController.text.trim(); if (code.isEmpty) { _showError('Insere o código da disciplina'); return; } + if (customName.isEmpty) { + _showError('Insere o nome da disciplina'); + return; + } + setState(() => _isLoading = true); try { @@ -105,6 +113,7 @@ class _JoinClassPageState extends ConsumerState { currentUser.displayName ?? currentUser.email?.split('@')[0] ?? 'Aluno', + 'customClassName': customName, 'joinedAt': FieldValue.serverTimestamp(), }); @@ -201,7 +210,7 @@ class _JoinClassPageState extends ConsumerState { ), Expanded( child: Text( - 'Entrar numa Disciplina', + 'Adicionar uma Disciplina', style: TextStyle( color: colorScheme.onSurface, fontSize: 18, @@ -345,7 +354,14 @@ class _JoinClassPageState extends ConsumerState { _buildInstructionItem( context, '3.', - 'Clicar em "Entrar na Disciplina" para confirmar', + 'Escrever o nome da disciplina', + colorScheme, + ), + const SizedBox(height: 8), + _buildInstructionItem( + context, + '4.', + 'Clicar em "Adicionar uma Disciplina" para confirmar', colorScheme, ), ], @@ -407,6 +423,53 @@ class _JoinClassPageState extends ConsumerState { ), ), ), + const SizedBox(height: 24), + + // Campo de nome da disciplina + Container( + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outline.withOpacity(0.3), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _nameController, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: 'Nome da disciplina', + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity( + 0.5, + ), + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(20), + prefixIcon: Icon( + Icons.edit, + color: colorScheme.primary, + size: 24, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), + ), + ), + ), const SizedBox(height: 32), // Botão de entrar @@ -445,7 +508,7 @@ class _JoinClassPageState extends ConsumerState { ), const SizedBox(width: 8), Text( - 'Entrar na Disciplina', + 'Adicionar uma Disciplina', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -540,26 +603,30 @@ class _JoinClassPageState extends ConsumerState { children: [ Icon(Icons.help_outline, color: colorScheme.primary), const SizedBox(width: 8), - Text( - 'Ajuda - Código da Disciplina', - style: TextStyle(color: colorScheme.onSurface), + Expanded( + child: Text( + 'Ajuda - Código da Disciplina', + style: TextStyle(color: colorScheme.onSurface), + ), ), ], ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'O código da disciplina é um código único de 6 caracteres que o teu professor cria para cada disciplina.', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 12), - Text( - 'Se não tens o código, contacta o teu professor.', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ], + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'O código da disciplina é um código único de 6 caracteres que o teu professor cria para cada disciplina.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + Text( + 'Se não tens o código, contacta o teu professor.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], + ), ), actions: [ TextButton( diff --git a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart index 576044b..eb93881 100644 --- a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart +++ b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart @@ -200,7 +200,7 @@ class QuickAccessWidget extends StatelessWidget { Widget _buildJoinClassCard(BuildContext context) { return DashboardActionCard( - title: 'Entrar numa Disciplina', + title: 'Adicionar uma Disciplina', subtitle: 'Junta-te a uma disciplina com o código', icon: Icons.group_add, layout: DashboardActionCardLayout.horizontal, @@ -264,7 +264,7 @@ class QuickAccessWidget extends StatelessWidget { }, ), _QuickAccessItem( - title: 'Entrar numa Disciplina', + title: 'Adicionar uma Disciplina', subtitle: 'Junta-te a uma disciplina com o código', icon: Icons.group_add, onTap: () { diff --git a/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart b/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart index 54e6858..aa9c0df 100644 --- a/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart +++ b/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart @@ -126,6 +126,7 @@ class _StudentClassesListWidgetState extends State { final enrollmentData = enrollmentDoc.data() as Map; final classId = enrollmentData['classId'] as String? ?? ''; final enrollmentId = enrollmentDoc.id; + final customClassName = enrollmentData['customClassName'] as String?; if (classId.isEmpty) { return const SizedBox.shrink(); @@ -163,11 +164,12 @@ class _StudentClassesListWidgetState extends State { } final classData = snapshot.data!.data() as Map; - final className = classData['name'] as String? ?? 'Sem nome'; + final className = + customClassName ?? (classData['name'] as String? ?? 'Sem nome'); final classCode = classData['code'] as String? ?? '----'; return GestureDetector( - onTap: () => _showRemoveClassDialog(context, enrollmentId, className), + onTap: () => _showEditNameDialog(context, enrollmentId, className), child: Container( width: 200, constraints: const BoxConstraints(minHeight: 150), @@ -187,19 +189,29 @@ class _StudentClassesListWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.school, - color: Theme.of(context).colorScheme.primary, - size: 24, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.school, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + Icon( + Icons.edit, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + ], ), const SizedBox(height: 12), Text( @@ -291,6 +303,7 @@ class _StudentClassesListWidgetState extends State { final enrollmentData = enrollmentDoc.data() as Map; final classId = enrollmentData['classId'] as String? ?? ''; final enrollmentId = enrollmentDoc.id; + final customClassName = enrollmentData['customClassName'] as String?; if (classId.isEmpty) { return const SizedBox.shrink(); @@ -312,7 +325,8 @@ class _StudentClassesListWidgetState extends State { } final classData = snapshot.data!.data() as Map; - final className = classData['name'] as String? ?? 'Sem nome'; + final className = + customClassName ?? (classData['name'] as String? ?? 'Sem nome'); final classCode = classData['code'] as String? ?? '----'; return Card( @@ -351,13 +365,30 @@ class _StudentClassesListWidgetState extends State { fontSize: 13, ), ), - trailing: Icon( - Icons.delete_outline, - color: Theme.of(context).colorScheme.error, - size: 20, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + Icons.edit, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + onPressed: () => + _showEditNameDialog(context, enrollmentId, className), + ), + IconButton( + icon: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + onPressed: () => + _showRemoveClassDialog(context, enrollmentId, className), + ), + ], ), - onTap: () => - _showRemoveClassDialog(context, enrollmentId, className), + onTap: () => _showEditNameDialog(context, enrollmentId, className), ), ); }, @@ -493,7 +524,7 @@ class _StudentClassesListWidgetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erro ao sair da disciplina: $e'), - backgroundColor: Colors.red, + backgroundColor: Theme.of(context).colorScheme.error, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -503,4 +534,49 @@ class _StudentClassesListWidgetState extends State { } } } + + void _showEditNameDialog( + BuildContext context, + String enrollmentId, + String currentName, + ) { + final controller = TextEditingController(text: currentName); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Editar nome da disciplina'), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'Nome da disciplina'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancelar'), + ), + TextButton( + onPressed: () async { + final newName = controller.text.trim(); + if (newName.isNotEmpty) { + await FirebaseFirestore.instance + .collection('enrollments') + .doc(enrollmentId) + .update({'customClassName': newName}); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nome atualizado com sucesso'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + child: const Text('Salvar'), + ), + ], + ), + ); + } } diff --git a/lib/features/materials/presentation/pages/content_management_page.dart b/lib/features/materials/presentation/pages/content_management_page.dart index 5165d98..fe1850c 100644 --- a/lib/features/materials/presentation/pages/content_management_page.dart +++ b/lib/features/materials/presentation/pages/content_management_page.dart @@ -349,7 +349,7 @@ class _ContentManagementPageState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 8), Container( decoration: BoxDecoration( color: cs.surface.withOpacity(0.8), @@ -396,13 +396,13 @@ class _ContentManagementPageState extends State { ), ), Container( - margin: const EdgeInsets.symmetric(horizontal: 16), + margin: const EdgeInsets.only(right: 16), decoration: BoxDecoration( color: cs.surface.withOpacity(0.8), borderRadius: BorderRadius.circular(16), ), child: TabBar( - isScrollable: _filteredClassIds.length > 3, + isScrollable: true, dividerColor: Colors.transparent, indicatorSize: TabBarIndicatorSize.tab, indicator: BoxDecoration( @@ -419,16 +419,18 @@ class _ContentManagementPageState extends State { fontWeight: FontWeight.normal, fontSize: 14, ), - tabs: _filteredClassIds - .map( - (classId) => Tab( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text(_classNames[classId] ?? classId), - ), + tabs: List.generate(_filteredClassIds.length, (index) { + final classId = _filteredClassIds[index]; + return Tab( + child: Padding( + padding: EdgeInsets.only( + left: index == 0 ? 4 : 12, + right: index == _filteredClassIds.length - 1 ? 4 : 12, ), - ) - .toList(), + child: Text(_classNames[classId] ?? classId), + ), + ); + }), ), ), const SizedBox(height: 16), diff --git a/lib/features/materials/presentation/pages/teacher_materials_page.dart b/lib/features/materials/presentation/pages/teacher_materials_page.dart index 18a2d27..a285e1a 100644 --- a/lib/features/materials/presentation/pages/teacher_materials_page.dart +++ b/lib/features/materials/presentation/pages/teacher_materials_page.dart @@ -157,7 +157,7 @@ class _TeacherMaterialsPageState extends State { ), if (_filteredClasses.isNotEmpty) TabBar( - isScrollable: _filteredClasses.length > 3, + isScrollable: true, indicatorColor: const Color(0xFFF68D2D), labelColor: const Color(0xFFF68D2D), unselectedLabelColor: Theme.of( diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index c10fec0..378fbf6 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -45,6 +45,10 @@ class _QuizListPageState extends State // generating state String? _generatingForId; + // Multi-select for history + Set _selectedQuizIds = {}; + bool _isSelectionMode = false; + @override void initState() { super.initState(); @@ -366,16 +370,68 @@ class _QuizListPageState extends State } } + /// Detect if a material is mathematics-based + bool _isMathematicsSubject(Map material) { + final matName = (material['name'] ?? '').toLowerCase(); + final classId = material['classId']; + String className = ''; + + // Get class name if classId is available + if (classId != null && _materialClassNames.containsKey(classId)) { + className = _materialClassNames[classId]!.toLowerCase(); + } + + // Keywords for mathematics + final mathKeywords = [ + 'matemática', + 'math', + 'álgebra', + 'geometria', + 'cálculo', + 'estatística', + 'trigonometria', + 'função', + 'equação', + 'fração', + 'raiz', + 'potência', + 'derivada', + 'integral', + 'número', + 'gráfico', + 'fórmula', + 'matriz', + 'vetor', + 'probabilidade', + 'percentagem', + 'ângulo', + 'triângulo', + 'quadrado', + 'círculo', + 'volume', + 'área', + 'perímetro', + ]; + + // Check if material name or class name contains math keywords + final combinedText = '$matName $className'; + return mathKeywords.any((keyword) => combinedText.contains(keyword)); + } + Future _generateQuiz(Map material) async { setState(() => _generatingForId = material['id']); try { final matId = material['id']!; final matName = material['name'] ?? 'Material'; + final isMathematics = _isMathematicsSubject(material); // Buscar contexto do PDF final context = await MaterialsRAGService.getRelevantChunks( - userQuery: 'conteúdo geral resumo tópicos principais', + userQuery: 'todos os exercícios todos os tópicos completo', selectedMaterialIds: [matId], + maxChunks: 20, // Aumentar para cobrir todo o documento + filterTableData: + isMathematics, // Filtrar dados de tabela para matemática ); if (context.isEmpty) { @@ -384,7 +440,10 @@ class _QuizListPageState extends State } // Gerar quiz via Ollama em formato JSON estruturado - final numQuestions = 5 + Random().nextInt(16); // 5..20 + final numQuestions = isMathematics + ? 10 + + Random().nextInt(11) // 10..20 para matemática + : 5 + Random().nextInt(16); // 5..20 para outras matérias final prompt = 'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n' '$context\n\n' @@ -394,7 +453,10 @@ class _QuizListPageState extends State '[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...]\n' 'ans é o índice (0-3) da opção correcta.'; - final raw = await RAGAIService.generateQuiz(prompt); + final raw = await RAGAIService.generateQuiz( + prompt, + isMathematics: isMathematics, + ); final questions = _parseQuizJson(raw); if (questions.isEmpty) { @@ -704,6 +766,9 @@ class _QuizListPageState extends State quizId: quizId, questions: questions, materialName: name, + onQuizCompleted: () { + _loadHistory(); + }, ), ); } @@ -798,6 +863,9 @@ class _QuizListPageState extends State title: title, questions: questions, historyDocId: historyDocId, + onQuizCompleted: () { + _loadHistory(); + }, ), ); } @@ -881,13 +949,17 @@ class _QuizListPageState extends State ); } - void _showQuizFromHistory(String title, String rawJson) { + void _showQuizFromHistory( + String title, + String rawJson, { + String? historyDocId, + }) { final questions = _parseQuizJson(rawJson); if (questions.isEmpty) { _showSnack('Não foi possível carregar este quiz.'); return; } - _showInteractiveQuiz(title, questions); + _showInteractiveQuiz(title, questions, historyDocId: historyDocId); } void _showSnack(String msg) { @@ -1760,11 +1832,152 @@ class _QuizListPageState extends State return groups; } + List> _getAiGeneratedQuizzes() { + return _history.where((q) => q['teacherQuizId'] == null).toList(); + } + + List> _getTeacherQuizzes() { + return _history.where((q) => q['teacherQuizId'] != null).toList(); + } + + Future _deleteQuizFromHistory(Map item) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Eliminar Quiz'), + content: Text( + 'Tem certeza que deseja eliminar "${item['title'] ?? 'Quiz'}"?', + ), + 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 { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(user.uid) + .collection('quizzes') + .doc(item['id']) + .delete(); + + setState(() { + _history.removeWhere((q) => q['id'] == item['id']); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Quiz eliminado com sucesso!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + Logger.error('Error deleting quiz: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao eliminar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _deleteSelectedQuizzes() async { + if (_selectedQuizIds.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Eliminar Quizzes'), + content: Text( + 'Tem certeza que deseja eliminar ${_selectedQuizIds.length} quiz${_selectedQuizIds.length != 1 ? 'zes' : ''}?', + ), + 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 { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + final batch = FirebaseFirestore.instance.batch(); + for (final quizId in _selectedQuizIds) { + final ref = FirebaseFirestore.instance + .collection('quizHistory') + .doc(user.uid) + .collection('quizzes') + .doc(quizId); + batch.delete(ref); + } + + await batch.commit(); + + setState(() { + _history.removeWhere((q) => _selectedQuizIds.contains(q['id'])); + _selectedQuizIds.clear(); + _isSelectionMode = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Quizzes eliminados com sucesso!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + Logger.error('Error deleting quizzes: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao eliminar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + Widget _buildHistoryTab(ColorScheme cs) { if (_loadingHistory) { return const Center(child: CircularProgressIndicator()); } - if (_history.isEmpty) { + + final aiQuizzes = _getAiGeneratedQuizzes(); + final teacherQuizzes = _getTeacherQuizzes(); + + if (aiQuizzes.isEmpty && teacherQuizzes.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -1788,130 +2001,263 @@ class _QuizListPageState extends State ); } + return DefaultTabController( + length: 2, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Histórico', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + IconButton( + icon: Icon( + _isSelectionMode ? Icons.close : Icons.checklist, + color: cs.onSurface, + ), + onPressed: () { + setState(() { + _isSelectionMode = !_isSelectionMode; + _selectedQuizIds.clear(); + }); + }, + tooltip: _isSelectionMode + ? 'Cancelar seleção' + : 'Selecionar múltiplos', + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TabBar( + labelColor: cs.onPrimary, + unselectedLabelColor: cs.onSurfaceVariant, + indicator: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(20), + ), + tabs: const [ + Tab(text: 'Gerados por IA'), + Tab(text: 'Do Professor'), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: TabBarView( + children: [ + _buildHistorySection(cs, aiQuizzes, isTeacherQuizzes: false), + _buildHistorySection( + cs, + teacherQuizzes, + isTeacherQuizzes: true, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHistorySection( + ColorScheme cs, + List> items, { + required bool isTeacherQuizzes, + }) { + if (items.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.quiz_outlined, + size: 64, + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: 16), + Text( + isTeacherQuizzes + ? 'Sem quizzes do professor' + : 'Sem quizzes gerados por IA', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + ), + ], + ), + ), + ); + } + + if (isTeacherQuizzes) { + return Stack( + children: [ + _buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes), + if (_isSelectionMode && _selectedQuizIds.isNotEmpty) + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.extended( + onPressed: _deleteSelectedQuizzes, + backgroundColor: Colors.red, + icon: const Icon(Icons.delete), + label: Text('Eliminar (${_selectedQuizIds.length})'), + ), + ), + ], + ); + } + final groups = _groupHistoryByDiscipline(); + final filteredItems = items.where((item) { + final cid = item['classId'] as String?; + return groups.containsKey(cid) && cid != null; + }).toList(); + + if (filteredItems.isEmpty) { + return Stack( + children: [ + _buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes), + if (_isSelectionMode && _selectedQuizIds.isNotEmpty) + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.extended( + onPressed: _deleteSelectedQuizzes, + backgroundColor: Colors.red, + icon: const Icon(Icons.delete), + label: Text('Eliminar (${_selectedQuizIds.length})'), + ), + ), + ], + ); + } + final realDisciplineIds = groups.keys .where((k) => k != '__geral__' && _historyClassNames.containsKey(k)) .toList(); if (realDisciplineIds.isEmpty) { - return _buildHistoryList(cs, _history); - } - - if (_selectedHistoryDisciplineId != null) { - final items = groups[_selectedHistoryDisciplineId] ?? []; - final dName = - _historyClassNames[_selectedHistoryDisciplineId] ?? - _selectedHistoryDisciplineId!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Stack( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: cs.onSurface), - onPressed: () => - setState(() => _selectedHistoryDisciplineId = null), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - dName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: cs.onSurface, - ), - ), - ), - Text( - '${items.length} quiz${items.length != 1 ? 'zes' : ''}', - style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), - ), - ], + _buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes), + if (_isSelectionMode && _selectedQuizIds.isNotEmpty) + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.extended( + onPressed: _deleteSelectedQuizzes, + backgroundColor: Colors.red, + icon: const Icon(Icons.delete), + label: Text('Eliminar (${_selectedQuizIds.length})'), + ), ), - ), - const Divider(height: 1), - Expanded(child: _buildHistoryList(cs, items)), ], ); } - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: realDisciplineIds.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, i) { - final dId = realDisciplineIds[i]; - final dName = _historyClassNames[dId] ?? dId; - final count = groups[dId]!.length; - return InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => setState(() => _selectedHistoryDisciplineId = dId), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: cs.surface, + return Stack( + children: [ + ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + itemCount: realDisciplineIds.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final dId = realDisciplineIds[i]; + final dName = _historyClassNames[dId] ?? dId; + final count = groups[dId]!.length; + return InkWell( 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), + onTap: () => setState(() => _selectedHistoryDisciplineId = dId), + child: Container( + padding: const EdgeInsets.all(20), + 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: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.history_edu, color: cs.primary, size: 26), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: cs.onSurface, - ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), - const SizedBox(height: 4), - Text( - '$count quiz${count != 1 ? 'zes' : ''}', - style: TextStyle( - fontSize: 13, - color: cs.onSurfaceVariant, - ), + child: Icon(Icons.school, color: cs.primary, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '$count quiz${count != 1 ? 'zes' : ''}', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], ), - ], - ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], ), - Icon(Icons.chevron_right, color: cs.onSurfaceVariant), - ], + ), + ); + }, + ), + if (_isSelectionMode && _selectedQuizIds.isNotEmpty) + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.extended( + onPressed: _deleteSelectedQuizzes, + backgroundColor: Colors.red, + icon: const Icon(Icons.delete), + label: Text('Eliminar (${_selectedQuizIds.length})'), ), ), - ); - }, + ], ); } - Widget _buildHistoryList(ColorScheme cs, List> items) { + Widget _buildHistoryList( + ColorScheme cs, + List> items, { + bool isTeacherQuizzes = false, + }) { return ListView.separated( padding: const EdgeInsets.all(16), itemCount: items.length, @@ -1928,11 +2274,22 @@ class _QuizListPageState extends State dateStr = '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } + final quizId = item['id'] as String?; + final isSelected = + _isSelectionMode && + quizId != null && + _selectedQuizIds.contains(quizId); + return Container( decoration: BoxDecoration( color: 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), + width: isSelected ? 2 : 1, + ), boxShadow: [ BoxShadow( color: cs.shadow.withValues(alpha: 0.05), @@ -1946,15 +2303,30 @@ class _QuizListPageState extends State horizontal: 16, vertical: 8, ), - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: cs.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.quiz, color: cs.primary, size: 22), - ), + leading: _isSelectionMode + ? Checkbox( + value: isSelected, + onChanged: (_) { + setState(() { + if (quizId != null) { + if (_selectedQuizIds.contains(quizId)) { + _selectedQuizIds.remove(quizId); + } else { + _selectedQuizIds.add(quizId); + } + } + }); + }, + ) + : Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.quiz, color: cs.primary, size: 22), + ), title: Text( matName, maxLines: 2, @@ -1979,21 +2351,60 @@ class _QuizListPageState extends State ], ], ), - trailing: Icon( - Icons.chevron_right, - color: cs.onSurfaceVariant, - size: 18, - ), - onTap: () { - final rawJson = - item['quizJson'] as String? ?? item['quizText'] as String?; - if (rawJson != null && rawJson.isNotEmpty) { - _showQuizFromHistory(matName, rawJson); - } else { - // Quiz sem JSON guardado (ex: quiz do professor) — mostrar resultado - _showHistoryResultDialog(item, matName); - } - }, + trailing: _isSelectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chevron_right, + color: cs.onSurfaceVariant, + size: 18, + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.delete_outline, + color: Colors.red, + size: 20, + ), + onPressed: () => _deleteQuizFromHistory(item), + tooltip: 'Eliminar', + ), + ], + ), + onTap: _isSelectionMode + ? () { + if (quizId != null) { + setState(() { + if (_selectedQuizIds.contains(quizId)) { + _selectedQuizIds.remove(quizId); + } else { + _selectedQuizIds.add(quizId); + } + }); + } + } + : () { + if (isTeacherQuizzes) { + // Teacher quizzes: only show results, don't allow retake + _showHistoryResultDialog(item, matName); + } else { + // AI-generated quizzes: allow retake + final rawJson = + item['quizJson'] as String? ?? + item['quizText'] as String?; + if (rawJson != null && rawJson.isNotEmpty) { + _showQuizFromHistory( + matName, + rawJson, + historyDocId: item['id'] as String?, + ); + } else { + _showHistoryResultDialog(item, matName); + } + } + }, ), ); }, @@ -2022,10 +2433,12 @@ class _InteractiveQuizSheet extends StatefulWidget { final String title; final List<_QuizQuestion> questions; final String? historyDocId; + final VoidCallback? onQuizCompleted; const _InteractiveQuizSheet({ required this.title, required this.questions, this.historyDocId, + this.onQuizCompleted, }); @override @@ -2038,6 +2451,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { bool _submitted = false; bool _saving = false; DateTime? _startTime; + bool _hasSaved = false; @override void initState() { @@ -2046,6 +2460,50 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { _startTime = DateTime.now(); } + @override + void dispose() { + // Save score if quiz is closed without submitting + if (!_submitted && !_hasSaved && widget.historyDocId != null) { + _savePartialScore().then((_) { + if (widget.onQuizCompleted != null) { + widget.onQuizCompleted!(); + } + }); + } + super.dispose(); + } + + Future _savePartialScore() async { + if (_hasSaved) return; + _hasSaved = true; + + try { + final user = FirebaseAuth.instance.currentUser; + if (user != null && widget.historyDocId != null) { + // Count how many questions were answered + final answeredCount = _chosen.where((c) => c != -1).length; + + if (answeredCount > 0) { + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(user.uid) + .collection('quizzes') + .doc(widget.historyDocId) + .update({ + 'score': _score, + 'totalQuestions': widget.questions.length, + 'completedAt': FieldValue.serverTimestamp(), + }); + Logger.info( + 'Saved partial quiz score: $_score/${widget.questions.length}', + ); + } + } + } catch (e) { + Logger.error('Error saving partial quiz score: $e'); + } + } + void _selectOption(int idx) { if (_submitted) return; setState(() => _chosen[_current] = idx); @@ -2072,6 +2530,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { setState(() { _submitted = true; _saving = true; + _hasSaved = true; }); try { final user = FirebaseAuth.instance.currentUser; @@ -2106,6 +2565,11 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { } } } + + // Call callback to refresh history + if (widget.onQuizCompleted != null) { + widget.onQuizCompleted!(); + } } catch (e) { Logger.error('Error saving quiz result: $e'); } finally { @@ -2497,11 +2961,13 @@ class _TeacherQuizInteractiveSheet extends StatefulWidget { final String quizId; final List<_QuizQuestion> questions; final String? materialName; + final VoidCallback? onQuizCompleted; const _TeacherQuizInteractiveSheet({ required this.title, required this.quizId, required this.questions, this.materialName, + this.onQuizCompleted, }); @override @@ -2607,6 +3073,11 @@ class _TeacherQuizInteractiveSheetState await GamificationService.recordStudyTime(user.uid, elapsedMinutes); } } + + // Call callback to refresh history + if (widget.onQuizCompleted != null) { + widget.onQuizCompleted!(); + } } } catch (e) { Logger.error('Error submitting teacher quiz result: $e'); diff --git a/lib/features/quiz/presentation/pages/quiz_management_page.dart b/lib/features/quiz/presentation/pages/quiz_management_page.dart index a1656e8..9abffbd 100644 --- a/lib/features/quiz/presentation/pages/quiz_management_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_management_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_service.dart'; @@ -20,10 +19,14 @@ class _QuizManagementPageState extends State { bool _loading = true; String _userRole = ''; - // Disciplina seleccionada (null = vista de disciplinas) - String? _selectedDisciplineId; + // Turma seleccionada (null = vista de turmas) + String? _selectedClassId; Map _classNames = {}; // classId → name + // Search and filter + final TextEditingController _searchController = TextEditingController(); + DateTime? _selectedDate; + @override void initState() { super.initState(); @@ -31,6 +34,24 @@ class _QuizManagementPageState extends State { _loadQuizHistory(); } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _selectDate() async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() => _selectedDate = picked); + } + } + Future _loadUserRole() async { final user = AuthService.currentUser; if (user != null) { @@ -302,7 +323,7 @@ class _QuizManagementPageState extends State { return result ?? false; } - Map>> _groupByDiscipline() { + Map>> _groupByClass() { final Map>> groups = {}; for (final quiz in _quizHistory) { final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; @@ -316,15 +337,69 @@ class _QuizManagementPageState extends State { return groups; } + List> _getFilteredQuizzes() { + final searchQuery = _searchController.text.toLowerCase(); + + return _quizHistory.where((quiz) { + // Filter by date if selected + if (_selectedDate != null) { + final quizDate = quiz['createdAt'] as Timestamp?; + if (quizDate == null) return false; + final quizDateTime = quizDate.toDate(); + if (quizDateTime.year != _selectedDate!.year || + quizDateTime.month != _selectedDate!.month || + quizDateTime.day != _selectedDate!.day) { + return false; + } + } + + // Filter by search query (class name or quiz title) + if (searchQuery.isNotEmpty) { + final title = (quiz['title'] as String? ?? '').toLowerCase(); + final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; + bool matchesSearch = title.contains(searchQuery); + + for (final classId in quizClassIds) { + if (_classNames.containsKey(classId)) { + final className = _classNames[classId]!.toLowerCase(); + if (className.contains(searchQuery)) { + matchesSearch = true; + break; + } + } + } + + if (!matchesSearch) return false; + } + + return true; + }).toList(); + } + + Map>> _getFilteredGroups() { + final filteredQuizzes = _getFilteredQuizzes(); + final Map>> groups = {}; + for (final quiz in filteredQuizzes) { + final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; + String? groupId = quizClassIds.cast().firstWhere( + (cid) => cid != null && _classNames.containsKey(cid), + orElse: () => null, + ); + groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__'; + groups.putIfAbsent(groupId, () => []).add(quiz); + } + return groups; + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return PopScope( - canPop: _selectedDisciplineId == null, + canPop: _selectedClassId == null, onPopInvokedWithResult: (didPop, _) { - if (!didPop && _selectedDisciplineId != null) { - setState(() => _selectedDisciplineId = null); + if (!didPop && _selectedClassId != null) { + setState(() => _selectedClassId = null); } }, child: Scaffold( @@ -339,8 +414,8 @@ class _QuizManagementPageState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { - if (_userRole == 'teacher' && _selectedDisciplineId != null) { - setState(() => _selectedDisciplineId = null); + if (_userRole == 'teacher' && _selectedClassId != null) { + setState(() => _selectedClassId = null); return; } if (Navigator.of(context).canPop()) { @@ -354,6 +429,44 @@ class _QuizManagementPageState extends State { } }, ), + actions: [ + IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + tooltip: 'Filtrar por data', + ), + if (_selectedDate != null) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() => _selectedDate = null); + }, + tooltip: 'Limpar filtro', + ), + ], + bottom: _selectedClassId == null + ? PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Pesquisar turma...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) => setState(() {}), + ), + ), + ) + : null, ), body: Container( decoration: BoxDecoration( @@ -365,15 +478,15 @@ class _QuizManagementPageState extends State { ), child: _loading ? const Center(child: CircularProgressIndicator()) - : _quizHistory.isEmpty + : _getFilteredQuizzes().isEmpty ? _buildEmptyState() : _userRole == 'teacher' ? _buildTeacherBody(cs) : ListView.builder( padding: const EdgeInsets.all(16), - itemCount: _quizHistory.length, + itemCount: _getFilteredQuizzes().length, itemBuilder: (context, index) { - final quiz = _quizHistory[index]; + final quiz = _getFilteredQuizzes()[index]; return _buildQuizCard(quiz) .animate() .slideX(duration: const Duration(milliseconds: 300)) @@ -386,16 +499,16 @@ class _QuizManagementPageState extends State { } Widget _buildTeacherBody(ColorScheme cs) { - final groups = _groupByDiscipline(); + final groups = _searchController.text.isNotEmpty || _selectedDate != null + ? _getFilteredGroups() + : _groupByClass(); - // Vista de quizzes de uma disciplina - if (_selectedDisciplineId != null) { - final quizzes = groups[_selectedDisciplineId] ?? []; - final disciplineName = - _classNames[_selectedDisciplineId] ?? - (_selectedDisciplineId == '__geral__' - ? 'Geral' - : _selectedDisciplineId!); + // Vista de quizzes de uma turma + if (_selectedClassId != null) { + final quizzes = groups[_selectedClassId] ?? []; + final className = + _classNames[_selectedClassId] ?? + (_selectedClassId == '__geral__' ? 'Geral' : _selectedClassId!); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -405,12 +518,12 @@ class _QuizManagementPageState extends State { children: [ IconButton( icon: Icon(Icons.arrow_back, color: cs.onSurface), - onPressed: () => setState(() => _selectedDisciplineId = null), + onPressed: () => setState(() => _selectedClassId = null), ), const SizedBox(width: 4), Expanded( child: Text( - disciplineName, + className, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -445,19 +558,19 @@ class _QuizManagementPageState extends State { ); } - // Vista de disciplinas - final disciplineIds = groups.keys.toList(); + // Vista de turmas + final classIds = groups.keys.toList(); return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: disciplineIds.length, + itemCount: classIds.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { - final dId = disciplineIds[i]; - final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); - final count = groups[dId]!.length; + final cId = classIds[i]; + final cName = _classNames[cId] ?? (cId == '__geral__' ? 'Geral' : cId); + final count = groups[cId]!.length; return InkWell( borderRadius: BorderRadius.circular(16), - onTap: () => setState(() => _selectedDisciplineId = dId), + onTap: () => setState(() => _selectedClassId = cId), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -489,7 +602,7 @@ class _QuizManagementPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - dName, + cName, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -631,7 +744,7 @@ class _QuizManagementPageState extends State { ), if (_userRole == 'teacher' && quiz['classIds'] != null && - _selectedDisciplineId == null) ...[ + _selectedClassId == null) ...[ const SizedBox(height: 8), Wrap( spacing: 4, diff --git a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart index 89cee01..5b6e719 100644 --- a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart +++ b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart @@ -164,15 +164,67 @@ class _TeacherQuizPageState extends State } } + /// Detect if a material is mathematics-based + bool _isMathematicsSubject(Map material) { + final matName = (material['name'] ?? '').toLowerCase(); + final classId = material['classId']; + String className = ''; + + // Get class name if classId is available + if (classId != null && _classNamesMap.containsKey(classId)) { + className = _classNamesMap[classId]!.toLowerCase(); + } + + // Keywords for mathematics + final mathKeywords = [ + 'matemática', + 'math', + 'álgebra', + 'geometria', + 'cálculo', + 'estatística', + 'trigonometria', + 'função', + 'equação', + 'fração', + 'raiz', + 'potência', + 'derivada', + 'integral', + 'número', + 'gráfico', + 'fórmula', + 'matriz', + 'vetor', + 'probabilidade', + 'percentagem', + 'ângulo', + 'triângulo', + 'quadrado', + 'círculo', + 'volume', + 'área', + 'perímetro', + ]; + + // Check if material name or class name contains math keywords + final combinedText = '$matName $className'; + return mathKeywords.any((keyword) => combinedText.contains(keyword)); + } + Future _generateQuiz(Map material) async { setState(() => _generatingForId = material['id']); try { final matId = material['id']!; final matName = material['name'] ?? 'Material'; + final isMathematics = _isMathematicsSubject(material); final pdfContext = await MaterialsRAGService.getRelevantChunks( - userQuery: 'conteúdo geral resumo tópicos principais', + userQuery: 'todos os exercícios todos os tópicos completo', selectedMaterialIds: [matId], + maxChunks: 20, // Aumentar para cobrir todo o documento + filterTableData: + isMathematics, // Filtrar dados de tabela para matemática ); if (pdfContext.isEmpty) { @@ -180,7 +232,10 @@ class _TeacherQuizPageState extends State return; } - final numQuestions = 5 + Random().nextInt(16); // 5..20 + final numQuestions = isMathematics + ? 10 + + Random().nextInt(11) // 10..20 para matemática + : 5 + Random().nextInt(16); // 5..20 para outras matérias final prompt = 'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n' '$pdfContext\n\n' @@ -190,7 +245,10 @@ class _TeacherQuizPageState extends State '[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...}]\n' 'ans é o índice (0-3) da opção correcta.'; - final raw = await RAGAIService.generateQuiz(prompt); + final raw = await RAGAIService.generateQuiz( + prompt, + isMathematics: isMathematics, + ); final questions = _parseQuizJson(raw); if (questions.isEmpty) { @@ -358,6 +416,73 @@ class _TeacherQuizPageState extends State return groups; } + Future _deleteQuiz(String quizId, String quizTitle) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Eliminar Quiz'), + content: Text('Tem certeza que deseja eliminar o quiz "$quizTitle"?'), + 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 { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return; + + // Delete from teacherQuizzes + await FirebaseFirestore.instance + .collection('teacherQuizzes') + .doc(quizId) + .delete(); + + // Also delete from student history + final historySnapshot = await FirebaseFirestore.instance + .collection('quizHistory') + .where('quizId', isEqualTo: quizId) + .get(); + + for (final doc in historySnapshot.docs) { + await doc.reference.delete(); + } + + setState(() { + _history.removeWhere((item) => item['id'] == quizId); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Quiz eliminado com sucesso!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + Logger.error('Error deleting quiz: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erro ao eliminar: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + Widget _buildHistoryQuizTile(Map item, ColorScheme cs) { final name = (item['materialName'] as String? ?? 'Material') .replaceAll('.pdf', '') @@ -409,7 +534,19 @@ class _TeacherQuizPageState extends State style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), ) : null, - trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bar_chart, color: cs.onSurfaceVariant), + const SizedBox(width: 8), + IconButton( + icon: Icon(Icons.delete_outline, color: Colors.red), + onPressed: () => + _deleteQuiz(item['id'], item['materialName'] ?? 'Quiz'), + tooltip: 'Eliminar Quiz', + ), + ], + ), onTap: () => _showResultsPopup(item), ), );