Modificações no chatbot (dashboard aluno) e analytics (dashboard stor)

This commit is contained in:
2026-05-18 20:43:13 +01:00
parent 9b53eb06b6
commit c0ade9ef76
6 changed files with 1436 additions and 496 deletions

View File

@@ -26,14 +26,14 @@ class RAGAIService {
// PASSO 1 — Criar a lista messages vazia // PASSO 1 — Criar a lista messages vazia
List<Map<String, String>> messages = []; List<Map<String, String>> messages = [];
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({ messages.add({
'role': 'system', '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 referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como a Alt. Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. 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(); return promptBuilder.toString();
} }
/// System message for O GOAT identity (for legacy calls) /// System message for Vico identity (for legacy calls)
static const String _systemMessage = 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 referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como a Alt. Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. 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'); 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) /// Detecta se a query é um follow-up (pergunta curta/vaga sem keywords de conteúdo)
static bool _isFollowUp(String query) { static bool _isFollowUp(String query) {
final q = query.trim().toLowerCase(); 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()); 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 /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno
static Future<String> ask( static Future<String> ask(
String userQuery, { String userQuery, {
@@ -567,23 +640,24 @@ Usas formatação clara e organizada.''';
// PASSO 1 — Criar a lista messages vazia // PASSO 1 — Criar a lista messages vazia
List<Map<String, String>> messages = []; List<Map<String, String>> messages = [];
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO GOAT (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({ messages.add({
'role': 'system', '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 referes o nome do modelo de linguagem.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.
Respondes sempre como Alt. Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade simpática, confiante e motivadora.
Usas formatação Markdown clara e organizada. Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.
REGRAS CRÍTICAS SOBRE O CONTEXTO: REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:
- Quando te for fornecido contexto de materiais (entre [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo. - Quando te for fornecido contexto de materiais do professor (indicado com [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. - NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.
- Se a resposta não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível." - Se a resposta educativa 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...").''', - 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) // 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) // 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 // Detectar follow-up e reutilizar contexto anterior se disponível
String pdfContext; String pdfContext;
if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) { if (_isFollowUp(userQuery) && _lastPdfContext.isNotEmpty) {
@@ -624,32 +707,13 @@ REGRAS CRÍTICAS SOBRE O CONTEXTO:
); );
} }
} }
if (pdfContext.isEmpty && if (pdfContext.isEmpty) {
selectedMaterialIds != null && // Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo
selectedMaterialIds.isNotEmpty) { messages.add({'role': 'user', 'content': userQuery});
// 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: 'user', content: userQuery);
await ChatMemoryService.saveMessage( final response = await _callOllamaAPIWithMessages(messages);
role: 'assistant', await ChatMemoryService.saveMessage(role: 'assistant', content: response);
content: noContextReply, return response;
);
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;
} }
// PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível) // PASSO 5 — adicionar a pergunta do user (com contexto embutido se disponível)

View File

@@ -61,12 +61,13 @@ class RAGService {
static const int maxContextTokens = 4000; static const int maxContextTokens = 4000;
static const int maxChunksInContext = 5; static const int maxChunksInContext = 5;
/// System message for O GOAT identity - ALWAYS first in every conversation /// System message for Vico identity - ALWAYS first in every conversation
static const String _systemMessage = '''Tu és "O GOAT", o Assistente IA oficial do Teach it. static const String _systemMessage =
'''Tu és "Vico", o Assistente IA oficial do Teach it.
Nunca referes o nome do modelo. Nunca referes o nome do modelo.
Nunca dizes que és Qwen ou OpenAI. Nunca dizes que és Qwen ou OpenAI.
Respondes sempre como o GOAT. Respondes sempre como o Vico.
Tens personalidade confiante, motivadora e orgulhosa. Tens personalidade confiante, motivadora e orgulhosa.
Ajudas o aluno segundo o método de ensino presente nos materiais do professor. Ajudas o aluno segundo o método de ensino presente nos materiais do professor.

View File

@@ -328,7 +328,7 @@ class _TutorChatPageState extends State<TutorChatPage>
void _addWelcomeMessage() { void _addWelcomeMessage() {
final welcomeMessage = { final welcomeMessage = {
'content': '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! Estou aqui para te ajudar a aprender de forma confiante e motivadora!

View File

@@ -4,7 +4,6 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/services/chat_memory_service.dart'; import '../../../../core/services/chat_memory_service.dart';
import '../../../../core/services/materials_rag_service.dart'; import '../../../../core/services/materials_rag_service.dart';
import '../../../../core/services/rag_ai_service.dart'; import '../../../../core/services/rag_ai_service.dart';
@@ -24,6 +23,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _isLoading = false; bool _isLoading = false;
bool _materialsConfirmed = false;
List<Map<String, dynamic>> _messages = []; List<Map<String, dynamic>> _messages = [];
List<Map<String, String>> _availableMaterials = []; List<Map<String, String>> _availableMaterials = [];
@@ -33,7 +33,6 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_addWelcomeMessage();
_loadAvailableMaterials(); _loadAvailableMaterials();
} }
@@ -84,84 +83,299 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
} }
}, },
child: Scaffold( child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
appBar: AppBar( appBar: PreferredSize(
backgroundColor: Theme.of(context).colorScheme.surface, preferredSize: const Size.fromHeight(kToolbarHeight),
elevation: 0, child: Container(
title: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primary.withOpacity(0.8), Theme.of(context).colorScheme.primary.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
], ],
), ),
borderRadius: BorderRadius.circular(20), child: SafeArea(
), child: Padding(
child: const Icon(Icons.school, color: Colors.white, size: 24), padding: const EdgeInsets.symmetric(horizontal: 8),
), child: Row(
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
'Assistente de Estudo AI',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
'Seu tutor educacional inteligente',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
actions: [
IconButton( IconButton(
onPressed: _handleLogout, onPressed: () => context.go('/student-dashboard'),
icon: Icon( icon: const Icon(
Icons.logout, Icons.arrow_back_ios_new,
color: Theme.of(context).colorScheme.onSurface, color: Colors.white,
size: 20,
),
),
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,
), ),
tooltip: 'Sair',
), ),
], ],
), ),
body: Column( ],
),
),
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: [ children: [
// Messages area Container(
Expanded( width: 88,
child: Container( 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),
),
],
),
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),
),
),
),
),
],
),
),
);
}
// ── Chat body (messages + input) ──────────────────────────────────────────
Widget _buildChatBody(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Theme.of(context).colorScheme.background, cs.primary.withValues(alpha: 0.06),
Theme.of(context).colorScheme.primary.withOpacity(0.05), cs.surfaceContainerLowest,
Theme.of(context).colorScheme.background, cs.secondary.withValues(alpha: 0.04),
], ],
), ),
), ),
child: _buildMessagesArea(context), child: Column(
), children: [
), Expanded(child: _buildMessagesArea(context)),
if (_isLoading) _buildTypingIndicator(context),
// Input area
_buildInputArea(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,
),
),
],
),
),
],
), ),
); );
} }
@@ -169,7 +383,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
Widget _buildMessagesArea(BuildContext context) { Widget _buildMessagesArea(BuildContext context) {
return ListView.builder( return ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.fromLTRB(0, 16, 0, 8),
itemCount: _messages.length, itemCount: _messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = _messages[index]; final message = _messages[index];
@@ -182,9 +396,15 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final isUser = message['isUser'] as bool; final isUser = message['isUser'] as bool;
final content = message['content'] as String; final content = message['content'] as String;
final timestamp = message['timestamp'] as DateTime; final timestamp = message['timestamp'] as DateTime;
final cs = Theme.of(context).colorScheme;
return Padding( 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( child: Column(
crossAxisAlignment: isUser crossAxisAlignment: isUser
? CrossAxisAlignment.end ? CrossAxisAlignment.end
@@ -194,67 +414,60 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
mainAxisAlignment: isUser mainAxisAlignment: isUser
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!isUser) ...[ if (!isUser) ...[
_buildAvatar(context), _buildVicoAvatar(cs),
const SizedBox(width: 12), const SizedBox(width: 8),
], ],
Flexible( Flexible(
child: Container( child: Container(
constraints: BoxConstraints( padding: const EdgeInsets.symmetric(
maxWidth: MediaQuery.of(context).size.width * 0.75, horizontal: 14,
vertical: 12,
), ),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isUser gradient: isUser
? LinearGradient( ? LinearGradient(
colors: [ colors: [
Theme.of(context).colorScheme.primary, cs.primary,
Theme.of( cs.secondary.withValues(alpha: 0.85),
context,
).colorScheme.primary.withOpacity(0.8),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
) )
: LinearGradient( : null,
colors: [ color: isUser ? null : cs.surfaceContainerHighest,
Theme.of(
context,
).colorScheme.surface.withOpacity(0.95),
Theme.of(
context,
).colorScheme.surface.withOpacity(0.9),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20), topLeft: const Radius.circular(18),
topRight: const Radius.circular(20), topRight: const Radius.circular(18),
bottomLeft: isUser bottomLeft: Radius.circular(isUser ? 18 : 4),
? const Radius.circular(20) bottomRight: Radius.circular(isUser ? 4 : 18),
: const Radius.circular(4), ),
bottomRight: isUser border: isUser
? const Radius.circular(4) ? null
: const Radius.circular(20), : Border(
left: BorderSide(
color: cs.primary.withValues(alpha: 0.5),
width: 3,
),
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: (isUser ? cs.primary : Colors.black)
blurRadius: 10, .withValues(alpha: 0.08),
offset: const Offset(0, 4), blurRadius: 8,
offset: const Offset(0, 3),
), ),
], ],
), ),
child: isUser child: isUser
? Text( ? Text(
content, content,
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 16, fontSize: 15,
height: 1.4, height: 1.45,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
) )
@@ -262,92 +475,104 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
data: content, data: content,
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: TextStyle( p: TextStyle(
color: Theme.of( color: cs.onSurface,
context, fontSize: 15,
).colorScheme.onSurface, height: 1.5,
fontSize: 16,
height: 1.4,
), ),
strong: TextStyle( strong: TextStyle(
color: Theme.of( color: cs.onSurface,
context, fontSize: 15,
).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.4,
), ),
em: TextStyle( em: TextStyle(
color: Theme.of( color: cs.onSurface,
context, fontSize: 15,
).colorScheme.onSurface,
fontSize: 16,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
height: 1.4,
), ),
listBullet: TextStyle( listBullet: TextStyle(
color: Theme.of( color: cs.primary,
context, fontSize: 15,
).colorScheme.onSurface, ),
fontSize: 16, code: TextStyle(
height: 1.4, backgroundColor: cs.primary.withValues(
alpha: 0.1,
),
color: cs.primary,
fontSize: 13,
), ),
), ),
), ),
), ),
), ),
if (isUser) ...[ if (isUser) ...[
const SizedBox(width: 12), const SizedBox(width: 8),
_buildAvatar(context), _buildUserAvatar(cs),
], ],
], ],
), ),
const SizedBox(height: 4),
Padding( Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: isUser ? 0 : 48, top: 3,
right: isUser ? 48 : 0, left: isUser ? 0 : 40,
right: isUser ? 40 : 0,
), ),
child: Text( child: Text(
_formatTimestamp(timestamp), _formatTimestamp(timestamp),
style: TextStyle( style: TextStyle(fontSize: 10, color: cs.onSurfaceVariant),
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
), ),
], ],
), ),
) )
.animate() .animate()
.fadeIn(duration: const Duration(milliseconds: 300)) .fadeIn(duration: const Duration(milliseconds: 250))
.slideY( .slideY(
begin: isUser ? 0.1 : -0.1, begin: 0.08,
end: 0, 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( return Container(
width: 36, width: 32,
height: 36, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [cs.primary, cs.primary.withValues(alpha: 0.7)],
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.primary.withOpacity(0.8),
],
), ),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(10),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3), color: cs.primary.withValues(alpha: 0.25),
blurRadius: 8, blurRadius: 6,
offset: const Offset(0, 2), 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<TutorChatPageSimple>
orElse: () => {'id': id, 'name': id}, orElse: () => {'id': id, 'name': id},
)['name'] ?? )['name'] ??
id; id;
final short = name.length > 18 final cleanName = name
? '${name.substring(0, 16)}' .replaceAll('.pdf', '')
: name; .replaceAll('_', ' ');
final short = cleanName.length > 18
? '${cleanName.substring(0, 16)}'
: cleanName;
const chipBg = Color(0xFFF68D2D);
final isLast = _selectedMaterialIds.length == 1;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 6), padding: const EdgeInsets.only(left: 6),
child: Chip( child: Chip(
label: Text( label: Text(
short, 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), deleteIcon: const Icon(Icons.close, size: 14),
onDeleted: () => setState( onDeleted: () {
() => _selectedMaterialIds.remove(id), 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:
MaterialTapTargetSize.shrinkWrap, MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(
horizontal: 4,
),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
); );
@@ -602,7 +889,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
return groups; return groups;
} }
void _showMaterialsPicker() { void _showMaterialsPicker({bool allowEmpty = true}) {
final groups = _groupMaterialsByClass(); final groups = _groupMaterialsByClass();
final disciplineIds = groups.keys.where((k) => k != '__geral__').toList() final disciplineIds = groups.keys.where((k) => k != '__geral__').toList()
..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b)); ..sort((a, b) => (_classNames[a] ?? a).compareTo(_classNames[b] ?? b));
@@ -716,11 +1003,9 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ConstrainedBox( if (filteredDisciplineIds.isEmpty)
constraints: BoxConstraints(maxHeight: listMaxHeight), SizedBox(
child: filteredDisciplineIds.isEmpty height: 36,
? Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center( child: Center(
child: Text( child: Text(
'Nenhum resultado para "$searchQuery"', 'Nenhum resultado para "$searchQuery"',
@@ -728,10 +1013,14 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
fontSize: 13, fontSize: 13,
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
), ),
textAlign: TextAlign.center,
), ),
), ),
) )
: ListView( else
ConstrainedBox(
constraints: BoxConstraints(maxHeight: listMaxHeight),
child: ListView(
shrinkWrap: true, shrinkWrap: true,
children: filteredDisciplineIds.map((groupKey) { children: filteredDisciplineIds.map((groupKey) {
// When searching, filter materials too // When searching, filter materials too
@@ -755,9 +1044,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final isExpanded = expanded[groupKey] ?? false; final isExpanded = expanded[groupKey] ?? false;
// Count how many in this group are selected // Count how many in this group are selected
final selectedInGroup = mats final selectedInGroup = mats
.where( .where((m) => tempSelected.contains(m['id']))
(m) => tempSelected.contains(m['id']),
)
.length; .length;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -774,12 +1061,8 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
vertical: 8, vertical: 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.primary.withValues( color: cs.primary.withValues(alpha: 0.07),
alpha: 0.07, borderRadius: BorderRadius.circular(8),
),
borderRadius: BorderRadius.circular(
8,
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -801,8 +1084,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
), ),
if (selectedInGroup > 0) if (selectedInGroup > 0)
Container( Container(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
vertical: 2, vertical: 2,
), ),
@@ -842,9 +1124,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final cleanName = name final cleanName = name
.replaceAll('.pdf', '') .replaceAll('.pdf', '')
.replaceAll('_', ' '); .replaceAll('_', ' ');
final isChecked = tempSelected.contains( final isChecked = tempSelected.contains(id);
id,
);
return CheckboxListTile( return CheckboxListTile(
value: isChecked, value: isChecked,
onChanged: (val) { onChanged: (val) {
@@ -858,9 +1138,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
}, },
title: Text( title: Text(
cleanName, cleanName,
style: const TextStyle( style: const TextStyle(fontSize: 13),
fontSize: 13,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -882,27 +1160,29 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
), ),
), ),
actions: [ actions: [
TextButton(
onPressed: () {
setDialogState(() => tempSelected.clear());
setState(() {
_selectedMaterialIds.clear();
_messages.clear();
});
ChatMemoryService.clearHistory();
RAGAIService.clearLastContext();
Navigator.of(dialogContext).pop();
},
child: const Text('Limpar'),
),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: tempSelected.isEmpty
? null
: () {
final isFirst = !_materialsConfirmed;
final selectionChanged =
!tempSelected.containsAll(_selectedMaterialIds) ||
!_selectedMaterialIds.containsAll(tempSelected);
setState(() { setState(() {
_selectedMaterialIds = tempSelected; _selectedMaterialIds = tempSelected;
_messages.clear(); _materialsConfirmed = true;
if (!isFirst && selectionChanged) {
final welcome = _messages.isNotEmpty
? [_messages.first]
: <Map<String, dynamic>>[];
_messages = welcome;
}
}); });
if (isFirst) _addWelcomeMessage();
if (selectionChanged) {
ChatMemoryService.clearHistory(); ChatMemoryService.clearHistory();
RAGAIService.clearLastContext(); RAGAIService.clearLastContext();
}
Navigator.of(dialogContext).pop(); Navigator.of(dialogContext).pop();
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -923,20 +1203,7 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
void _addWelcomeMessage() { void _addWelcomeMessage() {
final welcomeMessage = { final welcomeMessage = {
'content': 'content':
'''**Olá! Sou o GOAT, o teu Assistente IA oficial do Teach it.** 🐐 '''Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪''',
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
<EFBFBD> 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!** 💪''',
'isUser': false, 'isUser': false,
'timestamp': DateTime.now(), 'timestamp': DateTime.now(),
}; };
@@ -1011,23 +1278,6 @@ Envia-me a tua pergunta sobre qualquer assunto educacional e vou usar o material
_scrollToBottom(); _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() { void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {

View File

@@ -9,7 +9,7 @@ import '../../../../core/services/gamification_service.dart';
import '../../../../core/models/class_stats.dart'; import '../../../../core/models/class_stats.dart';
import '../../../../core/models/achievement.dart'; import '../../../../core/models/achievement.dart';
import '../widgets/class_analytics_card.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'; import '../widgets/create_achievement_dialog.dart';
/// Analytics page for teachers with class breakdowns and rankings /// Analytics page for teachers with class breakdowns and rankings
@@ -26,6 +26,7 @@ class _AnalyticsPageState extends State<AnalyticsPage>
List<ClassStats> _classStats = []; List<ClassStats> _classStats = [];
bool _loading = true; bool _loading = true;
String? _selectedClassId; String? _selectedClassId;
String? _selectedClassName;
@override @override
void initState() { void initState() {
@@ -86,8 +87,15 @@ class _AnalyticsPageState extends State<AnalyticsPage>
final themeExtras = AppThemeExtras.of(context); final themeExtras = AppThemeExtras.of(context);
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return Scaffold( return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (didPop) return;
context.go('/teacher-dashboard');
},
child: Scaffold(
backgroundColor: cs.surface, backgroundColor: cs.surface,
resizeToAvoidBottomInset: false,
body: Container( body: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -149,12 +157,14 @@ class _AnalyticsPageState extends State<AnalyticsPage>
TabBar( TabBar(
controller: _tabController, controller: _tabController,
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: Colors.white.withValues(alpha: 0.7), unselectedLabelColor: Colors.white.withValues(
alpha: 0.7,
),
indicatorColor: Colors.white, indicatorColor: Colors.white,
indicatorWeight: 2, indicatorWeight: 2,
tabs: const [ tabs: const [
Tab(text: 'Disciplinas'), Tab(text: 'Disciplinas'),
Tab(text: 'Rankings'), Tab(text: 'Alunos'),
], ],
), ),
], ],
@@ -165,13 +175,14 @@ class _AnalyticsPageState extends State<AnalyticsPage>
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [_buildClassesTab(), _buildRankingsTab()], children: [_buildClassesTab(), _buildStudentsTab()],
), ),
), ),
], ],
), ),
), ),
), ),
),
); );
} }
@@ -248,7 +259,7 @@ class _AnalyticsPageState extends State<AnalyticsPage>
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: ClassAnalyticsCard( child: ClassAnalyticsCard(
classStats: stats, classStats: stats,
onTap: () => _showClassRanking(stats), onTap: () => _showClassStudents(stats),
), ),
), ),
), ),
@@ -257,20 +268,20 @@ class _AnalyticsPageState extends State<AnalyticsPage>
); );
} }
Widget _buildRankingsTab() { Widget _buildStudentsTab() {
if (_selectedClassId == null) { if (_selectedClassId == null) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.leaderboard, Icons.people_outline,
size: 64, size: 64,
color: Colors.white.withValues(alpha: 0.5), color: Colors.white.withValues(alpha: 0.5),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Selecione uma disciplina', 'Seleciona uma disciplina',
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: Colors.white.withValues(alpha: 0.7),
fontSize: 18, fontSize: 18,
@@ -278,18 +289,22 @@ class _AnalyticsPageState extends State<AnalyticsPage>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Clique em uma disciplina na aba "Disciplinas" para ver o ranking', 'Clica numa disciplina no separador "Disciplinas" para ver os alunos',
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: Colors.white.withValues(alpha: 0.5),
fontSize: 14, fontSize: 14,
), ),
textAlign: TextAlign.center,
), ),
], ],
), ),
); );
} }
return ClassRankingWidget(classId: _selectedClassId!); return ClassStudentsInlineWidget(
classId: _selectedClassId!,
className: _selectedClassName ?? '',
);
} }
Widget _buildOverviewCard( Widget _buildOverviewCard(
@@ -331,11 +346,12 @@ class _AnalyticsPageState extends State<AnalyticsPage>
).animate().scale(duration: 600.ms, curve: Curves.elasticOut); ).animate().scale(duration: 600.ms, curve: Curves.elasticOut);
} }
void _showClassRanking(ClassStats stats) { void _showClassStudents(ClassStats stats) {
setState(() { setState(() {
_selectedClassId = stats.classId; _selectedClassId = stats.classId;
_selectedClassName = stats.className;
}); });
_tabController.animateTo(1); // Mudar para aba de rankings _tabController.animateTo(1);
} }
void _showCreateAchievementDialog() { void _showCreateAchievementDialog() {

View File

@@ -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<ClassStudentsInlineWidget> createState() =>
_ClassStudentsInlineWidgetState();
}
class _ClassStudentsInlineWidgetState extends State<ClassStudentsInlineWidget> {
String _searchQuery = '';
String? _classCode;
late Stream<QuerySnapshot> _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<void> _loadClassCode() async {
final doc = await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.get();
if (mounted) {
setState(() {
_classCode = doc.data()?['code'] as String? ?? '';
});
}
}
Future<void> _removeStudent(
BuildContext context,
String enrollmentDocId,
String studentName,
) async {
final cs = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
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<QuerySnapshot>(
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<QueryDocumentSnapshot>.from(docs)
..sort((a, b) {
final aData = a.data() as Map<String, dynamic>;
final bData = b.data() as Map<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<bool>(
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,
),
],
),
);
}
}