Files
TIS_RMS/frontend/lib/ui/screens/dashboard/dashboard_screen.dart
T

1380 lines
50 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/date_utils.dart' as pht;
import '../../shared/cards/stat_card.dart';
import '../../providers/dashboard_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/student_provider.dart';
import '../../providers/users_provider.dart';
import '../../providers/document_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/notification_provider.dart';
import '../../../domain/entities/dashboard_models.dart';
import '../../../domain/entities/user_model.dart';
import 'recent_activities_screen.dart';
import 'user_history_screen.dart';
import '../../shared/menus/profile_dropdown_menu.dart';
import '../../shared/inputs/app_search_bar.dart';
import '../../shared/dialogs/error_dialog.dart';
import '../settings/requirements_settings_screen.dart';
import '../settings/teacher_management_screen.dart';
import 'widgets/notification_dropdown.dart';
import '../../shared/modals/view_activity_modal.dart';
import '../../shared/modals/reset_requests_modal.dart';
import 'widgets/dashboard_kpis.dart';
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _setupBannerDismissed = false;
bool _setupBannerMinimized = false;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final FocusNode _shortcutFocusNode = FocusNode();
ProviderSubscription<String>? _tabListener;
Timer? _pollingTimer;
@override
void dispose() {
_pollingTimer?.cancel();
_tabListener?.close();
_searchController.dispose();
_searchFocusNode.dispose();
_shortcutFocusNode.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_searchFocusNode.addListener(() {
if (mounted) setState(() {});
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.invalidate(dashboardDataProvider);
ref.read(notificationsProvider.notifier).refreshNotifications();
if (ref.read(activeTabProvider) == 'Dashboard') {
_startPolling();
_shortcutFocusNode.requestFocus();
}
// Listen to tab changes outside of build() so it is properly cleaned up.
_tabListener = ref.listenManual<String>(activeTabProvider, (
previous,
next,
) {
if (!mounted) return;
if (next == 'Dashboard' && previous != 'Dashboard') {
setState(() {
_setupBannerDismissed = false;
_setupBannerMinimized = true;
});
if (_searchFocusNode.hasFocus) _searchFocusNode.unfocus();
_searchController.clear();
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) {
_shortcutFocusNode.requestFocus();
}
});
_handleRefresh();
ref.read(authProvider.notifier).refreshUser();
_startPolling();
} else if (next != 'Dashboard') {
_stopPolling();
if (_searchController.text.isNotEmpty) {
_searchController.clear();
}
}
});
});
}
void _startPolling() {
_pollingTimer?.cancel();
_pollingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (mounted &&
ref.read(authProvider).value != null &&
ref.read(activeTabProvider) == 'Dashboard') {
ref.invalidate(dashboardDataProvider);
ref.invalidate(dashboardKpisProvider);
ref.read(notificationsProvider.notifier).refreshNotifications();
}
});
}
void _stopPolling() {
_pollingTimer?.cancel();
}
Future<void> _handleRefresh() async {
if (!mounted) return;
ref.invalidate(dashboardDataProvider);
ref.invalidate(dashboardKpisProvider);
if (!mounted) return;
ref.read(notificationsProvider.notifier).refreshNotifications();
if (!mounted) return;
await ref.read(dashboardDataProvider.future);
}
Color _getNotificationColor(String title) {
final t = title.toLowerCase();
if (t.contains('student')) return Colors.blue;
if (t.contains('document')) return const Color(0xFF1C8248);
if (t.contains('password')) return Colors.orange;
return Colors.grey;
}
IconData _getNotificationIcon(String title) {
final t = title.toLowerCase();
if (t.contains('student')) return Icons.person_add;
if (t.contains('document')) return Icons.upload_file;
if (t.contains('password')) return Icons.lock_reset;
return Icons.notifications;
}
void _showNotifications(BuildContext context) {
final notificationsAsync = ref.read(notificationsProvider);
final list = notificationsAsync.value ?? [];
final RenderBox button = context.findRenderObject() as RenderBox;
final RenderBox overlay =
Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero),
ancestor: overlay,
),
),
Offset.zero & overlay.size,
);
showMenu(
context: context,
position: position,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 8,
items: [
// ── Header row ────────────────────────────────────────────────
PopupMenuItem(
enabled: false,
child: Container(
width: 300,
padding: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Notifications',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (list.isNotEmpty) ...[
InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () {
Navigator.pop(context);
ref
.read(notificationsProvider.notifier)
.markAllAsRead();
},
child: const Text(
'Mark all read',
style: TextStyle(
fontSize: 12,
color: Color(0xFF1C8248),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 10),
InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () {
Navigator.pop(context);
ref
.read(notificationsProvider.notifier)
.clearNotifications();
},
child: const Text(
'Clear',
style: TextStyle(
fontSize: 12,
color: Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
],
),
),
),
// ── Empty state ───────────────────────────────────────────────
if (list.isEmpty)
PopupMenuItem(
enabled: false,
child: SizedBox(
width: 300,
height: 120,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.notifications_off_rounded,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 12),
const Text(
'No new notifications',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
],
),
),
),
)
// ── Notification items ────────────────────────────────────────
else
PopupMenuItem(
enabled: false,
padding: EdgeInsets.zero,
child: NotificationDropdownWidget(
notifications: list,
onViewActivity: (context, title, desc, date, icon, color) {
ViewActivityModal.show(
context: context,
title: title,
description: desc,
date: date,
icon: icon,
actionColor: color,
);
},
getIcon: _getNotificationIcon,
getColor: _getNotificationColor,
),
),
],
);
}
@override
Widget build(BuildContext context) {
final dashboardAsync = ref.watch(dashboardDataProvider);
final user = ref.watch(authProvider).value;
final isAdmin = user?.role == 'admin';
final isDark = Theme.of(context).brightness == Brightness.dark;
// Clean up if not used
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.keyF, control: true): () {
_showSearchDialog(context);
},
},
child: Focus(
focusNode: _shortcutFocusNode,
autofocus: true,
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: dashboardAsync.when(
skipLoadingOnReload: true,
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 12),
Text('$error', style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _handleRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (data) => Stack(
children: [
// Scrollable Content
Positioned.fill(
child: RefreshIndicator(
onRefresh: _handleRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(24, 90, 24, 76),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard Overview',
style: TextStyle(
fontSize:
MediaQuery.of(context).size.width > 600
? 28
: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (isAdmin && !_setupBannerDismissed) ...[
_buildSetupGuidanceBanner(context),
const SizedBox(height: 24),
],
Text(
'Welcome back, ${user?.firstName ?? 'Admin'}. Here is what is happening today.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 24),
_buildStatGrid(data.stats, user),
const SizedBox(height: 32),
const DashboardKpisSection(),
const SizedBox(height: 32),
_buildHistorySections(data, user),
const SizedBox(height: 48),
],
),
),
),
),
// Sticky Blur Top Bar
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildTopBar(context, user),
),
// Sticky Blur Bottom Bar Overlay
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 60,
child: ShaderMask(
shaderCallback: (rect) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black],
stops: [0.0, 0.4],
).createShader(rect);
},
blendMode: BlendMode.dstIn,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
AppColors.darkPageBackground.withValues(alpha: 0.0),
AppColors.darkPageBackground.withValues(alpha: 0.85),
]
: [
Colors.white.withOpacity(0.0),
Colors.white.withOpacity(0.85),
],
),
),
),
),
),
),
),
],
),
),
),
),
),
);
}
// ── ADMIN SETUP GUIDANCE BANNER ───────────────────────────────────────────
Widget _buildSetupGuidanceBanner(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 600;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
width: 1.0,
),
),
child: isMobile
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
child: const Icon(
Icons.settings_suggest_rounded,
color: AppColors.primaryGreen,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Setup Required',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Theme.of(context).colorScheme.onSurface,
),
),
if (!_setupBannerMinimized) ...[
const SizedBox(height: 4),
Text(
'Before using the system, please configure the following sections to get started:',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
],
),
),
IconButton(
onPressed: () => setState(
() =>
_setupBannerMinimized = !_setupBannerMinimized,
),
icon: Icon(
_setupBannerMinimized
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
size: 18,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
IconButton(
onPressed: () =>
setState(() => _setupBannerDismissed = true),
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
if (!_setupBannerMinimized) ...[
const SizedBox(height: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_setupNavButton(
context,
icon: Icons.people_outline,
label: 'Teachers & Academic Setup',
isMobile: true,
onTap: () => TeacherManagementModal.open(context),
),
const SizedBox(height: 10),
_setupNavButton(
context,
icon: Icons.folder_open_outlined,
label: 'Document Requirements',
isMobile: true,
onTap: () => RequirementsModal.open(context),
),
],
),
],
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
child: const Icon(
Icons.settings_suggest_rounded,
color: AppColors.primaryGreen,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Setup Required',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Theme.of(context).colorScheme.onSurface,
),
),
if (!_setupBannerMinimized) ...[
const SizedBox(height: 4),
Text(
'Before using the system, please configure the following sections to get started:',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 8,
children: [
_setupNavButton(
context,
icon: Icons.people_outline,
label: 'Teachers & Academic Setup',
onTap: () =>
TeacherManagementModal.open(context),
),
_setupNavButton(
context,
icon: Icons.folder_open_outlined,
label: 'Document Requirements',
onTap: () => RequirementsModal.open(context),
),
],
),
],
],
),
),
// Minimize and Dismiss buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => setState(
() =>
_setupBannerMinimized = !_setupBannerMinimized,
),
icon: Icon(
_setupBannerMinimized
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
size: 18,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
IconButton(
onPressed: () =>
setState(() => _setupBannerDismissed = true),
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
),
),
),
);
}
Widget _setupNavButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
bool isMobile = false,
}) {
return InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkSurface2 : Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.primaryGreen.withOpacity(0.4),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min,
children: [
Icon(icon, size: 16, color: AppColors.primaryGreen),
const SizedBox(width: 6),
if (isMobile)
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
)
else
Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
const Icon(
Icons.arrow_forward_ios,
size: 12,
color: AppColors.primaryGreen,
),
],
),
),
);
}
void _showSearchDialog(BuildContext context) {
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: kToolbarHeight + 24),
child: Material(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
elevation: 4,
borderRadius: BorderRadius.circular(12),
child: AppSearchBar(
controller: _searchController,
focusNode: _searchFocusNode,
collapsible: false,
hint: 'Search students by LRN or Name...',
maxWidth: 600,
onSubmitted: (value) {
Navigator.of(context).pop(); // Close dialog
ref.read(studentQueryProvider.notifier).setSearch(value);
ref.invalidate(studentPageProvider);
ref.read(activeTabProvider.notifier).setTab('Students');
_searchController.clear();
},
),
),
),
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_searchFocusNode.requestFocus();
}
});
}
// ── TOP BAR ──────────────────────────────────────────────────────────────
Widget _buildTopBar(BuildContext context, UserModel? user) {
final notificationsAsync = ref.watch(notificationsProvider);
final unreadCount =
notificationsAsync.value?.where((n) => !n.isRead).length ?? 0;
final isDark = Theme.of(context).brightness == Brightness.dark;
return ShaderMask(
shaderCallback: (rect) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.transparent],
stops: [0.6, 1.0],
).createShader(rect);
},
blendMode: BlendMode.dstIn,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
AppColors.darkPageBackground.withValues(alpha: 0.85),
AppColors.darkPageBackground.withValues(alpha: 0.15),
]
: [
Colors.white.withOpacity(0.85),
Colors.white.withOpacity(0.15),
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Spacer(),
Row(
children: [
Tooltip(
richMessage: const TextSpan(
text: 'Search Students ',
children: [
TextSpan(
text: '(Ctrl+F)',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
),
child: IconButton(
icon: const Icon(Icons.search, size: 32),
onPressed: () => _showSearchDialog(context),
),
),
const SizedBox(width: 8),
Builder(
builder: (ctx) => Stack(
children: [
IconButton(
icon: const Icon(Icons.notifications, size: 32),
onPressed: () => _showNotifications(ctx),
),
if (unreadCount > 0)
Positioned(
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
child: Text(
unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
const SizedBox(width: 16),
ProfileDropdownMenu(user: user, onRefresh: _handleRefresh),
],
),
],
),
),
),
),
);
}
// ── STAT GRID ─────────────────────────────────────────────────────────────
Widget _buildStatGrid(DashboardStats stats, UserModel? user) {
final isAdmin = user?.role == 'admin';
final isTeacher = user?.role == 'teacher';
if (isTeacher && !stats.hasAssignedSections) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.orange.shade200),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.orange.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.class_outlined,
size: 56,
color: Colors.orange.shade400,
),
),
const SizedBox(height: 20),
const Text(
'No sections assigned',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'You have no sections assigned to your account yet.\nContact your administrator to assign sections so you can see your students here.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
height: 1.5,
),
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isWindowsApp =
Theme.of(context).platform == TargetPlatform.windows ||
constraints.maxWidth >= 800;
final int crossAxisCount = isWindowsApp ? (isAdmin ? 4 : 3) : 1;
final bool isSquare = isWindowsApp;
return GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: isSquare ? 135 : 85,
),
children: [
StatCard(
title: 'Total Students',
value: stats.totalStudents.toString(),
icon: Icons.school_rounded,
isSquare: isSquare,
onTap: () {
ref.read(studentQueryProvider.notifier).reset();
ref.invalidate(studentPageProvider);
ref.read(activeTabProvider.notifier).setTab('Students');
},
),
if (isAdmin)
StatCard(
title: 'Active Users',
value: stats.activeUsers.toString(),
icon: Icons.group,
iconColor: Colors.blue,
isSquare: isSquare,
onTap: () {
ref.invalidate(usersProvider);
ref.read(activeTabProvider.notifier).setTab('Users');
},
),
StatCard(
title: 'Complete Docs',
value: stats.completedDocuments.toString(),
icon: Icons.fact_check_rounded,
iconColor: AppColors.primaryGreen,
isSquare: isSquare,
onTap: () {
ref.invalidate(foldersProvider);
ref.invalidate(documentPageProvider);
ref.read(activeTabProvider.notifier).setTab('Documents');
},
),
StatCard(
title: 'Missing Docs',
value: stats.missingDocuments.toString(),
icon: Icons.assignment_late_rounded,
iconColor: Colors.orange,
isSquare: isSquare,
onTap: () {
ref.invalidate(foldersProvider);
ref.invalidate(documentPageProvider);
ref.read(activeTabProvider.notifier).setTab('Documents');
},
),
],
);
},
);
}
// ── HISTORY SECTIONS ─────────────────────────────────────────────
Widget _buildHistorySections(DashboardData data, UserModel? user) {
final width = MediaQuery.of(context).size.width;
final isMobile = width < 800;
final isAdmin = user?.role == 'admin';
Widget buildSection({
required String title,
required VoidCallback onViewAll,
required Widget listWidget,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
TextButton.icon(
onPressed: onViewAll,
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('View All'),
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
),
),
],
),
const SizedBox(height: 12),
listWidget,
],
);
}
final recentActivitiesSection = buildSection(
title: 'Recent Activities',
onViewAll: () => Navigator.of(context)
.push(
MaterialPageRoute(builder: (_) => const RecentActivitiesScreen()),
)
.then((_) => _handleRefresh()),
listWidget: _buildActivitiesList(data.recentActivities.activities),
);
if (!isAdmin) {
return recentActivitiesSection;
}
final userHistorySection = buildSection(
title: 'User History',
onViewAll: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const UserHistoryScreen()))
.then((_) => _handleRefresh()),
listWidget: _buildUserHistoryList(data.userHistory?.history ?? []),
);
if (isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
userHistorySection,
const SizedBox(height: 32),
recentActivitiesSection,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: userHistorySection),
const SizedBox(width: 24),
Expanded(child: recentActivitiesSection),
],
);
}
Widget _buildActivitiesList(List<RecentActivity> activities) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (activities.isEmpty) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
'No recent activities yet.',
style: TextStyle(color: Colors.grey),
),
),
);
}
final width = MediaQuery.of(context).size.width;
final isMobile = width < 600;
return Container(
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: activities.length,
separatorBuilder: (_, _s) =>
Divider(height: 1, color: Theme.of(context).dividerColor),
itemBuilder: (context, index) {
final a = activities[index];
return Material(
color: Colors.transparent,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () => ViewActivityModal.show(
context: context,
title: a.entityType.toUpperCase(),
description: a.description,
date: pht.formatModalDate(a.createdAt),
performedBy: a.performedBy ?? a.username ?? 'System',
action: a.action,
actionColor: _actionColor(a.action),
icon: _actionIcon(a.action, a.entityType),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
leading: Icon(
_actionIcon(a.action, a.entityType),
color: _actionColor(a.action),
size: 26,
),
title: Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Align(
alignment: Alignment.centerLeft,
child: _buildActionChip(a.action),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
a.description,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'${a.performedBy ?? a.username ?? 'System'} · ${_formatDate(a.createdAt)}',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
trailing: Icon(
Icons.chevron_right_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
),
),
);
},
),
);
}
Widget _buildUserHistoryList(List<UserHistoryEntry> history) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (history.isEmpty) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text(
'No user history yet.',
style: TextStyle(color: Colors.grey),
),
),
);
}
final width = MediaQuery.of(context).size.width;
final isMobile = width < 600;
return Container(
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: history.length,
separatorBuilder: (_, _s) =>
Divider(height: 1, color: Theme.of(context).dividerColor),
itemBuilder: (context, index) {
final h = history[index];
final desc = '${h.action.toUpperCase()} User: ${h.fullName}';
return Material(
color: Colors.transparent,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () {
ViewActivityModal.show(
context: context,
title: 'USER ACTIVITY',
description: desc,
date: pht.formatModalDate(h.createdAt),
performedBy:
h.performedByName ?? h.performedByUsername ?? 'System',
action: h.action,
actionColor: _actionColor(h.action),
icon: _actionIcon(h.action, 'user'),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
leading: CircleAvatar(
backgroundColor: _actionColor(
h.action,
).withValues(alpha: 0.1),
child: Text(
_getInitials(h.fullName),
style: TextStyle(
color: _actionColor(h.action),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
title: Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Align(
alignment: Alignment.centerLeft,
child: _buildActionChip(h.action),
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
desc,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'${h.performedByName ?? h.performedByUsername ?? 'System'} · ${_formatDate(h.createdAt)}',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
trailing: Icon(
Icons.chevron_right_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38),
),
),
),
);
},
),
);
}
// ── HELPERS ───────────────────────────────────────────────────────────────
Color _actionColor(String action) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (action.toUpperCase()) {
case 'CREATE':
return isDark ? Colors.green.shade300 : AppColors.primaryGreen;
case 'DELETE':
return isDark ? Colors.red.shade300 : Colors.red;
default:
return isDark ? Colors.blue.shade300 : Colors.blue;
}
}
IconData _actionIcon(String action, String entityType) {
if (entityType == 'user') {
switch (action.toUpperCase()) {
case 'CREATE':
return Icons.person_add_alt_1_rounded;
case 'DELETE':
return Icons.person_off_rounded;
default:
return Icons.manage_accounts_rounded;
}
}
if (entityType == 'student') {
switch (action.toUpperCase()) {
case 'CREATE':
return Icons.school_rounded;
case 'DELETE':
return Icons.delete_forever_rounded;
default:
return Icons.edit_rounded;
}
}
switch (action.toUpperCase()) {
case 'CREATE':
return Icons.upload_file_rounded;
case 'DELETE':
return Icons.delete_sweep_rounded;
default:
return Icons.description_rounded;
}
}
Widget _buildActionChip(String action) {
final color = _actionColor(action);
return Text(
action.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: color,
),
);
}
String _formatDate(String raw) => pht.formatRelative(raw);
String _getInitials(String name) {
if (name.trim().isEmpty) return '?';
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.length > 1) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return parts[0].length > 1
? parts[0].substring(0, 2).toUpperCase()
: parts[0].toUpperCase();
}
}