Files
LearnIT/lib/features/classes/presentation/pages/class_students_page.dart
2026-06-11 23:59:58 +01:00

987 lines
33 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../core/services/auth_service.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_theme_extension.dart';
/// Página para visualizar os alunos de uma turma específica
class ClassStudentsPage extends StatefulWidget {
final String classId;
final String className;
const ClassStudentsPage({
super.key,
required this.classId,
required this.className,
});
@override
State<ClassStudentsPage> createState() => _ClassStudentsPageState();
}
class _ClassStudentsPageState extends State<ClassStudentsPage> {
bool _isCheckingAccess = true;
bool _accessGranted = false;
String _currentClassName = '';
String? _classCode;
@override
void initState() {
super.initState();
_currentClassName = widget.className;
_verifyOwnership();
}
Future<void> _verifyOwnership() async {
final currentUser = AuthService.currentUser;
if (currentUser == null) {
setState(() {
_isCheckingAccess = false;
_accessGranted = false;
});
return;
}
final role = await AuthService.getUserRole(currentUser.uid);
if (role != 'teacher') {
setState(() {
_isCheckingAccess = false;
_accessGranted = false;
});
return;
}
final classDoc = await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.get();
final teacherId = classDoc.data()?['teacherId'] as String?;
final code = classDoc.data()?['code'] as String?;
setState(() {
_isCheckingAccess = false;
_accessGranted = classDoc.exists && teacherId == currentUser.uid;
_classCode = code ?? '----';
});
}
Future<void> _updateClassName(String newName) async {
try {
await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.update({'name': newName});
setState(() {
_currentClassName = newName;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Nome da turma atualizado com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao atualizar nome: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
Future<void> _deleteClass() async {
try {
// Delete all enrollments first
final enrollmentsSnapshot = await FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.get();
final batch = FirebaseFirestore.instance.batch();
for (final doc in enrollmentsSnapshot.docs) {
batch.delete(doc.reference);
}
await batch.commit();
// Delete the class
await FirebaseFirestore.instance
.collection('classes')
.doc(widget.classId)
.delete();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Turma eliminada com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
context.pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao eliminar turma: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
void _showEditClassNameDialog() {
final textController = TextEditingController(text: _currentClassName);
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.edit, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text('Editar Nome'),
],
),
content: TextField(
controller: textController,
decoration: InputDecoration(
labelText: 'Nome da Turma',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: const Icon(Icons.school),
),
autofocus: true,
maxLength: 50,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
final newName = textController.text.trim();
if (newName.isNotEmpty && newName != _currentClassName) {
Navigator.of(context).pop();
_updateClassName(newName);
}
},
child: const Text('Guardar'),
),
],
),
);
}
void _showDeleteClassDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
const Text('Eliminar Turma'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tens a certeza que desejas eliminar a turma "$_currentClassName"?',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.warning_amber,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Esta ação não pode ser desfeita. Todos os alunos serão removidos.',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
_deleteClass();
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
),
child: const Text('Eliminar'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final themeExtras = AppThemeExtras.of(context);
if (_isCheckingAccess) {
return Scaffold(
backgroundColor: cs.surface,
body: Center(child: CircularProgressIndicator(color: cs.primary)),
);
}
if (!_accessGranted) {
return Scaffold(
backgroundColor: cs.surface,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lock_outline, size: 64, color: cs.primary),
const SizedBox(height: 24),
Text(
'Sem permissão',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Só podes ver os alunos das tuas próprias turmas.',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: themeExtras.dashboardBackgroundGradient,
stops: themeExtras.dashboardGradientStops,
),
),
child: SafeArea(
top: false,
child: Column(
children: [
// Custom AppBar
_buildAppBar(cs),
// Main Content
Expanded(
child: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.orderBy('joinedAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(color: cs.primary),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: cs.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Erro ao carregar alunos',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
),
],
),
);
}
final enrollments = snapshot.data?.docs ?? [];
if (enrollments.isEmpty) {
return _buildEmptyState(cs);
}
return _buildStudentsList(cs, enrollments);
},
),
),
],
),
),
),
);
}
Widget _buildAppBar(ColorScheme cs) {
return Container(
padding: const EdgeInsets.only(left: 16, right: 16, top: 52, bottom: 16),
child: Column(
children: [
// Top Row with Back and Actions
Row(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => context.pop(),
tooltip: 'Voltar',
),
),
const Spacer(),
// Edit Button
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(Icons.edit, color: Colors.white),
onPressed: _showEditClassNameDialog,
tooltip: 'Editar nome',
),
),
const SizedBox(width: 8),
// Delete Button
Container(
decoration: BoxDecoration(
color:
(Theme.of(context).brightness == Brightness.dark
? DarkBrandColors.primaryOrange
: AppColors.primaryOrange)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(
Icons.delete_outline,
color: Theme.of(context).brightness == Brightness.dark
? DarkBrandColors.primaryOrange
: AppColors.primaryOrange,
),
onPressed: _showDeleteClassDialog,
tooltip: 'Eliminar turma',
),
),
],
),
const SizedBox(height: 20),
// Class Info Card
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.school,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentClassName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Código: $_classCode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Stats Row
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('enrollments')
.where('classId', isEqualTo: widget.classId)
.snapshots(),
builder: (context, snapshot) {
final count = snapshot.data?.docs.length ?? 0;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.people,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
'$count ${count == 1 ? 'aluno matriculado' : 'alunos matriculados'}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
],
),
).animate().fadeIn(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
),
],
),
);
}
Widget _buildEmptyState(ColorScheme cs) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 40),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Icon(
Icons.people_outline,
size: 80,
color: cs.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
Text(
'Nenhum aluno entrou nesta turma ainda.',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Partilha o código da turma para os alunos se juntarem.',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
GestureDetector(
onTap: _copyCodeToClipboard,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: cs.primary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.copy, color: cs.primary, size: 18),
const SizedBox(width: 8),
Text(
'Código: $_classCode',
style: TextStyle(
color: cs.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
)
.then(delay: const Duration(milliseconds: 100)),
],
),
);
}
Widget _buildStudentsList(
ColorScheme cs,
List<QueryDocumentSnapshot> enrollments,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title
Container(
margin: const EdgeInsets.only(bottom: 16, left: 8),
child: Row(
children: [
Icon(Icons.people, color: cs.onSurface, size: 20),
const SizedBox(width: 8),
Text(
'Alunos Matriculados',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: cs.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${enrollments.length}',
style: TextStyle(
color: cs.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// Students List
...enrollments.asMap().entries.map((entry) {
final index = entry.key;
final enrollment = entry.value.data() as Map<String, dynamic>;
final studentName =
enrollment['studentName'] as String? ?? 'Aluno sem nome';
final joinedAt = enrollment['joinedAt'] as Timestamp?;
final enrollmentId = entry.value.id;
return _buildStudentCard(
cs,
studentName,
joinedAt,
enrollmentId,
index,
);
}),
const SizedBox(height: 24),
],
),
);
}
Widget _buildStudentCard(
ColorScheme cs,
String studentName,
Timestamp? joinedAt,
String enrollmentId,
int index,
) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cs.primary.withValues(alpha: 0.8),
cs.primary.withValues(alpha: 0.4),
],
),
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Text(
studentName.isNotEmpty
? studentName[0].toUpperCase()
: '?',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 16),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
studentName,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
joinedAt != null
? 'Entrou em ${_formatDate(joinedAt.toDate())}'
: 'Data desconhecida',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
// Delete Button
Container(
decoration: BoxDecoration(
color: cs.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: Icon(
Icons.delete_outline,
color: cs.error,
size: 20,
),
onPressed: () => _showRemoveStudentDialog(
context,
enrollmentId,
studentName,
),
tooltip: 'Remover aluno',
padding: const EdgeInsets.all(10),
constraints: const BoxConstraints(),
),
),
],
),
),
),
),
)
.animate()
.fadeIn(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
)
.then(delay: Duration(milliseconds: index * 50));
}
String _formatDate(DateTime date) {
return DateFormat('dd/MM/yyyy').format(date);
}
void _copyCodeToClipboard() {
Clipboard.setData(ClipboardData(text: _classCode ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Código copiado!'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _showRemoveStudentDialog(
BuildContext context,
String enrollmentId,
String studentName,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
Icon(
Icons.person_remove,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
const Text('Remover Aluno'),
],
),
content: Text(
'Tens a certeza que desejas remover $studentName desta turma?',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
),
child: const Text('Remover'),
),
],
),
);
if (confirmed == true && context.mounted) {
try {
await FirebaseFirestore.instance
.collection('enrollments')
.doc(enrollmentId)
.delete();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white),
const SizedBox(width: 12),
const Text('Aluno removido com sucesso'),
],
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Text('Erro ao remover aluno: $e'),
],
),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 4),
),
);
}
}
}
}
}