Historico de quizzes e inicio de atualização da IA para leitura de pdfs de matemática (incompleto)
This commit is contained in:
@@ -86,11 +86,13 @@ class MaterialsRAGService {
|
|||||||
/// RAG CHUNK RETRIEVAL - Versão correta
|
/// RAG CHUNK RETRIEVAL - Versão correta
|
||||||
/// Busca chunks relevantes dos PDFs com base na query do usuário
|
/// 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 [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<String> getRelevantChunks({
|
static Future<String> getRelevantChunks({
|
||||||
required String userQuery,
|
required String userQuery,
|
||||||
int maxMaterials = 5,
|
int maxMaterials = 5,
|
||||||
int maxChunks = 5,
|
int maxChunks = 5,
|
||||||
List<String>? selectedMaterialIds,
|
List<String>? selectedMaterialIds,
|
||||||
|
bool filterTableData = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final user = _auth.currentUser;
|
final user = _auth.currentUser;
|
||||||
@@ -187,7 +189,7 @@ class MaterialsRAGService {
|
|||||||
|
|
||||||
// PDFs pequenos: enviar texto completo (formulários, notas, etc.)
|
// PDFs pequenos: enviar texto completo (formulários, notas, etc.)
|
||||||
// PDFs grandes: keyword window search para não sobrecarregar o modelo
|
// PDFs grandes: keyword window search para não sobrecarregar o modelo
|
||||||
final String context;
|
String context;
|
||||||
if (fullText.length <= 10000) {
|
if (fullText.length <= 10000) {
|
||||||
context = fullText;
|
context = fullText;
|
||||||
Logger.info(
|
Logger.info(
|
||||||
@@ -202,6 +204,13 @@ class MaterialsRAGService {
|
|||||||
context = windows.join('\n\n---\n\n');
|
context = windows.join('\n\n---\n\n');
|
||||||
Logger.info('Large PDF — keyword windows: ${windows.length}');
|
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) {
|
if (context.isNotEmpty) {
|
||||||
contextBuffer.writeln('\n[MATERIAL: $fileName]');
|
contextBuffer.writeln('\n[MATERIAL: $fileName]');
|
||||||
contextBuffer.writeln(context);
|
contextBuffer.writeln(context);
|
||||||
@@ -409,11 +418,6 @@ class MaterialsRAGService {
|
|||||||
int maxWindows, {
|
int maxWindows, {
|
||||||
int windowSize = 1200,
|
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)
|
// 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")
|
// Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François")
|
||||||
final properNouns = RegExp(
|
final properNouns = RegExp(
|
||||||
@@ -645,4 +649,36 @@ class MaterialsRAGService {
|
|||||||
_chunksCache.clear();
|
_chunksCache.clear();
|
||||||
Logger.info('Materials chunks cache cleared');
|
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 = <String>[];
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class RAGAIService {
|
|||||||
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
|
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
|
||||||
messages.add({
|
messages.add({
|
||||||
'role': 'system',
|
'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 referes o nome do modelo.
|
||||||
Nunca dizes que és Qwen ou OpenAI.
|
Nunca dizes que és Qwen ou OpenAI.
|
||||||
@@ -37,7 +37,17 @@ Respondes sempre como o Vico.
|
|||||||
|
|
||||||
Tens personalidade confiante, motivadora e orgulhosa.
|
Tens personalidade confiante, motivadora e orgulhosa.
|
||||||
Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
|
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
|
// 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)
|
// PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL)
|
||||||
final pdfContext = await MaterialsRAGService.getRelevantChunks(
|
final pdfContext = await MaterialsRAGService.getRelevantChunks(
|
||||||
userQuery: userQuery,
|
userQuery: userQuery,
|
||||||
maxMaterials: 5,
|
maxMaterials: 10,
|
||||||
maxChunks: 5,
|
maxChunks: 20,
|
||||||
);
|
);
|
||||||
if (pdfContext.isNotEmpty) {
|
if (pdfContext.isNotEmpty) {
|
||||||
messages.add({
|
messages.add({
|
||||||
@@ -178,7 +188,7 @@ Usas formatação Markdown clara e organizada.''',
|
|||||||
|
|
||||||
/// System message for Vico identity (for legacy calls)
|
/// System message for Vico identity (for legacy calls)
|
||||||
static const String _systemMessage =
|
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 referes o nome do modelo.
|
||||||
Nunca dizes que és Qwen ou OpenAI.
|
Nunca dizes que és Qwen ou OpenAI.
|
||||||
@@ -186,7 +196,17 @@ Respondes sempre como o Vico.
|
|||||||
|
|
||||||
Tens personalidade confiante, motivadora e orgulhosa.
|
Tens personalidade confiante, motivadora e orgulhosa.
|
||||||
Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
|
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
|
/// Call Ollama API with complete messages array
|
||||||
static Future<String> _callOllamaAPIWithMessages(
|
static Future<String> _callOllamaAPIWithMessages(
|
||||||
@@ -215,7 +235,10 @@ Usas formatação clara e organizada.''';
|
|||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = jsonDecode(response.body);
|
final responseData = jsonDecode(response.body);
|
||||||
final message = responseData['message'];
|
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');
|
Logger.info('Ollama API response received');
|
||||||
return content.trim();
|
return content.trim();
|
||||||
@@ -431,15 +454,8 @@ Usas formatação clara e organizada.''';
|
|||||||
final response = await http.get(url).timeout(Duration(seconds: 10));
|
final response = await http.get(url).timeout(Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = jsonDecode(response.body);
|
Logger.info('Ollama service available');
|
||||||
final models = responseData['models'] as List? ?? [];
|
return true;
|
||||||
|
|
||||||
final hasModel = models.any(
|
|
||||||
(model) => (model['name'] as String? ?? '').contains('qwen3-coder'),
|
|
||||||
);
|
|
||||||
|
|
||||||
Logger.info('Ollama service available, model found: $hasModel');
|
|
||||||
return hasModel;
|
|
||||||
} else {
|
} else {
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
'Ollama service returned status: ${response.statusCode}',
|
'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'(?<!\\)\$'), '');
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
/// Gerar quiz a partir de um prompt com contexto PDF embutido — sem histórico de conversa
|
/// Gerar quiz a partir de um prompt com contexto PDF embutido — sem histórico de conversa
|
||||||
static Future<String> generateQuiz(String prompt) async {
|
static Future<String> generateQuiz(
|
||||||
|
String prompt, {
|
||||||
|
bool isMathematics = false,
|
||||||
|
}) async {
|
||||||
|
final systemPrompt = isMathematics
|
||||||
|
? _getMathematicsSystemPrompt()
|
||||||
|
: _getTextBasedSystemPrompt();
|
||||||
|
|
||||||
final messages = <Map<String, String>>[
|
final messages = <Map<String, String>>[
|
||||||
{
|
{'role': 'system', 'content': systemPrompt},
|
||||||
'role': 'system',
|
|
||||||
'content':
|
|
||||||
'És um assistente educativo especializado em criar quizzes pedagógicos. '
|
|
||||||
'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.',
|
|
||||||
},
|
|
||||||
{'role': 'user', 'content': prompt},
|
{'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<dynamic> questions = jsonDecode(json);
|
||||||
|
final List<Map<String, dynamic>> 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<String, dynamic>) {
|
||||||
|
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
|
/// Test the service with a simple query
|
||||||
@@ -644,7 +870,7 @@ Usas formatação clara e organizada.''';
|
|||||||
messages.add({
|
messages.add({
|
||||||
'role': 'system',
|
'role': 'system',
|
||||||
'content':
|
'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 referes o nome do modelo de linguagem.
|
||||||
Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.
|
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.
|
Tens personalidade simpática, confiante e motivadora.
|
||||||
Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.
|
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:
|
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.
|
- 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.
|
- 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."
|
- 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)
|
// 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 {
|
} else {
|
||||||
pdfContext = await MaterialsRAGService.getRelevantChunks(
|
pdfContext = await MaterialsRAGService.getRelevantChunks(
|
||||||
userQuery: userQuery,
|
userQuery: userQuery,
|
||||||
maxMaterials: 5,
|
maxMaterials: 10,
|
||||||
maxChunks: 5,
|
maxChunks: 20,
|
||||||
selectedMaterialIds: selectedMaterialIds,
|
selectedMaterialIds: selectedMaterialIds,
|
||||||
);
|
);
|
||||||
if (pdfContext.isNotEmpty) {
|
if (pdfContext.isNotEmpty) {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class RAGService {
|
|||||||
|
|
||||||
/// System message for Vico identity - ALWAYS first in every conversation
|
/// System message for Vico identity - ALWAYS first in every conversation
|
||||||
static const String _systemMessage =
|
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 referes o nome do modelo.
|
||||||
Nunca dizes que és Qwen ou OpenAI.
|
Nunca dizes que és Qwen ou OpenAI.
|
||||||
@@ -71,7 +71,17 @@ Respondes sempre como o Vico.
|
|||||||
|
|
||||||
Tens personalidade confiante, motivadora e orgulhosa.
|
Tens personalidade confiante, motivadora e orgulhosa.
|
||||||
Ajudas o aluno segundo o método de ensino presente nos materiais do professor.
|
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
|
/// Process a user query through RAG pipeline
|
||||||
static Future<RAGResponse> processQuery({
|
static Future<RAGResponse> processQuery({
|
||||||
@@ -89,6 +99,10 @@ Usas formatação clara e organizada.''';
|
|||||||
'Processing RAG query: "${userQuery.substring(0, 50)}..." in ${mode.name} mode',
|
'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
|
// 1. Generate embedding for user query
|
||||||
final queryEmbedding = VectorService.generateEmbedding(userQuery);
|
final queryEmbedding = VectorService.generateEmbedding(userQuery);
|
||||||
|
|
||||||
@@ -109,8 +123,13 @@ Usas formatação clara e organizada.''';
|
|||||||
return _createNoContentResponse(userQuery, mode);
|
return _createNoContentResponse(userQuery, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Build context window
|
// 3. Build context window with math-specific filtering
|
||||||
final context = _buildContextWindow(relevantChunks, userQuery, mode);
|
final context = _buildContextWindow(
|
||||||
|
relevantChunks,
|
||||||
|
userQuery,
|
||||||
|
mode,
|
||||||
|
isMathSubject: isMathSubject,
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Generate response (this will be handled by RAGAIService)
|
// 4. Generate response (this will be handled by RAGAIService)
|
||||||
final response = await _generateResponse(
|
final response = await _generateResponse(
|
||||||
@@ -134,8 +153,9 @@ Usas formatação clara e organizada.''';
|
|||||||
static String _buildContextWindow(
|
static String _buildContextWindow(
|
||||||
List<ContentChunk> chunks,
|
List<ContentChunk> chunks,
|
||||||
String userQuery,
|
String userQuery,
|
||||||
TutorMode mode,
|
TutorMode mode, {
|
||||||
) {
|
bool isMathSubject = false,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
final contextBuilder = StringBuffer();
|
final contextBuilder = StringBuffer();
|
||||||
|
|
||||||
@@ -147,6 +167,13 @@ Usas formatação clara e organizada.''';
|
|||||||
|
|
||||||
for (int i = 0; i < sortedChunks.length; i++) {
|
for (int i = 0; i < sortedChunks.length; i++) {
|
||||||
final chunk = sortedChunks[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('--- Fonte ${i + 1} ---');
|
||||||
contextBuilder.writeln('Disciplina: ${chunk.subject}');
|
contextBuilder.writeln('Disciplina: ${chunk.subject}');
|
||||||
contextBuilder.writeln('Conceito: ${chunk.concept}');
|
contextBuilder.writeln('Conceito: ${chunk.concept}');
|
||||||
@@ -158,12 +185,30 @@ Usas formatação clara e organizada.''';
|
|||||||
if (chunk.pageNumber != null) {
|
if (chunk.pageNumber != null) {
|
||||||
contextBuilder.writeln('Página: ${chunk.pageNumber}');
|
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
|
// Add mode-specific instructions
|
||||||
contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ===');
|
contextBuilder.writeln('\n=== INSTRUÇÕES DE TUTORIA ===');
|
||||||
contextBuilder.writeln('Modo: ${_getModeInstructions(mode)}');
|
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');
|
contextBuilder.writeln('Pergunta do Aluno: $userQuery\n');
|
||||||
|
|
||||||
final contextText = contextBuilder.toString();
|
final contextText = contextBuilder.toString();
|
||||||
@@ -241,7 +286,11 @@ Usas formatação clara e organizada.''';
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final responseData = jsonDecode(response.body);
|
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 confidence = _calculateConfidence(sources);
|
||||||
final relatedConcepts = _extractRelatedConcepts(sources);
|
final relatedConcepts = _extractRelatedConcepts(sources);
|
||||||
|
|
||||||
@@ -344,7 +393,9 @@ $query
|
|||||||
- Use linguagem clara e educacional
|
- Use linguagem clara e educacional
|
||||||
- Adapte a resposta ao nível do aluno
|
- Adapte a resposta ao nível do aluno
|
||||||
- Forneça exemplos quando possível
|
- 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
|
/// Calculate relevance score
|
||||||
@@ -408,6 +459,77 @@ $query
|
|||||||
return concepts.toList()..sort();
|
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 = <String>[];
|
||||||
|
|
||||||
|
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
|
/// Create response for no content found
|
||||||
static RAGResponse _createNoContentResponse(String query, TutorMode mode) {
|
static RAGResponse _createNoContentResponse(String query, TutorMode mode) {
|
||||||
return RAGResponse(
|
return RAGResponse(
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class _TutorChatPageState extends State<TutorChatPage>
|
|||||||
void _addWelcomeMessage() {
|
void _addWelcomeMessage() {
|
||||||
final welcomeMessage = {
|
final welcomeMessage = {
|
||||||
'content':
|
'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!
|
Estou aqui para te ajudar a aprender de forma confiante e motivadora!
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,30 @@ class JoinClassPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _joinClass() async {
|
Future<void> _joinClass() async {
|
||||||
final code = _codeController.text.trim().toUpperCase();
|
final code = _codeController.text.trim().toUpperCase();
|
||||||
|
final customName = _nameController.text.trim();
|
||||||
|
|
||||||
if (code.isEmpty) {
|
if (code.isEmpty) {
|
||||||
_showError('Insere o código da disciplina');
|
_showError('Insere o código da disciplina');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (customName.isEmpty) {
|
||||||
|
_showError('Insere o nome da disciplina');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -105,6 +113,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
currentUser.displayName ??
|
currentUser.displayName ??
|
||||||
currentUser.email?.split('@')[0] ??
|
currentUser.email?.split('@')[0] ??
|
||||||
'Aluno',
|
'Aluno',
|
||||||
|
'customClassName': customName,
|
||||||
'joinedAt': FieldValue.serverTimestamp(),
|
'joinedAt': FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,7 +210,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Entrar numa Disciplina',
|
'Adicionar uma Disciplina',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -345,7 +354,14 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
_buildInstructionItem(
|
_buildInstructionItem(
|
||||||
context,
|
context,
|
||||||
'3.',
|
'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,
|
colorScheme,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -407,6 +423,53 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Botão de entrar
|
// Botão de entrar
|
||||||
@@ -445,7 +508,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Entrar na Disciplina',
|
'Adicionar uma Disciplina',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -540,13 +603,16 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.help_outline, color: colorScheme.primary),
|
Icon(Icons.help_outline, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'Ajuda - Código da Disciplina',
|
'Ajuda - Código da Disciplina',
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: Column(
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -561,6 +627,7 @@ class _JoinClassPageState extends ConsumerState<JoinClassPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildJoinClassCard(BuildContext context) {
|
Widget _buildJoinClassCard(BuildContext context) {
|
||||||
return DashboardActionCard(
|
return DashboardActionCard(
|
||||||
title: 'Entrar numa Disciplina',
|
title: 'Adicionar uma Disciplina',
|
||||||
subtitle: 'Junta-te a uma disciplina com o código',
|
subtitle: 'Junta-te a uma disciplina com o código',
|
||||||
icon: Icons.group_add,
|
icon: Icons.group_add,
|
||||||
layout: DashboardActionCardLayout.horizontal,
|
layout: DashboardActionCardLayout.horizontal,
|
||||||
@@ -264,7 +264,7 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_QuickAccessItem(
|
_QuickAccessItem(
|
||||||
title: 'Entrar numa Disciplina',
|
title: 'Adicionar uma Disciplina',
|
||||||
subtitle: 'Junta-te a uma disciplina com o código',
|
subtitle: 'Junta-te a uma disciplina com o código',
|
||||||
icon: Icons.group_add,
|
icon: Icons.group_add,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
|
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
|
||||||
final classId = enrollmentData['classId'] as String? ?? '';
|
final classId = enrollmentData['classId'] as String? ?? '';
|
||||||
final enrollmentId = enrollmentDoc.id;
|
final enrollmentId = enrollmentDoc.id;
|
||||||
|
final customClassName = enrollmentData['customClassName'] as String?;
|
||||||
|
|
||||||
if (classId.isEmpty) {
|
if (classId.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -163,11 +164,12 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final classData = snapshot.data!.data() as Map<String, dynamic>;
|
final classData = snapshot.data!.data() as Map<String, dynamic>;
|
||||||
final className = classData['name'] as String? ?? 'Sem nome';
|
final className =
|
||||||
|
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
|
||||||
final classCode = classData['code'] as String? ?? '----';
|
final classCode = classData['code'] as String? ?? '----';
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _showRemoveClassDialog(context, enrollmentId, className),
|
onTap: () => _showEditNameDialog(context, enrollmentId, className),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
constraints: const BoxConstraints(minHeight: 150),
|
constraints: const BoxConstraints(minHeight: 150),
|
||||||
@@ -186,6 +188,9 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
@@ -201,6 +206,13 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.edit,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
className,
|
className,
|
||||||
@@ -291,6 +303,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
|
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
|
||||||
final classId = enrollmentData['classId'] as String? ?? '';
|
final classId = enrollmentData['classId'] as String? ?? '';
|
||||||
final enrollmentId = enrollmentDoc.id;
|
final enrollmentId = enrollmentDoc.id;
|
||||||
|
final customClassName = enrollmentData['customClassName'] as String?;
|
||||||
|
|
||||||
if (classId.isEmpty) {
|
if (classId.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -312,7 +325,8 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final classData = snapshot.data!.data() as Map<String, dynamic>;
|
final classData = snapshot.data!.data() as Map<String, dynamic>;
|
||||||
final className = classData['name'] as String? ?? 'Sem nome';
|
final className =
|
||||||
|
customClassName ?? (classData['name'] as String? ?? 'Sem nome');
|
||||||
final classCode = classData['code'] as String? ?? '----';
|
final classCode = classData['code'] as String? ?? '----';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
@@ -351,14 +365,31 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: Icon(
|
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,
|
Icons.delete_outline,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onTap: () =>
|
onPressed: () =>
|
||||||
_showRemoveClassDialog(context, enrollmentId, className),
|
_showRemoveClassDialog(context, enrollmentId, className),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => _showEditNameDialog(context, enrollmentId, className),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -493,7 +524,7 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Erro ao sair da disciplina: $e'),
|
content: Text('Erro ao sair da disciplina: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -503,4 +534,49 @@ class _StudentClassesListWidgetState extends State<StudentClassesListWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surface.withOpacity(0.8),
|
color: cs.surface.withOpacity(0.8),
|
||||||
@@ -396,13 +396,13 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.only(right: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surface.withOpacity(0.8),
|
color: cs.surface.withOpacity(0.8),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
isScrollable: _filteredClassIds.length > 3,
|
isScrollable: true,
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: Colors.transparent,
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
indicator: BoxDecoration(
|
indicator: BoxDecoration(
|
||||||
@@ -419,16 +419,18 @@ class _ContentManagementPageState extends State<ContentManagementPage> {
|
|||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
tabs: _filteredClassIds
|
tabs: List.generate(_filteredClassIds.length, (index) {
|
||||||
.map(
|
final classId = _filteredClassIds[index];
|
||||||
(classId) => Tab(
|
return Tab(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: EdgeInsets.only(
|
||||||
|
left: index == 0 ? 4 : 12,
|
||||||
|
right: index == _filteredClassIds.length - 1 ? 4 : 12,
|
||||||
|
),
|
||||||
child: Text(_classNames[classId] ?? classId),
|
child: Text(_classNames[classId] ?? classId),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
)
|
}),
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class _TeacherMaterialsPageState extends State<TeacherMaterialsPage> {
|
|||||||
),
|
),
|
||||||
if (_filteredClasses.isNotEmpty)
|
if (_filteredClasses.isNotEmpty)
|
||||||
TabBar(
|
TabBar(
|
||||||
isScrollable: _filteredClasses.length > 3,
|
isScrollable: true,
|
||||||
indicatorColor: const Color(0xFFF68D2D),
|
indicatorColor: const Color(0xFFF68D2D),
|
||||||
labelColor: const Color(0xFFF68D2D),
|
labelColor: const Color(0xFFF68D2D),
|
||||||
unselectedLabelColor: Theme.of(
|
unselectedLabelColor: Theme.of(
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
// generating state
|
// generating state
|
||||||
String? _generatingForId;
|
String? _generatingForId;
|
||||||
|
|
||||||
|
// Multi-select for history
|
||||||
|
Set<String> _selectedQuizIds = {};
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -366,16 +370,68 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect if a material is mathematics-based
|
||||||
|
bool _isMathematicsSubject(Map<String, String> 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<void> _generateQuiz(Map<String, String> material) async {
|
Future<void> _generateQuiz(Map<String, String> material) async {
|
||||||
setState(() => _generatingForId = material['id']);
|
setState(() => _generatingForId = material['id']);
|
||||||
try {
|
try {
|
||||||
final matId = material['id']!;
|
final matId = material['id']!;
|
||||||
final matName = material['name'] ?? 'Material';
|
final matName = material['name'] ?? 'Material';
|
||||||
|
final isMathematics = _isMathematicsSubject(material);
|
||||||
|
|
||||||
// Buscar contexto do PDF
|
// Buscar contexto do PDF
|
||||||
final context = await MaterialsRAGService.getRelevantChunks(
|
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],
|
selectedMaterialIds: [matId],
|
||||||
|
maxChunks: 20, // Aumentar para cobrir todo o documento
|
||||||
|
filterTableData:
|
||||||
|
isMathematics, // Filtrar dados de tabela para matemática
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.isEmpty) {
|
if (context.isEmpty) {
|
||||||
@@ -384,7 +440,10 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gerar quiz via Ollama em formato JSON estruturado
|
// 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 =
|
final prompt =
|
||||||
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
|
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
|
||||||
'$context\n\n'
|
'$context\n\n'
|
||||||
@@ -394,7 +453,10 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
'[{"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'
|
'[{"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.';
|
'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);
|
final questions = _parseQuizJson(raw);
|
||||||
|
|
||||||
if (questions.isEmpty) {
|
if (questions.isEmpty) {
|
||||||
@@ -704,6 +766,9 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
quizId: quizId,
|
quizId: quizId,
|
||||||
questions: questions,
|
questions: questions,
|
||||||
materialName: name,
|
materialName: name,
|
||||||
|
onQuizCompleted: () {
|
||||||
|
_loadHistory();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -798,6 +863,9 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
title: title,
|
title: title,
|
||||||
questions: questions,
|
questions: questions,
|
||||||
historyDocId: historyDocId,
|
historyDocId: historyDocId,
|
||||||
|
onQuizCompleted: () {
|
||||||
|
_loadHistory();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -881,13 +949,17 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQuizFromHistory(String title, String rawJson) {
|
void _showQuizFromHistory(
|
||||||
|
String title,
|
||||||
|
String rawJson, {
|
||||||
|
String? historyDocId,
|
||||||
|
}) {
|
||||||
final questions = _parseQuizJson(rawJson);
|
final questions = _parseQuizJson(rawJson);
|
||||||
if (questions.isEmpty) {
|
if (questions.isEmpty) {
|
||||||
_showSnack('Não foi possível carregar este quiz.');
|
_showSnack('Não foi possível carregar este quiz.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_showInteractiveQuiz(title, questions);
|
_showInteractiveQuiz(title, questions, historyDocId: historyDocId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSnack(String msg) {
|
void _showSnack(String msg) {
|
||||||
@@ -1760,11 +1832,152 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _getAiGeneratedQuizzes() {
|
||||||
|
return _history.where((q) => q['teacherQuizId'] == null).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _getTeacherQuizzes() {
|
||||||
|
return _history.where((q) => q['teacherQuizId'] != null).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteQuizFromHistory(Map<String, dynamic> item) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _deleteSelectedQuizzes() async {
|
||||||
|
if (_selectedQuizIds.isEmpty) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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) {
|
Widget _buildHistoryTab(ColorScheme cs) {
|
||||||
if (_loadingHistory) {
|
if (_loadingHistory) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (_history.isEmpty) {
|
|
||||||
|
final aiQuizzes = _getAiGeneratedQuizzes();
|
||||||
|
final teacherQuizzes = _getTeacherQuizzes();
|
||||||
|
|
||||||
|
if (aiQuizzes.isEmpty && teacherQuizzes.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@@ -1788,60 +2001,176 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Map<String, dynamic>> 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 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
|
final realDisciplineIds = groups.keys
|
||||||
.where((k) => k != '__geral__' && _historyClassNames.containsKey(k))
|
.where((k) => k != '__geral__' && _historyClassNames.containsKey(k))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (realDisciplineIds.isEmpty) {
|
if (realDisciplineIds.isEmpty) {
|
||||||
return _buildHistoryList(cs, _history);
|
return Stack(
|
||||||
}
|
|
||||||
|
|
||||||
if (_selectedHistoryDisciplineId != null) {
|
|
||||||
final items = groups[_selectedHistoryDisciplineId] ?? [];
|
|
||||||
final dName =
|
|
||||||
_historyClassNames[_selectedHistoryDisciplineId] ??
|
|
||||||
_selectedHistoryDisciplineId!;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
_buildHistoryList(cs, items, isTeacherQuizzes: isTeacherQuizzes),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
if (_isSelectionMode && _selectedQuizIds.isNotEmpty)
|
||||||
child: Row(
|
Positioned(
|
||||||
children: [
|
bottom: 16,
|
||||||
IconButton(
|
right: 16,
|
||||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
child: FloatingActionButton.extended(
|
||||||
onPressed: () =>
|
onPressed: _deleteSelectedQuizzes,
|
||||||
setState(() => _selectedHistoryDisciplineId = null),
|
backgroundColor: Colors.red,
|
||||||
),
|
icon: const Icon(Icons.delete),
|
||||||
const SizedBox(width: 4),
|
label: Text('Eliminar (${_selectedQuizIds.length})'),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(child: _buildHistoryList(cs, items)),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return Stack(
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
|
ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
itemCount: realDisciplineIds.length,
|
itemCount: realDisciplineIds.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
@@ -1874,7 +2203,7 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
color: cs.primary.withValues(alpha: 0.1),
|
color: cs.primary.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(Icons.history_edu, color: cs.primary, size: 26),
|
child: Icon(Icons.school, color: cs.primary, size: 26),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1908,10 +2237,27 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
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<Map<String, dynamic>> items) {
|
Widget _buildHistoryList(
|
||||||
|
ColorScheme cs,
|
||||||
|
List<Map<String, dynamic>> items, {
|
||||||
|
bool isTeacherQuizzes = false,
|
||||||
|
}) {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
@@ -1928,11 +2274,22 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
dateStr =
|
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')}';
|
'${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(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surface,
|
color: cs.surface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: cs.shadow.withValues(alpha: 0.05),
|
color: cs.shadow.withValues(alpha: 0.05),
|
||||||
@@ -1946,7 +2303,22 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
leading: Container(
|
leading: _isSelectionMode
|
||||||
|
? Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (_) {
|
||||||
|
setState(() {
|
||||||
|
if (quizId != null) {
|
||||||
|
if (_selectedQuizIds.contains(quizId)) {
|
||||||
|
_selectedQuizIds.remove(quizId);
|
||||||
|
} else {
|
||||||
|
_selectedQuizIds.add(quizId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1979,19 +2351,58 @@ class _QuizListPageState extends State<QuizListPage>
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: Icon(
|
trailing: _isSelectionMode
|
||||||
|
? null
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
color: cs.onSurfaceVariant,
|
color: cs.onSurfaceVariant,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
onTap: () {
|
const SizedBox(width: 8),
|
||||||
final rawJson =
|
IconButton(
|
||||||
item['quizJson'] as String? ?? item['quizText'] as String?;
|
icon: Icon(
|
||||||
if (rawJson != null && rawJson.isNotEmpty) {
|
Icons.delete_outline,
|
||||||
_showQuizFromHistory(matName, rawJson);
|
color: Colors.red,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => _deleteQuizFromHistory(item),
|
||||||
|
tooltip: 'Eliminar',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: _isSelectionMode
|
||||||
|
? () {
|
||||||
|
if (quizId != null) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedQuizIds.contains(quizId)) {
|
||||||
|
_selectedQuizIds.remove(quizId);
|
||||||
} else {
|
} else {
|
||||||
// Quiz sem JSON guardado (ex: quiz do professor) — mostrar resultado
|
_selectedQuizIds.add(quizId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: () {
|
||||||
|
if (isTeacherQuizzes) {
|
||||||
|
// Teacher quizzes: only show results, don't allow retake
|
||||||
_showHistoryResultDialog(item, matName);
|
_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 String title;
|
||||||
final List<_QuizQuestion> questions;
|
final List<_QuizQuestion> questions;
|
||||||
final String? historyDocId;
|
final String? historyDocId;
|
||||||
|
final VoidCallback? onQuizCompleted;
|
||||||
const _InteractiveQuizSheet({
|
const _InteractiveQuizSheet({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.questions,
|
required this.questions,
|
||||||
this.historyDocId,
|
this.historyDocId,
|
||||||
|
this.onQuizCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2038,6 +2451,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
|
|||||||
bool _submitted = false;
|
bool _submitted = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
DateTime? _startTime;
|
DateTime? _startTime;
|
||||||
|
bool _hasSaved = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -2046,6 +2460,50 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
|
|||||||
_startTime = DateTime.now();
|
_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<void> _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) {
|
void _selectOption(int idx) {
|
||||||
if (_submitted) return;
|
if (_submitted) return;
|
||||||
setState(() => _chosen[_current] = idx);
|
setState(() => _chosen[_current] = idx);
|
||||||
@@ -2072,6 +2530,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_submitted = true;
|
_submitted = true;
|
||||||
_saving = true;
|
_saving = true;
|
||||||
|
_hasSaved = true;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
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) {
|
} catch (e) {
|
||||||
Logger.error('Error saving quiz result: $e');
|
Logger.error('Error saving quiz result: $e');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2497,11 +2961,13 @@ class _TeacherQuizInteractiveSheet extends StatefulWidget {
|
|||||||
final String quizId;
|
final String quizId;
|
||||||
final List<_QuizQuestion> questions;
|
final List<_QuizQuestion> questions;
|
||||||
final String? materialName;
|
final String? materialName;
|
||||||
|
final VoidCallback? onQuizCompleted;
|
||||||
const _TeacherQuizInteractiveSheet({
|
const _TeacherQuizInteractiveSheet({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.quizId,
|
required this.quizId,
|
||||||
required this.questions,
|
required this.questions,
|
||||||
this.materialName,
|
this.materialName,
|
||||||
|
this.onQuizCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2607,6 +3073,11 @@ class _TeacherQuizInteractiveSheetState
|
|||||||
await GamificationService.recordStudyTime(user.uid, elapsedMinutes);
|
await GamificationService.recordStudyTime(user.uid, elapsedMinutes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call callback to refresh history
|
||||||
|
if (widget.onQuizCompleted != null) {
|
||||||
|
widget.onQuizCompleted!();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Error submitting teacher quiz result: $e');
|
Logger.error('Error submitting teacher quiz result: $e');
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
@@ -20,10 +19,14 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String _userRole = '';
|
String _userRole = '';
|
||||||
|
|
||||||
// Disciplina seleccionada (null = vista de disciplinas)
|
// Turma seleccionada (null = vista de turmas)
|
||||||
String? _selectedDisciplineId;
|
String? _selectedClassId;
|
||||||
Map<String, String> _classNames = {}; // classId → name
|
Map<String, String> _classNames = {}; // classId → name
|
||||||
|
|
||||||
|
// Search and filter
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
DateTime? _selectedDate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -31,6 +34,24 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
_loadQuizHistory();
|
_loadQuizHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _loadUserRole() async {
|
Future<void> _loadUserRole() async {
|
||||||
final user = AuthService.currentUser;
|
final user = AuthService.currentUser;
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
@@ -302,7 +323,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
return result ?? false;
|
return result ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, List<Map<String, dynamic>>> _groupByDiscipline() {
|
Map<String, List<Map<String, dynamic>>> _groupByClass() {
|
||||||
final Map<String, List<Map<String, dynamic>>> groups = {};
|
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||||
for (final quiz in _quizHistory) {
|
for (final quiz in _quizHistory) {
|
||||||
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
|
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
|
||||||
@@ -316,15 +337,69 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _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<String>() ?? [];
|
||||||
|
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<String, List<Map<String, dynamic>>> _getFilteredGroups() {
|
||||||
|
final filteredQuizzes = _getFilteredQuizzes();
|
||||||
|
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||||
|
for (final quiz in filteredQuizzes) {
|
||||||
|
final quizClassIds = (quiz['classIds'] as List?)?.cast<String>() ?? [];
|
||||||
|
String? groupId = quizClassIds.cast<String?>().firstWhere(
|
||||||
|
(cid) => cid != null && _classNames.containsKey(cid),
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__';
|
||||||
|
groups.putIfAbsent(groupId, () => []).add(quiz);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: _selectedDisciplineId == null,
|
canPop: _selectedClassId == null,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (!didPop && _selectedDisciplineId != null) {
|
if (!didPop && _selectedClassId != null) {
|
||||||
setState(() => _selectedDisciplineId = null);
|
setState(() => _selectedClassId = null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -339,8 +414,8 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_userRole == 'teacher' && _selectedDisciplineId != null) {
|
if (_userRole == 'teacher' && _selectedClassId != null) {
|
||||||
setState(() => _selectedDisciplineId = null);
|
setState(() => _selectedClassId = null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Navigator.of(context).canPop()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
@@ -354,6 +429,44 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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(
|
body: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -365,15 +478,15 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
),
|
),
|
||||||
child: _loading
|
child: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _quizHistory.isEmpty
|
: _getFilteredQuizzes().isEmpty
|
||||||
? _buildEmptyState()
|
? _buildEmptyState()
|
||||||
: _userRole == 'teacher'
|
: _userRole == 'teacher'
|
||||||
? _buildTeacherBody(cs)
|
? _buildTeacherBody(cs)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _quizHistory.length,
|
itemCount: _getFilteredQuizzes().length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final quiz = _quizHistory[index];
|
final quiz = _getFilteredQuizzes()[index];
|
||||||
return _buildQuizCard(quiz)
|
return _buildQuizCard(quiz)
|
||||||
.animate()
|
.animate()
|
||||||
.slideX(duration: const Duration(milliseconds: 300))
|
.slideX(duration: const Duration(milliseconds: 300))
|
||||||
@@ -386,16 +499,16 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTeacherBody(ColorScheme cs) {
|
Widget _buildTeacherBody(ColorScheme cs) {
|
||||||
final groups = _groupByDiscipline();
|
final groups = _searchController.text.isNotEmpty || _selectedDate != null
|
||||||
|
? _getFilteredGroups()
|
||||||
|
: _groupByClass();
|
||||||
|
|
||||||
// Vista de quizzes de uma disciplina
|
// Vista de quizzes de uma turma
|
||||||
if (_selectedDisciplineId != null) {
|
if (_selectedClassId != null) {
|
||||||
final quizzes = groups[_selectedDisciplineId] ?? [];
|
final quizzes = groups[_selectedClassId] ?? [];
|
||||||
final disciplineName =
|
final className =
|
||||||
_classNames[_selectedDisciplineId] ??
|
_classNames[_selectedClassId] ??
|
||||||
(_selectedDisciplineId == '__geral__'
|
(_selectedClassId == '__geral__' ? 'Geral' : _selectedClassId!);
|
||||||
? 'Geral'
|
|
||||||
: _selectedDisciplineId!);
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -405,12 +518,12 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
icon: Icon(Icons.arrow_back, color: cs.onSurface),
|
||||||
onPressed: () => setState(() => _selectedDisciplineId = null),
|
onPressed: () => setState(() => _selectedClassId = null),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
disciplineName,
|
className,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -445,19 +558,19 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vista de disciplinas
|
// Vista de turmas
|
||||||
final disciplineIds = groups.keys.toList();
|
final classIds = groups.keys.toList();
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: disciplineIds.length,
|
itemCount: classIds.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final dId = disciplineIds[i];
|
final cId = classIds[i];
|
||||||
final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId);
|
final cName = _classNames[cId] ?? (cId == '__geral__' ? 'Geral' : cId);
|
||||||
final count = groups[dId]!.length;
|
final count = groups[cId]!.length;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () => setState(() => _selectedDisciplineId = dId),
|
onTap: () => setState(() => _selectedClassId = cId),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -489,7 +602,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
dName,
|
cName,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -631,7 +744,7 @@ class _QuizManagementPageState extends State<QuizManagementPage> {
|
|||||||
),
|
),
|
||||||
if (_userRole == 'teacher' &&
|
if (_userRole == 'teacher' &&
|
||||||
quiz['classIds'] != null &&
|
quiz['classIds'] != null &&
|
||||||
_selectedDisciplineId == null) ...[
|
_selectedClassId == null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 4,
|
spacing: 4,
|
||||||
|
|||||||
@@ -164,15 +164,67 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect if a material is mathematics-based
|
||||||
|
bool _isMathematicsSubject(Map<String, String> 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<void> _generateQuiz(Map<String, String> material) async {
|
Future<void> _generateQuiz(Map<String, String> material) async {
|
||||||
setState(() => _generatingForId = material['id']);
|
setState(() => _generatingForId = material['id']);
|
||||||
try {
|
try {
|
||||||
final matId = material['id']!;
|
final matId = material['id']!;
|
||||||
final matName = material['name'] ?? 'Material';
|
final matName = material['name'] ?? 'Material';
|
||||||
|
final isMathematics = _isMathematicsSubject(material);
|
||||||
|
|
||||||
final pdfContext = await MaterialsRAGService.getRelevantChunks(
|
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],
|
selectedMaterialIds: [matId],
|
||||||
|
maxChunks: 20, // Aumentar para cobrir todo o documento
|
||||||
|
filterTableData:
|
||||||
|
isMathematics, // Filtrar dados de tabela para matemática
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pdfContext.isEmpty) {
|
if (pdfContext.isEmpty) {
|
||||||
@@ -180,7 +232,10 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
return;
|
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 =
|
final prompt =
|
||||||
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
|
'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n'
|
||||||
'$pdfContext\n\n'
|
'$pdfContext\n\n'
|
||||||
@@ -190,7 +245,10 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
'[{"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'
|
'[{"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.';
|
'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);
|
final questions = _parseQuizJson(raw);
|
||||||
|
|
||||||
if (questions.isEmpty) {
|
if (questions.isEmpty) {
|
||||||
@@ -358,6 +416,73 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteQuiz(String quizId, String quizTitle) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<String, dynamic> item, ColorScheme cs) {
|
Widget _buildHistoryQuizTile(Map<String, dynamic> item, ColorScheme cs) {
|
||||||
final name = (item['materialName'] as String? ?? 'Material')
|
final name = (item['materialName'] as String? ?? 'Material')
|
||||||
.replaceAll('.pdf', '')
|
.replaceAll('.pdf', '')
|
||||||
@@ -409,7 +534,19 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
|
|||||||
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
||||||
)
|
)
|
||||||
: null,
|
: 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),
|
onTap: () => _showResultsPopup(item),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user