From 14509c04d3654b63edfdf5470182faae2dea8413 Mon Sep 17 00:00:00 2001 From: 240403 <240403@epvc.pt> Date: Sat, 16 May 2026 21:17:55 +0100 Subject: [PATCH] =?UTF-8?q?mais=20corre=C3=A7=C3=B5es=20e=20implementa?= =?UTF-8?q?=C3=A7=C3=B5es=20nos=20quizzes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/pages/quiz_list_page.dart | 28 ++-- .../presentation/pages/teacher_quiz_page.dart | 155 ++++++++++++++++-- 2 files changed, 152 insertions(+), 31 deletions(-) diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index 9fa0c57..88c411a 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -66,30 +66,24 @@ class _QuizListPageState extends State if (mounted) setState(() => _loadingTeacherQuizzes = false); return; } - 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(); - if (teacherIds.isEmpty) { - if (mounted) setState(() => _loadingTeacherQuizzes = false); - return; - } - // Quizzes publicados por esses professores (máx 10 teacherIds por query) + // Quizzes publicados para as turmas do aluno (array-contains-any, máx 10 por query) + final classIdList = classIds.toList(); final batches = >[]; - for (int i = 0; i < teacherIds.length; i += 10) { + for (int i = 0; i < classIdList.length; i += 10) { batches.add(FirebaseFirestore.instance .collection('teacherQuizzes') - .where('teacherId', whereIn: teacherIds.sublist(i, (i + 10).clamp(0, teacherIds.length))) + .where('classIds', arrayContainsAny: classIdList.sublist(i, (i + 10).clamp(0, classIdList.length))) .orderBy('createdAt', descending: true) .get()); } final results = await Future.wait(batches); - final quizzes = results.expand((s) => s.docs).map((d) => {'id': d.id, ...d.data() as Map}).toList(); + // 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(); if (mounted) setState(() { _teacherQuizzes = quizzes; _loadingTeacherQuizzes = false; }); } catch (e) { Logger.error('Error loading teacher quizzes: $e'); diff --git a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart index 56fc769..96e04fa 100644 --- a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart +++ b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; @@ -56,6 +57,7 @@ class _TeacherQuizPageState extends State List> _materials = []; List> _history = []; + List> _teacherClasses = []; bool _loadingMaterials = true; bool _loadingHistory = true; String? _generatingForId; @@ -66,6 +68,7 @@ class _TeacherQuizPageState extends State _tabController = TabController(length: 2, vsync: this); _loadMaterials(); _loadHistory(); + _loadTeacherClasses(); } @override @@ -74,6 +77,24 @@ class _TeacherQuizPageState extends State super.dispose(); } + Future _loadTeacherClasses() async { + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid == null) return; + final snap = await FirebaseFirestore.instance + .collection('classes') + .where('teacherId', isEqualTo: uid) + .orderBy('createdAt', descending: true) + .get(); + final classes = snap.docs + .map((d) => {'id': d.id, 'name': d.data()['name'] as String? ?? d.id}) + .toList(); + if (mounted) setState(() => _teacherClasses = classes); + } catch (e) { + Logger.error('Error loading teacher classes: $e'); + } + } + Future _loadMaterials() async { try { final uid = FirebaseAuth.instance.currentUser?.uid; @@ -128,13 +149,14 @@ class _TeacherQuizPageState extends State return; } + final numQuestions = 5 + Random().nextInt(16); // 5..20 final prompt = 'Usa APENAS o seguinte contexto para criar um quiz. Não uses conhecimento externo.\n\n' '$pdfContext\n\n' - 'Cria um quiz com 5 perguntas de escolha múltipla sobre o conteúdo acima.\n' + 'Cria um quiz com EXACTAMENTE $numQuestions perguntas de escolha múltipla sobre o conteúdo acima. Não repitas perguntas.\n' 'Responde SOMENTE com JSON válido, sem texto adicional, sem markdown, sem blocos de código.\n' 'Formato exacto:\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' + '[{"q":"Pergunta aqui","opts":["A) opção","B) opção","C) opção","D) opção"],"ans":0,"exp":"Explicação breve da resposta correcta"},...}]\n' 'ans é o índice (0-3) da opção correcta.'; final raw = await RAGAIService.generateQuiz(prompt); @@ -153,6 +175,7 @@ class _TeacherQuizPageState extends State materialName: matName, materialId: matId, questions: questions, + availableClasses: _teacherClasses, ), ), ); @@ -399,11 +422,13 @@ class _QuizEditorPage extends StatefulWidget { final String materialName; final String materialId; final List<_QuizQuestion> questions; + final List> availableClasses; const _QuizEditorPage({ required this.materialName, required this.materialId, required this.questions, + required this.availableClasses, }); @override @@ -412,6 +437,7 @@ class _QuizEditorPage extends StatefulWidget { class _QuizEditorPageState extends State<_QuizEditorPage> { late List<_QuizQuestion> _questions; + final Set _selectedClassIds = {}; bool _saving = false; @override @@ -426,6 +452,21 @@ class _QuizEditorPageState extends State<_QuizEditorPage> { explanation: q.explanation, )) .toList(); + // selecionar todas as turmas por defeito + for (final c in widget.availableClasses) { + if (c['id'] != null) _selectedClassIds.add(c['id']!); + } + } + + void _addBlankQuestion() { + setState(() { + _questions.add(_QuizQuestion( + question: '', + options: ['A) ', 'B) ', 'C) ', 'D) '], + correctIndex: 0, + explanation: '', + )); + }); } Future _publish() async { @@ -439,6 +480,7 @@ class _QuizEditorPageState extends State<_QuizEditorPage> { 'materialId': widget.materialId, 'materialName': widget.materialName, 'quizJson': jsonStr, + 'classIds': _selectedClassIds.toList(), 'createdAt': FieldValue.serverTimestamp(), }); if (mounted) Navigator.of(context).pop(true); @@ -474,22 +516,97 @@ class _QuizEditorPageState extends State<_QuizEditorPage> { child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), ) : TextButton.icon( - onPressed: _publish, + onPressed: (widget.availableClasses.isEmpty || _selectedClassIds.isNotEmpty) ? _publish : null, icon: const Icon(Icons.publish), label: const Text('Publicar'), ), ], ), - body: ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _questions.length, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (context, i) => _QuestionEditor( - index: i, - question: _questions[i], - cs: cs, - onChanged: () => setState(() {}), - ), + body: ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: _questions.length + 2, // +1 turmas header +1 add button + itemBuilder: (context, i) { + if (i == 0) return _buildClassSelector(cs); + if (i == _questions.length + 1) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: OutlinedButton.icon( + onPressed: _addBlankQuestion, + icon: const Icon(Icons.add), + label: const Text('Adicionar Pergunta'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ); + } + final qIdx = i - 1; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _QuestionEditor( + index: qIdx, + question: _questions[qIdx], + cs: cs, + onChanged: () => setState(() {}), + onDelete: _questions.length > 1 + ? () => setState(() => _questions.removeAt(qIdx)) + : null, + ), + ); + }, + ), + ); + } + + Widget _buildClassSelector(ColorScheme cs) { + if (widget.availableClasses.isEmpty) return const SizedBox(height: 0); + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.2)), + boxShadow: [BoxShadow(color: cs.shadow.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.groups, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text('Turmas com acesso', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: cs.onSurface)), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: widget.availableClasses.map((c) { + final id = c['id']!; + final name = c['name'] ?? id; + final selected = _selectedClassIds.contains(id); + return FilterChip( + label: Text(name, style: TextStyle(fontSize: 13, color: selected ? cs.onPrimary : cs.onSurface)), + selected: selected, + onSelected: (v) => setState(() => v ? _selectedClassIds.add(id) : _selectedClassIds.remove(id)), + selectedColor: cs.primary, + checkmarkColor: cs.onPrimary, + backgroundColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), + side: BorderSide(color: selected ? cs.primary : cs.outline.withValues(alpha: 0.3)), + ); + }).toList(), + ), + if (_selectedClassIds.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Seleciona pelo menos uma turma.', + style: TextStyle(fontSize: 12, color: cs.error)), + ), + ], ), ); } @@ -502,7 +619,8 @@ class _QuestionEditor extends StatefulWidget { final _QuizQuestion question; final ColorScheme cs; final VoidCallback onChanged; - const _QuestionEditor({required this.index, required this.question, required this.cs, required this.onChanged}); + final VoidCallback? onDelete; + const _QuestionEditor({required this.index, required this.question, required this.cs, required this.onChanged, this.onDelete}); @override State<_QuestionEditor> createState() => _QuestionEditorState(); @@ -580,6 +698,15 @@ class _QuestionEditorState extends State<_QuestionEditor> { style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface), ), ), + if (widget.onDelete != null) + IconButton( + icon: Icon(Icons.delete_outline, color: cs.error, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: widget.onDelete, + tooltip: 'Remover', + ), + const SizedBox(width: 4), Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: cs.onSurfaceVariant), ], ),