From 9b53eb06b61b908e7a471150dd1a94b42ca2c063 Mon Sep 17 00:00:00 2001 From: 240405 <240405@epvc.pt> Date: Mon, 18 May 2026 14:27:30 +0100 Subject: [PATCH] =?UTF-8?q?Quiz=20e=20tutor=20chat=20modifica=C3=A7=C3=B5e?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/services/rag_ai_service.dart | 125 +-- .../pages/tutor_chat_page_simple.dart | 426 ++++++++-- .../presentation/pages/quiz_list_page.dart | 757 +++++++++--------- 3 files changed, 811 insertions(+), 497 deletions(-) diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index ce16c80..c73452f 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -29,11 +29,11 @@ class RAGAIService { // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', - 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + 'content': '''Tu és "Alt", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como o GOAT. +Respondes sempre como a Alt. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. @@ -41,7 +41,9 @@ Usas formatação Markdown clara e organizada.''', }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore - final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 20); + final conversationHistory = await ChatMemoryService.getRecentMessages( + limit: 20, + ); for (final msg in conversationHistory) { messages.add({ 'role': msg['role'] as String, @@ -58,33 +60,27 @@ Usas formatação Markdown clara e organizada.''', if (pdfContext.isNotEmpty) { messages.add({ 'role': 'system', - 'content': pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. + 'content': + pdfContext, // Já vem formatado com [CHUNK 1], [CHUNK 2], etc. }); } // PASSO 5 — SÓ AGORA adicionar a pergunta do user - messages.add({ - 'role': 'user', - 'content': userQuery, - }); + messages.add({'role': 'user', 'content': userQuery}); // Log do tamanho do array para verificação - Logger.info('Built messages array with ${messages.length} messages for API'); + Logger.info( + 'Built messages array with ${messages.length} messages for API', + ); // Save user message to Firestore (after building the messages array) - await ChatMemoryService.saveMessage( - role: 'user', - content: userQuery, - ); + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); // Call Ollama API with complete messages array final response = await _callOllamaAPIWithMessages(messages); // Save AI response to memory - await ChatMemoryService.saveMessage( - role: 'assistant', - content: response, - ); + await ChatMemoryService.saveMessage(role: 'assistant', content: response); // Process response and create RAGResponse final ragResponse = _createRAGResponse( @@ -181,11 +177,12 @@ Usas formatação Markdown clara e organizada.''', } /// System message for O GOAT identity (for legacy calls) - static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + static const String _systemMessage = + '''Tu és "Alt", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como o GOAT. +Respondes sempre como a Alt. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. @@ -485,13 +482,11 @@ Usas formatação clara e organizada.'''; final messages = >[ { 'role': 'system', - 'content': 'És um assistente educativo especializado em criar quizzes pedagógicos. ' + 'content': + 'És um assistente educativo especializado em criar quizzes pedagógicos. ' 'Cria sempre perguntas claras, baseadas exclusivamente no contexto fornecido.', }, - { - 'role': 'user', - 'content': prompt, - }, + {'role': 'user', 'content': prompt}, ]; return await _callOllamaAPIWithMessages(messages); } @@ -524,13 +519,39 @@ Usas formatação clara e organizada.'''; final words = q.split(RegExp(r'\s+')); if (words.length > 8) return false; const followUpStarters = [ - 'e ', 'e o', 'e a', 'e os', 'e as', 'mas ', 'então ', - 'explica', 'explique', 'explica melhor', 'melhor', 'mais detalhes', - 'podes', 'pode ', 'consegues', 'e se ', 'e quando', - 'dá um exemplo', 'da um exemplo', 'um exemplo', 'exemplo', - 'como assim', 'o que significa', 'porquê', 'porque isso', - 'e o ponto', 'e a regra', 'continua', 'continua', - 'o que mais', 'mais algum', 'e depois', 'e agora', + 'e ', + 'e o', + 'e a', + 'e os', + 'e as', + 'mas ', + 'então ', + 'explica', + 'explique', + 'explica melhor', + 'melhor', + 'mais detalhes', + 'podes', + 'pode ', + 'consegues', + 'e se ', + 'e quando', + 'dá um exemplo', + 'da um exemplo', + 'um exemplo', + 'exemplo', + 'como assim', + 'o que significa', + 'porquê', + 'porque isso', + 'e o ponto', + 'e a regra', + 'continua', + 'continua', + 'o que mais', + 'mais algum', + 'e depois', + 'e agora', ]; return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); } @@ -549,11 +570,11 @@ Usas formatação clara e organizada.'''; // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', - 'content': '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + 'content': '''Tu és "Alt", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como o GOAT. +Respondes sempre como Alt. Tens personalidade confiante, motivadora e orgulhosa. Usas formatação Markdown clara e organizada. @@ -566,7 +587,9 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO: }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) - final conversationHistory = await ChatMemoryService.getRecentMessages(limit: 4); + final conversationHistory = await ChatMemoryService.getRecentMessages( + limit: 4, + ); for (final msg in conversationHistory) { messages.add({ 'role': msg['role'] as String, @@ -584,7 +607,9 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO: String pdfContext; if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) { pdfContext = _lastPdfContext; - Logger.info('Follow-up detected — reusing last PDF context (${pdfContext.length} chars)'); + Logger.info( + 'Follow-up detected — reusing last PDF context (${pdfContext.length} chars)', + ); } else { pdfContext = await MaterialsRAGService.getRelevantChunks( userQuery: userQuery, @@ -594,25 +619,36 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO: ); if (pdfContext.isNotEmpty) { _lastPdfContext = pdfContext; - Logger.info('PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}'); + Logger.info( + 'PDF context sent to model (${pdfContext.length} chars): ${pdfContext.length > 300 ? pdfContext.substring(0, 300) : pdfContext}', + ); } } - if (pdfContext.isEmpty && selectedMaterialIds != null && selectedMaterialIds.isNotEmpty) { + if (pdfContext.isEmpty && + selectedMaterialIds != null && + selectedMaterialIds.isNotEmpty) { // Contexto vazio com materiais seleccionados — retornar resposta local imediatamente const noContextReply = 'Neste momento não tenho acesso ao conteúdo do ficheiro selecionado. ' 'Tenta novamente ou faz uma pergunta geral — estou aqui para ajudar! 💪'; await ChatMemoryService.saveMessage(role: 'user', content: userQuery); - await ChatMemoryService.saveMessage(role: 'assistant', content: noContextReply); + await ChatMemoryService.saveMessage( + role: 'assistant', + content: noContextReply, + ); return noContextReply; } - if (pdfContext.isEmpty && (selectedMaterialIds == null || selectedMaterialIds.isEmpty)) { + if (pdfContext.isEmpty && + (selectedMaterialIds == null || selectedMaterialIds.isEmpty)) { // Sem material seleccionado — pedir ao utilizador para seleccionar um const noMaterialReply = 'Para responder a perguntas sobre conteúdo, preciso que selecciones um material primeiro. ' '📚 Usa o botão de materiais para escolher um PDF e depois faz a tua pergunta!'; await ChatMemoryService.saveMessage(role: 'user', content: userQuery); - await ChatMemoryService.saveMessage(role: 'assistant', content: noMaterialReply); + await ChatMemoryService.saveMessage( + role: 'assistant', + content: noMaterialReply, + ); return noMaterialReply; } @@ -625,12 +661,11 @@ $pdfContext Pergunta: $userQuery''' : userQuery; - messages.add({ - 'role': 'user', - 'content': userContent, - }); + messages.add({'role': 'user', 'content': userContent}); - Logger.info('USING RAG AI SERVICE - Built messages array with ${messages.length} messages'); + Logger.info( + 'USING RAG AI SERVICE - Built messages array with ${messages.length} messages', + ); // Save user message to Firestore await ChatMemoryService.saveMessage(role: 'user', content: userQuery); diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart index e7a6988..bb835b6 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page_simple.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import '../../../../core/services/auth_service.dart'; import '../../../../core/services/chat_memory_service.dart'; import '../../../../core/services/materials_rag_service.dart'; @@ -26,6 +27,7 @@ class _TutorChatPageSimpleState extends State List> _messages = []; List> _availableMaterials = []; + Map _classNames = {}; // classId → name Set _selectedMaterialIds = {}; @override @@ -38,8 +40,28 @@ class _TutorChatPageSimpleState extends State Future _loadAvailableMaterials() async { final materials = await MaterialsRAGService.getAvailableMaterialsForStudent(); + // Collect unique classIds that don't have a name yet + final classIds = materials + .map((m) => m['classId']) + .whereType() + .toSet(); + final namesMap = {}; + if (classIds.isNotEmpty) { + final docs = await Future.wait( + classIds.map( + (id) => + FirebaseFirestore.instance.collection('classes').doc(id).get(), + ), + ); + for (final doc in docs.where((d) => d.exists)) { + namesMap[doc.id] = doc.data()?['name'] as String? ?? doc.id; + } + } if (mounted) { - setState(() => _availableMaterials = materials); + setState(() { + _availableMaterials = materials; + _classNames = namesMap; + }); } } @@ -567,102 +589,332 @@ class _TutorChatPageSimpleState extends State ); } + // Group materials by classId; ungrouped go to '__geral__' + Map>> _groupMaterialsByClass() { + final groups = >>{}; + for (final m in _availableMaterials) { + final cid = m['classId']; + final key = (cid != null && _classNames.containsKey(cid)) + ? cid + : '__geral__'; + groups.putIfAbsent(key, () => []).add(m); + } + return groups; + } + void _showMaterialsPicker() { + final groups = _groupMaterialsByClass(); + final disciplineIds = groups.keys.where((k) => k != '__geral__').toList() + ..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b)); + if (groups.containsKey('__geral__')) disciplineIds.add('__geral__'); + showDialog( context: context, builder: (dialogContext) { final tempSelected = Set.from(_selectedMaterialIds); + // Disciplines start collapsed + final expanded = { + for (final k in disciplineIds) k: false, + }; + final searchController = TextEditingController(); + String searchQuery = ''; return StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text( - 'Escolher Materiais', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Seleciona os materiais que o tutor deve analisar:', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: ListView( - shrinkWrap: true, - children: _availableMaterials.map((material) { - final id = material['id']!; - final name = material['name']!; - final isChecked = tempSelected.contains(id); - return CheckboxListTile( - value: isChecked, - onChanged: (val) { - setDialogState(() { - if (val == true) { - tempSelected.add(id); - } else { - tempSelected.remove(id); - } - }); - }, - title: Text( - name, - style: const TextStyle(fontSize: 14), - maxLines: 2, - overflow: TextOverflow.ellipsis, + builder: (context, setDialogState) { + final cs = Theme.of(context).colorScheme; + // Filter groups by search query + final filteredDisciplineIds = disciplineIds.where((groupKey) { + if (searchQuery.isEmpty) return true; + final q = searchQuery.toLowerCase(); + final label = groupKey == '__geral__' + ? 'geral' + : (_classNames[groupKey] ?? groupKey).toLowerCase(); + if (label.contains(q)) return true; + return (groups[groupKey] ?? []).any( + (m) => (m['name'] ?? '').toLowerCase().contains(q), + ); + }).toList(); + // Auto-expand disciplines that match by material name (not discipline name) + for (final groupKey in filteredDisciplineIds) { + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + final label = groupKey == '__geral__' + ? 'geral' + : (_classNames[groupKey] ?? groupKey).toLowerCase(); + if (!label.contains(q)) expanded[groupKey] = true; + } + } + final viewInsets = MediaQuery.of(context).viewInsets; + final screenHeight = MediaQuery.of(context).size.height; + // Leave room for keyboard + dialog chrome (~360px for title/search/actions/padding) + final listMaxHeight = (screenHeight - viewInsets.bottom - 360) + .clamp(60.0, 340.0); + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Escolher Materiais', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search bar + TextField( + controller: searchController, + onChanged: (v) => + setDialogState(() => searchQuery = v.trim()), + style: TextStyle(fontSize: 13, color: cs.onSurface), + decoration: InputDecoration( + hintText: 'Pesquisar disciplina ou material…', + hintStyle: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + prefixIcon: Icon( + Icons.search, + size: 18, + color: cs.onSurfaceVariant, + ), + suffixIcon: searchQuery.isNotEmpty + ? IconButton( + icon: Icon( + Icons.close, + size: 16, + color: cs.onSurfaceVariant, + ), + onPressed: () { + searchController.clear(); + setDialogState(() => searchQuery = ''); + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: cs.outline.withValues(alpha: 0.4), ), - controlAffinity: ListTileControlAffinity.leading, - dense: true, - contentPadding: EdgeInsets.zero, - ); - }).toList(), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: cs.outline.withValues(alpha: 0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: cs.primary), + ), + ), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: listMaxHeight), + child: filteredDisciplineIds.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'Nenhum resultado para "$searchQuery"', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ), + ) + : ListView( + shrinkWrap: true, + children: filteredDisciplineIds.map((groupKey) { + // When searching, filter materials too + final allMats = groups[groupKey]!; + final mats = searchQuery.isEmpty + ? allMats + : allMats.where((m) { + final q = searchQuery.toLowerCase(); + final label = groupKey == '__geral__' + ? 'geral' + : (_classNames[groupKey] ?? '') + .toLowerCase(); + return label.contains(q) || + (m['name'] ?? '') + .toLowerCase() + .contains(q); + }).toList(); + final label = groupKey == '__geral__' + ? 'Geral' + : (_classNames[groupKey] ?? groupKey); + final isExpanded = expanded[groupKey] ?? false; + // Count how many in this group are selected + final selectedInGroup = mats + .where( + (m) => tempSelected.contains(m['id']), + ) + .length; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Discipline header row + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setDialogState( + () => expanded[groupKey] = !isExpanded, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.primary.withValues( + alpha: 0.07, + ), + borderRadius: BorderRadius.circular( + 8, + ), + ), + child: Row( + children: [ + Icon( + Icons.folder_outlined, + size: 16, + color: cs.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: cs.primary, + ), + ), + ), + if (selectedInGroup > 0) + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: cs.primary.withValues( + alpha: 0.15, + ), + borderRadius: + BorderRadius.circular(10), + ), + child: Text( + '$selectedInGroup', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: cs.primary, + ), + ), + ), + const SizedBox(width: 4), + Icon( + isExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 18, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + // Material items + if (isExpanded) + ...mats.map((material) { + final id = material['id']!; + final name = material['name']!; + final cleanName = name + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + final isChecked = tempSelected.contains( + id, + ); + return CheckboxListTile( + value: isChecked, + onChanged: (val) { + setDialogState(() { + if (val == true) { + tempSelected.add(id); + } else { + tempSelected.remove(id); + } + }); + }, + title: Text( + cleanName, + style: const TextStyle( + fontSize: 13, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + controlAffinity: + ListTileControlAffinity.leading, + dense: true, + contentPadding: const EdgeInsets.only( + left: 16, + ), + ); + }), + const SizedBox(height: 6), + ], + ); + }).toList(), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + setDialogState(() => tempSelected.clear()); + setState(() { + _selectedMaterialIds.clear(); + _messages.clear(); + }); + ChatMemoryService.clearHistory(); + RAGAIService.clearLastContext(); + Navigator.of(dialogContext).pop(); + }, + child: const Text('Limpar'), + ), + ElevatedButton( + onPressed: () { + setState(() { + _selectedMaterialIds = tempSelected; + _messages.clear(); + }); + ChatMemoryService.clearHistory(); + RAGAIService.clearLastContext(); + Navigator.of(dialogContext).pop(); + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - setDialogState(() => tempSelected.clear()); - setState(() { - _selectedMaterialIds.clear(); - _messages.clear(); - }); - ChatMemoryService.clearHistory(); - RAGAIService.clearLastContext(); - Navigator.of(dialogContext).pop(); - }, - child: const Text('Limpar'), - ), - ElevatedButton( - onPressed: () { - setState(() { - _selectedMaterialIds = tempSelected; - _messages.clear(); - }); - ChatMemoryService.clearHistory(); - RAGAIService.clearLastContext(); - Navigator.of(dialogContext).pop(); - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + child: const Text('Confirmar'), ), - child: const Text('Confirmar'), - ), - ], - ), + ], + ); + }, ); }, ); diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index 86fa793..6c5835c 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -8,6 +8,7 @@ import '../../../../core/services/materials_rag_service.dart'; import '../../../../core/services/rag_ai_service.dart'; import '../../../../core/services/gamification_service.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../core/theme/app_theme_extension.dart'; class QuizListPage extends StatefulWidget { const QuizListPage({super.key}); @@ -46,8 +47,7 @@ class _QuizListPageState extends State void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); - _loadMaterials(); - _loadHistory(); + _loadMaterialsThenHistory(); _loadTeacherQuizzes(); } @@ -57,6 +57,11 @@ class _QuizListPageState extends State super.dispose(); } + Future _loadMaterialsThenHistory() async { + await _loadMaterials(); + await _loadHistory(); + } + Future _loadMaterials() async { final mats = await MaterialsRAGService.getAvailableMaterialsForStudent(); if (!mounted) return; @@ -400,6 +405,7 @@ class _QuizListPageState extends State final uid = FirebaseAuth.instance.currentUser?.uid; String? historyDocId; if (uid != null) { + final classId = material['classId']; final docRef = await FirebaseFirestore.instance .collection('quizHistory') .doc(uid) @@ -407,6 +413,7 @@ class _QuizListPageState extends State .add({ 'materialId': matId, 'materialName': matName, + if (classId != null) 'classId': classId, 'quizJson': raw, 'createdAt': FieldValue.serverTimestamp(), }); @@ -789,7 +796,7 @@ class _QuizListPageState extends State borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, -4), ), @@ -805,7 +812,7 @@ class _QuizListPageState extends State decoration: BoxDecoration( color: Theme.of( context, - ).colorScheme.onSurfaceVariant.withOpacity(0.4), + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ), @@ -820,7 +827,9 @@ class _QuizListPageState extends State gradient: LinearGradient( colors: [ Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.primary.withOpacity(0.8), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.8), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -837,10 +846,10 @@ class _QuizListPageState extends State width: 48, height: 48, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.3), + color: Colors.white.withValues(alpha: 0.3), ), ), child: Icon( @@ -869,7 +878,7 @@ class _QuizListPageState extends State 'Pré-visualização do conteúdo completo', style: TextStyle( fontSize: 13, - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), ), ), ], @@ -877,7 +886,7 @@ class _QuizListPageState extends State ), Container( decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: IconButton( @@ -900,17 +909,17 @@ class _QuizListPageState extends State vertical: 12, ), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), + color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), ), ), child: Row( children: [ Icon( Icons.description, - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), size: 18, ), const SizedBox(width: 8), @@ -919,7 +928,7 @@ class _QuizListPageState extends State '${(fullText.length / 1000).toStringAsFixed(1)}K caracteres • ${fullText.split('\n').length} linhas', style: TextStyle( fontSize: 12, - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), fontWeight: FontWeight.w500, ), ), @@ -930,7 +939,7 @@ class _QuizListPageState extends State vertical: 4, ), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -961,7 +970,7 @@ class _QuizListPageState extends State border: Border.all( color: Theme.of( context, - ).colorScheme.outline.withOpacity(0.1), + ).colorScheme.outline.withValues(alpha: 0.1), ), ), child: Column( @@ -1005,7 +1014,7 @@ class _QuizListPageState extends State decoration: BoxDecoration( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.1), + ).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -1048,7 +1057,7 @@ class _QuizListPageState extends State Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.vertical( bottom: Radius.circular(24), ), @@ -1060,7 +1069,7 @@ class _QuizListPageState extends State decoration: BoxDecoration( color: Theme.of( context, - ).colorScheme.primary.withOpacity(0.1), + ).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -1089,9 +1098,10 @@ class _QuizListPageState extends State 'Formatação otimizada para melhor legibilidade', style: TextStyle( fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.8), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.8), ), ), ], @@ -1249,46 +1259,100 @@ class _QuizListPageState extends State } context.go('/student-dashboard'); }, - child: Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - title: const Text('Quiz'), - backgroundColor: cs.surface, - foregroundColor: cs.onSurface, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - if (_selectedMaterialDisciplineId != null) { - setState(() => _selectedMaterialDisciplineId = null); - } else if (_selectedHistoryDisciplineId != null) { - setState(() => _selectedHistoryDisciplineId = null); - } else if (_selectedDisciplineId != null) { - setState(() => _selectedDisciplineId = null); - } else { - context.go('/student-dashboard'); - } - }, - ), - bottom: TabBar( - controller: _tabController, - labelColor: cs.primary, - unselectedLabelColor: cs.onSurfaceVariant, - indicatorColor: cs.primary, - tabs: const [ - Tab(text: 'Gerar Quiz'), - Tab(text: 'Histórico'), - Tab(text: 'Do Professor'), - ], + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppThemeExtras.of(context).authBackgroundGradient, ), ), - body: TabBarView( - controller: _tabController, - children: [ - _buildMaterialsTab(cs), - _buildHistoryTab(cs), - _buildTeacherQuizzesTab(cs), - ], + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: cs.surface, + foregroundColor: cs.onSurface, + elevation: 0, + scrolledUnderElevation: 1, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (_selectedMaterialDisciplineId != null) { + setState(() => _selectedMaterialDisciplineId = null); + } else if (_selectedHistoryDisciplineId != null) { + setState(() => _selectedHistoryDisciplineId = null); + } else if (_selectedDisciplineId != null) { + setState(() => _selectedDisciplineId = null); + } else { + context.go('/student-dashboard'); + } + }, + ), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.quiz_rounded, color: cs.primary, size: 20), + ), + const SizedBox(width: 10), + Text( + 'Quiz', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + color: cs.onSurface, + ), + ), + ], + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(52), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 10), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: _tabController, + labelColor: cs.onPrimary, + unselectedLabelColor: cs.onSurfaceVariant, + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + indicator: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(10), + ), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 12, + ), + padding: const EdgeInsets.all(4), + tabs: const [ + Tab(text: 'Gerar Quiz'), + Tab(text: 'Histórico'), + Tab(text: 'Professor'), + ], + ), + ), + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildMaterialsTab(cs), + _buildHistoryTab(cs), + _buildTeacherQuizzesTab(cs), + ], + ), ), ), ); @@ -1320,7 +1384,7 @@ class _QuizListPageState extends State Icon( Icons.folder_open, size: 64, - color: cs.onSurfaceVariant.withOpacity(0.4), + color: cs.onSurfaceVariant.withValues(alpha: 0.4), ), const SizedBox(height: 16), Text( @@ -1333,7 +1397,7 @@ class _QuizListPageState extends State 'Inscreve-te numa disciplina para aceder aos PDFs do professor.', textAlign: TextAlign.center, style: TextStyle( - color: cs.onSurfaceVariant.withOpacity(0.7), + color: cs.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 13, ), ), @@ -1505,30 +1569,24 @@ class _QuizListPageState extends State ColorScheme cs, ) { final cleanName = name.replaceAll('.pdf', '').replaceAll('_', ' '); - return Container( margin: const EdgeInsets.only(bottom: 4), decoration: BoxDecoration( color: cs.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: cs.outline.withOpacity(0.08), width: 1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withValues(alpha: 0.12), width: 1), boxShadow: [ BoxShadow( - color: cs.shadow.withOpacity(0.04), - blurRadius: 12, - offset: const Offset(0, 4), - ), - BoxShadow( - color: cs.primary.withOpacity(0.03), - blurRadius: 20, - offset: const Offset(0, 8), + color: cs.shadow.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 3), ), ], ), child: Material( color: Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), onTap: isGenerating ? null : () => _showMaterialOptions(mat, name, cs), @@ -1536,22 +1594,14 @@ class _QuizListPageState extends State padding: const EdgeInsets.all(16), child: Row( children: [ - // PDF Icon com design melhorado Container( - width: 56, - height: 56, + width: 52, + height: 52, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - cs.secondary.withOpacity(0.15), - cs.secondary.withOpacity(0.08), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), + color: cs.secondaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), border: Border.all( - color: cs.secondary.withOpacity(0.2), + color: cs.secondary.withValues(alpha: 0.2), width: 1, ), ), @@ -1572,7 +1622,7 @@ class _QuizListPageState extends State ), child: Icon( Icons.play_arrow, - color: Colors.white, + color: cs.onPrimary, size: 10, ), ), @@ -1580,9 +1630,7 @@ class _QuizListPageState extends State ], ), ), - const SizedBox(width: 16), - // Content Expanded( child: Column( @@ -1606,7 +1654,7 @@ class _QuizListPageState extends State vertical: 4, ), decoration: BoxDecoration( - color: cs.primaryContainer.withOpacity(0.5), + color: cs.primaryContainer.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -1628,27 +1676,26 @@ class _QuizListPageState extends State ], ), ), - // Action indicator Container( width: 40, height: 40, decoration: BoxDecoration( color: isGenerating - ? cs.primary.withOpacity(0.1) - : cs.surfaceVariant.withOpacity(0.5), + ? cs.primary.withValues(alpha: 0.1) + : cs.surfaceContainerHighest.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(12), border: Border.all( color: isGenerating - ? cs.primary.withOpacity(0.2) - : cs.outline.withOpacity(0.1), + ? cs.primary.withValues(alpha: 0.2) + : cs.outline.withValues(alpha: 0.12), ), ), child: isGenerating ? Center( child: SizedBox( - width: 20, - height: 20, + width: 18, + height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: cs.primary, @@ -1656,9 +1703,9 @@ class _QuizListPageState extends State ), ) : Icon( - Icons.more_vert, + Icons.chevron_right, color: cs.onSurfaceVariant, - size: 20, + size: 22, ), ), ], @@ -1686,9 +1733,9 @@ class _QuizListPageState extends State borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 25, - offset: const Offset(0, -5), + color: cs.shadow.withValues(alpha: 0.12), + blurRadius: 20, + offset: const Offset(0, -4), ), ], ), @@ -1702,7 +1749,7 @@ class _QuizListPageState extends State height: 5, margin: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( - color: cs.onSurfaceVariant.withOpacity(0.3), + color: cs.onSurfaceVariant.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(3), ), ), @@ -1714,14 +1761,14 @@ class _QuizListPageState extends State decoration: BoxDecoration( gradient: LinearGradient( colors: [ - cs.primary.withOpacity(0.05), - cs.secondary.withOpacity(0.05), + cs.primary.withValues(alpha: 0.06), + cs.secondary.withValues(alpha: 0.06), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(20), - border: Border.all(color: cs.outline.withOpacity(0.1)), + border: Border.all(color: cs.outline.withValues(alpha: 0.1)), ), child: Row( children: [ @@ -1730,17 +1777,10 @@ class _QuizListPageState extends State width: 52, height: 52, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - cs.primary.withOpacity(0.2), - cs.secondary.withOpacity(0.15), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: cs.primaryContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(16), border: Border.all( - color: cs.primary.withOpacity(0.3), + color: cs.primary.withValues(alpha: 0.3), width: 1.5, ), ), @@ -1798,7 +1838,7 @@ class _QuizListPageState extends State vertical: 6, ), decoration: BoxDecoration( - color: cs.primaryContainer.withOpacity(0.6), + color: cs.primaryContainer.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(20), ), child: Text( @@ -1856,8 +1896,8 @@ class _QuizListPageState extends State color: cs.primary, gradient: LinearGradient( colors: [ - cs.primary.withOpacity(0.1), - cs.primary.withOpacity(0.05), + cs.primary.withValues(alpha: 0.1), + cs.primary.withValues(alpha: 0.05), ], ), onTap: () { @@ -1877,8 +1917,8 @@ class _QuizListPageState extends State color: cs.secondary, gradient: LinearGradient( colors: [ - cs.secondary.withOpacity(0.1), - cs.secondary.withOpacity(0.05), + cs.secondary.withValues(alpha: 0.1), + cs.secondary.withValues(alpha: 0.05), ], ), onTap: () { @@ -1916,10 +1956,10 @@ class _QuizListPageState extends State decoration: BoxDecoration( gradient: gradient, borderRadius: BorderRadius.circular(20), - border: Border.all(color: color.withOpacity(0.2), width: 1.5), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), boxShadow: [ BoxShadow( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4), ), @@ -1932,9 +1972,12 @@ class _QuizListPageState extends State width: 56, height: 56, decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withOpacity(0.3), width: 1), + border: Border.all( + color: color.withValues(alpha: 0.3), + width: 1, + ), ), child: Stack( alignment: Alignment.center, @@ -1991,7 +2034,7 @@ class _QuizListPageState extends State description, style: TextStyle( fontSize: 11, - color: cs.onSurfaceVariant.withOpacity(0.8), + color: cs.onSurfaceVariant.withValues(alpha: 0.8), ), ), ], @@ -2003,10 +2046,10 @@ class _QuizListPageState extends State width: 32, height: 32, decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), - child: Icon(Icons.chevron_right, color: color, size: 18), + child: Icon(Icons.arrow_forward_ios, color: color, size: 15), ), ], ), @@ -2014,65 +2057,36 @@ class _QuizListPageState extends State ); } - Widget _buildOptionTile({ - required IconData icon, - required String title, - required String subtitle, - required Color color, - required VoidCallback onTap, - }) { - final cs = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withOpacity(0.2)), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: cs.onSurface, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), - ), - ], - ), - ), - Icon(Icons.chevron_right, color: cs.onSurfaceVariant, size: 20), - ], + Widget _buildScoreChip(Map item, ColorScheme cs) { + final score = item['score'] as int; + final total = item['totalQuestions'] as int? ?? 0; + if (total == 0) return const SizedBox.shrink(); + final pct = (score / total * 100).round(); + final color = pct >= 80 + ? cs.tertiary + : pct >= 50 + ? cs.secondary + : cs.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text( + '$score/$total · $pct%', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, ), ), ); } Map>> _groupHistoryByDiscipline() { - final groups = >>{}; + final Map>> groups = {}; for (final item in _history) { final cid = item['classId'] as String?; final groupId = (cid != null && _historyClassNames.containsKey(cid)) @@ -2097,7 +2111,7 @@ class _QuizListPageState extends State Icon( Icons.history, size: 64, - color: cs.onSurfaceVariant.withOpacity(0.4), + color: cs.onSurfaceVariant.withValues(alpha: 0.4), ), const SizedBox(height: 16), Text( @@ -2116,12 +2130,10 @@ class _QuizListPageState extends State .where((k) => k != '__geral__' && _historyClassNames.containsKey(k)) .toList(); - // Sem disciplinas reais ou só 1 → lista plana - if (realDisciplineIds.length <= 1) { + if (realDisciplineIds.isEmpty) { return _buildHistoryList(cs, _history); } - // Vista de quizzes de uma disciplina if (_selectedHistoryDisciplineId != null) { final items = groups[_selectedHistoryDisciplineId] ?? []; final dName = @@ -2165,7 +2177,6 @@ class _QuizListPageState extends State ); } - // Vista de disciplinas return ListView.separated( padding: const EdgeInsets.all(16), itemCount: realDisciplineIds.length, @@ -2258,10 +2269,10 @@ class _QuizListPageState extends State decoration: BoxDecoration( color: cs.surface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outline.withOpacity(0.15)), + border: Border.all(color: cs.outline.withValues(alpha: 0.15)), boxShadow: [ BoxShadow( - color: cs.shadow.withOpacity(0.05), + color: cs.shadow.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2), ), @@ -2276,7 +2287,7 @@ class _QuizListPageState extends State width: 44, height: 44, decoration: BoxDecoration( - color: cs.primary.withOpacity(0.1), + color: cs.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Icon(Icons.quiz, color: cs.primary, size: 22), @@ -2299,18 +2310,17 @@ class _QuizListPageState extends State dateStr, style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), ), - if (item['score'] != null) - Text( - 'Resultado: ${item['score']}/${item['totalQuestions'] ?? '?'}', - style: TextStyle( - fontSize: 12, - color: cs.primary, - fontWeight: FontWeight.w500, - ), - ), + if (item['score'] != null) ...[ + const SizedBox(height: 6), + _buildScoreChip(item, cs), + ], ], ), - trailing: Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + trailing: Icon( + Icons.chevron_right, + color: cs.onSurfaceVariant, + size: 18, + ), onTap: () { final rawJson = item['quizJson'] as String? ?? item['quizText'] as String?; @@ -2328,7 +2338,7 @@ class _QuizListPageState extends State } } -// ─── Modelo de dados ──────────────────────────────────────────────────────── +// ─── Modelo de dados para uma questão de quiz ─────────────────────────────── class _QuizQuestion { final String question; @@ -2343,7 +2353,7 @@ class _QuizQuestion { }); } -// ─── Sheet interativa ──────────────────────────────────────────────────────── +// ─── Sheet interativa para quizzes gerados pelo aluno ───────────────────────── class _InteractiveQuizSheet extends StatefulWidget { final String title; @@ -2360,11 +2370,10 @@ class _InteractiveQuizSheet extends StatefulWidget { } class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { - // índice da pergunta actual (-1 = resultados finais) int _current = 0; - // respostas escolhidas: -1 = sem resposta late List _chosen; bool _submitted = false; + bool _saving = false; @override void initState() { @@ -2377,71 +2386,64 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { setState(() => _chosen[_current] = idx); } - Future _next() async { + void _next() { if (_current < widget.questions.length - 1) { - setState(() { - _current++; - }); + setState(() => _current++); } else { - await _submit(); + _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); + setState(() { + _submitted = true; + _saving = 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(), - }); - } + final user = FirebaseAuth.instance.currentUser; + if (user != null && widget.historyDocId != null) { + await FirebaseFirestore.instance + .collection('quizHistory') + .doc(user.uid) + .collection('quizzes') + .doc(widget.historyDocId) + .update({ + 'score': _score, + 'totalQuestions': widget.questions.length, + 'completedAt': FieldValue.serverTimestamp(), + }); await GamificationService.recordQuizActivity( - uid, - score: score, - totalQuestions: total, + user.uid, + score: _score, + totalQuestions: widget.questions.length, materialName: widget.title, ); } } catch (e) { Logger.error('Error saving quiz result: $e'); + } finally { + if (mounted) setState(() => _saving = false); } } - void _prev() { - if (_current > 0) - setState(() { - _current--; - }); - } - - int get _score => List.generate(widget.questions.length, (i) { - return _chosen[i] == widget.questions[i].correctIndex ? 1 : 0; - }).fold(0, (a, b) => a + b); + Widget _buildHandle(ColorScheme cs) => 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), + ), + ); @override Widget build(BuildContext context) { @@ -2450,7 +2452,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { initialChildSize: 0.93, minChildSize: 0.6, maxChildSize: 0.97, - builder: (_, scrollController) => Container( + builder: (_, sc) => Container( decoration: BoxDecoration( color: cs.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), @@ -2458,12 +2460,54 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { child: Column( children: [ _buildHandle(cs), - _buildHeader(cs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + maxLines: 2, + 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, scrollController) - : _buildQuestion(cs, scrollController), + ? _buildResults(cs, sc) + : _buildQuestion(cs, sc), ), ], ), @@ -2471,50 +2515,6 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ); } - Widget _buildHandle(ColorScheme cs) => Container( - margin: const EdgeInsets.only(top: 12, bottom: 4), - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.outline.withOpacity(0.3), - borderRadius: BorderRadius.circular(2), - ), - ); - - Widget _buildHeader(ColorScheme cs) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - maxLines: 2, - 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), - ), - ], - ), - ), - IconButton( - icon: Icon(Icons.close, color: cs.onSurfaceVariant), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - Widget _buildQuestion(ColorScheme cs, ScrollController sc) { final q = widget.questions[_current]; final chosen = _chosen[_current]; @@ -2522,18 +2522,16 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { controller: sc, padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), children: [ - // Barra de progresso ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: (_current + 1) / widget.questions.length, minHeight: 6, - backgroundColor: cs.surfaceVariant, + backgroundColor: cs.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation(cs.primary), ), ), const SizedBox(height: 20), - // Pergunta Text( q.question, style: TextStyle( @@ -2544,7 +2542,6 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ), ), const SizedBox(height: 20), - // Opções ...List.generate(q.options.length, (i) { final isSelected = chosen == i; return Padding( @@ -2560,13 +2557,13 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ), decoration: BoxDecoration( color: isSelected - ? cs.primary.withOpacity(0.12) - : cs.surfaceVariant.withOpacity(0.4), + ? cs.primary.withValues(alpha: 0.12) + : cs.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? cs.primary - : cs.outline.withOpacity(0.2), + : cs.outline.withValues(alpha: 0.2), width: isSelected ? 2 : 1, ), ), @@ -2593,10 +2590,9 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ); }), const SizedBox(height: 8), - // Botões navegação Row( children: [ - if (_current > 0) + if (_current > 0) ...[ Expanded( child: OutlinedButton( onPressed: _prev, @@ -2609,7 +2605,8 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { child: const Text('Anterior'), ), ), - if (_current > 0) const SizedBox(width: 12), + const SizedBox(width: 12), + ], Expanded( child: ElevatedButton( onPressed: chosen == -1 ? null : _next, @@ -2638,38 +2635,57 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { final total = widget.questions.length; final score = _score; final pct = (score / total * 100).round(); - final Color scoreColor = pct >= 80 - ? const Color(0xFF10B981) + final isDark = cs.brightness == Brightness.dark; + final correctColor = isDark ? cs.primary : const Color(0xFF22C55E); + final wrongColor = isDark ? cs.secondary : const Color(0xFFEF4444); + final scoreColor = pct >= 80 + ? correctColor : pct >= 50 - ? const Color(0xFFF59E0B) - : const Color(0xFFEF4444); - + ? cs.tertiary + : wrongColor; return ListView( controller: sc, padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), children: [ - // Resultado global Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), decoration: BoxDecoration( - color: scoreColor.withOpacity(0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: scoreColor.withOpacity(0.3)), + color: scoreColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: scoreColor.withValues(alpha: 0.25)), ), child: Column( children: [ - Text( - '$score / $total', - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.bold, - color: scoreColor, + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scoreColor.withValues(alpha: 0.15), + border: Border.all( + color: scoreColor.withValues(alpha: 0.4), + width: 2, + ), + ), + child: Center( + child: Text( + '$score/$total', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scoreColor, + ), + ), ), ), - const SizedBox(height: 4), + const SizedBox(height: 12), Text( '$pct% de respostas correctas', - style: TextStyle(fontSize: 14, color: scoreColor), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: scoreColor, + ), ), ], ), @@ -2684,21 +2700,18 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ), ), const SizedBox(height: 12), - // Revisão pergunta a pergunta ...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); + final revColor = isCorrect ? correctColor : wrongColor; return Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: revColor.withOpacity(0.06), + color: revColor.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(14), - border: Border.all(color: revColor.withOpacity(0.25)), + border: Border.all(color: revColor.withValues(alpha: 0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2736,16 +2749,13 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { if (chosen >= 0 && chosen < q.options.length && !isCorrect) Text( 'A tua resposta: ${q.options[chosen]}', - style: const TextStyle( - fontSize: 13, - color: Color(0xFFEF4444), - ), + style: TextStyle(fontSize: 13, color: wrongColor), ), Text( 'Resposta correcta: ${q.options[q.correctIndex]}', style: TextStyle( fontSize: 13, - color: isCorrect ? const Color(0xFF10B981) : cs.onSurface, + color: isCorrect ? correctColor : cs.onSurface, fontWeight: FontWeight.w500, ), ), @@ -2754,7 +2764,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: cs.surfaceVariant.withOpacity(0.5), + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -2784,7 +2794,7 @@ class _InteractiveQuizSheetState extends State<_InteractiveQuizSheet> { ), ); }), - // Botão fechar + const SizedBox(height: 8), ElevatedButton( onPressed: () => Navigator.of(context).pop(), style: ElevatedButton.styleFrom( @@ -3112,36 +3122,57 @@ class _TeacherQuizInteractiveSheetState final total = widget.questions.length; final score = _score; final pct = (score / total * 100).round(); - final Color scoreColor = pct >= 80 - ? const Color(0xFF10B981) + final isDark = cs.brightness == Brightness.dark; + final correctColor = isDark ? cs.primary : const Color(0xFF22C55E); + final wrongColor = isDark ? cs.secondary : const Color(0xFFEF4444); + final scoreColor = pct >= 80 + ? correctColor : pct >= 50 - ? const Color(0xFFF59E0B) - : const Color(0xFFEF4444); + ? cs.tertiary + : wrongColor; return ListView( controller: sc, padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), children: [ Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), decoration: BoxDecoration( color: scoreColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: scoreColor.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: scoreColor.withValues(alpha: 0.25)), ), child: Column( children: [ - Text( - '$score / $total', - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.bold, - color: scoreColor, + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scoreColor.withValues(alpha: 0.15), + border: Border.all( + color: scoreColor.withValues(alpha: 0.4), + width: 2, + ), + ), + child: Center( + child: Text( + '$score/$total', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scoreColor, + ), + ), ), ), - const SizedBox(height: 4), + const SizedBox(height: 12), Text( '$pct% de respostas correctas', - style: TextStyle(fontSize: 14, color: scoreColor), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: scoreColor, + ), ), const SizedBox(height: 4), Text( @@ -3165,16 +3196,14 @@ class _TeacherQuizInteractiveSheetState final q = widget.questions[i]; final chosen = _chosen[i]; final isCorrect = chosen == q.correctIndex; - final revColor = isCorrect - ? const Color(0xFF10B981) - : const Color(0xFFEF4444); + final revColor = isCorrect ? correctColor : wrongColor; 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)), + border: Border.all(color: revColor.withValues(alpha: 0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -3212,16 +3241,13 @@ class _TeacherQuizInteractiveSheetState if (chosen >= 0 && chosen < q.options.length && !isCorrect) Text( 'A tua resposta: ${q.options[chosen]}', - style: const TextStyle( - fontSize: 13, - color: Color(0xFFEF4444), - ), + style: TextStyle(fontSize: 13, color: wrongColor), ), Text( 'Resposta correcta: ${q.options[q.correctIndex]}', style: TextStyle( fontSize: 13, - color: isCorrect ? const Color(0xFF10B981) : cs.onSurface, + color: isCorrect ? correctColor : cs.onSurface, fontWeight: FontWeight.w500, ), ), @@ -3230,7 +3256,7 @@ class _TeacherQuizInteractiveSheetState Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -3260,6 +3286,7 @@ class _TeacherQuizInteractiveSheetState ), ); }), + const SizedBox(height: 8), ElevatedButton( onPressed: () => Navigator.of(context).pop(), style: ElevatedButton.styleFrom(