diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 4002e4d..f0ce6b1 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -11,6 +11,7 @@ import '../../features/dashboard/presentation/pages/teacher_dashboard_page.dart' import '../../features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart'; import '../../features/quiz/presentation/pages/quiz_list_page.dart'; import '../../features/quiz/presentation/pages/quiz_page.dart'; +import '../../features/quiz/presentation/pages/teacher_quiz_page.dart'; import '../../features/profile/presentation/pages/profile_page.dart'; import '../../features/splash/presentation/pages/splash_page.dart'; import '../../features/auth/presentation/pages/role_selection_page.dart'; @@ -146,6 +147,13 @@ class AppRouter { name: 'quizList', builder: (context, state) => const QuizListPage(), ), + + // Teacher Quiz Create Route + GoRoute( + path: '/teacher/quiz/create', + name: 'teacherQuizCreate', + builder: (context, state) => const TeacherQuizPage(), + ), ], // Let splash screen handle all navigation logic diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index f9fdb70..9fa0c57 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -20,8 +20,10 @@ class _QuizListPageState extends State List> _materials = []; List> _history = []; + List> _teacherQuizzes = []; bool _loadingMaterials = true; bool _loadingHistory = true; + bool _loadingTeacherQuizzes = true; // generating state String? _generatingForId; @@ -29,9 +31,10 @@ class _QuizListPageState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); _loadMaterials(); _loadHistory(); + _loadTeacherQuizzes(); } @override @@ -45,6 +48,55 @@ class _QuizListPageState extends State if (mounted) setState(() { _materials = mats; _loadingMaterials = false; }); } + Future _loadTeacherQuizzes() async { + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid == null) { if (mounted) setState(() => _loadingTeacherQuizzes = false); return; } + + // Obter teacherIds dos professores do aluno + final enrollSnap = await FirebaseFirestore.instance + .collection('enrollments') + .where('studentId', isEqualTo: uid) + .get(); + final classIds = enrollSnap.docs + .map((d) => d.data()['classId'] as String?) + .whereType() + .toSet(); + if (classIds.isEmpty) { + 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) + final batches = >[]; + for (int i = 0; i < teacherIds.length; i += 10) { + batches.add(FirebaseFirestore.instance + .collection('teacherQuizzes') + .where('teacherId', whereIn: teacherIds.sublist(i, (i + 10).clamp(0, teacherIds.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(); + if (mounted) setState(() { _teacherQuizzes = quizzes; _loadingTeacherQuizzes = false; }); + } catch (e) { + Logger.error('Error loading teacher quizzes: $e'); + if (mounted) setState(() => _loadingTeacherQuizzes = false); + } + } + Future _loadHistory() async { try { final uid = FirebaseAuth.instance.currentUser?.uid; @@ -124,6 +176,101 @@ class _QuizListPageState extends State } } + Widget _buildTeacherQuizzesTab(ColorScheme cs) { + if (_loadingTeacherQuizzes) return const Center(child: CircularProgressIndicator()); + if (_teacherQuizzes.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.school_outlined, size: 64, color: cs.onSurfaceVariant.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text('Sem quizzes do professor disponíveis.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)), + const SizedBox(height: 8), + Text('O teu professor ainda não publicou quizzes.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 13)), + ], + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _teacherQuizzes.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), + 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), + ), + 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), + ), + ); + }, + ); + } + + void _showTeacherQuiz(Map quizDoc) { + final rawJson = quizDoc['quizJson'] as String? ?? ''; + final questions = _parseQuizJson(rawJson); + if (questions.isEmpty) { + _showSnack('Não foi possível carregar este quiz.'); + return; + } + final quizId = quizDoc['id'] as String; + final name = (quizDoc['materialName'] as String? ?? 'Quiz') + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: false, + builder: (_) => _TeacherQuizInteractiveSheet( + title: name, + quizId: quizId, + questions: questions, + ), + ); + } + /// Parse do JSON gerado pelo modelo — tolerante a erros List<_QuizQuestion> _parseQuizJson(String raw) { try { @@ -205,6 +352,7 @@ class _QuizListPageState extends State tabs: const [ Tab(text: 'Gerar Quiz'), Tab(text: 'Histórico'), + Tab(text: 'Do Professor'), ], ), ), @@ -213,6 +361,7 @@ class _QuizListPageState extends State children: [ _buildMaterialsTab(cs), _buildHistoryTab(cs), + _buildTeacherQuizzesTab(cs), ], ), ), @@ -744,3 +893,343 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ); } } + +// ─── Sheet interativa para quizzes do professor (submete resultado) ─────────── + +class _TeacherQuizInteractiveSheet extends StatefulWidget { + final String title; + final String quizId; + final List<_QuizQuestion> questions; + const _TeacherQuizInteractiveSheet({ + required this.title, + required this.quizId, + required this.questions, + }); + + @override + State<_TeacherQuizInteractiveSheet> createState() => _TeacherQuizInteractiveSheetState(); +} + +class _TeacherQuizInteractiveSheetState extends State<_TeacherQuizInteractiveSheet> { + int _current = 0; + late List _chosen; + bool _submitted = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + _chosen = List.filled(widget.questions.length, -1); + } + + void _selectOption(int idx) { + if (_submitted) return; + setState(() => _chosen[_current] = idx); + } + + void _next() { + if (_current < widget.questions.length - 1) { + setState(() => _current++); + } else { + _submit(); + } + } + + void _prev() { + if (_current > 0) setState(() => _current--); + } + + int get _score => List.generate(widget.questions.length, + (i) => _chosen[i] == widget.questions[i].correctIndex ? 1 : 0).fold(0, (a, b) => a + b); + + Future _submit() async { + setState(() { _submitted = true; _saving = true; }); + try { + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + await FirebaseFirestore.instance + .collection('teacherQuizzes') + .doc(widget.quizId) + .collection('submissions') + .doc(user.uid) // 1 submissão por aluno (sobrescreve) + .set({ + 'studentId': user.uid, + 'studentName': user.displayName ?? user.email?.split('@')[0] ?? 'Aluno', + 'score': _score, + 'total': widget.questions.length, + 'submittedAt': FieldValue.serverTimestamp(), + }); + } + } catch (e) { + Logger.error('Error submitting teacher quiz result: $e'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return DraggableScrollableSheet( + initialChildSize: 0.93, + minChildSize: 0.6, + maxChildSize: 0.97, + builder: (_, sc) => Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: 12, bottom: 4), + width: 40, height: 4, + decoration: BoxDecoration( + color: cs.outline.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: cs.onSurface)), + if (!_submitted) + Text('Pergunta ${_current + 1} de ${widget.questions.length}', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant)), + ], + ), + ), + if (_saving) + const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + else + IconButton( + icon: Icon(Icons.close, color: cs.onSurfaceVariant), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: _submitted + ? _buildResults(cs, sc) + : _buildQuestion(cs, sc), + ), + ], + ), + ), + ); + } + + Widget _buildQuestion(ColorScheme cs, ScrollController sc) { + final q = widget.questions[_current]; + final chosen = _chosen[_current]; + return ListView( + controller: sc, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (_current + 1) / widget.questions.length, + minHeight: 6, + backgroundColor: cs.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation(cs.secondary), + ), + ), + const SizedBox(height: 20), + Text(q.question, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: cs.onSurface, height: 1.4)), + const SizedBox(height: 20), + ...List.generate(q.options.length, (i) { + final isSelected = chosen == i; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _selectOption(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isSelected ? cs.secondary.withValues(alpha: 0.12) : cs.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? cs.secondary : cs.outline.withValues(alpha: 0.2), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Text(q.options[i], + style: TextStyle( + fontSize: 14, + color: isSelected ? cs.secondary : cs.onSurface, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + )), + ), + if (isSelected) Icon(Icons.check_circle, color: cs.secondary, size: 20), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 8), + Row( + children: [ + if (_current > 0) ...[ + Expanded( + child: OutlinedButton( + onPressed: _prev, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Anterior'), + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: ElevatedButton( + onPressed: chosen == -1 ? null : _next, + style: ElevatedButton.styleFrom( + backgroundColor: cs.secondary, + foregroundColor: cs.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text(_current < widget.questions.length - 1 ? 'Próxima' : 'Submeter'), + ), + ), + ], + ), + ], + ); + } + + Widget _buildResults(ColorScheme cs, ScrollController sc) { + final total = widget.questions.length; + final score = _score; + final pct = (score / total * 100).round(); + final Color scoreColor = pct >= 80 + ? const Color(0xFF10B981) + : pct >= 50 + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + return ListView( + controller: sc, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: scoreColor.withValues(alpha: 0.3)), + ), + child: Column( + children: [ + Text('$score / $total', + style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold, color: scoreColor)), + const SizedBox(height: 4), + Text('$pct% de respostas correctas', + style: TextStyle(fontSize: 14, color: scoreColor)), + const SizedBox(height: 4), + Text('Resultado enviado ao professor ✓', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant)), + ], + ), + ), + const SizedBox(height: 24), + Text('Revisão', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: cs.onSurface)), + const SizedBox(height: 12), + ...List.generate(total, (i) { + final q = widget.questions[i]; + final chosen = _chosen[i]; + final isCorrect = chosen == q.correctIndex; + final revColor = isCorrect ? const Color(0xFF10B981) : const Color(0xFFEF4444); + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: revColor.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: revColor.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(isCorrect ? Icons.check_circle : Icons.cancel, color: revColor, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text('Pergunta ${i + 1}', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: revColor)), + ), + ], + ), + const SizedBox(height: 8), + Text(q.question, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface)), + const SizedBox(height: 8), + if (chosen >= 0 && chosen < q.options.length && !isCorrect) + Text('A tua resposta: ${q.options[chosen]}', + style: const TextStyle(fontSize: 13, color: Color(0xFFEF4444))), + Text('Resposta correcta: ${q.options[q.correctIndex]}', + style: TextStyle( + fontSize: 13, + color: isCorrect ? const Color(0xFF10B981) : cs.onSurface, + fontWeight: FontWeight.w500)), + if (q.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 6), + Expanded( + child: Text(q.explanation, + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant, height: 1.4)), + ), + ], + ), + ), + ], + ], + ), + ); + }), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: cs.secondary, + foregroundColor: cs.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Fechar'), + ), + ], + ); + } +} diff --git a/lib/features/quiz/presentation/pages/teacher_quiz_page.dart b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart new file mode 100644 index 0000000..56fc769 --- /dev/null +++ b/lib/features/quiz/presentation/pages/teacher_quiz_page.dart @@ -0,0 +1,853 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../../../../core/services/materials_rag_service.dart'; +import '../../../../core/services/rag_ai_service.dart'; +import '../../../../core/utils/logger.dart'; + +// ─── Modelo de dados (partilhado internamente) ──────────────────────────────── + +class _QuizQuestion { + String question; + List options; + int correctIndex; + String explanation; + + _QuizQuestion({ + required this.question, + required this.options, + required this.correctIndex, + required this.explanation, + }); + + Map toJson() => { + 'q': question, + 'opts': options, + 'ans': correctIndex, + 'exp': explanation, + }; + + static _QuizQuestion? fromMap(dynamic e) { + if (e is! Map) return null; + final q = e['q'] as String?; + final opts = (e['opts'] as List?)?.cast(); + final ans = e['ans'] as int?; + final exp = e['exp'] as String? ?? ''; + if (q == null || opts == null || ans == null) return null; + if (opts.length < 2 || ans < 0 || ans >= opts.length) return null; + return _QuizQuestion(question: q, options: opts, correctIndex: ans, explanation: exp); + } +} + +// ─── Página principal professor ─────────────────────────────────────────────── + +class TeacherQuizPage extends StatefulWidget { + const TeacherQuizPage({super.key}); + + @override + State createState() => _TeacherQuizPageState(); +} + +class _TeacherQuizPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + List> _materials = []; + List> _history = []; + bool _loadingMaterials = true; + bool _loadingHistory = true; + String? _generatingForId; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _loadMaterials(); + _loadHistory(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadMaterials() async { + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid == null) { if (mounted) setState(() => _loadingMaterials = false); return; } + final snap = await FirebaseFirestore.instance + .collection('materials') + .where('teacherId', isEqualTo: uid) + .orderBy('createdAt', descending: true) + .get(); + final mats = snap.docs + .where((d) => (d.data()['fileName'] as String? ?? '').toLowerCase().endsWith('.pdf')) + .map((d) => {'id': d.id, 'name': d.data()['fileName'] as String? ?? 'Material'}) + .toList(); + if (mounted) setState(() { _materials = mats; _loadingMaterials = false; }); + } catch (e) { + Logger.error('Teacher quiz load materials: $e'); + if (mounted) setState(() => _loadingMaterials = false); + } + } + + Future _loadHistory() async { + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid == null) { if (mounted) setState(() => _loadingHistory = false); return; } + final snap = await FirebaseFirestore.instance + .collection('teacherQuizzes') + .where('teacherId', isEqualTo: uid) + .orderBy('createdAt', descending: true) + .limit(30) + .get(); + final list = snap.docs.map((d) => {'id': d.id, ...d.data()}).toList(); + if (mounted) setState(() { _history = list; _loadingHistory = false; }); + } catch (e) { + Logger.error('Teacher quiz load history: $e'); + if (mounted) setState(() => _loadingHistory = false); + } + } + + Future _generateQuiz(Map material) async { + setState(() => _generatingForId = material['id']); + try { + final matId = material['id']!; + final matName = material['name'] ?? 'Material'; + + final pdfContext = await MaterialsRAGService.getRelevantChunks( + userQuery: 'conteúdo geral resumo tópicos principais', + selectedMaterialIds: [matId], + ); + + if (pdfContext.isEmpty) { + if (mounted) _showSnack('Não foi possível aceder ao conteúdo do PDF.'); + return; + } + + 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' + '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' + 'ans é o índice (0-3) da opção correcta.'; + + final raw = await RAGAIService.generateQuiz(prompt); + final questions = _parseQuizJson(raw); + + if (questions.isEmpty) { + if (mounted) _showSnack('Não foi possível gerar o quiz. Tenta novamente.'); + return; + } + + // Abrir editor — o professor pode rever/editar antes de publicar + if (mounted) { + final published = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _QuizEditorPage( + materialName: matName, + materialId: matId, + questions: questions, + ), + ), + ); + if (published == true) { + await _loadHistory(); + } + } + } catch (e) { + Logger.error('Error generating teacher quiz: $e'); + if (mounted) _showSnack('Erro ao gerar quiz. Tenta novamente.'); + } finally { + if (mounted) setState(() => _generatingForId = null); + } + } + + List<_QuizQuestion> _parseQuizJson(String raw) { + try { + final start = raw.indexOf('['); + final end = raw.lastIndexOf(']'); + if (start == -1 || end == -1 || end <= start) return []; + final jsonStr = raw.substring(start, end + 1); + final decoded = jsonDecode(jsonStr); + if (decoded is! List) return []; + return decoded + .map<_QuizQuestion?>(_QuizQuestion.fromMap) + .whereType<_QuizQuestion>() + .toList(); + } catch (e) { + Logger.error('Quiz JSON parse error: $e'); + return []; + } + } + + void _showSnack(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + title: const Text('Criar Quiz'), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/teacher-dashboard'), + ), + bottom: TabBar( + controller: _tabController, + labelColor: cs.primary, + unselectedLabelColor: cs.onSurfaceVariant, + indicatorColor: cs.primary, + tabs: const [ + Tab(text: 'Gerar Quiz'), + Tab(text: 'Histórico'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildMaterialsTab(cs), + _buildHistoryTab(cs), + ], + ), + ); + } + + Widget _buildMaterialsTab(ColorScheme cs) { + if (_loadingMaterials) return const Center(child: CircularProgressIndicator()); + if (_materials.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.folder_open, size: 64, color: cs.onSurfaceVariant.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text('Sem PDFs carregados.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)), + const SizedBox(height: 8), + Text('Faz upload de um PDF nos teus materiais primeiro.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 13)), + ], + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _materials.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final mat = _materials[i]; + final isGenerating = _generatingForId == mat['id']; + final name = (mat['name'] ?? 'Material').replaceAll('.pdf', '').replaceAll('_', ' '); + return _MaterialCard( + name: name, + isGenerating: isGenerating, + onTap: isGenerating ? null : () => _generateQuiz(mat), + cs: cs, + ); + }, + ); + } + + Widget _buildHistoryTab(ColorScheme cs) { + if (_loadingHistory) return const Center(child: CircularProgressIndicator()); + if (_history.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history, size: 64, color: cs.onSurfaceVariant.withValues(alpha: 0.4)), + const SizedBox(height: 16), + Text('Ainda não criaste nenhum quiz.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)), + ], + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _history.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), + 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), + ), + ); + }, + ); + } + + void _showResultsPopup(Map quizDoc) { + final quizId = quizDoc['id'] as String; + final name = (quizDoc['materialName'] as String? ?? 'Material') + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + showDialog( + context: context, + builder: (_) => _StudentResultsDialog(quizId: quizId, quizTitle: name), + ); + } +} + +// ─── Card de material reutilizável ──────────────────────────────────────────── + +class _MaterialCard extends StatelessWidget { + final String name; + final bool isGenerating; + final VoidCallback? onTap; + final ColorScheme cs; + const _MaterialCard({required this.name, required this.isGenerating, required this.onTap, required this.cs}); + + @override + Widget build(BuildContext context) { + 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.picture_as_pdf, color: cs.secondary, size: 22), + ), + title: Text(name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: cs.onSurface)), + subtitle: Text('Gerar quiz com IA', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant)), + trailing: isGenerating + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5, color: cs.primary), + ) + : Icon(Icons.auto_awesome, color: cs.primary, size: 26), + onTap: onTap, + ), + ); + } +} + +// ─── Editor de quiz ────────────────────────────────────────────────────────── + +class _QuizEditorPage extends StatefulWidget { + final String materialName; + final String materialId; + final List<_QuizQuestion> questions; + + const _QuizEditorPage({ + required this.materialName, + required this.materialId, + required this.questions, + }); + + @override + State<_QuizEditorPage> createState() => _QuizEditorPageState(); +} + +class _QuizEditorPageState extends State<_QuizEditorPage> { + late List<_QuizQuestion> _questions; + bool _saving = false; + + @override + void initState() { + super.initState(); + // cópia editável + _questions = widget.questions + .map((q) => _QuizQuestion( + question: q.question, + options: List.from(q.options), + correctIndex: q.correctIndex, + explanation: q.explanation, + )) + .toList(); + } + + Future _publish() async { + setState(() => _saving = true); + try { + final uid = FirebaseAuth.instance.currentUser?.uid; + if (uid == null) return; + final jsonStr = jsonEncode(_questions.map((q) => q.toJson()).toList()); + await FirebaseFirestore.instance.collection('teacherQuizzes').add({ + 'teacherId': uid, + 'materialId': widget.materialId, + 'materialName': widget.materialName, + 'quizJson': jsonStr, + 'createdAt': FieldValue.serverTimestamp(), + }); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + Logger.error('Error publishing teacher quiz: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erro ao publicar: $e'), behavior: SnackBarBehavior.floating), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + title: Text( + widget.materialName.replaceAll('.pdf', '').replaceAll('_', ' '), + overflow: TextOverflow.ellipsis, + ), + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + elevation: 0, + actions: [ + _saving + ? const Padding( + padding: EdgeInsets.only(right: 16), + child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + ) + : TextButton.icon( + onPressed: _publish, + 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(() {}), + ), + ), + ); + } +} + +// ─── Editor de uma pergunta ─────────────────────────────────────────────────── + +class _QuestionEditor extends StatefulWidget { + final int index; + final _QuizQuestion question; + final ColorScheme cs; + final VoidCallback onChanged; + const _QuestionEditor({required this.index, required this.question, required this.cs, required this.onChanged}); + + @override + State<_QuestionEditor> createState() => _QuestionEditorState(); +} + +class _QuestionEditorState extends State<_QuestionEditor> { + late TextEditingController _qCtrl; + late TextEditingController _expCtrl; + late List _optCtrl; + bool _expanded = true; + + @override + void initState() { + super.initState(); + _qCtrl = TextEditingController(text: widget.question.question); + _expCtrl = TextEditingController(text: widget.question.explanation); + _optCtrl = widget.question.options.map((o) => TextEditingController(text: o)).toList(); + } + + @override + void dispose() { + _qCtrl.dispose(); + _expCtrl.dispose(); + for (final c in _optCtrl) c.dispose(); + super.dispose(); + } + + void _save() { + widget.question.question = _qCtrl.text; + widget.question.explanation = _expCtrl.text; + for (int i = 0; i < _optCtrl.length; i++) { + widget.question.options[i] = _optCtrl[i].text; + } + widget.onChanged(); + } + + @override + Widget build(BuildContext context) { + final cs = widget.cs; + return Container( + 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( + children: [ + // Header expansível + InkWell( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text('${widget.index + 1}', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: cs.primary)), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.question.question.isEmpty ? 'Pergunta ${widget.index + 1}' : widget.question.question, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.onSurface), + ), + ), + Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: cs.onSurfaceVariant), + ], + ), + ), + ), + if (_expanded) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Pergunta + _label('Pergunta', cs), + const SizedBox(height: 6), + _textField(_qCtrl, cs, maxLines: 3), + const SizedBox(height: 14), + + // Opções + _label('Opções', cs), + const SizedBox(height: 6), + ...List.generate(widget.question.options.length, (i) { + final isCorrect = widget.question.correctIndex == i; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + GestureDetector( + onTap: () { + setState(() => widget.question.correctIndex = i); + widget.onChanged(); + }, + child: Container( + width: 28, + height: 28, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCorrect ? cs.primary : cs.surfaceContainerHighest, + border: Border.all(color: isCorrect ? cs.primary : cs.outline.withValues(alpha: 0.3)), + ), + child: isCorrect + ? Icon(Icons.check, size: 16, color: cs.onPrimary) + : Center( + child: Text(String.fromCharCode(65 + i), + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant)), + ), + ), + ), + Expanded(child: _textField(_optCtrl[i], cs, hint: 'Opção ${String.fromCharCode(65 + i)}')), + ], + ), + ); + }), + Text('Toca no círculo para marcar a correcta', + style: TextStyle(fontSize: 11, color: cs.onSurfaceVariant)), + + const SizedBox(height: 14), + + // Explicação + _label('Explicação (quando erra)', cs), + const SizedBox(height: 6), + _textField(_expCtrl, cs, maxLines: 3, hint: 'Explica porque esta é a resposta correcta...'), + const SizedBox(height: 12), + + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: _save, + icon: const Icon(Icons.save_outlined, size: 18), + label: const Text('Guardar'), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _label(String text, ColorScheme cs) => Text(text, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: cs.onSurfaceVariant)); + + Widget _textField(TextEditingController ctrl, ColorScheme cs, {int maxLines = 1, String? hint}) => + TextField( + controller: ctrl, + maxLines: maxLines, + style: TextStyle(fontSize: 14, color: cs.onSurface), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: cs.onSurfaceVariant.withValues(alpha: 0.5), fontSize: 13), + filled: true, + fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: cs.primary, width: 1.5), + ), + ), + ); +} + +// ─── Popup de resultados por aluno ──────────────────────────────────────────── + +class _StudentResultsDialog extends StatefulWidget { + final String quizId; + final String quizTitle; + const _StudentResultsDialog({required this.quizId, required this.quizTitle}); + + @override + State<_StudentResultsDialog> createState() => _StudentResultsDialogState(); +} + +class _StudentResultsDialogState extends State<_StudentResultsDialog> { + List> _results = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final snap = await FirebaseFirestore.instance + .collection('teacherQuizzes') + .doc(widget.quizId) + .collection('submissions') + .orderBy('submittedAt', descending: true) + .get(); + final results = >[]; + for (final doc in snap.docs) { + final data = doc.data(); + // Tentar resolver nome do aluno + final studentId = data['studentId'] as String?; + String studentName = data['studentName'] as String? ?? 'Aluno'; + if (studentId != null && studentName == 'Aluno') { + try { + final userDoc = await FirebaseFirestore.instance.collection('users').doc(studentId).get(); + if (userDoc.exists) { + studentName = userDoc.data()?['displayName'] as String? ?? + userDoc.data()?['name'] as String? ?? + studentName; + } + } catch (_) {} + } + results.add({...data, 'studentName': studentName}); + } + if (mounted) setState(() { _results = results; _loading = false; }); + } catch (e) { + Logger.error('Error loading quiz submissions: $e'); + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 12, 16), + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.08), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Row( + children: [ + Icon(Icons.bar_chart, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.quizTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: cs.onSurface), + ), + ), + IconButton( + icon: Icon(Icons.close, color: cs.onSurfaceVariant), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + // Body + Flexible( + child: _loading + ? const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator())) + : _results.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'Nenhum aluno submeteu este quiz ainda.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shrinkWrap: true, + itemCount: _results.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, i) { + final r = _results[i]; + final score = r['score'] as int? ?? 0; + final total = r['total'] as int? ?? 1; + final pct = (score / total * 100).round(); + final Color c = pct >= 80 + ? const Color(0xFF10B981) + : pct >= 50 + ? const Color(0xFFF59E0B) + : const Color(0xFFEF4444); + final ts = r['submittedAt']; + 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( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: c.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r['studentName'] as String, + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: cs.onSurface)), + if (dateStr.isNotEmpty) + Text(dateStr, + style: TextStyle(fontSize: 11, color: cs.onSurfaceVariant)), + ], + ), + ), + Text('$score/$total', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: c)), + const SizedBox(width: 6), + Text('($pct%)', style: TextStyle(fontSize: 12, color: c)), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +}