From c979692fd9840691fd0809e3cbc23993182a25a9 Mon Sep 17 00:00:00 2001 From: 240405 <240405@epvc.pt> Date: Sun, 17 May 2026 22:21:23 +0100 Subject: [PATCH] =?UTF-8?q?Mudan=C3=A7as=20na=20aba=20de=20quiz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/services/materials_rag_service.dart | 118 +- .../widgets/quick_access_widget.dart | 38 - .../presentation/pages/quiz_list_page.dart | 1007 +++++++++++++---- .../pages/quiz_management_page.dart | 294 ++++- .../presentation/pages/teacher_quiz_page.dart | 261 ++++- 5 files changed, 1289 insertions(+), 429 deletions(-) diff --git a/lib/core/services/materials_rag_service.dart b/lib/core/services/materials_rag_service.dart index 1b88912..5591f20 100644 --- a/lib/core/services/materials_rag_service.dart +++ b/lib/core/services/materials_rag_service.dart @@ -21,7 +21,8 @@ class MaterialsRAGService { /// Listar materiais disponíveis para o aluno autenticado /// Retorna apenas materiais cujo classId corresponde a uma turma onde o aluno está inscrito - static Future>> getAvailableMaterialsForStudent() async { + static Future>> + getAvailableMaterialsForStudent() async { try { final user = _auth.currentUser; if (user == null) return []; @@ -62,7 +63,13 @@ class MaterialsRAGService { final classId = data['classId'] as String?; if (classId == null || enrolledClassIds.contains(classId)) { final fileName = data['fileName'] as String? ?? 'Material'; - result.add({'id': doc.id, 'name': fileName}); + final teacherId = data['teacherId'] as String?; + result.add({ + 'id': doc.id, + 'name': fileName, + if (classId != null) 'classId': classId, + if (teacherId != null) 'teacherId': teacherId, + }); } } @@ -120,9 +127,12 @@ class MaterialsRAGService { // Usar cache do texto completo se disponível (sufixo v2 invalida caches antigos) final cacheKey = '${fileName}_v6'; String fullText; - if (_chunksCache.containsKey(cacheKey) && _chunksCache[cacheKey]!.isNotEmpty) { + if (_chunksCache.containsKey(cacheKey) && + _chunksCache[cacheKey]!.isNotEmpty) { fullText = _chunksCache[cacheKey]!.first; - Logger.info('Using cached text for $fileName: ${fullText.length} chars'); + Logger.info( + 'Using cached text for $fileName: ${fullText.length} chars', + ); } else { try { final teacherId = data['teacherId'] as String?; @@ -164,7 +174,9 @@ class MaterialsRAGService { fullText = cleaned; // Guardar texto completo no cache com key versionada _chunksCache[cacheKey] = [fullText]; - Logger.info('PDF "$fileName" -> ${fullText.length} chars extracted'); + Logger.info( + 'PDF "$fileName" -> ${fullText.length} chars extracted', + ); } catch (e) { Logger.error('Error extracting text from $fileName: $e'); continue; @@ -176,9 +188,15 @@ class MaterialsRAGService { final String context; if (fullText.length <= 10000) { context = fullText; - Logger.info('Small PDF — sending full text (${fullText.length} chars)'); + Logger.info( + 'Small PDF — sending full text (${fullText.length} chars)', + ); } else { - final windows = _extractKeywordWindows(fullText, userQuery, _maxRelevantChunks); + final windows = _extractKeywordWindows( + fullText, + userQuery, + _maxRelevantChunks, + ); context = windows.join('\n\n---\n\n'); Logger.info('Large PDF — keyword windows: ${windows.length}'); } @@ -205,7 +223,11 @@ class MaterialsRAGService { /// Método legacy - mantido para compatibilidade mas usa chunk retrieval @Deprecated('Use getRelevantChunks with userQuery instead') static Future getMaterialsContext({int maxMaterials = 5}) async { - return getRelevantChunks(userQuery: '', maxMaterials: maxMaterials, maxChunks: 3); + return getRelevantChunks( + userQuery: '', + maxMaterials: maxMaterials, + maxChunks: 3, + ); } /// Get teacher IDs from student's enrolled classes @@ -239,11 +261,11 @@ class MaterialsRAGService { // 3. Buscar turmas e extrair teacherIds final Set teacherIds = {}; - + // Firestore whereIn limit is 10, so process in batches if needed for (int i = 0; i < classIds.length; i += 10) { final batch = classIds.skip(i).take(10).toList(); - + final classSnapshot = await _firestore .collection('classes') .where(FieldPath.documentId, whereIn: batch) @@ -273,7 +295,10 @@ class MaterialsRAGService { /// Extrair texto real do PDF usando Firebase Storage SDK + syncfusion_flutter_pdf /// Usa getData() para descarregar o ficheiro completo (sem truncar a meio do stream) - static Future _extractFullText(String fileName, String teacherId) async { + static Future _extractFullText( + String fileName, + String teacherId, + ) async { PdfDocument? document; try { final ref = _storage @@ -306,12 +331,16 @@ class MaterialsRAGService { for (int i = startPage; i < totalPages; i++) { if (buffer.length >= _maxExtractedChars) break; try { - final pageText = extractor.extractText(startPageIndex: i, endPageIndex: i).trim(); + final pageText = extractor + .extractText(startPageIndex: i, endPageIndex: i) + .trim(); if (pageText.length < 80) continue; final lowerText = pageText.toLowerCase(); final pipeCount = '|'.allMatches(pageText).length; - final isStructurePage = pipeCount > 3 || - (lowerText.contains('table of contents') && pageText.length < 800) || + final isStructurePage = + pipeCount > 3 || + (lowerText.contains('table of contents') && + pageText.length < 800) || (lowerText.contains('copyright') && pageText.length < 400) || (lowerText.contains('color insert') && pageText.length < 400) || lowerText.contains('just light novels') || @@ -355,8 +384,12 @@ class MaterialsRAGService { ? fullText.substring(0, _maxExtractedChars) : fullText; - Logger.info('Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)'); - Logger.info('Text preview: ${result.length > 200 ? result.substring(0, 200) : result}'); + Logger.info( + 'Extracted ${result.length} chars from $fileName (${document.pages.count} pages, ${form.fields.count} form fields)', + ); + Logger.info( + 'Text preview: ${result.length > 200 ? result.substring(0, 200) : result}', + ); return result.trim(); } catch (e) { Logger.error('Error extracting text from $fileName: $e'); @@ -381,10 +414,9 @@ class MaterialsRAGService { // Extrair keywords: palavras com >3 chars + nomes próprios (palavras com maiúscula, >2 chars) // Os nomes próprios são invariantes entre línguas (ex: "Claire", "Rae", "François") - final properNouns = RegExp(r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b') - .allMatches(userQuery) - .map((m) => m.group(0)!.toLowerCase()) - .toSet(); + final properNouns = RegExp( + r'\b[A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][a-záéíóúàâêôãõç]{2,}\b', + ).allMatches(userQuery).map((m) => m.group(0)!.toLowerCase()).toSet(); final generalKeywords = userQuery .toLowerCase() .split(RegExp(r'[^\w]')) @@ -428,7 +460,9 @@ class MaterialsRAGService { lastEnd = end; } - Logger.info('Keyword windows found: ${windows.length} for query "$userQuery"'); + Logger.info( + 'Keyword windows found: ${windows.length} for query "$userQuery"', + ); return windows; } @@ -436,15 +470,15 @@ class MaterialsRAGService { static List _chunkText(String text, int chunkSize, int overlap) { final List chunks = []; final int textLength = text.length; - + if (textLength <= chunkSize) { return [text]; } - + int start = 0; while (start < textLength) { int end = start + chunkSize; - + if (end >= textLength) { end = textLength; } else { @@ -456,68 +490,70 @@ class MaterialsRAGService { end = start + chunkSize; // Forçar quebra se não encontrar espaço } } - + chunks.add(text.substring(start, end).trim()); - + // Avançar com overlap start = end - overlap; if (start >= end) break; // Prevenir loop infinito } - + return chunks; } /// Selecionar chunks mais relevantes usando keyword matching simples static List _selectRelevantChunks( - List chunks, - String userQuery, + List chunks, + String userQuery, int maxChunks, ) { if (userQuery.isEmpty || chunks.isEmpty) { // Se não há query, retornar primeiros chunks return chunks.take(maxChunks).toList(); } - + // Extrair keywords da query (palavras com mais de 3 caracteres) final queryWords = userQuery .toLowerCase() .split(RegExp(r'[^\w]')) .where((w) => w.length > 3) .toSet(); - + if (queryWords.isEmpty) { return chunks.take(maxChunks).toList(); } - + // Calcular score para cada chunk final List> scoredChunks = []; - + for (final chunk in chunks) { final chunkLower = chunk.toLowerCase(); int score = 0; - + for (final word in queryWords) { // Contar ocorrências da palavra no chunk final matches = word.allMatches(chunkLower).length; score += matches * 10; // Peso por ocorrência - + // Bonus se a palavra estiver no início do chunk if (chunkLower.startsWith(word)) { score += 5; } } - + // Bonus por tamanho do chunk (preferir chunks mais completos) score += (chunk.length / 100).floor(); - + scoredChunks.add(MapEntry(chunk, score)); } - + // Ordenar por score decrescente scoredChunks.sort((a, b) => b.value.compareTo(a.value)); - - Logger.info('Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}'); - + + Logger.info( + 'Top chunk scores: ${scoredChunks.take(3).map((e) => e.value).toList()}', + ); + // Retornar os N chunks mais relevantes return scoredChunks.take(maxChunks).map((e) => e.key).toList(); } diff --git a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart index 8c51b95..5cae775 100644 --- a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart +++ b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart @@ -24,7 +24,6 @@ class QuickAccessWidget extends StatelessWidget { _buildTutorIACard(context), _buildQuizCard(context), _buildAchievementsCard(context), - _buildQuizManagementCard(context), ]; return Column( @@ -70,8 +69,6 @@ class QuickAccessWidget extends StatelessWidget { SizedBox(width: _scrollCardWidth, child: cards[1]), const SizedBox(width: 12), SizedBox(width: _scrollCardWidth, child: cards[2]), - const SizedBox(width: 12), - SizedBox(width: _scrollCardWidth, child: cards[3]), ], ), ), @@ -166,32 +163,6 @@ class QuickAccessWidget extends StatelessWidget { ); } - Widget _buildQuizManagementCard(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return ClipRRect( - borderRadius: BorderRadius.circular(16), - child: - DashboardActionCardSurface( - title: 'Gerenciar Quizzes', - subtitle: 'Ver histórico ou eliminar', - icon: Icons.manage_history, - minHeight: _cardMinHeight, - titleFontSize: _titleFontSize, - subtitleFontSize: _subtitleFontSize, - iconSize: _iconSize, - padding: _cardPadding, - iconColor: cs.tertiary, - onTap: () => context.go('/quiz-management'), - ) - .animate() - .fadeIn( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ) - .then(delay: const Duration(milliseconds: 250)), - ); - } - Widget _buildJoinClassCard(BuildContext context) { return DashboardActionCard( title: 'Entrar numa Disciplina', @@ -245,15 +216,6 @@ class QuickAccessWidget extends StatelessWidget { context.go('/student/achievements'); }, ), - _QuickAccessItem( - title: 'Gerenciar Quizzes', - subtitle: 'Ver histórico ou eliminar', - icon: Icons.manage_history, - onTap: () { - Navigator.pop(context); - context.go('/quiz-management'); - }, - ), _QuickAccessItem( title: 'Entrar numa Disciplina', subtitle: 'Junta-te a uma disciplina com o código', diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index 83f242d..037e7b3 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -21,12 +21,20 @@ class _QuizListPageState extends State late TabController _tabController; List> _materials = []; + Map _materialClassNames = + {}; // classId → name (para Gerar Quiz) List> _history = []; List> _teacherQuizzes = []; + Map _classNames = {}; // classId → name (para Do Professor) bool _loadingMaterials = true; bool _loadingHistory = true; bool _loadingTeacherQuizzes = true; + // disciplina seleccionada no tab "Gerar Quiz" (null = vista de disciplinas) + String? _selectedMaterialDisciplineId; + // disciplina seleccionada no tab "Do Professor" (null = vista de disciplinas) + String? _selectedDisciplineId; + // generating state String? _generatingForId; @@ -47,9 +55,77 @@ class _QuizListPageState extends State Future _loadMaterials() async { final mats = await MaterialsRAGService.getAvailableMaterialsForStudent(); + if (!mounted) return; + + final uid = FirebaseAuth.instance.currentUser?.uid; + final classNamesMap = {}; // classId \u2192 name + // teacherId \u2192 classId (para materiais sem classId explícito) + final teacherToClass = {}; + + if (uid != null) { + // Buscar turmas em que o aluno está inscrito + final enrollSnap = await FirebaseFirestore.instance + .collection('enrollments') + .where('studentId', isEqualTo: uid) + .get(); + final enrolledClassIds = enrollSnap.docs + .map((d) => d.data()['classId'] as String?) + .whereType() + .toSet(); + + if (enrolledClassIds.isNotEmpty) { + final classDocs = await Future.wait( + enrolledClassIds.map( + (id) => + FirebaseFirestore.instance.collection('classes').doc(id).get(), + ), + ); + for (final doc in classDocs.where((d) => d.exists)) { + final name = doc.data()?['name'] as String? ?? doc.id; + classNamesMap[doc.id] = name; + final tid = doc.data()?['teacherId'] as String?; + if (tid != null) teacherToClass[tid] = doc.id; + } + } + } + + // Se algum material não tem classId, inferir pelo teacherId do material + final enriched = mats.map>((m) { + if (m['classId'] != null) return m; + final tid = m['teacherId']; + final inferred = tid != null ? teacherToClass[tid] : null; + if (inferred != null) { + return Map.from({...m, 'classId': inferred}); + } + return m; + }).toList(); + + // Buscar classIds que vieram explicitamente nos materiais e ainda não temos nome + final extraClassIds = enriched + .map((m) => m['classId']) + .whereType() + .where((id) => !classNamesMap.containsKey(id)) + .toSet(); + if (extraClassIds.isNotEmpty) { + final docs = await Future.wait( + extraClassIds.map( + (id) => + FirebaseFirestore.instance.collection('classes').doc(id).get(), + ), + ); + for (final doc in docs.where((d) => d.exists)) { + classNamesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id; + } + } + + Logger.info( + 'Materials loaded: ${enriched.length}, classNames: $classNamesMap, teacherToClass: $teacherToClass', + ); + if (mounted) setState(() { - _materials = mats; + _materials = enriched; + _materialClassNames = classNamesMap; _loadingMaterials = false; }); } @@ -59,160 +135,110 @@ class _QuizListPageState extends State try { final uid = FirebaseAuth.instance.currentUser?.uid; if (uid == null) { - Logger.info('User UID is null, returning'); if (mounted) setState(() => _loadingTeacherQuizzes = false); return; } - Logger.info('Loading teacher quizzes for user: $uid'); // Obter enrollments do aluno final enrollSnap = await FirebaseFirestore.instance .collection('enrollments') .where('studentId', isEqualTo: uid) .get(); - Logger.info( - 'Enrollments found for student $uid: ${enrollSnap.docs.length}', - ); final classIds = enrollSnap.docs .map((d) => d.data()['classId'] as String?) .whereType() .toSet(); - Logger.info('ClassIds from enrollments: $classIds'); if (classIds.isEmpty) { - Logger.info('No classIds found for student, returning empty'); if (mounted) setState(() => _loadingTeacherQuizzes = false); return; } - // Obter também os teacherIds (fallback para quizzes sem classIds) + + // Obter dados das turmas (name + teacherId) final classSnaps = await Future.wait( classIds.map( (id) => FirebaseFirestore.instance.collection('classes').doc(id).get(), ), ); - final teacherIds = classSnaps - .where((d) => d.exists) - .map((d) => d.data()?['teacherId'] as String?) - .whereType() - .toSet() - .toList(); - Logger.info('TeacherIds from classes: $teacherIds'); - // Simplificar: tentar query básica primeiro + final classNamesMap = {}; + final teacherIds = {}; + for (final doc in classSnaps.where((d) => d.exists)) { + final data = doc.data()!; + classNamesMap[doc.id] = data['name'] as String? ?? doc.id; + final tid = data['teacherId'] as String?; + if (tid != null) teacherIds.add(tid); + } + final classIdList = classIds.toList(); final batches = >[]; - // Query 1: por teacherId (mais simples e compatível) if (teacherIds.isNotEmpty) { - Logger.info('Executing simple teacherIds query with: $teacherIds'); - // Limitar a 10 teacherIds por query (limite do Firestore) final batch = teacherIds.take(10).toList(); - final query = FirebaseFirestore.instance - .collection('teacherQuizzes') - .where('teacherId', whereIn: batch) - .orderBy('createdAt', descending: true); - batches.add(query.get()); - Logger.info('Added teacherIds batch query: $batch'); + batches.add( + FirebaseFirestore.instance + .collection('teacherQuizzes') + .where('teacherId', whereIn: batch) + .orderBy('createdAt', descending: true) + .get(), + ); } - // Query 2: por classIds (se a primeira não retornar resultados) if (classIdList.isNotEmpty) { - Logger.info('Executing classIds query with: $classIdList'); final batch = classIdList.take(10).toList(); - final query = FirebaseFirestore.instance - .collection('teacherQuizzes') - .where('classIds', arrayContainsAny: batch) - .orderBy('createdAt', descending: true); - batches.add(query.get()); - Logger.info('Added classIds batch query: $batch'); + batches.add( + FirebaseFirestore.instance + .collection('teacherQuizzes') + .where('classIds', arrayContainsAny: batch) + .orderBy('createdAt', descending: true) + .get(), + ); } - // Debug: query super simplificada para teste - try { - final testSnap = await FirebaseFirestore.instance - .collection('teacherQuizzes') - .limit(1) - .get(); - Logger.info('=== SUPER SIMPLE TEST QUERY ==='); - Logger.info('Test query result: ${testSnap.docs.length} documents'); - if (testSnap.docs.isNotEmpty) { - final data = testSnap.docs.first.data(); - Logger.info('First quiz data: $data'); - } - Logger.info('=== END TEST QUERY ==='); - } catch (e) { - Logger.error('Super simple test query failed: $e'); - } + List> quizzes = []; - // Teste: retornar todos os quizzes sem filtros (temporário para debug) - try { - final allSnap = await FirebaseFirestore.instance - .collection('teacherQuizzes') - .orderBy('createdAt', descending: true) - .limit(10) - .get(); - Logger.info('=== ALL QUIZZES NO FILTER ==='); - Logger.info('All quizzes count: ${allSnap.docs.length}'); - final allQuizzes = allSnap.docs + if (batches.isNotEmpty) { + final results = await Future.wait(batches); + final seen = {}; + quizzes = results + .expand((s) => s.docs) + .where((d) => seen.add(d.id)) .map((d) => {'id': d.id, ...d.data() as Map}) .toList(); + } - // Filtrar manualmente para testar - final filteredQuizzes = allQuizzes.where((quiz) { - final quizTeacherId = quiz['teacherId'] as String?; - final quizClassIds = - (quiz['classIds'] as List?)?.cast() ?? []; - return teacherIds.contains(quizTeacherId) || - quizClassIds.any((cid) => classIds.contains(cid)); - }).toList(); - - Logger.info('Manual filtered quizzes: ${filteredQuizzes.length}'); - Logger.info('=== END ALL QUIZZES NO FILTER ==='); - - // Usar este resultado temporariamente para debug - if (filteredQuizzes.isNotEmpty) { - Logger.info('Using manually filtered quizzes for UI'); - if (mounted) { - setState(() { - _teacherQuizzes = filteredQuizzes; - _loadingTeacherQuizzes = false; - }); - } - return; // Sair cedo para não sobrescrever com a query normal + // Fallback: busca sem filtro e filtra manualmente + if (quizzes.isEmpty) { + try { + final allSnap = await FirebaseFirestore.instance + .collection('teacherQuizzes') + .orderBy('createdAt', descending: true) + .limit(50) + .get(); + quizzes = allSnap.docs + .map((d) => {'id': d.id, ...d.data() as Map}) + .where((quiz) { + final quizTeacherId = quiz['teacherId'] as String?; + final quizClassIds = + (quiz['classIds'] as List?)?.cast() ?? []; + return teacherIds.contains(quizTeacherId) || + quizClassIds.any((cid) => classIds.contains(cid)); + }) + .toList(); + } catch (e) { + Logger.error('Fallback all quizzes query failed: $e'); } - } catch (e) { - Logger.error('All quizzes query failed: $e'); } - // Executar queries e processar resultados - final results = await Future.wait(batches); - Logger.info('Query batches completed: ${results.length} results'); - - // deduplicar por id (pode aparecer em múltiplos batches) - final seen = {}; - final quizzes = results - .expand((s) => s.docs) - .where((d) => seen.add(d.id)) - .map((d) => {'id': d.id, ...d.data() as Map}) - .toList(); - - Logger.info('Final quizzes after deduplication: ${quizzes.length}'); - for (final quiz in quizzes.take(3)) { - Logger.info( - 'Quiz sample: ${quiz['materialName']} - classIds: ${quiz['classIds']} - teacherId: ${quiz['teacherId']}', - ); - } + Logger.info('Teacher quizzes loaded: ${quizzes.length}'); if (mounted) { - Logger.info( - 'Updating UI state: _teacherQuizzes.length = ${quizzes.length}', - ); setState(() { _teacherQuizzes = quizzes; + _classNames = classNamesMap; _loadingTeacherQuizzes = false; }); - Logger.info('UI state updated'); } } catch (e) { Logger.error('Error loading teacher quizzes: $e'); @@ -285,8 +311,9 @@ class _QuizListPageState extends State // Guardar no histórico (guardar JSON raw para poder rever) final uid = FirebaseAuth.instance.currentUser?.uid; + String? historyDocId; if (uid != null) { - await FirebaseFirestore.instance + final docRef = await FirebaseFirestore.instance .collection('quizHistory') .doc(uid) .collection('quizzes') @@ -296,10 +323,12 @@ class _QuizListPageState extends State 'quizJson': raw, 'createdAt': FieldValue.serverTimestamp(), }); + historyDocId = docRef.id; await _loadHistory(); } - if (mounted) _showInteractiveQuiz(matName, questions); + if (mounted) + _showInteractiveQuiz(matName, questions, historyDocId: historyDocId); } catch (e) { Logger.error('Error generating quiz: $e'); if (mounted) _showSnack('Erro ao gerar quiz. Tenta novamente.'); @@ -308,6 +337,24 @@ class _QuizListPageState extends State } } + /// Agrupa os quizzes por disciplina. + /// Usa o primeiro classId do quiz para determinar a disciplina. + /// Se não houver classId, agrupa em "Geral". + Map>> _groupByDiscipline() { + final Map>> groups = {}; + for (final quiz in _teacherQuizzes) { + final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; + // Encontrar o primeiro classId que temos nome + String? groupId = quizClassIds.cast().firstWhere( + (cid) => cid != null && _classNames.containsKey(cid), + orElse: () => null, + ); + groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__'; + groups.putIfAbsent(groupId, () => []).add(quiz); + } + return groups; + } + Widget _buildTeacherQuizzesTab(ColorScheme cs) { if (_loadingTeacherQuizzes) return const Center(child: CircularProgressIndicator()); @@ -343,71 +390,197 @@ class _QuizListPageState extends State ), ); } + + final groups = _groupByDiscipline(); + + // Vista de lista de quizzes de uma disciplina + if (_selectedDisciplineId != null) { + final quizzes = groups[_selectedDisciplineId] ?? []; + final disciplineName = + _classNames[_selectedDisciplineId] ?? + (_selectedDisciplineId == '__geral__' + ? 'Geral' + : _selectedDisciplineId!); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cabeçalho com botão voltar + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => setState(() => _selectedDisciplineId = null), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + disciplineName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ), + Text( + '${quizzes.length} quiz${quizzes.length != 1 ? 'zes' : ''}', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: quizzes.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final quiz = quizzes[i]; + final name = (quiz['materialName'] as String? ?? 'Quiz') + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + final ts = quiz['createdAt']; + String dateStr = ''; + if (ts is Timestamp) { + final dt = ts.toDate(); + dateStr = + '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}'; + } + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outline.withValues(alpha: 0.15), + ), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.quiz, color: cs.secondary, size: 22), + ), + title: Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: cs.onSurface, + ), + ), + subtitle: dateStr.isNotEmpty + ? Text( + 'Publicado em $dateStr', + style: TextStyle( + fontSize: 12, + color: cs.onSurfaceVariant, + ), + ) + : null, + trailing: Icon( + Icons.play_circle_outline, + color: cs.secondary, + size: 28, + ), + onTap: () => _showTeacherQuiz(quiz), + ), + ); + }, + ), + ), + ], + ); + } + + // Vista de disciplinas (cartões) + final disciplineIds = groups.keys.toList(); return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: _teacherQuizzes.length, + itemCount: disciplineIds.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { - final quiz = _teacherQuizzes[i]; - final name = (quiz['materialName'] as String? ?? 'Quiz') - .replaceAll('.pdf', '') - .replaceAll('_', ' '); - final ts = quiz['createdAt']; - String dateStr = ''; - if (ts is Timestamp) { - final dt = ts.toDate(); - dateStr = - '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}'; - } - return Container( - decoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withValues(alpha: 0.15)), - boxShadow: [ - BoxShadow( - color: cs.shadow.withValues(alpha: 0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + final dId = disciplineIds[i]; + final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); + final count = groups[dId]!.length; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => setState(() => _selectedDisciplineId = dId), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: cs.secondary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.school, color: cs.secondary, size: 22), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.school, color: cs.secondary, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '$count quiz${count != 1 ? 'zes' : ''} disponív${count != 1 ? 'eis' : 'el'}', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], ), - title: Text( - name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: cs.onSurface, - ), - ), - subtitle: dateStr.isNotEmpty - ? Text( - 'Publicado em $dateStr', - style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), - ) - : null, - trailing: Icon( - Icons.play_circle_outline, - color: cs.secondary, - size: 28, - ), - onTap: () => _showTeacherQuiz(quiz), ), ); }, @@ -475,13 +648,100 @@ class _QuizListPageState extends State dynamic _jsonDecode(String s) => jsonDecode(s); - void _showInteractiveQuiz(String title, List<_QuizQuestion> questions) { + void _showInteractiveQuiz( + String title, + List<_QuizQuestion> questions, { + String? historyDocId, + }) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, isDismissible: false, - builder: (_) => _InteractiveQuizSheet(title: title, questions: questions), + builder: (_) => _InteractiveQuizSheet( + title: title, + questions: questions, + historyDocId: historyDocId, + ), + ); + } + + void _showHistoryResultDialog(Map item, String matName) { + final cs = Theme.of(context).colorScheme; + final score = item['score'] as int?; + final total = item['totalQuestions'] as int?; + final isTeacher = item['teacherQuizId'] != null; + final ts = item['completedAt'] ?? item['createdAt']; + String dateStr = ''; + if (ts is Timestamp) { + final dt = ts.toDate(); + dateStr = + '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}'; + } + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text( + matName, + style: TextStyle( + fontWeight: FontWeight.bold, + color: cs.onSurface, + fontSize: 16, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (score != null && total != null) ...[ + const SizedBox(height: 8), + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary.withValues(alpha: 0.1), + ), + child: Center( + child: Text( + '$score/$total', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: cs.primary, + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + '${((score / total) * 100).toStringAsFixed(0)}% correcto', + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + ] else + Text( + 'Sem resultado guardado.', + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + if (dateStr.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + isTeacher + ? 'Quiz do professor · $dateStr' + : 'Completado em $dateStr', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fechar'), + ), + ], + ), ); } @@ -507,6 +767,14 @@ class _QuizListPageState extends State canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; + if (_selectedMaterialDisciplineId != null) { + setState(() => _selectedMaterialDisciplineId = null); + return; + } + if (_selectedDisciplineId != null) { + setState(() => _selectedDisciplineId = null); + return; + } context.go('/student-dashboard'); }, child: Scaffold( @@ -518,7 +786,15 @@ class _QuizListPageState extends State elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/student-dashboard'), + onPressed: () { + if (_selectedMaterialDisciplineId != null) { + setState(() => _selectedMaterialDisciplineId = null); + } else if (_selectedDisciplineId != null) { + setState(() => _selectedDisciplineId = null); + } else { + context.go('/student-dashboard'); + } + }, ), bottom: TabBar( controller: _tabController, @@ -544,6 +820,18 @@ class _QuizListPageState extends State ); } + Map>> _groupMaterialsByDiscipline() { + final Map>> groups = {}; + for (final mat in _materials) { + final cid = mat['classId']; + // Usar sempre o classId como chave quando existe; + // materiais sem classId vão para '__geral__' + final groupId = cid ?? '__geral__'; + groups.putIfAbsent(groupId, () => []).add(mat); + } + return groups; + } + Widget _buildMaterialsTab(ColorScheme cs) { if (_loadingMaterials) { return const Center(child: CircularProgressIndicator()); @@ -580,72 +868,221 @@ class _QuizListPageState extends State ), ); } + + final groups = _groupMaterialsByDiscipline(); + + // Vista de materiais de uma disciplina + if (_selectedMaterialDisciplineId != null) { + final mats = groups[_selectedMaterialDisciplineId] ?? []; + final disciplineName = + _materialClassNames[_selectedMaterialDisciplineId] ?? + (_selectedMaterialDisciplineId == '__geral__' + ? 'Geral' + : _selectedMaterialDisciplineId!); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => + setState(() => _selectedMaterialDisciplineId = null), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + disciplineName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ), + Text( + '${mats.length} material${mats.length != 1 ? 'is' : ''}', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: mats.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final mat = mats[i]; + final isGenerating = _generatingForId == mat['id']; + final name = mat['name'] ?? 'Material'; + return _buildMaterialTile(mat, name, isGenerating, cs); + }, + ), + ), + ], + ); + } + + // Sem disciplinas reais OU só 1 disciplina: mostrar lista plana directamente + final realDisciplineIds = groups.keys + .where((k) => k != '__geral__' && _materialClassNames.containsKey(k)) + .toList(); + if (realDisciplineIds.length <= 1) { + final allMats = groups.values.expand((l) => l).toList(); + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: allMats.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final mat = allMats[i]; + return _buildMaterialTile( + mat, + mat['name'] ?? 'Material', + _generatingForId == mat['id'], + cs, + ); + }, + ); + } + + final disciplineIds = realDisciplineIds; return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: _materials.length, + itemCount: disciplineIds.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { - final mat = _materials[i]; - final isGenerating = _generatingForId == mat['id']; - final name = mat['name'] ?? 'Material'; - return Container( - decoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withOpacity(0.15)), - boxShadow: [ - BoxShadow( - color: cs.shadow.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + final dId = disciplineIds[i]; + final dName = + _materialClassNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); + final count = groups[dId]!.length; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => setState(() => _selectedMaterialDisciplineId = dId), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: cs.secondary.withOpacity(0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.picture_as_pdf, color: cs.secondary, size: 22), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.secondary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.school, color: cs.secondary, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '$count material${count != 1 ? 'is' : ''}', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], ), - title: Text( - name.replaceAll('.pdf', '').replaceAll('_', ' '), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: cs.onSurface, - ), - ), - subtitle: Text( - 'Toca para gerar um quiz', - style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), - ), - trailing: isGenerating - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: cs.primary, - ), - ) - : Icon(Icons.play_circle_outline, color: cs.primary, size: 28), - onTap: isGenerating ? null : () => _generateQuiz(mat), ), ); }, ); } + Widget _buildMaterialTile( + Map mat, + String name, + bool isGenerating, + ColorScheme cs, + ) { + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withOpacity(0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.secondary.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.picture_as_pdf, color: cs.secondary, size: 22), + ), + title: Text( + name.replaceAll('.pdf', '').replaceAll('_', ' '), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: cs.onSurface, + ), + ), + subtitle: Text( + 'Toca para gerar um quiz', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), + ), + trailing: isGenerating + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: cs.primary, + ), + ) + : Icon(Icons.play_circle_outline, color: cs.primary, size: 28), + onTap: isGenerating ? null : () => _generateQuiz(mat), + ), + ); + } + Widget _buildHistoryTab(ColorScheme cs) { if (_loadingHistory) { return const Center(child: CircularProgressIndicator()); @@ -726,17 +1163,36 @@ class _QuizListPageState extends State color: cs.onSurface, ), ), - subtitle: dateStr.isNotEmpty - ? Text( + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (dateStr.isNotEmpty) + Text( dateStr, style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), - ) - : null, - trailing: Icon(Icons.chevron_right, color: cs.onSurfaceVariant), - onTap: () => _showQuizFromHistory( - matName, - item['quizJson'] as String? ?? item['quizText'] as String? ?? '', + ), + if (item['score'] != null) + Text( + 'Resultado: ${item['score']}/${item['totalQuestions'] ?? '?'}', + style: TextStyle( + fontSize: 12, + color: cs.primary, + fontWeight: FontWeight.w500, + ), + ), + ], ), + trailing: Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + onTap: () { + final rawJson = + item['quizJson'] as String? ?? item['quizText'] as String?; + if (rawJson != null && rawJson.isNotEmpty) { + _showQuizFromHistory(matName, rawJson); + } else { + // Quiz sem JSON guardado (ex: quiz do professor) — mostrar resultado + _showHistoryResultDialog(item, matName); + } + }, ), ); }, @@ -764,7 +1220,12 @@ class _QuizQuestion { class _InteractiveQuizSheet extends StatefulWidget { final String title; final List<_QuizQuestion> questions; - const _InteractiveQuizSheet({required this.title, required this.questions}); + final String? historyDocId; + const _InteractiveQuizSheet({ + required this.title, + required this.questions, + this.historyDocId, + }); @override State<_InteractiveQuizSheet> createState() => _InteractiveQuizSheetState(); @@ -788,13 +1249,58 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { setState(() => _chosen[_current] = idx); } - void _next() { + Future _next() async { if (_current < widget.questions.length - 1) { setState(() { _current++; }); } else { - setState(() => _submitted = true); + await _submit(); + } + } + + Future _submit() async { + setState(() => _submitted = true); + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid != null) { + final score = _score; + final total = widget.questions.length; + if (widget.historyDocId != null) { + // Actualizar doc existente com o resultado + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(uid) + .collection('quizzes') + .doc(widget.historyDocId) + .update({ + 'score': score, + 'totalQuestions': total, + 'completedAt': FieldValue.serverTimestamp(), + }); + } else { + // Quiz aberto do histórico sem doc id — guardar novo registo + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(uid) + .collection('quizzes') + .add({ + 'materialName': widget.title, + 'score': score, + 'totalQuestions': total, + 'completedAt': FieldValue.serverTimestamp(), + 'createdAt': FieldValue.serverTimestamp(), + }); + } + await GamificationService.recordQuizActivity( + uid, + score: score, + totalQuestions: total, + materialName: widget.title, + ); + } + } catch (e) { + Logger.error('Error saving quiz result: $e'); } } @@ -1230,26 +1736,45 @@ class _TeacherQuizInteractiveSheetState try { final user = FirebaseAuth.instance.currentUser; if (user != null) { + final score = _score; + final total = widget.questions.length; + final matName = widget.materialName ?? widget.title; + + // Guardar submissão no quiz do professor await FirebaseFirestore.instance .collection('teacherQuizzes') .doc(widget.quizId) .collection('submissions') - .doc(user.uid) // 1 submissão por aluno (sobrescreve) + .doc(user.uid) .set({ 'studentId': user.uid, 'studentName': user.displayName ?? user.email?.split('@')[0] ?? 'Aluno', - 'score': _score, - 'total': widget.questions.length, + 'score': score, + 'total': total, 'submittedAt': FieldValue.serverTimestamp(), }); + // Guardar no historial do aluno para aparecer em Quiz > Histórico + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(user.uid) + .collection('quizzes') + .add({ + 'materialName': matName, + 'score': score, + 'totalQuestions': total, + 'teacherQuizId': widget.quizId, + 'createdAt': FieldValue.serverTimestamp(), + 'completedAt': FieldValue.serverTimestamp(), + }); + // Registrar atividade no sistema de gamificação await GamificationService.recordQuizActivity( user.uid, - score: _score, - totalQuestions: widget.questions.length, - materialName: widget.materialName ?? 'Quiz', + score: score, + totalQuestions: total, + materialName: matName, ); } } catch (e) { diff --git a/lib/features/quiz/presentation/pages/quiz_management_page.dart b/lib/features/quiz/presentation/pages/quiz_management_page.dart index 4cc78e3..a1656e8 100644 --- a/lib/features/quiz/presentation/pages/quiz_management_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_management_page.dart @@ -20,6 +20,10 @@ class _QuizManagementPageState extends State { bool _loading = true; String _userRole = ''; + // Disciplina seleccionada (null = vista de disciplinas) + String? _selectedDisciplineId; + Map _classNames = {}; // classId → name + @override void initState() { super.initState(); @@ -116,16 +120,37 @@ class _QuizManagementPageState extends State { } final snapshot = await query.get(); + final quizzes = snapshot.docs + .map((doc) { + final data = Map.from(doc.data() as Map); + data['id'] = doc.id; + return data; + }) + .cast>() + .toList(); + + // Obter nomes das disciplinas + final classIdSet = {}; + for (final q in quizzes) { + final ids = (q['classIds'] as List?)?.cast() ?? []; + classIdSet.addAll(ids); + } + final classNamesMap = {}; + if (classIdSet.isNotEmpty) { + final docs = await Future.wait( + classIdSet.map( + (id) => + FirebaseFirestore.instance.collection('classes').doc(id).get(), + ), + ); + for (final doc in docs.where((d) => d.exists)) { + classNamesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id; + } + } setState(() { - _quizHistory = snapshot.docs - .map((doc) { - final data = Map.from(doc.data() as Map); - data['id'] = doc.id; - return data; - }) - .cast>() - .toList(); + _quizHistory = quizzes; + _classNames = classNamesMap; _loading = false; }); } catch (e) { @@ -277,61 +302,222 @@ class _QuizManagementPageState extends State { return result ?? false; } + Map>> _groupByDiscipline() { + final Map>> groups = {}; + for (final quiz in _quizHistory) { + final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; + String? groupId = quizClassIds.cast().firstWhere( + (cid) => cid != null && _classNames.containsKey(cid), + orElse: () => null, + ); + groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__'; + groups.putIfAbsent(groupId, () => []).add(quiz); + } + return groups; + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - title: Text( - _userRole == 'teacher' ? 'Gerenciar Quizzes' : 'Meu Histórico', - ), + return PopScope( + canPop: _selectedDisciplineId == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _selectedDisciplineId != null) { + setState(() => _selectedDisciplineId = null); + } + }, + child: Scaffold( backgroundColor: cs.surface, - foregroundColor: cs.onSurface, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } else { - context.go( - _userRole == 'teacher' - ? '/teacher-dashboard' - : '/student-dashboard', - ); - } - }, - ), - ), - body: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [cs.primary.withValues(alpha: 0.05), cs.surface], + appBar: AppBar( + title: Text( + _userRole == 'teacher' ? 'Gerenciar Quizzes' : 'Meu Histórico', + ), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (_userRole == 'teacher' && _selectedDisciplineId != null) { + setState(() => _selectedDisciplineId = null); + return; + } + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + context.go( + _userRole == 'teacher' + ? '/teacher-dashboard' + : '/student-dashboard', + ); + } + }, ), ), - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _quizHistory.isEmpty - ? _buildEmptyState() - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _quizHistory.length, - itemBuilder: (context, index) { - final quiz = _quizHistory[index]; - return _buildQuizCard(quiz) - .animate() - .slideX(duration: const Duration(milliseconds: 300)) - .then(delay: Duration(milliseconds: index * 50)); - }, - ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [cs.primary.withValues(alpha: 0.05), cs.surface], + ), + ), + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _quizHistory.isEmpty + ? _buildEmptyState() + : _userRole == 'teacher' + ? _buildTeacherBody(cs) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _quizHistory.length, + itemBuilder: (context, index) { + final quiz = _quizHistory[index]; + return _buildQuizCard(quiz) + .animate() + .slideX(duration: const Duration(milliseconds: 300)) + .then(delay: Duration(milliseconds: index * 50)); + }, + ), + ), ), ); } + Widget _buildTeacherBody(ColorScheme cs) { + final groups = _groupByDiscipline(); + + // Vista de quizzes de uma disciplina + if (_selectedDisciplineId != null) { + final quizzes = groups[_selectedDisciplineId] ?? []; + final disciplineName = + _classNames[_selectedDisciplineId] ?? + (_selectedDisciplineId == '__geral__' + ? 'Geral' + : _selectedDisciplineId!); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => setState(() => _selectedDisciplineId = null), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + disciplineName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ), + Text( + '${quizzes.length} quiz${quizzes.length != 1 ? 'zes' : ''}', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: quizzes.length, + itemBuilder: (context, index) { + final quiz = quizzes[index]; + return _buildQuizCard(quiz) + .animate() + .slideX(duration: const Duration(milliseconds: 300)) + .then(delay: Duration(milliseconds: index * 50)); + }, + ), + ), + ], + ); + } + + // Vista de disciplinas + final disciplineIds = groups.keys.toList(); + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: disciplineIds.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final dId = disciplineIds[i]; + final dName = _classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); + final count = groups[dId]!.length; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => setState(() => _selectedDisciplineId = dId), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.school, color: cs.primary, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '$count quiz${count != 1 ? 'zes' : ''} criado${count != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ), + ); + }, + ); + } + Widget _buildEmptyState() { final cs = Theme.of(context).colorScheme; @@ -443,7 +629,9 @@ class _QuizManagementPageState extends State { ), ], ), - if (_userRole == 'teacher' && quiz['classIds'] != null) ...[ + if (_userRole == 'teacher' && + quiz['classIds'] != null && + _selectedDisciplineId == null) ...[ const SizedBox(height: 8), Wrap( spacing: 4, diff --git a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart index 687badb..89cee01 100644 --- a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart +++ b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart @@ -67,6 +67,9 @@ class _TeacherQuizPageState extends State bool _loadingHistory = true; String? _generatingForId; + // Disciplina seleccionada no histórico (null = vista de disciplinas) + String? _selectedHistoryDisciplineId; + @override void initState() { super.initState(); @@ -256,7 +259,13 @@ class _TeacherQuizPageState extends State elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/teacher-dashboard'), + onPressed: () { + if (_selectedHistoryDisciplineId != null) { + setState(() => _selectedHistoryDisciplineId = null); + } else { + context.go('/teacher-dashboard'); + } + }, ), bottom: TabBar( controller: _tabController, @@ -330,6 +339,82 @@ class _TeacherQuizPageState extends State ); } + Map get _classNamesMap { + return {for (final c in _teacherClasses) c['id']!: c['name'] ?? c['id']!}; + } + + Map>> _groupHistoryByDiscipline() { + final classNames = _classNamesMap; + final Map>> groups = {}; + for (final quiz in _history) { + final quizClassIds = (quiz['classIds'] as List?)?.cast() ?? []; + String? groupId = quizClassIds.cast().firstWhere( + (cid) => cid != null && classNames.containsKey(cid), + orElse: () => null, + ); + groupId ??= quizClassIds.isNotEmpty ? quizClassIds.first : '__geral__'; + groups.putIfAbsent(groupId, () => []).add(quiz); + } + return groups; + } + + Widget _buildHistoryQuizTile(Map item, ColorScheme cs) { + final name = (item['materialName'] as String? ?? 'Material') + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + final ts = item['createdAt']; + String dateStr = ''; + if (ts is Timestamp) { + final dt = ts.toDate(); + 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')}'; + } + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.quiz, color: cs.primary, size: 22), + ), + title: Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: cs.onSurface, + ), + ), + subtitle: dateStr.isNotEmpty + ? Text( + dateStr, + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), + ) + : null, + trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant), + onTap: () => _showResultsPopup(item), + ), + ); + } + Widget _buildHistoryTab(ColorScheme cs) { if (_loadingHistory) return const Center(child: CircularProgressIndicator()); @@ -355,67 +440,131 @@ class _TeacherQuizPageState extends State ), ); } + + final groups = _groupHistoryByDiscipline(); + final classNames = _classNamesMap; + + // Vista de quizzes de uma disciplina + if (_selectedHistoryDisciplineId != null) { + final quizzes = groups[_selectedHistoryDisciplineId] ?? []; + final disciplineName = + classNames[_selectedHistoryDisciplineId] ?? + (_selectedHistoryDisciplineId == '__geral__' + ? 'Geral' + : _selectedHistoryDisciplineId!); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => + setState(() => _selectedHistoryDisciplineId = null), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + disciplineName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ), + Text( + '${quizzes.length} quiz${quizzes.length != 1 ? 'zes' : ''}', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: quizzes.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) => + _buildHistoryQuizTile(quizzes[i], cs), + ), + ), + ], + ); + } + + // Vista de disciplinas + final disciplineIds = groups.keys.toList(); return ListView.separated( padding: const EdgeInsets.all(16), - itemCount: _history.length, + itemCount: disciplineIds.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, i) { - final item = _history[i]; - final name = (item['materialName'] as String? ?? 'Material') - .replaceAll('.pdf', '') - .replaceAll('_', ' '); - final ts = item['createdAt']; - String dateStr = ''; - if (ts is Timestamp) { - final dt = ts.toDate(); - 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')}'; - } - return Container( - decoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withValues(alpha: 0.15)), - boxShadow: [ - BoxShadow( - color: cs.shadow.withValues(alpha: 0.05), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + final dId = disciplineIds[i]; + final dName = classNames[dId] ?? (dId == '__geral__' ? 'Geral' : dId); + final count = groups[dId]!.length; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => setState(() => _selectedHistoryDisciplineId = dId), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: cs.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.quiz, color: cs.primary, size: 22), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.school, color: cs.primary, size: 26), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '$count quiz${count != 1 ? 'zes' : ''} criado${count != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], ), - title: Text( - name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: cs.onSurface, - ), - ), - subtitle: dateStr.isNotEmpty - ? Text( - dateStr, - style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), - ) - : null, - trailing: Icon(Icons.bar_chart, color: cs.onSurfaceVariant), - onTap: () => _showResultsPopup(item), ), ); },