diff --git a/lib/core/services/rag_ai_service.dart b/lib/core/services/rag_ai_service.dart index c73452f..ba6d9f9 100644 --- a/lib/core/services/rag_ai_service.dart +++ b/lib/core/services/rag_ai_service.dart @@ -26,14 +26,14 @@ class RAGAIService { // PASSO 1 — Criar a lista messages vazia List> messages = []; - // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) + // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', - 'content': '''Tu és "Alt", o Assistente IA oficial do Teach it. + 'content': '''Tu és "Vico", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como a Alt. +Respondes sempre como o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. @@ -176,13 +176,13 @@ Usas formatação Markdown clara e organizada.''', return promptBuilder.toString(); } - /// System message for O GOAT identity (for legacy calls) + /// System message for Vico identity (for legacy calls) static const String _systemMessage = - '''Tu és "Alt", o Assistente IA oficial do Teach it. + '''Tu és "Vico", o Assistente IA oficial do Teach it. Nunca referes o nome do modelo. Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como a Alt. +Respondes sempre como o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. @@ -512,6 +512,79 @@ Usas formatação clara e organizada.'''; Logger.info('Last PDF context cleared'); } + /// Detecta se a query é small talk (saudação, conversa casual) — sem necessidade de contexto PDF + static bool _isSmallTalk(String query) { + final q = query.trim().toLowerCase(); + const triggers = [ + 'olá', + 'ola', + 'oi', + 'ei', + 'hey', + 'hi', + 'tudo bem', + 'tudo bom', + 'como estás', + 'como estas', + 'como vai', + 'bom dia', + 'boa tarde', + 'boa noite', + 'obrigado', + 'obrigada', + 'muito obrigado', + 'muito obrigada', + 'valeu', + 'ok', + 'okay', + 'fixe', + 'ótimo', + 'otimo', + 'perfeito', + 'excelente', + 'adeus', + 'até logo', + 'até mais', + 'tchau', + 'quem és', + 'quem es', + 'quem é o vico', + 'o que és', + 'o que fazes', + 'apresenta-te', + 'apresentate', + ]; + // Exact match or starts with a trigger phrase + if (triggers.any( + (t) => q == t || q.startsWith('$t ') || q.startsWith('$t,'), + )) { + return true; + } + // Very short messages with no educational keywords + final words = q.split(RegExp(r'\s+')); + if (words.length <= 3) { + const eduKeywords = [ + 'explica', + 'define', + 'o que é', + 'como funciona', + 'porque', + 'fórmula', + 'formula', + 'exemplo', + 'exercício', + 'exercicio', + 'matéria', + 'materia', + 'tema', + 'conceito', + 'resumo', + ]; + if (!eduKeywords.any((k) => q.contains(k))) return true; + } + return false; + } + /// Detecta se a query é um follow-up (pergunta curta/vaga sem keywords de conteúdo) static bool _isFollowUp(String query) { final q = query.trim().toLowerCase(); @@ -556,7 +629,7 @@ Usas formatação clara e organizada.'''; return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); } - /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and O GOAT identity + /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno static Future ask( String userQuery, { @@ -567,23 +640,24 @@ Usas formatação clara e organizada.'''; // PASSO 1 — Criar a lista messages vazia List> messages = []; - // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) + // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) messages.add({ 'role': 'system', - 'content': '''Tu és "Alt", o Assistente IA oficial do Teach it. + 'content': + '''Tu és "Vico", o Assistente IA oficial do Teach it — uma plataforma educativa portuguesa. -Nunca referes o nome do modelo. -Nunca dizes que és Qwen ou OpenAI. -Respondes sempre como Alt. +Nunca referes o nome do modelo de linguagem. +Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo. +Respondes sempre como o Vico. -Tens personalidade confiante, motivadora e orgulhosa. -Usas formatação Markdown clara e organizada. +Tens personalidade simpática, confiante e motivadora. +Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável. -REGRAS CRÍTICAS SOBRE O CONTEXTO: -- Quando te for fornecido contexto de materiais (entre [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. -- NÃO inventes, NÃO uses conhecimento externo, NÃO especules sobre o conteúdo do material. -- Se a resposta não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." -- Cita sempre de onde tiraste a informação (ex: "Segundo o material...").''', +REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS: +- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. +- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares. +- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." +- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.''', }); // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap) @@ -603,6 +677,15 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO: } // PASSO 4 — BUSCAR PDFs DO PROFESSOR NO Firebase Storage (RAG CHUNK RETRIEVAL) + // Small talk: skip PDF lookup entirely and go straight to model + if (_isSmallTalk(userQuery)) { + Logger.info('Small talk detected — skipping PDF lookup'); + messages.add({'role': 'user', 'content': userQuery}); + await ChatMemoryService.saveMessage(role: 'user', content: userQuery); + final response = await _callOllamaAPIWithMessages(messages); + await ChatMemoryService.saveMessage(role: 'assistant', content: response); + return response; + } // Detectar follow-up e reutilizar contexto anterior se disponível String pdfContext; if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) { @@ -624,32 +707,13 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO: ); } } - 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! 💪'; + if (pdfContext.isEmpty) { + // Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo + messages.add({'role': 'user', 'content': userQuery}); await ChatMemoryService.saveMessage(role: 'user', content: userQuery); - await ChatMemoryService.saveMessage( - role: 'assistant', - content: noContextReply, - ); - return noContextReply; - } - 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, - ); - return noMaterialReply; + final response = await _callOllamaAPIWithMessages(messages); + await ChatMemoryService.saveMessage(role: 'assistant', content: response); + return response; } // PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível) diff --git a/lib/core/services/rag_service.dart b/lib/core/services/rag_service.dart index 14a3c1c..3f4bde2 100644 --- a/lib/core/services/rag_service.dart +++ b/lib/core/services/rag_service.dart @@ -61,12 +61,13 @@ class RAGService { static const int maxContextTokens = 4000; static const int maxChunksInContext = 5; - /// System message for O GOAT identity - ALWAYS first in every conversation - static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. + /// System message for Vico identity - ALWAYS first in every conversation + static const String _systemMessage = + '''Tu és "Vico", 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 o Vico. Tens personalidade confiante, motivadora e orgulhosa. Ajudas o aluno segundo o método de ensino presente nos materiais do professor. diff --git a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart index 9f35e5a..ab5f6f6 100644 --- a/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart +++ b/lib/features/ai_tutor/presentation/pages/tutor_chat_page.dart @@ -328,7 +328,7 @@ class _TutorChatPageState extends State void _addWelcomeMessage() { final welcomeMessage = { 'content': - '''**Olá! Sou a Alt, o teu Assistente IA oficial do Teach it.** + '''**Olá! Sou o Vico, o teu Assistente IA oficial do Teach it.** Estou aqui para te ajudar a aprender de forma confiante e motivadora! 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 bb835b6..c0c5657 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 @@ -4,7 +4,6 @@ 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'; import '../../../../core/services/rag_ai_service.dart'; @@ -24,6 +23,7 @@ class _TutorChatPageSimpleState extends State final ScrollController _scrollController = ScrollController(); bool _isLoading = false; + bool _materialsConfirmed = false; List> _messages = []; List> _availableMaterials = []; @@ -33,7 +33,6 @@ class _TutorChatPageSimpleState extends State @override void initState() { super.initState(); - _addWelcomeMessage(); _loadAvailableMaterials(); } @@ -84,84 +83,299 @@ class _TutorChatPageSimpleState extends State } }, child: Scaffold( - backgroundColor: Theme.of(context).colorScheme.background, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - elevation: 0, - title: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.primary.withOpacity(0.8), - ], - ), - borderRadius: BorderRadius.circular(20), - ), - child: const Icon(Icons.school, color: Colors.white, size: 24), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.primary.withValues(alpha: 0.85), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Assistente de Estudo AI', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, + boxShadow: [ + BoxShadow( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + IconButton( + onPressed: () => context.go('/student-dashboard'), + icon: const Icon( + Icons.arrow_back_ios_new, + color: Colors.white, + size: 20, + ), ), - ), - Text( - 'Seu tutor educacional inteligente', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.school, + color: Colors.white, + size: 22, + ), ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Vico', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: Color(0xFF4ADE80), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + const Text( + 'Online', + style: TextStyle( + fontSize: 11, + color: Colors.white70, + ), + ), + ], + ), + ], + ), + ), + if (_materialsConfirmed) + TextButton.icon( + onPressed: () => + _showMaterialsPicker(allowEmpty: false), + icon: const Icon( + Icons.folder_open, + size: 16, + color: Colors.white, + ), + label: Text( + '${_selectedMaterialIds.length}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: TextButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 4), + ], + ), + ), + ), + ), + ), + body: _materialsConfirmed + ? _buildChatBody(context) + : _buildIntroScreen(context), + ), + ); + } + + // ── Intro screen shown before any material is selected ────────────────── + Widget _buildIntroScreen(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), ), ], ), - ], - ), - actions: [ - IconButton( - onPressed: _handleLogout, - icon: Icon( - Icons.logout, - color: Theme.of(context).colorScheme.onSurface, - ), - tooltip: 'Sair', + child: const Icon(Icons.school, color: Colors.white, size: 44), ), + const SizedBox(height: 24), + Text( + 'Olá! Sou o Vico', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'O teu assistente de estudo inteligente.\nRespondo com base nos materiais do teu professor, ajudo-te a perceber conceitos e a preparares-te para os testes.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: cs.onSurfaceVariant, + height: 1.55, + ), + ), + const SizedBox(height: 36), + _availableMaterials.isEmpty + ? Column( + children: [ + CircularProgressIndicator(color: cs.primary), + const SizedBox(height: 16), + Text( + 'A carregar materiais\u2026', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + ), + ], + ) + : SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _showMaterialsPicker(allowEmpty: true), + icon: const Icon(Icons.folder_open_rounded), + label: const Text( + 'Escolher materiais para estudar', + style: TextStyle(fontSize: 15), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), ], ), - body: Column( - children: [ - // Messages area - Expanded( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of(context).colorScheme.background, - Theme.of(context).colorScheme.primary.withOpacity(0.05), - Theme.of(context).colorScheme.background, - ], + ), + ); + } + + // ── Chat body (messages + input) ────────────────────────────────────────── + Widget _buildChatBody(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.primary.withValues(alpha: 0.06), + cs.surfaceContainerLowest, + cs.secondary.withValues(alpha: 0.04), + ], + ), + ), + child: Column( + children: [ + Expanded(child: _buildMessagesArea(context)), + if (_isLoading) _buildTypingIndicator(context), + _buildInputArea(context), + ], + ), + ); + } + + Widget _buildTypingIndicator(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], + ), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.school, color: Colors.white, size: 18), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + bottomRight: Radius.circular(18), + bottomLeft: Radius.circular(4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Vico está a escrever', + style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant), + ), + const SizedBox(width: 6), + SizedBox( + width: 24, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: cs.primary, ), ), - child: _buildMessagesArea(context), - ), + ], ), - - // Input area - _buildInputArea(context), - ], - ), + ), + ], ), ); } @@ -169,7 +383,7 @@ class _TutorChatPageSimpleState extends State Widget _buildMessagesArea(BuildContext context) { return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.symmetric(vertical: 16.0), + padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[index]; @@ -182,9 +396,15 @@ class _TutorChatPageSimpleState extends State final isUser = message['isUser'] as bool; final content = message['content'] as String; final timestamp = message['timestamp'] as DateTime; + final cs = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + padding: EdgeInsets.only( + top: 4, + bottom: 4, + left: isUser ? 48 : 16, + right: isUser ? 16 : 48, + ), child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end @@ -194,67 +414,60 @@ class _TutorChatPageSimpleState extends State mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isUser) ...[ - _buildAvatar(context), - const SizedBox(width: 12), + _buildVicoAvatar(cs), + const SizedBox(width: 8), ], Flexible( child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, ), - padding: const EdgeInsets.all(16.0), decoration: BoxDecoration( gradient: isUser ? LinearGradient( colors: [ - Theme.of(context).colorScheme.primary, - Theme.of( - context, - ).colorScheme.primary.withOpacity(0.8), + cs.primary, + cs.secondary.withValues(alpha: 0.85), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ) - : LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.surface.withOpacity(0.95), - Theme.of( - context, - ).colorScheme.surface.withOpacity(0.9), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + : null, + color: isUser ? null : cs.surfaceContainerHighest, borderRadius: BorderRadius.only( - topLeft: const Radius.circular(20), - topRight: const Radius.circular(20), - bottomLeft: isUser - ? const Radius.circular(20) - : const Radius.circular(4), - bottomRight: isUser - ? const Radius.circular(4) - : const Radius.circular(20), + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isUser ? 18 : 4), + bottomRight: Radius.circular(isUser ? 4 : 18), ), + border: isUser + ? null + : Border( + left: BorderSide( + color: cs.primary.withValues(alpha: 0.5), + width: 3, + ), + ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 4), + color: (isUser ? cs.primary : Colors.black) + .withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 3), ), ], ), child: isUser ? Text( content, - style: TextStyle( + style: const TextStyle( color: Colors.white, - fontSize: 16, - height: 1.4, + fontSize: 15, + height: 1.45, fontWeight: FontWeight.w500, ), ) @@ -262,92 +475,104 @@ class _TutorChatPageSimpleState extends State data: content, styleSheet: MarkdownStyleSheet( p: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontSize: 16, - height: 1.4, + color: cs.onSurface, + fontSize: 15, + height: 1.5, ), strong: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontSize: 16, + color: cs.onSurface, + fontSize: 15, fontWeight: FontWeight.bold, - height: 1.4, ), em: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontSize: 16, + color: cs.onSurface, + fontSize: 15, fontStyle: FontStyle.italic, - height: 1.4, ), listBullet: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontSize: 16, - height: 1.4, + color: cs.primary, + fontSize: 15, + ), + code: TextStyle( + backgroundColor: cs.primary.withValues( + alpha: 0.1, + ), + color: cs.primary, + fontSize: 13, ), ), ), ), ), if (isUser) ...[ - const SizedBox(width: 12), - _buildAvatar(context), + const SizedBox(width: 8), + _buildUserAvatar(cs), ], ], ), - const SizedBox(height: 4), Padding( padding: EdgeInsets.only( - left: isUser ? 0 : 48, - right: isUser ? 48 : 0, + top: 3, + left: isUser ? 0 : 40, + right: isUser ? 40 : 0, ), child: Text( _formatTimestamp(timestamp), - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: TextStyle(fontSize: 10, color: cs.onSurfaceVariant), ), ), ], ), ) .animate() - .fadeIn(duration: const Duration(milliseconds: 300)) + .fadeIn(duration: const Duration(milliseconds: 250)) .slideY( - begin: isUser ? 0.1 : -0.1, + begin: 0.08, end: 0, - duration: const Duration(milliseconds: 400), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, ); } - Widget _buildAvatar(BuildContext context) { + Widget _buildVicoAvatar(ColorScheme cs) { return Container( - width: 36, - height: 36, + width: 32, + height: 32, decoration: BoxDecoration( gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.primary.withOpacity(0.8), - ], + colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], ), - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Theme.of(context).colorScheme.primary.withOpacity(0.3), - blurRadius: 8, + color: cs.primary.withValues(alpha: 0.25), + blurRadius: 6, offset: const Offset(0, 2), ), ], ), - child: const Icon(Icons.person, color: Colors.white, size: 20), + child: const Icon(Icons.school, color: Colors.white, size: 18), + ); + } + + Widget _buildUserAvatar(ColorScheme cs) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.secondary, cs.secondary.withValues(alpha: 0.8)], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: cs.secondary.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.person, color: Colors.white, size: 18), ); } @@ -455,23 +680,85 @@ class _TutorChatPageSimpleState extends State orElse: () => {'id': id, 'name': id}, )['name'] ?? id; - final short = name.length > 18 - ? '${name.substring(0, 16)}…' - : name; + final cleanName = name + .replaceAll('.pdf', '') + .replaceAll('_', ' '); + final short = cleanName.length > 18 + ? '${cleanName.substring(0, 16)}…' + : cleanName; + const chipBg = Color(0xFFF68D2D); + final isLast = _selectedMaterialIds.length == 1; return Padding( padding: const EdgeInsets.only(left: 6), child: Chip( label: Text( short, - style: const TextStyle(fontSize: 11), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: chipBg, + deleteIconColor: Colors.white.withValues( + alpha: 0.85, ), deleteIcon: const Icon(Icons.close, size: 14), - onDeleted: () => setState( - () => _selectedMaterialIds.remove(id), - ), + onDeleted: () { + if (isLast) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + backgroundColor: const Color( + 0xFFF68D2D, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + duration: const Duration( + seconds: 2, + ), + content: const Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.white, + size: 20, + ), + SizedBox(width: 10), + Expanded( + child: Text( + 'Tens de manter pelo menos um material selecionado.', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: + FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } else { + setState( + () => _selectedMaterialIds.remove(id), + ); + } + }, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), visualDensity: VisualDensity.compact, ), ); @@ -602,7 +889,7 @@ class _TutorChatPageSimpleState extends State return groups; } - void _showMaterialsPicker() { + void _showMaterialsPicker({bool allowEmpty = true}) { final groups = _groupMaterialsByClass(); final disciplineIds = groups.keys.where((k) => k != '__geral__').toList() ..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b)); @@ -716,195 +1003,188 @@ class _TutorChatPageSimpleState extends State ), ), 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, + if (filteredDisciplineIds.isEmpty) + SizedBox( + height: 36, + child: Center( + child: Text( + 'Nenhum resultado para "$searchQuery"', + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ) + else + ConstrainedBox( + constraints: BoxConstraints(maxHeight: listMaxHeight), + child: 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, ), - ), - ), - ) - : 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( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(8), - onTap: () => setDialogState( - () => expanded[groupKey] = !isExpanded, - ), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + ), + child: Row( + children: [ + Icon( + Icons.folder_outlined, + size: 16, + color: cs.primary, ), - decoration: BoxDecoration( - color: cs.primary.withValues( - alpha: 0.07, - ), - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: Row( - children: [ - Icon( - Icons.folder_outlined, - size: 16, + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, 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, + 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, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - controlAffinity: - ListTileControlAffinity.leading, - dense: true, - contentPadding: const EdgeInsets.only( - left: 16, - ), - ); - }), - const SizedBox(height: 6), - ], - ); - }).toList(), - ), - ), + 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(); - }, + onPressed: tempSelected.isEmpty + ? null + : () { + final isFirst = !_materialsConfirmed; + final selectionChanged = + !tempSelected.containsAll(_selectedMaterialIds) || + !_selectedMaterialIds.containsAll(tempSelected); + setState(() { + _selectedMaterialIds = tempSelected; + _materialsConfirmed = true; + if (!isFirst && selectionChanged) { + final welcome = _messages.isNotEmpty + ? [_messages.first] + : >[]; + _messages = welcome; + } + }); + if (isFirst) _addWelcomeMessage(); + if (selectionChanged) { + ChatMemoryService.clearHistory(); + RAGAIService.clearLastContext(); + } + Navigator.of(dialogContext).pop(); + }, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -923,20 +1203,7 @@ class _TutorChatPageSimpleState extends State void _addWelcomeMessage() { final welcomeMessage = { 'content': - '''**Olá! Sou o GOAT, o teu Assistente IA oficial do Teach it.** 🐐 - -Estou aqui para te ajudar a aprender de forma confiante e motivadora! - -**O que posso fazer por ti:** -📚 Responder com base no material do teu professor -🔍 Usar os PDFs e documentos disponibilizados -� Explicar conceitos de forma clara e organizada -🎯 Adaptar-me ao método de ensino do teu professor - -**Como funciona:** -Envia-me a tua pergunta sobre qualquer assunto educacional e vou usar o material disponível para te dar a melhor resposta possível. - -**Estou pronto quando tu estiveres!** 💪''', + '''Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪''', 'isUser': false, 'timestamp': DateTime.now(), }; @@ -1011,23 +1278,6 @@ Envia-me a tua pergunta sobre qualquer assunto educacional e vou usar o material _scrollToBottom(); } - String _generateMockResponse(String userQuery) { - final responses = [ - 'Esta é uma resposta simulada para: "$userQuery". Na versão completa, esta resposta seria gerada pela API Ollama com base no conteúdo dos professores.', - 'Recebi sua pergunta sobre "$userQuery". O sistema RAG completo buscaria conteúdo relevante no banco de dados e geraria uma resposta personalizada.', - 'Sobre "$userQuery": A versão completa usaria embeddings vetoriais para encontrar o conteúdo mais relevante e fornecer uma resposta baseada apenas no material educacional.', - ]; - - return responses[(userQuery.hashCode % responses.length)]; - } - - void _handleLogout() async { - await AuthService.signOut(); - if (mounted) { - context.go('/login'); - } - } - void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { diff --git a/lib/features/analytics/presentation/pages/analytics_page.dart b/lib/features/analytics/presentation/pages/analytics_page.dart index 5adba30..a49ab14 100644 --- a/lib/features/analytics/presentation/pages/analytics_page.dart +++ b/lib/features/analytics/presentation/pages/analytics_page.dart @@ -9,7 +9,7 @@ import '../../../../core/services/gamification_service.dart'; import '../../../../core/models/class_stats.dart'; import '../../../../core/models/achievement.dart'; import '../widgets/class_analytics_card.dart'; -import '../widgets/class_ranking_widget.dart'; +import '../widgets/class_students_inline_widget.dart'; import '../widgets/create_achievement_dialog.dart'; /// Analytics page for teachers with class breakdowns and rankings @@ -26,6 +26,7 @@ class _AnalyticsPageState extends State List _classStats = []; bool _loading = true; String? _selectedClassId; + String? _selectedClassName; @override void initState() { @@ -86,89 +87,99 @@ class _AnalyticsPageState extends State final themeExtras = AppThemeExtras.of(context); final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - body: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: themeExtras.dashboardBackgroundGradient, - stops: themeExtras.dashboardGradientStops, + return PopScope( + canPop: false, + onPopInvoked: (didPop) { + if (didPop) return; + context.go('/teacher-dashboard'); + }, + child: Scaffold( + backgroundColor: cs.surface, + resizeToAvoidBottomInset: false, + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: themeExtras.dashboardBackgroundGradient, + stops: themeExtras.dashboardGradientStops, + ), ), - ), - child: SafeArea( - child: Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Row( - children: [ - IconButton( - icon: const Icon( - Icons.arrow_back, - color: Colors.white, + child: SafeArea( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + onPressed: () => context.go('/teacher-dashboard'), ), - onPressed: () => context.go('/teacher-dashboard'), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Analytics', - style: TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.bold, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Analytics', + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Acompanhe o desempenho das disciplinas', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 16, + const SizedBox(height: 4), + Text( + 'Acompanhe o desempenho das disciplinas', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 16, + ), ), - ), - ], + ], + ), ), + IconButton( + icon: const Icon(Icons.add, color: Colors.white), + onPressed: _showCreateAchievementDialog, + tooltip: 'Criar Conquista', + ), + ], + ), + const SizedBox(height: 20), + TabBar( + controller: _tabController, + labelColor: Colors.white, + unselectedLabelColor: Colors.white.withValues( + alpha: 0.7, ), - IconButton( - icon: const Icon(Icons.add, color: Colors.white), - onPressed: _showCreateAchievementDialog, - tooltip: 'Criar Conquista', - ), - ], - ), - const SizedBox(height: 20), - TabBar( - controller: _tabController, - labelColor: Colors.white, - unselectedLabelColor: Colors.white.withValues(alpha: 0.7), - indicatorColor: Colors.white, - indicatorWeight: 2, - tabs: const [ - Tab(text: 'Disciplinas'), - Tab(text: 'Rankings'), - ], - ), - ], + indicatorColor: Colors.white, + indicatorWeight: 2, + tabs: const [ + Tab(text: 'Disciplinas'), + Tab(text: 'Alunos'), + ], + ), + ], + ), ), - ), - // Content - Expanded( - child: TabBarView( - controller: _tabController, - children: [_buildClassesTab(), _buildRankingsTab()], + // Content + Expanded( + child: TabBarView( + controller: _tabController, + children: [_buildClassesTab(), _buildStudentsTab()], + ), ), - ), - ], + ], + ), ), ), ), @@ -248,7 +259,7 @@ class _AnalyticsPageState extends State padding: const EdgeInsets.only(bottom: 16), child: ClassAnalyticsCard( classStats: stats, - onTap: () => _showClassRanking(stats), + onTap: () => _showClassStudents(stats), ), ), ), @@ -257,20 +268,20 @@ class _AnalyticsPageState extends State ); } - Widget _buildRankingsTab() { + Widget _buildStudentsTab() { if (_selectedClassId == null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.leaderboard, + Icons.people_outline, size: 64, color: Colors.white.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( - 'Selecione uma disciplina', + 'Seleciona uma disciplina', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 18, @@ -278,18 +289,22 @@ class _AnalyticsPageState extends State ), const SizedBox(height: 8), Text( - 'Clique em uma disciplina na aba "Disciplinas" para ver o ranking', + 'Clica numa disciplina no separador "Disciplinas" para ver os alunos', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), fontSize: 14, ), + textAlign: TextAlign.center, ), ], ), ); } - return ClassRankingWidget(classId: _selectedClassId!); + return ClassStudentsInlineWidget( + classId: _selectedClassId!, + className: _selectedClassName ?? '', + ); } Widget _buildOverviewCard( @@ -331,11 +346,12 @@ class _AnalyticsPageState extends State ).animate().scale(duration: 600.ms, curve: Curves.elasticOut); } - void _showClassRanking(ClassStats stats) { + void _showClassStudents(ClassStats stats) { setState(() { _selectedClassId = stats.classId; + _selectedClassName = stats.className; }); - _tabController.animateTo(1); // Mudar para aba de rankings + _tabController.animateTo(1); } void _showCreateAchievementDialog() { diff --git a/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart b/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart new file mode 100644 index 0000000..ebbd95c --- /dev/null +++ b/lib/features/analytics/presentation/widgets/class_students_inline_widget.dart @@ -0,0 +1,609 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +/// Inline widget (no Scaffold) showing enrolled students for a class, +/// with search, real-time updates, and remove-student functionality. +class ClassStudentsInlineWidget extends StatefulWidget { + final String classId; + final String className; + + const ClassStudentsInlineWidget({ + super.key, + required this.classId, + required this.className, + }); + + @override + State createState() => + _ClassStudentsInlineWidgetState(); +} + +class _ClassStudentsInlineWidgetState extends State { + String _searchQuery = ''; + String? _classCode; + late Stream _enrollmentsStream; + + @override + void initState() { + super.initState(); + _loadClassCode(); + _initStream(); + } + + void _initStream() { + _enrollmentsStream = FirebaseFirestore.instance + .collection('enrollments') + .where('classId', isEqualTo: widget.classId) + .snapshots(); + } + + @override + void didUpdateWidget(ClassStudentsInlineWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.classId != widget.classId) { + _loadClassCode(); + _initStream(); + } + } + + Future _loadClassCode() async { + final doc = await FirebaseFirestore.instance + .collection('classes') + .doc(widget.classId) + .get(); + if (mounted) { + setState(() { + _classCode = doc.data()?['code'] as String? ?? '—'; + }); + } + } + + Future _removeStudent( + BuildContext context, + String enrollmentDocId, + String studentName, + ) async { + final cs = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Remover aluno'), + content: Text( + 'Tens a certeza que queres remover "$studentName" desta disciplina?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Remover'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + await FirebaseFirestore.instance + .collection('enrollments') + .doc(enrollmentDocId) + .delete(); + if (mounted) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + backgroundColor: cs.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + content: Text( + '"$studentName" foi removido da disciplina.', + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Erro ao remover aluno: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return StreamBuilder( + stream: _enrollmentsStream, + builder: (context, snapshot) { + // Keep showing previous data while new data loads (prevents flash) + final docs = snapshot.data?.docs ?? []; + // Sort client-side by joinedAt ascending + final enrollments = List.from(docs) + ..sort((a, b) { + final aData = a.data() as Map; + final bData = b.data() as Map; + final aTs = aData['joinedAt'] as Timestamp?; + final bTs = bData['joinedAt'] as Timestamp?; + if (aTs == null && bTs == null) return 0; + if (aTs == null) return 1; + if (bTs == null) return -1; + return aTs.compareTo(bTs); + }); + final filtered = _searchQuery.isEmpty + ? enrollments + : enrollments.where((doc) { + final data = doc.data() as Map; + final name = (data['studentName'] as String? ?? '') + .toLowerCase(); + final email = (data['studentEmail'] as String? ?? '') + .toLowerCase(); + final q = _searchQuery.toLowerCase(); + return name.contains(q) || email.contains(q); + }).toList(); + + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + margin: const EdgeInsets.all(20), + child: Column( + children: [ + // ── Header ────────────────────────────────────────────────── + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [cs.primary, cs.primary.withValues(alpha: 0.8)], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.className, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + snapshot.connectionState == + ConnectionState.waiting + ? 'A carregar…' + : '${enrollments.length} aluno${enrollments.length == 1 ? '' : 's'} inscrito${enrollments.length == 1 ? '' : 's'}', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 13, + ), + ), + ], + ), + ), + // Código da disciplina + GestureDetector( + onTap: () { + if (_classCode != null && _classCode != '—') { + Clipboard.setData(ClipboardData(text: _classCode!)); + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + backgroundColor: cs.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + duration: const Duration(seconds: 2), + content: const Text( + 'Código copiado!', + style: TextStyle(color: Colors.white), + ), + ), + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Código', + style: TextStyle( + color: Colors.white70, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _classCode ?? '…', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 2, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.copy, + color: Colors.white.withValues(alpha: 0.7), + size: 10, + ), + const SizedBox(width: 3), + Text( + 'copiar', + style: TextStyle( + color: Colors.white.withValues( + alpha: 0.7, + ), + fontSize: 9, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // ── Search bar ────────────────────────────────────────────── + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.search, + color: Colors.white.withValues(alpha: 0.7), + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + onChanged: (v) => + setState(() => _searchQuery = v.trim()), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: 'Pesquisar aluno…', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _searchQuery = ''), + child: Icon( + Icons.close, + color: Colors.white.withValues(alpha: 0.7), + size: 18, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // ── List ──────────────────────────────────────────────────── + Expanded( + child: + snapshot.connectionState == ConnectionState.waiting && + snapshot.data == null + ? const Center( + child: CircularProgressIndicator(color: Colors.white), + ) + : filtered.isEmpty + ? _buildEmpty(cs) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: filtered.length, + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final doc = filtered[index]; + final data = doc.data() as Map; + final studentName = + data['studentName'] as String? ?? + 'Aluno sem nome'; + final studentEmail = + data['studentEmail'] as String? ?? ''; + final joinedAt = data['joinedAt'] as Timestamp?; + final enrollmentDocId = doc.id; + + return _buildStudentCard( + cs: cs, + enrollmentDocId: enrollmentDocId, + studentName: studentName, + studentEmail: studentEmail, + joinedAt: joinedAt, + index: index, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildStudentCard({ + required ColorScheme cs, + required String enrollmentDocId, + required String studentName, + required String studentEmail, + required Timestamp? joinedAt, + required int index, + }) { + return Dismissible( + key: Key(enrollmentDocId), + direction: DismissDirection.endToStart, + confirmDismiss: (_) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Remover aluno'), + content: Text( + 'Tens a certeza que queres remover "$studentName" desta disciplina?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancelar'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: cs.error), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Remover'), + ), + ], + ), + ); + return confirmed ?? false; + }, + onDismissed: (_) async { + try { + await FirebaseFirestore.instance + .collection('enrollments') + .doc(enrollmentDocId) + .delete(); + if (mounted) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + backgroundColor: cs.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + content: Text( + '"$studentName" foi removido da disciplina.', + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Erro ao remover: $e'))); + } + } + }, + background: Container( + decoration: BoxDecoration( + color: cs.error.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete_outline, color: Colors.white, size: 24), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + ), + child: Row( + children: [ + // Avatar with initial + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + studentName.isNotEmpty ? studentName[0].toUpperCase() : '?', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 14), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + studentName, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (studentEmail.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + studentEmail, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 12, + ), + ), + ], + if (joinedAt != null) ...[ + const SizedBox(height: 2), + Text( + 'Entrou em ${DateFormat('dd/MM/yyyy').format(joinedAt.toDate())}', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 11, + ), + ), + ], + ], + ), + ), + // Remove button + IconButton( + icon: Icon( + Icons.person_remove_outlined, + color: cs.error.withValues(alpha: 0.85), + size: 20, + ), + tooltip: 'Remover aluno', + onPressed: () => + _removeStudent(context, enrollmentDocId, studentName), + ), + ], + ), + ), + ); + } + + Widget _buildEmpty(ColorScheme cs) { + if (_searchQuery.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 48, + color: Colors.white.withValues(alpha: 0.4), + ), + const SizedBox(height: 12), + Text( + 'Nenhum aluno encontrado', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 16, + ), + ), + ], + ), + ); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 56, + color: Colors.white.withValues(alpha: 0.35), + ), + const SizedBox(height: 14), + Text( + 'Nenhum aluno inscrito', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 16, + ), + ), + const SizedBox(height: 6), + Text( + 'Partilha o código da disciplina com os alunos.', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +}