feat(dashboard): implement Priority 4 action-oriented operational hub with needs attention and activity feed
This commit is contained in:
1 parent
dfc2b095d7
commit
c8c26240c9
4 files changed
+1062
-2
No files matched your search
+2
-2
@@ -1069,8 +1069,8 @@ Files backup/restore - mirror mode or zip -->
|
||||
- [x] Add status system badges (✓ Complete, ⚠ Pending, ! Missing, ○ Archived) across document lists.
|
||||
|
||||
## Priority 4: Action-Oriented Dashboard
|
||||
- [ ] Add "Needs Attention" operational card (missing SF9/SF10, documents requiring verification).
|
||||
- [ ] Add simplified recent operational activity feed linking directly to student targets.
|
||||
- [x] Add "Needs Attention" operational card (missing SF9/SF10, documents requiring verification).
|
||||
- [x] Add simplified recent operational activity feed linking directly to student targets.
|
||||
|
||||
## Priority 5: OCR / Import Review Flow
|
||||
- [ ] Implement 4-step wizard UI: Select Files → Processing → Review & Match Results → Confirm Commit.
|
||||
|
||||
@@ -23,6 +23,8 @@ import '../settings/teacher_management_screen.dart';
|
||||
import 'widgets/notification_dropdown.dart';
|
||||
import '../../shared/modals/view_activity_modal.dart';
|
||||
import 'widgets/dashboard_kpis.dart';
|
||||
import 'widgets/needs_attention_card.dart';
|
||||
import 'widgets/recent_activity_feed.dart';
|
||||
import '../../shared/widgets/app_error_state.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
@@ -380,6 +382,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatGrid(data.stats, user),
|
||||
const SizedBox(height: 28),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth >= 900) {
|
||||
return const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 6, child: NeedsAttentionCard()),
|
||||
SizedBox(width: 20),
|
||||
Expanded(flex: 5, child: RecentActivityFeed()),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NeedsAttentionCard(),
|
||||
SizedBox(height: 20),
|
||||
RecentActivityFeed(),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAdmin || data.stats.hasAssignedSections) ...[
|
||||
const SizedBox(height: 32),
|
||||
const DashboardKpisSection(),
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../providers/dashboard_provider.dart';
|
||||
import '../../../providers/student_provider.dart';
|
||||
import '../../../providers/document_provider.dart';
|
||||
import '../../../providers/navigation_provider.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../documents/widgets/student_profile_modal.dart';
|
||||
import '../../documents/widgets/upload_ocr_modal.dart';
|
||||
import '../../../../domain/entities/student_model.dart';
|
||||
import '../../../../domain/entities/dashboard_models.dart';
|
||||
|
||||
/// "Needs Attention" operational card for Dashboard (Priority 4).
|
||||
/// Shows missing SF9, missing SF10, documents requiring verification,
|
||||
/// and immediate action links directly to student targets.
|
||||
class NeedsAttentionCard extends ConsumerWidget {
|
||||
const NeedsAttentionCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final user = ref.watch(authProvider).value;
|
||||
final userRole = user?.role ?? 'teacher';
|
||||
|
||||
final kpisAsync = ref.watch(dashboardKpisProvider);
|
||||
final studentsAsync = ref.watch(enrolledStudentsForRequirementsProvider);
|
||||
|
||||
final enrolledStudents = studentsAsync.asData?.value ?? [];
|
||||
final kpis = kpisAsync.asData?.value;
|
||||
|
||||
// 1. Calculate missing SF9 count
|
||||
final missingSf9Students = enrolledStudents.where((s) {
|
||||
return s.missingDocuments.any(
|
||||
(d) => d.toUpperCase().contains('SF9') || d.toUpperCase().contains('FORM 9'),
|
||||
);
|
||||
}).toList();
|
||||
final sf9Count = missingSf9Students.isNotEmpty
|
||||
? missingSf9Students.length
|
||||
: (kpis?.bottomStudents
|
||||
.where((s) => s.missingRequirements.any((d) => d.toUpperCase().contains('SF9')))
|
||||
.length ??
|
||||
0);
|
||||
|
||||
// 2. Calculate missing SF10 count
|
||||
final missingSf10Students = enrolledStudents.where((s) {
|
||||
return s.missingDocuments.any(
|
||||
(d) => d.toUpperCase().contains('SF10') || d.toUpperCase().contains('FORM 10'),
|
||||
);
|
||||
}).toList();
|
||||
final sf10Count = missingSf10Students.isNotEmpty
|
||||
? missingSf10Students.length
|
||||
: (kpis?.bottomStudents
|
||||
.where((s) => s.missingRequirements.any((d) => d.toUpperCase().contains('SF10')))
|
||||
.length ??
|
||||
0);
|
||||
|
||||
// 3. Documents to verify (Pending status)
|
||||
final pendingCount = kpis?.statusDistribution
|
||||
.firstWhere(
|
||||
(e) => e.status.toLowerCase() == 'pending',
|
||||
orElse: () => const StatusDistributionEntry(status: 'Pending', count: 0),
|
||||
)
|
||||
.count ??
|
||||
0;
|
||||
|
||||
final totalAlerts = sf9Count + sf10Count + pendingCount;
|
||||
|
||||
// Top students needing immediate attention (most missing docs)
|
||||
final urgentStudents = enrolledStudents
|
||||
.where((s) => s.missingDocumentsCount > 0 || s.missingDocuments.isNotEmpty)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final countA = a.missingDocumentsCount > 0 ? a.missingDocumentsCount : a.missingDocuments.length;
|
||||
final countB = b.missingDocumentsCount > 0 ? b.missingDocumentsCount : b.missingDocuments.length;
|
||||
return countB.compareTo(countA);
|
||||
});
|
||||
final displayedStudents = urgentStudents.take(3).toList();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: totalAlerts > 0
|
||||
? (isDark ? AppColors.darkBorder : Colors.orange.shade200.withValues(alpha: 0.6))
|
||||
: (isDark ? AppColors.darkBorder : Colors.grey.shade200),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.25 : 0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: totalAlerts > 0
|
||||
? Colors.orange.withValues(alpha: isDark ? 0.25 : 0.12)
|
||||
: AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(
|
||||
totalAlerts > 0 ? Icons.warning_amber_rounded : Icons.check_circle_outline_rounded,
|
||||
color: totalAlerts > 0 ? Colors.orange.shade800 : AppColors.primaryGreen,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Needs Attention',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (totalAlerts > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: isDark ? 0.3 : 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$totalAlerts items',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
totalAlerts > 0
|
||||
? 'Operational items requiring action'
|
||||
: 'All document requirements compliant',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(activeTabProvider.notifier).setTab('Documents');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'View all',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Operational Alert Rows ──
|
||||
if (totalAlerts > 0) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// SF9 alert
|
||||
_buildAlertRow(
|
||||
context: context,
|
||||
icon: Icons.assignment_late_outlined,
|
||||
iconColor: Colors.orange.shade800,
|
||||
label: '$sf9Count missing SF9',
|
||||
sublabel: 'Learner Progress Report Card missing for enrolled students',
|
||||
actionLabel: 'View',
|
||||
isDark: isDark,
|
||||
onAction: () => _openMissingDocSheet(
|
||||
context,
|
||||
title: 'Students Missing SF9',
|
||||
students: missingSf9Students,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// SF10 alert
|
||||
_buildAlertRow(
|
||||
context: context,
|
||||
icon: Icons.description_outlined,
|
||||
iconColor: Colors.amber.shade800,
|
||||
label: '$sf10Count missing SF10',
|
||||
sublabel: 'Learner Permanent Academic Record missing',
|
||||
actionLabel: 'View',
|
||||
isDark: isDark,
|
||||
onAction: () => _openMissingDocSheet(
|
||||
context,
|
||||
title: 'Students Missing SF10',
|
||||
students: missingSf10Students,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Pending verification alert
|
||||
_buildAlertRow(
|
||||
context: context,
|
||||
icon: Icons.verified_outlined,
|
||||
iconColor: const Color(0xFF1565C0),
|
||||
label: '$pendingCount documents to verify',
|
||||
sublabel: 'Uploaded records awaiting administrative review & verification',
|
||||
actionLabel: 'Review',
|
||||
isDark: isDark,
|
||||
onAction: () {
|
||||
ref.read(documentQueryProvider.notifier).setStatus('Pending');
|
||||
ref.invalidate(documentPageProvider);
|
||||
ref.read(activeTabProvider.notifier).setTab('Documents');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Urgent Student Spotlight ──
|
||||
if (displayedStudents.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'STUDENTS NEEDING ATTENTION',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
itemCount: displayedStudents.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
||||
itemBuilder: (context, i) {
|
||||
final s = displayedStudents[i];
|
||||
return _buildStudentTile(
|
||||
context: context,
|
||||
student: s,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
// All compliant state
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.task_alt_rounded,
|
||||
size: 36,
|
||||
color: AppColors.primaryGreen.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'All records in good standing',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'No missing SF9/SF10 or pending verification queues detected.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertRow({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String label,
|
||||
required String sublabel,
|
||||
required String actionLabel,
|
||||
required bool isDark,
|
||||
required VoidCallback onAction,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onAction,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: isDark ? 0.12 : 0.06),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: iconColor.withValues(alpha: isDark ? 0.25 : 0.18),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: iconColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
sublabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
actionLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.arrow_forward, size: 11, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStudentTile({
|
||||
required BuildContext context,
|
||||
required StudentModel student,
|
||||
required String userRole,
|
||||
required bool isDark,
|
||||
}) {
|
||||
final missingList = student.missingDocuments.take(2).toList();
|
||||
final extraCount = student.missingDocuments.length - missingList.length;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF8F9FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: AppColors.primaryGreen.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
student.firstName.isNotEmpty ? student.firstName[0].toUpperCase() : 'S',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
student.listDisplayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
for (final doc in missingList) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'! $doc',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (extraCount > 0) ...[
|
||||
Text(
|
||||
'+$extraCount more',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? Colors.white54 : Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload_outlined, size: 18),
|
||||
tooltip: 'Upload Document',
|
||||
color: AppColors.primaryGreen,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: student.id,
|
||||
prefilledLrn: student.lrn,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_outline_rounded, size: 18),
|
||||
tooltip: 'View Profile',
|
||||
color: isDark ? Colors.white70 : Colors.black87,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
showStudentProfileModal(
|
||||
context,
|
||||
studentId: student.id,
|
||||
userRole: userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openMissingDocSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<StudentModel> students,
|
||||
required String userRole,
|
||||
required bool isDark,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.white24 : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 20,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${students.length} students currently missing this record',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 16),
|
||||
if (students.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: Text('No students missing this requirement.')),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: students.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade100,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final s = students[i];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
title: Text(
|
||||
s.listDisplayName,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'LRN: ${s.lrn}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload_outlined, size: 20),
|
||||
tooltip: 'Upload Document',
|
||||
color: AppColors.primaryGreen,
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
UploadOcrModal.show(
|
||||
context,
|
||||
prefilledStudentId: s.id,
|
||||
prefilledLrn: s.lrn,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_outline_rounded, size: 20),
|
||||
tooltip: 'View Profile',
|
||||
color: isDark ? Colors.white70 : Colors.black87,
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
showStudentProfileModal(
|
||||
context,
|
||||
studentId: s.id,
|
||||
userRole: userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/constants/app_colors.dart';
|
||||
import '../../../../core/utils/date_utils.dart' as pht;
|
||||
import '../../../providers/dashboard_provider.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
import '../../documents/widgets/student_profile_modal.dart';
|
||||
import '../../../shared/modals/view_activity_modal.dart';
|
||||
import '../recent_activities_screen.dart';
|
||||
import '../../../../domain/entities/dashboard_models.dart';
|
||||
|
||||
/// Simplified Recent Operational Activity Feed (Priority 4).
|
||||
/// Displays recent document & student actions with human-readable format
|
||||
/// and direct one-click links to student targets.
|
||||
class RecentActivityFeed extends ConsumerWidget {
|
||||
const RecentActivityFeed({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final user = ref.watch(authProvider).value;
|
||||
final userRole = user?.role ?? 'teacher';
|
||||
|
||||
final dashboardAsync = ref.watch(dashboardDataProvider);
|
||||
final activities = dashboardAsync.asData?.value.recentActivities.activities ?? [];
|
||||
final displayedActivities = activities.take(5).toList();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.25 : 0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.12),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.history_rounded,
|
||||
color: AppColors.primaryGreen,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Activity',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
'Operational document & student audit trail',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecentActivitiesScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Full History',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_rounded, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// ── Activity List ──
|
||||
if (displayedActivities.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: 36,
|
||||
color: isDark ? Colors.white30 : Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No recent activity recorded',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
itemCount: displayedActivities.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: isDark ? AppColors.darkBorder : Colors.grey.shade100,
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final activity = displayedActivities[i];
|
||||
return _buildActivityItem(
|
||||
context: context,
|
||||
activity: activity,
|
||||
userRole: userRole,
|
||||
isDark: isDark,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityItem({
|
||||
required BuildContext context,
|
||||
required RecentActivity activity,
|
||||
required String userRole,
|
||||
required bool isDark,
|
||||
}) {
|
||||
final meta = _getActivityMeta(activity);
|
||||
final relativeTime = pht.formatRelative(activity.createdAt);
|
||||
final isStudentTarget = activity.entityType.toLowerCase() == 'student' && activity.entityId != null;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (isStudentTarget) {
|
||||
showStudentProfileModal(
|
||||
context,
|
||||
studentId: activity.entityId!,
|
||||
userRole: userRole,
|
||||
hideEnrollmentActions: true,
|
||||
);
|
||||
} else {
|
||||
ViewActivityModal.show(
|
||||
context: context,
|
||||
title: activity.action.toUpperCase(),
|
||||
description: activity.description,
|
||||
date: activity.createdAt,
|
||||
icon: meta.icon,
|
||||
actionColor: meta.color,
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
// Event icon
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.color.withValues(alpha: isDark ? 0.20 : 0.10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(meta.icon, size: 17, color: meta.color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Title and human-readable narrative
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
meta.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
relativeTime,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? AppColors.darkTextMuted : AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
meta.subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (activity.performedBy != null || activity.username != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'• by ${activity.performedBy ?? activity.username}',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: isDark ? Colors.white38 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: isDark ? Colors.white30 : Colors.grey.shade400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ActivityMeta _getActivityMeta(RecentActivity activity) {
|
||||
final act = activity.action.toUpperCase();
|
||||
final type = activity.entityType.toLowerCase();
|
||||
final desc = activity.description;
|
||||
|
||||
// Determine icon and color
|
||||
IconData icon = Icons.info_outline;
|
||||
Color color = AppColors.primaryGreen;
|
||||
|
||||
if (act.contains('UPLOAD') || desc.toLowerCase().contains('upload')) {
|
||||
icon = Icons.cloud_upload_outlined;
|
||||
color = AppColors.primaryGreen;
|
||||
} else if (act.contains('VERIF') || desc.toLowerCase().contains('verif')) {
|
||||
icon = Icons.verified_outlined;
|
||||
color = const Color(0xFF1565C0);
|
||||
} else if (act.contains('DELETE') || desc.toLowerCase().contains('delet')) {
|
||||
icon = Icons.delete_outline_rounded;
|
||||
color = AppColors.error;
|
||||
} else if (act.contains('CREATE') || act.contains('ENROLL')) {
|
||||
icon = Icons.add_circle_outline_rounded;
|
||||
color = Colors.teal;
|
||||
} else if (act.contains('UPDATE') || act.contains('EDIT')) {
|
||||
icon = Icons.edit_note_rounded;
|
||||
color = Colors.orange.shade800;
|
||||
} else if (type == 'student') {
|
||||
icon = Icons.person_outline_rounded;
|
||||
color = Colors.indigo;
|
||||
}
|
||||
|
||||
// Determine clean title and subtitle
|
||||
String title = desc;
|
||||
String subtitle = '$act • ${type.toUpperCase()}';
|
||||
|
||||
// Parse natural phrasing from description
|
||||
if (desc.isNotEmpty) {
|
||||
title = desc;
|
||||
subtitle = '$act on $type';
|
||||
}
|
||||
|
||||
return _ActivityMeta(icon: icon, color: color, title: title, subtitle: subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityMeta {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
_ActivityMeta({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user