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);
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<String>()
.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 = <Future<QuerySnapshot>>[];
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<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; });
} catch (e) {
Logger.error('Error loading teacher quizzes: $e');

View File

@@ -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<TeacherQuizPage>
List<Map<String, String>> _materials = [];
List<Map<String, dynamic>> _history = [];
List<Map<String, String>> _teacherClasses = [];
bool _loadingMaterials = true;
bool _loadingHistory = true;
String? _generatingForId;
@@ -66,6 +68,7 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
_tabController = TabController(length: 2, vsync: this);
_loadMaterials();
_loadHistory();
_loadTeacherClasses();
}
@override
@@ -74,6 +77,24 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
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 {
try {
final uid = FirebaseAuth.instance.currentUser?.uid;
@@ -128,13 +149,14 @@ class _TeacherQuizPageState extends State<TeacherQuizPage>
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<TeacherQuizPage>
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<Map<String, String>> 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<String> _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<void> _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],
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),
],
),