feat(dashboard): implement Priority 4 action-oriented operational hub with needs attention and activity feed

This commit is contained in:
ssbg04 committed 2026-10-09 11:32:09 +08:00
1 parent dfc2b095d7
commit c8c26240c9
4 files changed
+1062 -2

No files matched your search

+2 -2
View File
@@ -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,
});
}