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
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({
'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<String> ask(
String userQuery, {
@@ -567,23 +640,24 @@ Usas formatação clara e organizada.''';
// PASSO 1 — Criar a lista messages vazia
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({
'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)

View File

@@ -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.

View File

@@ -328,7 +328,7 @@ class _TutorChatPageState extends State<TutorChatPage>
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!

View File

@@ -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<AnalyticsPage>
List<ClassStats> _classStats = [];
bool _loading = true;
String? _selectedClassId;
String? _selectedClassName;
@override
void initState() {
@@ -86,89 +87,99 @@ class _AnalyticsPageState extends State<AnalyticsPage>
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<AnalyticsPage>
padding: const EdgeInsets.only(bottom: 16),
child: ClassAnalyticsCard(
classStats: stats,
onTap: () => _showClassRanking(stats),
onTap: () => _showClassStudents(stats),
),
),
),
@@ -257,20 +268,20 @@ class _AnalyticsPageState extends State<AnalyticsPage>
);
}
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<AnalyticsPage>
),
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<AnalyticsPage>
).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() {

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,
),
],
),
);
}
}