1194 lines
43 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|