tela de verificação de turma, possibilidade de alunos entrarem em turmas
This commit is contained in:
@@ -7,6 +7,43 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
- New "Criar Turma" button in Teacher Dashboard Quick Actions
|
||||
- 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:**
|
||||
|
||||
- ✅ **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
|
||||
- New `TeacherClassesListWidget` component
|
||||
- "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 '../widgets/progress_hero_widget.dart';
|
||||
import '../widgets/quick_access_widget.dart';
|
||||
import '../widgets/student_classes_list_widget.dart';
|
||||
import '../widgets/profile_section_widget.dart';
|
||||
|
||||
class StudentDashboardPage extends StatefulWidget {
|
||||
@@ -146,7 +147,12 @@ class _StudentDashboardPageState extends State<StudentDashboardPage> {
|
||||
|
||||
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 SizedBox(height: 40),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.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
|
||||
class QuickAccessWidget extends StatelessWidget {
|
||||
@@ -30,6 +31,9 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
Expanded(flex: 2, child: _buildQuizCard(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Join Class Card
|
||||
_buildJoinClassCard(context),
|
||||
],
|
||||
)
|
||||
.animate()
|
||||
@@ -235,4 +239,93 @@ class QuickAccessWidget extends StatelessWidget {
|
||||
)
|
||||
.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 '../../../../core/services/auth_service.dart';
|
||||
import '../../../classes/presentation/pages/class_students_page.dart';
|
||||
|
||||
/// Widget para listar as turmas criadas pelo professor
|
||||
class TeacherClassesListWidget extends StatelessWidget {
|
||||
@@ -77,10 +78,10 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildClassCard(classes[firstIndex]),
|
||||
_buildClassCard(classes[firstIndex], context),
|
||||
const SizedBox(height: 12),
|
||||
if (secondIndex < classes.length)
|
||||
_buildClassCard(classes[secondIndex]),
|
||||
_buildClassCard(classes[secondIndex], context),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -93,62 +94,76 @@ class TeacherClassesListWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClassCard(DocumentSnapshot doc) {
|
||||
Widget _buildClassCard(DocumentSnapshot doc, BuildContext context) {
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
final classId = doc.id;
|
||||
final className = data['name'] as String? ?? 'Sem nome';
|
||||
final classCode = data['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,
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ClassStudentsPage(
|
||||
classId: classId,
|
||||
className: className,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
className,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF2D3748),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
},
|
||||
child: 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),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Código: $classCode',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 13,
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user