896 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/attendance.dart';
import '../../models/dashboard.dart';
import '../../models/fee.dart';
import '../../providers/auth_provider.dart';
import '../../providers/dashboard_provider.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dashboardAsync = ref.watch(dashboardProvider);
final authState = ref.watch(authProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Student Portal'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: 'Sign Out',
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
),
child: const Text('Sign Out'),
),
],
),
);
if (confirm == true) {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
}
},
),
],
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(dashboardProvider);
await ref.read(dashboardProvider.future);
},
child: dashboardAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (err, stack) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline_rounded,
size: 48,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
const Text(
'Failed to load dashboard data',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 13,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => ref.refresh(dashboardProvider),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Try Again'),
),
],
),
),
),
data: (data) => _buildDashboardContent(context, data, authState.student?.name),
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: 0,
backgroundColor: Colors.white,
elevation: 2,
onDestinationSelected: (index) {
switch (index) {
case 0:
// Already on dashboard
break;
case 1:
context.push('/attendance');
break;
case 2:
context.push('/timetable');
break;
case 3:
context.push('/exams');
break;
case 4:
context.push('/profile');
break;
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard_rounded, color: AppTheme.primaryColor),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.fact_check_outlined),
selectedIcon: Icon(Icons.fact_check_rounded, color: AppTheme.primaryColor),
label: 'Attendance',
),
NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today_rounded, color: AppTheme.primaryColor),
label: 'Schedule',
),
NavigationDestination(
icon: Icon(Icons.quiz_outlined),
selectedIcon: Icon(Icons.quiz_rounded, color: AppTheme.primaryColor),
label: 'Exams',
),
NavigationDestination(
icon: Icon(Icons.person_outline_rounded),
selectedIcon: Icon(Icons.person_rounded, color: AppTheme.primaryColor),
label: 'Profile',
),
],
),
);
}
Widget _buildDashboardContent(
BuildContext context,
DashboardData data,
String? fallbackName,
) {
final studentName = data.student?.fullName.isNotEmpty == true
? data.student!.fullName
: (fallbackName ?? 'Student');
final programName = data.student?.program?.name ?? 'Enrolled Student';
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// Student Info Banner Card
InkWell(
onTap: () => context.push('/profile'),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppTheme.primaryDark, AppTheme.primaryColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Colors.white.withOpacity(0.2),
child: Text(
studentName.isNotEmpty ? studentName[0].toUpperCase() : 'S',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
studentName,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
programName,
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 13,
),
),
],
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: Colors.white70,
),
],
),
),
),
const SizedBox(height: 16),
// Quick Attendance Card
_buildAttendanceCard(context, data.attendance),
const SizedBox(height: 16),
// Quick Fee Card
if (data.fees != null) ...[
_buildFeeCard(context, data.fees!),
const SizedBox(height: 16),
],
// Today's Timetable Section
_buildTimetableSection(context, data.todayTimetable),
const SizedBox(height: 16),
// Upcoming Exams Section
_buildExamsSection(context, data.upcomingExams),
const SizedBox(height: 16),
// Recent Results Section
_buildResultsSection(context, data.recentResults),
const SizedBox(height: 16),
// Announcements Section
if (data.announcements.isNotEmpty) ...[
_buildAnnouncementsSection(context, data.announcements),
const SizedBox(height: 20),
],
],
);
}
Widget _buildAttendanceCard(BuildContext context, AttendanceSummary summary) {
return Card(
child: InkWell(
onTap: () => context.push('/attendance'),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Attendance Overview',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
'View Details →',
style: TextStyle(
fontSize: 13,
color: AppTheme.primaryLight,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
// Attendance Percentage Circular Indicator
SizedBox(
width: 64,
height: 64,
child: Stack(
fit: StackFit.expand,
children: [
CircularProgressIndicator(
value: (summary.percentage / 100).clamp(0.0, 1.0),
backgroundColor: AppTheme.borderLight,
color: summary.percentage >= 75
? AppTheme.successColor
: (summary.percentage >= 60
? AppTheme.warningColor
: AppTheme.errorColor),
strokeWidth: 6,
),
Center(
child: Text(
'${summary.percentage.toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildAttendanceMetric('Present', summary.present, AppTheme.successColor),
_buildAttendanceMetric('Absent', summary.absent, AppTheme.errorColor),
_buildAttendanceMetric('Late', summary.late, AppTheme.warningColor),
],
),
),
],
),
],
),
),
),
);
}
Widget _buildAttendanceMetric(String label, int count, Color color) {
return Column(
children: [
Text(
'$count',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildFeeCard(BuildContext context, DashboardFeeSummary fees) {
Color statusColor;
String statusLabel;
switch (fees.paymentStatus) {
case 'fully_paid':
case 'paid':
statusColor = AppTheme.successColor;
statusLabel = 'Paid';
break;
case 'overdue':
statusColor = AppTheme.errorColor;
statusLabel = 'Overdue';
break;
case 'partial':
statusColor = AppTheme.warningColor;
statusLabel = 'Partial';
break;
default:
statusColor = fees.outstandingBalance > 0 ? AppTheme.primaryLight : AppTheme.successColor;
statusLabel = fees.outstandingBalance > 0 ? 'Pending' : 'No Dues';
}
final sym = fees.currencySymbol;
return Card(
child: InkWell(
onTap: () => context.push('/fees'),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.account_balance_wallet_outlined, size: 18, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Fee Management',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: statusColor.withValues(alpha: 0.5)),
),
child: Text(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
],
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Outstanding Dues',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(
'$sym${fees.outstandingBalance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: fees.overdueAmount > 0 ? AppTheme.errorColor : AppTheme.textPrimary,
),
),
],
),
const Row(
children: [
Text(
'View Invoices →',
style: TextStyle(
fontSize: 13,
color: AppTheme.primaryLight,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
if (fees.nextDueDate != null) ...[
const SizedBox(height: 10),
Text(
'Next Due Date: ${fees.nextDueDate}',
style: const TextStyle(fontSize: 11, color: AppTheme.textMuted),
),
],
],
),
),
),
);
}
Widget _buildTimetableSection(BuildContext context, List todaySlots) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Today's Schedule",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/timetable'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Full Schedule →'),
),
],
),
const SizedBox(height: 12),
if (todaySlots.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.event_available_rounded, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No classes scheduled today',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...todaySlots.map(
(slot) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${slot.startTime} - ${slot.endTime}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppTheme.primaryColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
slot.subject?.name ?? 'Class Session',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (slot.room?.name != null)
Text(
slot.room!.name,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
),
),
],
),
),
);
}
Widget _buildExamsSection(BuildContext context, List<DashboardExam> upcomingExams) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Upcoming Exams',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/exams'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('View All →'),
),
],
),
const SizedBox(height: 12),
if (upcomingExams.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.quiz_outlined, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No upcoming exams',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...upcomingExams.map(
(exam) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.edit_calendar, size: 18, color: AppTheme.secondaryColor),
),
title: Text(
exam.name,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
subtitle: Text(
exam.startDate != null ? 'Date: ${exam.startDate}' : 'Scheduled',
style: const TextStyle(fontSize: 12),
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
exam.state.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
),
],
),
),
);
}
Widget _buildResultsSection(BuildContext context, List<DashboardResult> recentResults) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Recent Results',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () => context.push('/results'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('View All →'),
),
],
),
const SizedBox(height: 12),
if (recentResults.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.grade_outlined, size: 28, color: AppTheme.textMuted),
SizedBox(height: 6),
Text(
'No recent results',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
)
else
...recentResults.map(
(result) => ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 16,
backgroundColor: result.status.toLowerCase() == 'pass'
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
child: Text(
result.grade ?? (result.status.toLowerCase() == 'pass' ? 'P' : 'F'),
style: TextStyle(
color: result.status.toLowerCase() == 'pass'
? AppTheme.successColor
: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
result.exam?.name ?? 'Exam Result',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
subtitle: Text(
'Score: ${result.percentage.toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 12),
),
trailing: Text(
result.status.toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: result.status.toLowerCase() == 'pass'
? AppTheme.successColor
: AppTheme.errorColor,
),
),
),
),
],
),
),
);
}
Widget _buildAnnouncementsSection(BuildContext context, List<Announcement> announcements) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.campaign_outlined, color: AppTheme.primaryColor, size: 20),
SizedBox(width: 8),
Text(
'Announcements',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
TextButton(
onPressed: () => context.push('/announcements'),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('View All', style: TextStyle(fontSize: 12)),
Icon(Icons.chevron_right, size: 16),
],
),
),
],
),
const SizedBox(height: 8),
...announcements.map(
(ann) => InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => context.push('/announcements'),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
ann.name,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
if (ann.date.isNotEmpty)
Text(
ann.date,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
if (ann.summary.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
ann.summary,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
],
),
),
),
),
],
),
),
);
}
}