1194 lines
43 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/student.dart';
import '../../providers/dashboard_provider.dart';
import '../../providers/student_provider.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Student Profile'),
bottom: TabBar(
controller: _tabController,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
indicatorColor: Colors.white,
tabs: const [
Tab(icon: Icon(Icons.person_outline, size: 20), text: 'Profile'),
Tab(icon: Icon(Icons.folder_outlined, size: 20), text: 'Documents'),
Tab(icon: Icon(Icons.card_membership_outlined, size: 20), text: 'Certificates'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_ProfileTab(),
_DocumentsTab(),
_CertificatesTab(),
],
),
);
}
}
// ============================================================================
// 1. PROFILE TAB
// ============================================================================
class _ProfileTab extends ConsumerWidget {
const _ProfileTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(studentProfileProvider);
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(studentProfileProvider);
await ref.read(studentProfileProvider.future);
},
child: profileAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => 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: 12),
const Text(
'Failed to load profile details',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
err.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(studentProfileProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (profile) => _buildProfileBody(context, ref, profile),
),
);
}
Widget _buildProfileBody(BuildContext context, WidgetRef ref, StudentProfile profile) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Profile Header Card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
CircleAvatar(
radius: 36,
backgroundColor: AppTheme.primaryColor,
child: Text(
profile.fullName.isNotEmpty ? profile.fullName[0].toUpperCase() : 'S',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
profile.fullName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryLight.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ID: ${profile.studentId}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
profile.status.toUpperCase(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 14),
// Profile Completion Progress Bar
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Profile Completion',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: AppTheme.textSecondary),
),
Text(
'${profile.profileCompletion}%',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
),
],
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: profile.profileCompletion / 100.0,
backgroundColor: Colors.grey.shade200,
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
minHeight: 6,
borderRadius: BorderRadius.circular(3),
),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _openEditProfileDialog(context, ref, profile),
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit Contact Info'),
),
],
),
),
),
const SizedBox(height: 16),
// Academic Information Card (Read-only)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.school_outlined, size: 20, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Academic Details (Institutional)',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const Divider(),
_buildInfoTile('Admission Number', profile.admissionNumber),
if (profile.registrationNumber != null)
_buildInfoTile('Registration Number', profile.registrationNumber!),
if (profile.rollNumber != null) _buildInfoTile('Roll Number', profile.rollNumber!),
if (profile.program?.name != null) _buildInfoTile('Program', profile.program!.name),
if (profile.department?.name != null)
_buildInfoTile('Department', profile.department!.name),
if (profile.level?.name != null) _buildInfoTile('Level', profile.level!.name),
if (profile.academicYear?.name != null)
_buildInfoTile('Academic Year', profile.academicYear!.name),
if (profile.academicTerm?.name != null)
_buildInfoTile('Academic Term', profile.academicTerm!.name),
if (profile.batch?.name != null) _buildInfoTile('Batch', profile.batch!.name),
if (profile.semester?.name != null)
_buildInfoTile('Semester', profile.semester!.name),
if (profile.section?.name != null) _buildInfoTile('Section', profile.section!.name),
if (profile.campus?.name != null) _buildInfoTile('Campus', profile.campus!.name),
_buildInfoTile('Status', profile.status.toUpperCase()),
],
),
),
),
const SizedBox(height: 16),
// Contact & Emergency Information Card (Self-service editable)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.contact_phone_outlined, size: 20, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Contact & Emergency',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
IconButton(
icon: const Icon(Icons.edit, size: 18, color: AppTheme.primaryColor),
onPressed: () => _openEditProfileDialog(context, ref, profile),
tooltip: 'Edit Details',
),
],
),
const Divider(),
_buildInfoTile('Email', profile.email ?? 'Not set'),
_buildInfoTile('Mobile', profile.mobile ?? 'Not set'),
if (profile.phone != null) _buildInfoTile('Landline', profile.phone!),
_buildInfoTile('Blood Group', profile.bloodGroup?.toUpperCase() ?? 'Not set'),
_buildInfoTile('Emergency Contact', profile.emergencyContact ?? 'Not set'),
_buildInfoTile('Emergency Phone', profile.emergencyPhone ?? 'Not set'),
_buildInfoTile('City', profile.city ?? 'Not set'),
if (profile.street != null) _buildInfoTile('Address', profile.street!),
if (profile.zip != null) _buildInfoTile('ZIP', profile.zip!),
],
),
),
),
const SizedBox(height: 16),
// Guardian Information Card
if (profile.guardianName != null || profile.guardianPhone != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.family_restroom_outlined, size: 20, color: AppTheme.primaryColor),
SizedBox(width: 8),
Text(
'Guardian Information',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const Divider(),
if (profile.guardianName != null)
_buildInfoTile('Guardian Name', profile.guardianName!),
if (profile.guardianRelation != null)
_buildInfoTile('Relationship', profile.guardianRelation!),
if (profile.guardianPhone != null)
_buildInfoTile('Phone', profile.guardianPhone!),
if (profile.guardianEmail != null)
_buildInfoTile('Email', profile.guardianEmail!),
],
),
),
),
],
);
}
void _openEditProfileDialog(BuildContext context, WidgetRef ref, StudentProfile profile) {
showDialog(
context: context,
builder: (ctx) => _EditProfileDialog(profile: profile),
);
}
Widget _buildInfoTile(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
],
),
);
}
}
// ============================================================================
// EDIT PROFILE DIALOG
// ============================================================================
class _EditProfileDialog extends ConsumerStatefulWidget {
final StudentProfile profile;
const _EditProfileDialog({required this.profile});
@override
ConsumerState<_EditProfileDialog> createState() => _EditProfileDialogState();
}
class _EditProfileDialogState extends ConsumerState<_EditProfileDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _mobileCtrl;
late TextEditingController _emailCtrl;
late TextEditingController _bloodGroupCtrl;
late TextEditingController _emergencyContactCtrl;
late TextEditingController _emergencyPhoneCtrl;
late TextEditingController _streetCtrl;
late TextEditingController _cityCtrl;
late TextEditingController _zipCtrl;
bool _isLoading = false;
@override
void initState() {
super.initState();
_mobileCtrl = TextEditingController(text: widget.profile.mobile ?? '');
_emailCtrl = TextEditingController(text: widget.profile.email ?? '');
_bloodGroupCtrl = TextEditingController(text: widget.profile.bloodGroup ?? '');
_emergencyContactCtrl = TextEditingController(text: widget.profile.emergencyContact ?? '');
_emergencyPhoneCtrl = TextEditingController(text: widget.profile.emergencyPhone ?? '');
_streetCtrl = TextEditingController(text: widget.profile.street ?? '');
_cityCtrl = TextEditingController(text: widget.profile.city ?? '');
_zipCtrl = TextEditingController(text: widget.profile.zip ?? '');
}
@override
void dispose() {
_mobileCtrl.dispose();
_emailCtrl.dispose();
_bloodGroupCtrl.dispose();
_emergencyContactCtrl.dispose();
_emergencyPhoneCtrl.dispose();
_streetCtrl.dispose();
_cityCtrl.dispose();
_zipCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final repo = ref.read(studentRepositoryProvider);
await repo.updateProfile({
'email': _emailCtrl.text.trim(),
'mobile': _mobileCtrl.text.trim(),
if (_bloodGroupCtrl.text.trim().isNotEmpty)
'blood_group': _bloodGroupCtrl.text.trim().toLowerCase(),
'emergency_contact': _emergencyContactCtrl.text.trim(),
'emergency_phone': _emergencyPhoneCtrl.text.trim(),
'street': _streetCtrl.text.trim(),
'city': _cityCtrl.text.trim(),
'zip': _zipCtrl.text.trim(),
});
ref.invalidate(studentProfileProvider);
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profile updated successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Update failed: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Edit Contact Info'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'Email Address'),
keyboardType: TextInputType.emailAddress,
validator: (val) {
if (val == null || val.isEmpty) return 'Email cannot be blank';
if (!val.contains('@') || !val.contains('.')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _mobileCtrl,
decoration: const InputDecoration(labelText: 'Mobile Number'),
keyboardType: TextInputType.phone,
validator: (val) {
if (val != null && val.isNotEmpty && val.length < 7) {
return 'Enter a valid mobile number';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emergencyContactCtrl,
decoration: const InputDecoration(labelText: 'Emergency Contact Name'),
),
const SizedBox(height: 12),
TextFormField(
controller: _emergencyPhoneCtrl,
decoration: const InputDecoration(labelText: 'Emergency Phone'),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
TextFormField(
controller: _bloodGroupCtrl,
decoration: const InputDecoration(labelText: 'Blood Group (e.g. O+, A+, B-)'),
),
const SizedBox(height: 12),
TextFormField(
controller: _cityCtrl,
decoration: const InputDecoration(labelText: 'City'),
),
const SizedBox(height: 12),
TextFormField(
controller: _streetCtrl,
decoration: const InputDecoration(labelText: 'Address Line'),
),
const SizedBox(height: 12),
TextFormField(
controller: _zipCtrl,
decoration: const InputDecoration(labelText: 'ZIP / Postal Code'),
),
],
),
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save Changes'),
),
],
);
}
}
// ============================================================================
// 2. DOCUMENTS TAB
// ============================================================================
class _DocumentsTab extends ConsumerWidget {
const _DocumentsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final docsAsync = ref.watch(studentDocumentsProvider);
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openUploadDialog(context, ref),
icon: const Icon(Icons.upload_file),
label: const Text('Upload Document'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(studentDocumentsProvider);
await ref.read(studentDocumentsProvider.future);
},
child: docsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => 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: 12),
const Text('Failed to load documents', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(), style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(studentDocumentsProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (docs) {
if (docs.isEmpty) {
return ListView(
children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.25),
const Center(
child: Column(
children: [
Icon(Icons.folder_open_outlined, size: 64, color: AppTheme.textSecondary),
SizedBox(height: 12),
Text(
'No documents uploaded yet',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 6),
Text(
'Upload identification or previous academic marksheet documents.',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
),
],
);
}
return ListView.separated(
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
itemCount: docs.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (ctx, index) {
final doc = docs[index];
return _buildDocumentCard(context, doc);
},
);
},
),
),
);
}
Widget _buildDocumentCard(BuildContext context, StudentDocument doc) {
Color badgeColor;
IconData badgeIcon;
if (doc.isVerified) {
badgeColor = Colors.green;
badgeIcon = Icons.check_circle_outline;
} else if (doc.isRejected) {
badgeColor = AppTheme.errorColor;
badgeIcon = Icons.cancel_outlined;
} else {
badgeColor = Colors.orange;
badgeIcon = Icons.hourglass_top_outlined;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
doc.name,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(badgeIcon, size: 14, color: badgeColor),
const SizedBox(width: 4),
Text(
doc.verificationStatus.toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: badgeColor),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(
doc.categoryLabel,
style: const TextStyle(fontSize: 11, color: Colors.blue, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: 8),
if (doc.fileName != null)
Expanded(
child: Text(
doc.fileName!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
),
],
),
if (doc.isRejected && doc.rejectionReason != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Reason: ${doc.rejectionReason}',
style: const TextStyle(fontSize: 12, color: AppTheme.errorColor),
),
),
],
const SizedBox(height: 8),
Text(
'Uploaded: ${doc.uploadDate}',
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
],
),
),
);
}
void _openUploadDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => const _UploadDocumentDialog(),
);
}
}
// ============================================================================
// UPLOAD DOCUMENT DIALOG
// ============================================================================
class _UploadDocumentDialog extends ConsumerStatefulWidget {
const _UploadDocumentDialog();
@override
ConsumerState<_UploadDocumentDialog> createState() => _UploadDocumentDialogState();
}
class _UploadDocumentDialogState extends ConsumerState<_UploadDocumentDialog> {
final _formKey = GlobalKey<FormState>();
final _titleCtrl = TextEditingController();
final _fileNameCtrl = TextEditingController(text: 'document.pdf');
String _category = 'academic';
bool _isLoading = false;
final Map<String, String> _categories = {
'identity': 'Identity Proof',
'academic': 'Academic Certificate / Marksheet',
'transfer': 'Transfer / Migration Certificate',
'address': 'Address Proof',
'other': 'Other Document',
};
@override
void dispose() {
_titleCtrl.dispose();
_fileNameCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final repo = ref.read(studentRepositoryProvider);
// Valid dummy PDF in base64 format for mobile upload
final dummyPdfBase64 = base64Encode(utf8.encode('%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\n'));
await repo.uploadDocument(
name: _titleCtrl.text.trim(),
category: _category,
fileContentBase64: dummyPdfBase64,
fileName: _fileNameCtrl.text.trim(),
);
ref.invalidate(studentDocumentsProvider);
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Document uploaded successfully for verification!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Upload failed: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Upload Document'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _titleCtrl,
decoration: const InputDecoration(labelText: 'Document Title (e.g. 12th Marksheet)'),
validator: (val) => val == null || val.isEmpty ? 'Title is required' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _category,
decoration: const InputDecoration(labelText: 'Category'),
items: _categories.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (val) => setState(() => _category = val ?? 'academic'),
),
const SizedBox(height: 12),
TextFormField(
controller: _fileNameCtrl,
decoration: const InputDecoration(labelText: 'Filename (.pdf, .jpg, .png)'),
validator: (val) {
if (val == null || val.isEmpty) return 'Filename is required';
final ext = val.split('.').last.toLowerCase();
if (!['pdf', 'jpg', 'jpeg', 'png'].contains(ext)) {
return 'Only PDF, JPG, PNG allowed';
}
return null;
},
),
const SizedBox(height: 12),
const Text(
'Note: Maximum file size is 5 MB. Documents will be reviewed by academic administration.',
style: TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
],
),
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Upload'),
),
],
);
}
}
// ============================================================================
// 3. CERTIFICATES TAB
// ============================================================================
class _CertificatesTab extends ConsumerWidget {
const _CertificatesTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final certsAsync = ref.watch(studentCertificatesProvider);
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openRequestDialog(context, ref),
icon: const Icon(Icons.add_task),
label: const Text('Request Certificate'),
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(studentCertificatesProvider);
await ref.read(studentCertificatesProvider.future);
},
child: certsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => 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: 12),
const Text('Failed to load certificates', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text(err.toString(), style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(studentCertificatesProvider),
child: const Text('Retry'),
),
],
),
),
),
data: (certs) {
if (certs.isEmpty) {
return ListView(
children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.25),
const Center(
child: Column(
children: [
Icon(Icons.card_membership_outlined, size: 64, color: AppTheme.textSecondary),
SizedBox(height: 12),
Text(
'No certificates requested yet',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 6),
Text(
'Request bonafide certificate, student ID card, or course completion.',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
),
],
),
),
],
);
}
return ListView.separated(
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
itemCount: certs.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (ctx, index) {
final cert = certs[index];
return _buildCertificateCard(context, cert);
},
);
},
),
),
);
}
Widget _buildCertificateCard(BuildContext context, StudentCertificate cert) {
Color badgeColor;
IconData badgeIcon;
if (cert.isIssued) {
badgeColor = Colors.green;
badgeIcon = Icons.verified_outlined;
} else if (cert.isApproved) {
badgeColor = Colors.blue;
badgeIcon = Icons.thumb_up_alt_outlined;
} else if (cert.isRejected) {
badgeColor = AppTheme.errorColor;
badgeIcon = Icons.cancel_outlined;
} else {
badgeColor = Colors.orange;
badgeIcon = Icons.pending_actions_outlined;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
cert.certificateTypeLabel,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(badgeIcon, size: 14, color: badgeColor),
const SizedBox(width: 4),
Text(
cert.state.toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: badgeColor),
),
],
),
),
],
),
const SizedBox(height: 6),
Text(
'Cert No: ${cert.name}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppTheme.primaryDark),
),
if (cert.remarks != null && cert.remarks!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Purpose: ${cert.remarks}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
if (cert.isRejected && cert.rejectionReason != null) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Reason: ${cert.rejectionReason}',
style: const TextStyle(fontSize: 12, color: AppTheme.errorColor),
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Requested: ${cert.requestDate}',
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
if (cert.issueDate != null)
Text(
'Issued: ${cert.issueDate}',
style: const TextStyle(fontSize: 11, color: Colors.green, fontWeight: FontWeight.w500),
),
],
),
],
),
),
);
}
void _openRequestDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => const _RequestCertificateDialog(),
);
}
}
// ============================================================================
// REQUEST CERTIFICATE DIALOG
// ============================================================================
class _RequestCertificateDialog extends ConsumerStatefulWidget {
const _RequestCertificateDialog();
@override
ConsumerState<_RequestCertificateDialog> createState() => _RequestCertificateDialogState();
}
class _RequestCertificateDialogState extends ConsumerState<_RequestCertificateDialog> {
final _formKey = GlobalKey<FormState>();
final _remarksCtrl = TextEditingController();
String _selectedType = 'bonafide';
bool _isLoading = false;
final Map<String, String> _certTypes = {
'bonafide': 'Bonafide Certificate',
'id_card': 'Student ID Card',
'course_completion': 'Course Completion Certificate',
'conduct': 'Conduct / Character Certificate',
};
@override
void dispose() {
_remarksCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final repo = ref.read(studentRepositoryProvider);
await repo.requestCertificate(
certificateType: _selectedType,
remarks: _remarksCtrl.text.trim(),
);
ref.invalidate(studentCertificatesProvider);
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Certificate request submitted to academic office!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Request failed: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Request Certificate / ID Card'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: _selectedType,
decoration: const InputDecoration(labelText: 'Certificate Type'),
items: _certTypes.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (val) => setState(() => _selectedType = val ?? 'bonafide'),
),
const SizedBox(height: 12),
TextFormField(
controller: _remarksCtrl,
decoration: const InputDecoration(labelText: 'Purpose / Remarks (e.g. For Passport)'),
maxLines: 2,
),
const SizedBox(height: 12),
const Text(
'Note: Official certificates require institutional review and approval by authorized staff before issuance.',
style: TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
],
),
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Submit Request'),
),
],
);
}
}