mais correções e implementações nos quizzes

This commit is contained in:
2026-05-16 21:17:55 +01:00
parent 27263e86ba
commit 14509c04d3
2 changed files with 152 additions and 31 deletions

View File

@@ -66,30 +66,24 @@ class _QuizListPageState extends State<QuizListPage>
if (mounted) setState(() => _loadingTeacherQuizzes = false); if (mounted) setState(() => _loadingTeacherQuizzes = false);
return; return;
} }
final classSnaps = await Future.wait( // Quizzes publicados para as turmas do aluno (array-contains-any, máx 10 por query)
classIds.map((id) => FirebaseFirestore.instance.collection('classes').doc(id).get()), final classIdList = classIds.toList();
);
final teacherIds = classSnaps
.where((d) => d.exists)
.map((d) => d.data()?['teacherId'] as String?)
.whereType<String>()
.toSet()
.toList();
if (teacherIds.isEmpty) {
if (mounted) setState(() => _loadingTeacherQuizzes = false);
return;
}
// Quizzes publicados por esses professores (máx 10 teacherIds por query)
final batches = <Future<QuerySnapshot>>[]; final batches = <Future<QuerySnapshot>>[];
for (int i = 0; i < teacherIds.length; i += 10) { for (int i = 0; i < classIdList.length; i += 10) {
batches.add(FirebaseFirestore.instance batches.add(FirebaseFirestore.instance
.collection('teacherQuizzes') .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) .orderBy('createdAt', descending: true)
.get()); .get());
} }
final results = await Future.wait(batches); final results = await Future.wait(batches);
final quizzes = results.expand((s) => s.docs).map((d) => {'id': d.id, ...d.data() as Map<String, dynamic>}).toList(); // deduplicar por id (pode aparecer em múltiplos batches)
final seen = <String>{};
final quizzes = results
.expand((s) => s.docs)
.where((d) => seen.add(d.id))
.map((d) => {'id': d.id, ...d.data() as Map<String, dynamic>})
.toList();
if (mounted) setState(() { _teacherQuizzes = quizzes; _loadingTeacherQuizzes = false; }); if (mounted) setState(() { _teacherQuizzes = quizzes; _loadingTeacherQuizzes = false; });
} catch (e) { } catch (e) {
Logger.error('Error loading teacher quizzes: $e'); Logger.error('Error loading teacher quizzes: $e');

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
@@ -56,6 +57,7 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
List<Map<String, String>> _materials = []; List<Map<String, String>> _materials = [];
List<Map<String, dynamic>> _history = []; List<Map<String, dynamic>> _history = [];
List<Map<String, String>> _teacherClasses = [];
bool _loadingMaterials = true; bool _loadingMaterials = true;
bool _loadingHistory = true; bool _loadingHistory = true;
String? _generatingForId; String? _generatingForId;
@@ -66,6 +68,7 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_loadMaterials(); _loadMaterials();
_loadHistory(); _loadHistory();
_loadTeacherClasses();
} }
@override @override
@@ -74,6 +77,24 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
super.dispose(); super.dispose();
} }
Future<void> _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<void> _loadMaterials() async { Future<void> _loadMaterials() async {
try { try {
final uid = FirebaseAuth.instance.currentUser?.uid; final uid = FirebaseAuth.instance.currentUser?.uid;
@@ -128,13 +149,14 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
return; return;
} }
final numQuestions = 5 + Random().nextInt(16); // 5..20
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'
'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' 'Responde SOMENTE com JSON válido, sem texto adicional, sem markdown, sem blocos de código.\n'
'Formato exacto:\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.'; 'ans é o índice (0-3) da opção correcta.';
final raw = await RAGAIService.generateQuiz(prompt); final raw = await RAGAIService.generateQuiz(prompt);
@@ -153,6 +175,7 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
materialName: matName, materialName: matName,
materialId: matId, materialId: matId,
questions: questions, questions: questions,
availableClasses: _teacherClasses,
), ),
), ),
); );
@@ -399,11 +422,13 @@ class _QuizEditorPage extends StatefulWidget {
final String materialName; final String materialName;
final String materialId; final String materialId;
final List<_QuizQuestion> questions; final List<_QuizQuestion> questions;
final List<Map<String, String>> availableClasses;
const _QuizEditorPage({ const _QuizEditorPage({
required this.materialName, required this.materialName,
required this.materialId, required this.materialId,
required this.questions, required this.questions,
required this.availableClasses,
}); });
@override @override
@@ -412,6 +437,7 @@ class _QuizEditorPage extends StatefulWidget {
class _QuizEditorPageState extends State<_QuizEditorPage> { class _QuizEditorPageState extends State<_QuizEditorPage> {
late List<_QuizQuestion> _questions; late List<_QuizQuestion> _questions;
final Set<String> _selectedClassIds = {};
bool _saving = false; bool _saving = false;
@override @override
@@ -426,6 +452,21 @@ class _QuizEditorPageState extends State<_QuizEditorPage> {
explanation: q.explanation, explanation: q.explanation,
)) ))
.toList(); .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<void> _publish() async { Future<void> _publish() async {
@@ -439,6 +480,7 @@ class _QuizEditorPageState extends State<_QuizEditorPage> {
'materialId': widget.materialId, 'materialId': widget.materialId,
'materialName': widget.materialName, 'materialName': widget.materialName,
'quizJson': jsonStr, 'quizJson': jsonStr,
'classIds': _selectedClassIds.toList(),
'createdAt': FieldValue.serverTimestamp(), 'createdAt': FieldValue.serverTimestamp(),
}); });
if (mounted) Navigator.of(context).pop(true); 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))), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
) )
: TextButton.icon( : TextButton.icon(
onPressed: _publish, onPressed: (widget.availableClasses.isEmpty || _selectedClassIds.isNotEmpty) ? _publish : null,
icon: const Icon(Icons.publish), icon: const Icon(Icons.publish),
label: const Text('Publicar'), label: const Text('Publicar'),
), ),
], ],
), ),
body: ListView.separated( body: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
itemCount: _questions.length, itemCount: _questions.length + 2, // +1 turmas header +1 add button
separatorBuilder: (_, __) => const SizedBox(height: 16), itemBuilder: (context, i) {
itemBuilder: (context, i) => _QuestionEditor( if (i == 0) return _buildClassSelector(cs);
index: i, if (i == _questions.length + 1) {
question: _questions[i], return Padding(
cs: cs, padding: const EdgeInsets.only(top: 8),
onChanged: () => setState(() {}), 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 _QuizQuestion question;
final ColorScheme cs; final ColorScheme cs;
final VoidCallback onChanged; 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 @override
State<_QuestionEditor> createState() => _QuestionEditorState(); State<_QuestionEditor> createState() => _QuestionEditorState();
@@ -580,6 +698,15 @@ class _QuestionEditorState extends State<_QuestionEditor> {
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface), 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), Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: cs.onSurfaceVariant),
], ],
), ),