tela de verificação de turma, possibilidade de alunos entrarem em turmas
This commit is contained in:
@@ -7,6 +7,43 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Student Classes List (ETAPA 5)** - Students can now view their enrolled classes on the home page
|
||||||
|
- New `StudentClassesListWidget` at `/lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart`
|
||||||
|
- Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)`
|
||||||
|
- For each enrollment, fetches corresponding class document from `classes` collection using `classId`
|
||||||
|
- Layout: Same horizontal 2-row scroll pattern as `TeacherClassesListWidget`
|
||||||
|
- Cards display: Class name + Class code
|
||||||
|
- Loading state: `CircularProgressIndicator` (centered in card while loading class data)
|
||||||
|
- Empty state: Text "Ainda não entraste em nenhuma turma."
|
||||||
|
- Widget inserted in `StudentDashboardPage` after QuickAccessWidget
|
||||||
|
- Visual design: White cards (#FFFFFF), teal icon background (#82C9BD with 10% opacity), rounded corners (16px), subtle shadows
|
||||||
|
- Title: "As Minhas Turmas" (same style as teacher dashboard)
|
||||||
|
- System now bidirectional: Teachers see students, Students see classes
|
||||||
|
|
||||||
|
- **Join Class Feature (ETAPA 4)** - Students can now join classes using class codes
|
||||||
|
- New `JoinClassPage` screen at `/lib/features/classes/presentation/pages/join_class_page.dart`
|
||||||
|
- TextField for entering 6-character class code (uppercase, centered, letter-spacing)
|
||||||
|
- "Entrar na Turma" button with loading state and visual feedback
|
||||||
|
- Firestore query: `.collection('classes').where('code', isEqualTo: enteredCode).limit(1)`
|
||||||
|
- Validation: checks if code exists, if student already enrolled
|
||||||
|
- On success: creates document in `enrollments` collection with `classId`, `studentId`, `studentName`, `joinedAt`
|
||||||
|
- Success feedback: green SnackBar "Entraste na turma com sucesso!"
|
||||||
|
- Error feedback: red SnackBar for invalid code, duplicate enrollment, or auth errors
|
||||||
|
- Auto-returns to student home after successful join
|
||||||
|
- Visual design: teal AppBar (#82C9BD), centered icon, clean input field with rounded corners
|
||||||
|
- New "Entrar numa Turma" card in Student Dashboard Quick Access section
|
||||||
|
- Card design: horizontal layout with `Icons.group_add`, white background, rounded corners
|
||||||
|
|
||||||
|
- **Class Students View (ETAPA 3)** - Teachers can now view enrolled students in each class
|
||||||
|
- New `ClassStudentsPage` screen at `/lib/features/classes/presentation/pages/class_students_page.dart`
|
||||||
|
- StreamBuilder query on `enrollments` collection with filter by `classId`
|
||||||
|
- ListTile layout showing student name and join date
|
||||||
|
- Loading state with `CircularProgressIndicator`
|
||||||
|
- Empty state message when no students enrolled
|
||||||
|
- Date formatting using `intl` package (dd/MM/yyyy format)
|
||||||
|
- Consistent styling with existing app design (teal colors, rounded cards)
|
||||||
|
- Navigation via `MaterialPageRoute` from class card tap
|
||||||
|
|
||||||
- **Class Creation Feature (ETAPA 1)** - Teachers can now create classes from the dashboard
|
- **Class Creation Feature (ETAPA 1)** - Teachers can now create classes from the dashboard
|
||||||
- New "Criar Turma" button in Teacher Dashboard Quick Actions
|
- New "Criar Turma" button in Teacher Dashboard Quick Actions
|
||||||
- Simple dialog interface for entering class name
|
- Simple dialog interface for entering class name
|
||||||
|
|||||||
@@ -404,6 +404,74 @@ This document tracks the overall progress of the AI Study Assistant project deve
|
|||||||
|
|
||||||
### **Last 24 Hours:**
|
### **Last 24 Hours:**
|
||||||
|
|
||||||
|
- ✅ **ETAPA 5: Student Classes List** - Students can now view their enrolled classes on the home page
|
||||||
|
- New `StudentClassesListWidget` component at `lib/features/dashboard/presentation/widgets/student_classes_list_widget.dart`
|
||||||
|
- Query: `.collection('enrollments').where('studentId', isEqualTo: currentUser.uid).orderBy('joinedAt', descending: true)`
|
||||||
|
- For each enrollment document, uses `FutureBuilder` to fetch the corresponding class from `classes` collection
|
||||||
|
- Layout identical to `TeacherClassesListWidget`:
|
||||||
|
- Horizontal `ListView.builder` with `scrollDirection: Axis.horizontal`
|
||||||
|
- 2-row grid layout: Column with cards at index * 2 and index * 2 + 1
|
||||||
|
- Card size: 200x150 pixels
|
||||||
|
- Card styling: White background, 16px border radius, subtle shadow
|
||||||
|
- Card content: Icon (school), class name (bold), class code (grey)
|
||||||
|
- Title: "As Minhas Turmas" with `textTheme.titleLarge` style
|
||||||
|
- Loading state: `CircularProgressIndicator` centered in card while loading class data
|
||||||
|
- Empty state: "Ainda não entraste em nenhuma turma."
|
||||||
|
- Widget inserted in `StudentDashboardPage` between `QuickAccessWidget` and `ProfileSectionWidget`
|
||||||
|
- **System is now bidirectional:**
|
||||||
|
- Professor creates classes → Students can join
|
||||||
|
- Professor sees list of students in each class (ETAPA 3)
|
||||||
|
- Students see list of classes they joined (ETAPA 5)
|
||||||
|
- Both use same visual patterns for consistency
|
||||||
|
- Testing: After joining a class (ETAPA 4), the class appears immediately in the student's home list
|
||||||
|
|
||||||
|
- ✅ **ETAPA 4: Join Class Feature** - Students can now join classes using class codes
|
||||||
|
- New `JoinClassPage` component at `lib/features/classes/presentation/pages/join_class_page.dart`
|
||||||
|
- Query: `.collection('classes').where('code', isEqualTo: enteredCode).limit(1)`
|
||||||
|
- Input: TextField with uppercase formatting, 6 character limit, centered text, letter spacing
|
||||||
|
- Validation flow:
|
||||||
|
1. Check if code is empty → show error "Insere o código da turma"
|
||||||
|
2. Query Firestore for class with matching code
|
||||||
|
3. If no class found → show error "Código de turma inválido"
|
||||||
|
4. Check if student already enrolled → show error "Já estás inscrito nesta turma"
|
||||||
|
5. Create enrollment document in `enrollments` collection
|
||||||
|
- Enrollment document structure:
|
||||||
|
```dart
|
||||||
|
{
|
||||||
|
'classId': classDoc.id,
|
||||||
|
'studentId': currentUser.uid,
|
||||||
|
'studentName': currentUser.displayName ?? email.split('@')[0] ?? 'Aluno',
|
||||||
|
'joinedAt': FieldValue.serverTimestamp(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Success feedback: Green SnackBar "Entraste na turma com sucesso!" (2 seconds)
|
||||||
|
- Error feedback: Red SnackBar with specific error message (3 seconds)
|
||||||
|
- Auto-navigates back to Student Dashboard after successful join
|
||||||
|
- Loading state: CircularProgressIndicator in button while processing
|
||||||
|
- Visual design: Teal AppBar (#82C9BD), centered group_add icon, clean white input card
|
||||||
|
- New "Entrar numa Turma" card added to `QuickAccessWidget` in Student Dashboard
|
||||||
|
- Card design: Horizontal layout with `Icons.group_add`, white background, rounded corners (16px)
|
||||||
|
- Testing in Firebase Console:
|
||||||
|
1. Go to Firestore Database → classes collection
|
||||||
|
2. Copy the `code` field from any class document
|
||||||
|
3. In app: Student Dashboard → "Entrar numa Turma"
|
||||||
|
4. Paste code and tap "Entrar na Turma"
|
||||||
|
5. Check enrollments collection for new document with correct data
|
||||||
|
|
||||||
|
- ✅ **ETAPA 3: Class Students View** - Teachers can now view enrolled students in each class
|
||||||
|
- New `ClassStudentsPage` component at `lib/features/classes/presentation/pages/class_students_page.dart`
|
||||||
|
- Query: `.collection('enrollments').where('classId', isEqualTo: classId).orderBy('joinedAt', descending: true)`
|
||||||
|
- StreamBuilder for real-time updates when students join
|
||||||
|
- ListTile design with student icon, name, and formatted join date
|
||||||
|
- Empty state: "Nenhum aluno entrou nesta turma ainda."
|
||||||
|
- Loading state with `CircularProgressIndicator`
|
||||||
|
- Error state with error icon and message
|
||||||
|
- Date formatting using `intl` package (Portuguese format: dd/MM/yyyy)
|
||||||
|
- Navigation via `GestureDetector` onTap in `TeacherClassesListWidget`
|
||||||
|
- MaterialPageRoute navigation passing `classId` and `className` as parameters
|
||||||
|
- AppBar with back button and two-line title (class name + subtitle)
|
||||||
|
- Consistent visual design: teal colors (#82C9BD), white cards, rounded corners (16px), subtle shadows
|
||||||
|
|
||||||
- ✅ **ETAPA 2: Classes List Display** - Teachers can now view their created classes
|
- ✅ **ETAPA 2: Classes List Display** - Teachers can now view their created classes
|
||||||
- New `TeacherClassesListWidget` component
|
- New `TeacherClassesListWidget` component
|
||||||
- "As Minhas Turmas" section added to Teacher Dashboard
|
- "As Minhas Turmas" section added to Teacher Dashboard
|
||||||
|
|||||||
196
lib/features/classes/presentation/pages/class_students_page.dart
Normal file
196
lib/features/classes/presentation/pages/class_students_page.dart
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Página para visualizar os alunos de uma turma específica
|
||||||
|
class ClassStudentsPage extends StatelessWidget {
|
||||||
|
final String classId;
|
||||||
|
final String className;
|
||||||
|
|
||||||
|
const ClassStudentsPage({
|
||||||
|
super.key,
|
||||||
|
required this.classId,
|
||||||
|
required this.className,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF82C9BD),
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
className,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Alunos Matriculados',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: StreamBuilder<QuerySnapshot>(
|
||||||
|
stream: FirebaseFirestore.instance
|
||||||
|
.collection('enrollments')
|
||||||
|
.where('classId', isEqualTo: classId)
|
||||||
|
.orderBy('joinedAt', descending: true)
|
||||||
|
.snapshots(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Erro ao carregar alunos',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final enrollments = snapshot.data?.docs ?? [];
|
||||||
|
|
||||||
|
if (enrollments.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.people_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Nenhum aluno entrou nesta turma ainda.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
|
child: Text(
|
||||||
|
'Partilha o código da turma para os alunos se juntarem.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
itemCount: enrollments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final enrollment = enrollments[index].data() as Map<String, dynamic>;
|
||||||
|
final studentName = enrollment['studentName'] as String? ?? 'Aluno sem nome';
|
||||||
|
final joinedAt = enrollment['joinedAt'] as Timestamp?;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16.0),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(16.0),
|
||||||
|
leading: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
studentName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF2D3748),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
joinedAt != null
|
||||||
|
? 'Entrou em ${_formatDate(joinedAt.toDate())}'
|
||||||
|
: 'Data desconhecida',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return DateFormat('dd/MM/yyyy').format(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
lib/features/classes/presentation/pages/join_class_page.dart
Normal file
247
lib/features/classes/presentation/pages/join_class_page.dart
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
|
||||||
|
/// Página para o aluno entrar numa turma usando o código
|
||||||
|
class JoinClassPage extends StatefulWidget {
|
||||||
|
const JoinClassPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<JoinClassPage> createState() => _JoinClassPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _JoinClassPageState extends State<JoinClassPage> {
|
||||||
|
final _codeController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_codeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _joinClass() async {
|
||||||
|
final code = _codeController.text.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (code.isEmpty) {
|
||||||
|
_showError('Insere o código da turma');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Procurar turma pelo código
|
||||||
|
final classQuery = await FirebaseFirestore.instance
|
||||||
|
.collection('classes')
|
||||||
|
.where('code', isEqualTo: code)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (classQuery.docs.isEmpty) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
_showError('Código de turma inválido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final classDoc = classQuery.docs.first;
|
||||||
|
final classId = classDoc.id;
|
||||||
|
final currentUser = AuthService.currentUser;
|
||||||
|
|
||||||
|
if (currentUser == null) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
_showError('Erro: Utilizador não autenticado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já está inscrito nesta turma
|
||||||
|
final existingEnrollment = await FirebaseFirestore.instance
|
||||||
|
.collection('enrollments')
|
||||||
|
.where('classId', isEqualTo: classId)
|
||||||
|
.where('studentId', isEqualTo: currentUser.uid)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingEnrollment.docs.isNotEmpty) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
_showError('Já estás inscrito nesta turma');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar documento de inscrição
|
||||||
|
await FirebaseFirestore.instance.collection('enrollments').add({
|
||||||
|
'classId': classId,
|
||||||
|
'studentId': currentUser.uid,
|
||||||
|
'studentName': currentUser.displayName ?? currentUser.email?.split('@')[0] ?? 'Aluno',
|
||||||
|
'joinedAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
// Mostrar sucesso
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Entraste na turma com sucesso!'),
|
||||||
|
backgroundColor: Color(0xFF10B981),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Voltar para a home
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
_showError('Erro ao entrar na turma: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: const Color(0xFFEF4444),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF82C9BD),
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Entrar numa Turma',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Ícone e descrição
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.group_add,
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'Insere o código da turma',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF2D3748),
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'O professor partilhou contigo um código de 6 caracteres.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Campo de código
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _codeController,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
maxLength: 6,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 4,
|
||||||
|
color: Color(0xFF2D3748),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'XXXXXX',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 4,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: const EdgeInsets.all(20),
|
||||||
|
counterText: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Botão de entrar
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _joinClass,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF82C9BD),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
disabledBackgroundColor: const Color(0xFF82C9BD).withOpacity(0.5),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Entrar na Turma',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
import '../widgets/progress_hero_widget.dart';
|
import '../widgets/progress_hero_widget.dart';
|
||||||
import '../widgets/quick_access_widget.dart';
|
import '../widgets/quick_access_widget.dart';
|
||||||
|
import '../widgets/student_classes_list_widget.dart';
|
||||||
import '../widgets/profile_section_widget.dart';
|
import '../widgets/profile_section_widget.dart';
|
||||||
|
|
||||||
class StudentDashboardPage extends StatefulWidget {
|
class StudentDashboardPage extends StatefulWidget {
|
||||||
@@ -146,7 +147,12 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Profile Section (Priority 3)
|
// Classes List Section (Priority 3)
|
||||||
|
const StudentClassesListWidget(),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Profile Section (Priority 4)
|
||||||
const ProfileSectionWidget(),
|
const ProfileSectionWidget(),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../classes/presentation/pages/join_class_page.dart';
|
||||||
|
|
||||||
/// Quick access cards for Tutor IA and Quiz with fixed overflow
|
/// Quick access cards for Tutor IA and Quiz with fixed overflow
|
||||||
class QuickAccessWidget extends StatelessWidget {
|
class QuickAccessWidget extends StatelessWidget {
|
||||||
@@ -30,6 +31,9 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
Expanded(flex: 2, child: _buildQuizCard(context)),
|
Expanded(flex: 2, child: _buildQuizCard(context)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Join Class Card
|
||||||
|
_buildJoinClassCard(context),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.animate()
|
.animate()
|
||||||
@@ -235,4 +239,93 @@ class QuickAccessWidget extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
.then(delay: const Duration(milliseconds: 200));
|
.then(delay: const Duration(milliseconds: 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildJoinClassCard(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0), width: 1),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const JoinClassPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.group_add,
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Entrar numa Turma',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF2D3748),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Junta-te a uma turma com o código',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF718096),
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.scale(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
)
|
||||||
|
.then(delay: const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
|
||||||
|
/// Widget para listar as turmas onde o aluno está inscrito
|
||||||
|
class StudentClassesListWidget extends StatelessWidget {
|
||||||
|
const StudentClassesListWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final currentUser = AuthService.currentUser;
|
||||||
|
|
||||||
|
if (currentUser == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamBuilder<QuerySnapshot>(
|
||||||
|
stream: FirebaseFirestore.instance
|
||||||
|
.collection('enrollments')
|
||||||
|
.where('studentId', isEqualTo: currentUser.uid)
|
||||||
|
.orderBy('joinedAt', descending: true)
|
||||||
|
.snapshots(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final enrollments = snapshot.data?.docs ?? [];
|
||||||
|
|
||||||
|
if (enrollments.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Text(
|
||||||
|
'Ainda não entraste em nenhuma turma.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'As Minhas Turmas',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: const Color(0xFF2D3748),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 330,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
itemCount: (enrollments.length + 1) ~/ 2,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final firstIndex = index * 2;
|
||||||
|
final secondIndex = firstIndex + 1;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildClassCard(enrollments[firstIndex]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (secondIndex < enrollments.length)
|
||||||
|
_buildClassCard(enrollments[secondIndex]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClassCard(DocumentSnapshot enrollmentDoc) {
|
||||||
|
final enrollmentData = enrollmentDoc.data() as Map<String, dynamic>;
|
||||||
|
final classId = enrollmentData['classId'] as String? ?? '';
|
||||||
|
|
||||||
|
if (classId.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return FutureBuilder<DocumentSnapshot>(
|
||||||
|
future: FirebaseFirestore.instance.collection('classes').doc(classId).get(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData || !snapshot.data!.exists) {
|
||||||
|
return Container(
|
||||||
|
width: 200,
|
||||||
|
height: 150,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final classData = snapshot.data!.data() as Map<String, dynamic>;
|
||||||
|
final className = classData['name'] as String? ?? 'Sem nome';
|
||||||
|
final classCode = classData['code'] as String? ?? '----';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 200,
|
||||||
|
height: 150,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF82C9BD).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.school,
|
||||||
|
color: Color(0xFF82C9BD),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
className,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF2D3748),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Código: $classCode',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
import '../../../classes/presentation/pages/class_students_page.dart';
|
||||||
|
|
||||||
/// Widget para listar as turmas criadas pelo professor
|
/// Widget para listar as turmas criadas pelo professor
|
||||||
class TeacherClassesListWidget extends StatelessWidget {
|
class TeacherClassesListWidget extends StatelessWidget {
|
||||||
@@ -77,10 +78,10 @@ class TeacherClassesListWidget extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildClassCard(classes[firstIndex]),
|
_buildClassCard(classes[firstIndex], context),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (secondIndex < classes.length)
|
if (secondIndex < classes.length)
|
||||||
_buildClassCard(classes[secondIndex]),
|
_buildClassCard(classes[secondIndex], context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -93,12 +94,25 @@ class TeacherClassesListWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildClassCard(DocumentSnapshot doc) {
|
Widget _buildClassCard(DocumentSnapshot doc, BuildContext context) {
|
||||||
final data = doc.data() as Map<String, dynamic>;
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
final classId = doc.id;
|
||||||
final className = data['name'] as String? ?? 'Sem nome';
|
final className = data['name'] as String? ?? 'Sem nome';
|
||||||
final classCode = data['code'] as String? ?? '----';
|
final classCode = data['code'] as String? ?? '----';
|
||||||
|
|
||||||
return Container(
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ClassStudentsPage(
|
||||||
|
classId: classId,
|
||||||
|
className: className,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 150,
|
height: 150,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -150,6 +164,7 @@ class TeacherClassesListWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user