import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/attendance_log.dart'; import '../../models/duty_schedule.dart'; import '../../models/leave_of_absence.dart'; import '../../models/live_position.dart'; import '../../models/pass_slip.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../models/it_service_request.dart'; import '../../models/it_service_request_assignment.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/leave_provider.dart'; import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../providers/teams_provider.dart'; import '../../models/team.dart'; import '../../models/team_member.dart'; import 'dart:math' as math; import '../../widgets/responsive_body.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../widgets/profile_avatar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/app_page_header.dart'; import '../../utils/app_time.dart'; class DashboardMetrics { DashboardMetrics({ required this.newTicketsToday, required this.closedToday, required this.openTickets, required this.avgResponse, required this.avgTriage, required this.longestResponse, required this.tasksCreatedToday, required this.tasksCompletedToday, required this.openTasks, required this.staffRows, }); final int newTicketsToday; final int closedToday; final int openTickets; final Duration? avgResponse; final Duration? avgTriage; final Duration? longestResponse; final int tasksCreatedToday; final int tasksCompletedToday; final int openTasks; final List staffRows; } class StaffRowMetrics { StaffRowMetrics({ required this.userId, required this.name, required this.status, required this.whereabouts, required this.ticketsRespondedToday, required this.tasksClosedToday, required this.eventsHandledToday, this.avatarUrl, this.teamColor, }); final String userId; final String name; final String status; final String whereabouts; final int ticketsRespondedToday; final int tasksClosedToday; final int eventsHandledToday; final String? avatarUrl; final String? teamColor; } final dashboardMetricsProvider = Provider>((ref) { final ticketsAsync = ref.watch(ticketsProvider); final tasksAsync = ref.watch(tasksProvider); final profilesAsync = ref.watch(profilesProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); final messagesAsync = ref.watch(ticketMessagesAllProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final logsAsync = ref.watch(attendanceLogsProvider); final positionsAsync = ref.watch(livePositionsProvider); final leavesAsync = ref.watch(leavesProvider); final passSlipsAsync = ref.watch(passSlipsProvider); final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); final isrAsync = ref.watch(itServiceRequestsProvider); final teamsAsync = ref.watch(teamsProvider); final teamMembersAsync = ref.watch(teamMembersProvider); final asyncValues = [ ticketsAsync, tasksAsync, profilesAsync, assignmentsAsync, messagesAsync, schedulesAsync, logsAsync, positionsAsync, leavesAsync, passSlipsAsync, ]; // Debug: log dependency loading/error states to diagnose full-page refreshes. debugPrint( '[dashboardMetricsProvider] recompute: ' 'tickets=${ticketsAsync.isLoading ? "loading" : "ready"} ' 'tasks=${tasksAsync.isLoading ? "loading" : "ready"} ' 'profiles=${profilesAsync.isLoading ? "loading" : "ready"} ' 'assignments=${assignmentsAsync.isLoading ? "loading" : "ready"} ' 'messages=${messagesAsync.isLoading ? "loading" : "ready"} ' 'schedules=${schedulesAsync.isLoading ? "loading" : "ready"} ' 'logs=${logsAsync.isLoading ? "loading" : "ready"} ' 'positions=${positionsAsync.isLoading ? "loading" : "ready"} ' 'leaves=${leavesAsync.isLoading ? "loading" : "ready"}', ); if (asyncValues.any((value) => value.hasError)) { final errorValue = asyncValues.firstWhere((value) => value.hasError); final error = errorValue.error ?? 'Failed to load dashboard'; final stack = errorValue.stackTrace ?? StackTrace.current; return AsyncError(error, stack); } // Avoid returning a loading state for the whole dashboard when *one* // dependency is temporarily loading (this caused the top linear // progress banner to appear during ticket→task promotion / task // completion). Only treat the dashboard as loading when *none* of the // dependencies have any data yet (initial load). final anyHasData = asyncValues.any((v) => v.valueOrNull != null); if (!anyHasData) { debugPrint( '[dashboardMetricsProvider] returning AsyncLoading (no dep data)', ); return const AsyncLoading(); } final tickets = ticketsAsync.valueOrNull ?? const []; final tasks = tasksAsync.valueOrNull ?? const []; final profiles = profilesAsync.valueOrNull ?? const []; final assignments = assignmentsAsync.valueOrNull ?? const []; final messages = messagesAsync.valueOrNull ?? const []; final schedules = schedulesAsync.valueOrNull ?? const []; final allLogs = logsAsync.valueOrNull ?? const []; final positions = positionsAsync.valueOrNull ?? const []; final allLeaves = leavesAsync.valueOrNull ?? const []; final allPassSlips = passSlipsAsync.valueOrNull ?? const []; final now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); final endOfDay = startOfDay.add(const Duration(days: 1)); final staffProfiles = profiles .where((profile) => profile.role == 'it_staff') .toList(); final staffIds = profiles .where( (profile) => profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff', ) .map((profile) => profile.id) .toSet(); bool isToday(DateTime value) => !value.isBefore(startOfDay); final firstStaffMessageByTicket = {}; final lastStaffMessageByUser = {}; final respondedTicketsByUser = >{}; for (final message in messages) { final ticketId = message.ticketId; final senderId = message.senderId; if (ticketId != null && senderId != null && staffIds.contains(senderId)) { final current = firstStaffMessageByTicket[ticketId]; if (current == null || message.createdAt.isBefore(current)) { firstStaffMessageByTicket[ticketId] = message.createdAt; } final last = lastStaffMessageByUser[senderId]; if (last == null || message.createdAt.isAfter(last)) { lastStaffMessageByUser[senderId] = message.createdAt; } if (isToday(message.createdAt)) { respondedTicketsByUser .putIfAbsent(senderId, () => {}) .add(ticketId); } } } DateTime? respondedAtForTicket(Ticket ticket) { final staffMessageAt = firstStaffMessageByTicket[ticket.id]; if (staffMessageAt != null) { return staffMessageAt; } if (ticket.promotedAt != null) { return ticket.promotedAt; } return null; } Duration? responseDuration(Ticket ticket) { final respondedAt = respondedAtForTicket(ticket); if (respondedAt == null) { return null; } final duration = respondedAt.difference(ticket.createdAt); return duration.isNegative ? Duration.zero : duration; } Duration? triageDuration(Ticket ticket) { final respondedAt = respondedAtForTicket(ticket); if (respondedAt == null) { return null; } final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt); if (triageEnd == null) { return null; } final duration = triageEnd.difference(respondedAt); return duration.isNegative ? Duration.zero : duration; } final ticketsToday = tickets.where((ticket) => isToday(ticket.createdAt)); final closedToday = tickets.where( (ticket) => ticket.closedAt != null && isToday(ticket.closedAt!), ); final openTickets = tickets.where((ticket) => ticket.status != 'closed'); final responseDurationsToday = ticketsToday .map(responseDuration) .whereType() .toList(); final triageDurationsToday = ticketsToday .map(triageDuration) .whereType() .toList(); final avgResponse = _averageDuration(responseDurationsToday); final avgTriage = _averageDuration(triageDurationsToday); final longestResponse = responseDurationsToday.isEmpty ? null : responseDurationsToday.reduce( (a, b) => a.inSeconds >= b.inSeconds ? a : b, ); // Exclude cancelled tasks from dashboard metrics final tasksCreatedToday = tasks.where( (task) => isToday(task.createdAt) && task.status != 'cancelled', ); final tasksCompletedToday = tasks.where( (task) => task.completedAt != null && isToday(task.completedAt!) && task.status != 'cancelled', ); final openTasks = tasks.where( (task) => task.status != 'completed' && task.status != 'cancelled', ); final taskById = {for (final task in tasks) task.id: task}; final staffOnTask = {}; for (final assignment in assignments) { final task = taskById[assignment.taskId]; if (task == null) { continue; } if (task.status == 'in_progress') { staffOnTask.add(assignment.userId); } } final tasksClosedByUser = >{}; for (final assignment in assignments) { final task = taskById[assignment.taskId]; if (task == null || task.completedAt == null) { continue; } if (!isToday(task.completedAt!)) { continue; } tasksClosedByUser .putIfAbsent(assignment.userId, () => {}) .add(task.id); } // Determine which staff members are currently handling an IT service // request. The dashboard should treat these assignments like being "on // task" so that the status pill reflects the fact that they are busy. final isrList = isrAsync.valueOrNull ?? const []; final isrById = {for (final r in isrList) r.id: r}; final staffOnService = {}; for (final assign in isrAssignmentsAsync.valueOrNull ?? const []) { final isr = isrById[assign.requestId]; if (isr == null) continue; if (isr.status == ItServiceRequestStatus.inProgress || isr.status == ItServiceRequestStatus.inProgressDryRun) { staffOnService.add(assign.userId); } } const triageWindow = Duration(minutes: 1); final triageCutoff = now.subtract(triageWindow); // Pre-index team membership: map user → team color (hex string). final teams = teamsAsync.valueOrNull ?? const []; final teamMembers = teamMembersAsync.valueOrNull ?? const []; final teamById = {for (final t in teams) t.id: t}; final teamColorByUser = {}; for (final m in teamMembers) { final team = teamById[m.teamId]; if (team != null) { teamColorByUser[m.userId] = team.color; } } // Pre-index schedules, logs, and positions by user for efficient lookup. // Include schedules starting today AND overnight schedules from yesterday // that span into today (e.g. on_call 11 PM – 7 AM). final todaySchedulesByUser = >{}; for (final s in schedules) { if (s.shiftType == 'overtime') continue; final startsToday = !s.startTime.isBefore(startOfDay) && s.startTime.isBefore(endOfDay); final overnightFromYesterday = s.startTime.isBefore(startOfDay) && s.endTime.isAfter(startOfDay); if (startsToday || overnightFromYesterday) { todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s); } } final todayLogsByUser = >{}; for (final l in allLogs) { if (!l.checkInAt.isBefore(startOfDay)) { todayLogsByUser.putIfAbsent(l.userId, () => []).add(l); } } final positionByUser = {}; for (final p in positions) { positionByUser[p.userId] = p; } // Index today's leaves by user. final todayLeaveByUser = {}; for (final l in allLeaves) { if (l.status == 'approved' && !l.startTime.isAfter(now) && l.endTime.isAfter(now)) { todayLeaveByUser[l.userId] = l; } } // Index active pass slips by user. final activePassSlipByUser = {}; for (final slip in allPassSlips) { if (slip.isActive) { activePassSlipByUser[slip.userId] = slip; } } final noon = DateTime(now.year, now.month, now.day, 12, 0); final onePM = DateTime(now.year, now.month, now.day, 13, 0); var staffRows = staffProfiles.map((staff) { final lastMessage = lastStaffMessageByUser[staff.id]; final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0; final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0; // users are considered "on task" if they have either a regular task // assignment or an active service request in progress/dry run. // determine whether staff have regular tasks or service requests final onTask = staffOnTask.contains(staff.id); final onService = staffOnService.contains(staff.id); final userSchedules = todaySchedulesByUser[staff.id] ?? const []; final userLogs = todayLogsByUser[staff.id] ?? const []; final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff); // Whereabouts from live position, with tracking-off inference. final livePos = positionByUser[staff.id]; final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut); final String whereabouts; if (livePos != null) { final stale = AppTime.now().difference(livePos.updatedAt) > const Duration(minutes: 15); if (stale) { final diff = AppTime.now().difference(livePos.updatedAt); final ago = diff.inMinutes < 60 ? '${diff.inMinutes}m ago' : '${diff.inHours}h ago'; whereabouts = livePos.inPremise ? 'Last seen in premise \u00b7 $ago' : 'Last seen outside \u00b7 $ago'; } else { whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise'; } } else if (!staff.allowTracking) { // Tracking off — infer from active check-in (geofence validated). whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off'; } else { whereabouts = '\u2014'; } final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull; final completedLogs = userLogs.where((l) => l.isCheckedOut).toList(); String status; // Check leave first — overrides all schedule-based statuses. if (todayLeaveByUser.containsKey(staff.id)) { status = 'On leave'; } else if (activePassSlipByUser.containsKey(staff.id)) { // Active pass slip — user is temporarily away from duty. status = 'PASS SLIP'; } else if (userSchedules.isEmpty) { // No schedule today — off duty unless actively on task/service/triage. status = onService ? 'On event' : onTask ? 'On task' : inTriage ? 'In triage' : 'Off duty'; } else { // Pick the most relevant schedule: prefer one that is currently // active (now is within start–end), then the nearest upcoming one, // and finally the most recently ended one. final activeSchedule = userSchedules .where((s) => !now.isBefore(s.startTime) && now.isBefore(s.endTime)) .firstOrNull; DutySchedule? upcomingSchedule; if (activeSchedule == null) { final upcoming = userSchedules.where((s) => now.isBefore(s.startTime)).toList() ..sort((a, b) => a.startTime.compareTo(b.startTime)); if (upcoming.isNotEmpty) upcomingSchedule = upcoming.first; } final schedule = activeSchedule ?? upcomingSchedule ?? userSchedules.reduce((a, b) => a.endTime.isAfter(b.endTime) ? a : b); final isShiftOver = !now.isBefore(schedule.endTime); final isFullDay = schedule.endTime.difference(schedule.startTime).inHours >= 6; final isNoonBreakWindow = isFullDay && !now.isBefore(noon) && now.isBefore(onePM); final isOnCall = schedule.shiftType == 'on_call'; if (activeLog != null) { // Currently checked in — on-duty, can be overridden. status = onService ? 'On event' : onTask ? 'On task' : inTriage ? 'In triage' : isOnCall ? 'On duty' : 'Vacant'; } else if (completedLogs.isNotEmpty) { // Has checked out at least once. if (isNoonBreakWindow) { status = 'Noon break'; } else if (isShiftOver) { status = 'Off duty'; } else { // Checked out before shift end and not noon break → Early Out. status = 'Early out'; } } else { // Not checked in yet, no completed logs. // // Check whether ANY of the user's schedules is on_call so we // can apply on-call logic even when the "most relevant" // schedule that was selected is a regular shift. final anyOnCallActive = userSchedules.any( (s) => s.shiftType == 'on_call' && !now.isBefore(s.startTime) && now.isBefore(s.endTime), ); final onlyOnCallSchedules = userSchedules.every( (s) => s.shiftType == 'on_call', ); if (anyOnCallActive) { // An on_call shift is currently in its window → ON CALL. status = 'ON CALL'; } else if (isOnCall || onlyOnCallSchedules) { // Selected schedule is on_call, or user has ONLY on_call // schedules with none currently active → Off duty (between // on-call shifts). On-call staff can never be Late/Absent. status = 'Off duty'; } else if (isShiftOver) { // Shift ended with no check-in at all → Absent. status = 'Absent'; } else { final oneHourBefore = schedule.startTime.subtract( const Duration(hours: 1), ); if (!now.isBefore(oneHourBefore) && now.isBefore(schedule.startTime)) { status = 'Arrival'; } else if (!now.isBefore(schedule.startTime)) { status = 'Late'; } else { status = 'Off duty'; } } } } return StaffRowMetrics( userId: staff.id, name: staff.fullName.isNotEmpty ? staff.fullName : staff.id, status: status, whereabouts: whereabouts, ticketsRespondedToday: ticketsResponded, tasksClosedToday: tasksClosed, eventsHandledToday: _countEventsHandledToday( staff.id, isrAssignmentsAsync.valueOrNull ?? [], isrAsync.valueOrNull ?? [], now, ), avatarUrl: staff.avatarUrl, teamColor: teamColorByUser[staff.id], ); }).toList(); // Order IT staff by combined activity (tickets responded today + tasks closed today) // descending so most-active staff appear first. Use name as a stable tiebreaker. staffRows.sort((a, b) { final aCount = a.ticketsRespondedToday + a.tasksClosedToday + a.eventsHandledToday; final bCount = b.ticketsRespondedToday + b.tasksClosedToday + b.eventsHandledToday; if (bCount != aCount) return bCount.compareTo(aCount); return a.name.compareTo(b.name); }); return AsyncData( DashboardMetrics( newTicketsToday: ticketsToday.length, closedToday: closedToday.length, openTickets: openTickets.length, avgResponse: avgResponse, avgTriage: avgTriage, longestResponse: longestResponse, tasksCreatedToday: tasksCreatedToday.length, tasksCompletedToday: tasksCompletedToday.length, openTasks: openTasks.length, staffRows: staffRows, ), ); }); int _countEventsHandledToday( String userId, List isrAssignments, List isrList, DateTime now, ) { final startOfDay = DateTime(now.year, now.month, now.day); final endOfDay = startOfDay.add(const Duration(days: 1)); // Find all ISR IDs assigned to this user final assignedIsrIds = {}; for (final a in isrAssignments) { if (a.userId == userId) { assignedIsrIds.add(a.requestId); } } if (assignedIsrIds.isEmpty) return 0; // Count ISRs that are in active status today int count = 0; for (final isr in isrList) { if (!assignedIsrIds.contains(isr.id)) continue; if (isr.status == 'in_progress' || isr.status == 'in_progress_dry_run' || isr.status == 'completed') { // Check if event date or dry run date is today final eventToday = isr.eventDate != null && !isr.eventDate!.isBefore(startOfDay) && isr.eventDate!.isBefore(endOfDay); final dryRunToday = isr.dryRunDate != null && !isr.dryRunDate!.isBefore(startOfDay) && isr.dryRunDate!.isBefore(endOfDay); if (eventToday || dryRunToday) count++; } } return count; } class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { final prefs = await SharedPreferences.getInstance(); final seen = prefs.getBool('has_seen_notif_showcase') ?? false; if (!seen) { if (!mounted) return; await m3ShowDialog( context: context, builder: (context) => AlertDialog( title: const Text('Never miss an update'), content: const Text( 'Ensure notification sounds and vibration are enabled for important alerts.', ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); openAppSettings(); }, child: const Text('Open settings'), ), TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Got it'), ), ], ), ); await prefs.setBool('has_seen_notif_showcase', true); } }); } @override Widget build(BuildContext context) { final realtime = ProviderScope.containerOf( context, ).read(realtimeControllerProvider); return ResponsiveBody( child: Skeletonizer( enabled: realtime.isAnyStreamRecovering, child: LayoutBuilder( builder: (context, constraints) { final sections = [ const SizedBox(height: 16), _sectionTitle(context, 'IT Staff Pulse'), const _StaffTable(), const SizedBox(height: 12), _sectionTitle(context, 'Team Activity'), const _StaffActivityChart(), const SizedBox(height: 20), _sectionTitle(context, 'Core Daily KPIs'), _cardGrid(context, [ _MetricCard( title: 'New tickets today', valueBuilder: (metrics) => metrics.newTicketsToday.toString(), ), _MetricCard( title: 'Closed today', valueBuilder: (metrics) => metrics.closedToday.toString(), ), _MetricCard( title: 'Open tickets', valueBuilder: (metrics) => metrics.openTickets.toString(), ), ]), const SizedBox(height: 20), _sectionTitle(context, 'Task Flow'), _cardGrid(context, [ _MetricCard( title: 'Tasks created', valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(), ), _MetricCard( title: 'Tasks completed', valueBuilder: (metrics) => metrics.tasksCompletedToday.toString(), ), _MetricCard( title: 'Open tasks', valueBuilder: (metrics) => metrics.openTasks.toString(), ), ]), const SizedBox(height: 20), _sectionTitle(context, 'TAT / Response'), _cardGrid(context, [ _MetricCard( title: 'Avg response', valueBuilder: (metrics) => _formatDuration(metrics.avgResponse), ), _MetricCard( title: 'Avg triage', valueBuilder: (metrics) => _formatDuration(metrics.avgTriage), ), _MetricCard( title: 'Longest response', valueBuilder: (metrics) => _formatDuration(metrics.longestResponse), ), ]), ]; final content = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const AppPageHeader( title: 'Dashboard', subtitle: 'Live metrics and team activity', ), const _DashboardStatusBanner(), ...sections, ], ); return Stack( children: [ SingleChildScrollView( padding: const EdgeInsets.only(bottom: 24), child: Center( child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight, ), child: content, ), ), ), if (realtime.isAnyStreamRecovering) const Positioned( bottom: 16, right: 16, child: ReconnectIndicator(), ), ], ); }, ), ), ); } Widget _sectionTitle(BuildContext context, String title) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); } Widget _cardGrid(BuildContext context, List cards) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; if (width < 520) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < cards.length; i++) ...[ cards[i], if (i < cards.length - 1) const SizedBox(height: 12), ], ], ); } final spacing = 12.0; final minCardWidth = 220.0; final totalWidth = cards.length * minCardWidth + spacing * (cards.length - 1); final fits = totalWidth <= width; final cardWidth = fits ? (width - spacing * (cards.length - 1)) / cards.length : minCardWidth; final row = Row( children: [ for (var i = 0; i < cards.length; i++) ...[ SizedBox(width: cardWidth, child: cards[i]), if (i < cards.length - 1) const SizedBox(width: 12), ], ], ); if (fits) { return row; } return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox(width: totalWidth, child: row), ); }, ); } } class _DashboardStatusBanner extends ConsumerWidget { const _DashboardStatusBanner(); @override Widget build(BuildContext context, WidgetRef ref) { // Watch a small derived string state so only the banner rebuilds when // its visibility/content actually changes. final bannerState = ref.watch( dashboardMetricsProvider.select( (av) => av.when( data: (_) => 'data', loading: () => 'loading', error: (e, _) => 'error:${e.toString()}', ), ), ); if (bannerState == 'loading') { return const Padding( padding: EdgeInsets.only(bottom: 12), child: LinearProgressIndicator(minHeight: 2), ); } if (bannerState.startsWith('error:')) { final errorText = bannerState.substring(6); final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 12), child: Material( color: cs.errorContainer, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ Icon( Icons.warning_amber_rounded, size: 18, color: cs.onErrorContainer, ), const SizedBox(width: 10), Expanded( child: Text( errorText, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: cs.onErrorContainer, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ], ), ), ), ); } return const SizedBox.shrink(); } } class _MetricCard extends ConsumerWidget { const _MetricCard({required this.title, required this.valueBuilder}); final String title; final String Function(DashboardMetrics metrics) valueBuilder; @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; // Only watch the single string value for this card so unrelated metric // updates don't rebuild the whole card. This makes updates feel much // smoother and avoids full-page refreshes. final value = ref.watch( dashboardMetricsProvider.select( (av) => av.when( data: (m) => valueBuilder(m), loading: () => '—', error: (error, _) => 'Error', ), ), ); return AnimatedContainer( duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of(context).textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w600, color: cs.onSurfaceVariant, ), ), const SizedBox(height: 12), // Animate only the metric text (not the whole card) for a // subtle, smooth update. AnimatedSwitcher( duration: const Duration(milliseconds: 400), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, anim) => FadeTransition(opacity: anim, child: child), child: MonoText( value, key: ValueKey(value), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w700, color: cs.onSurface, ), ), ), ], ), ); } } class _StaffTable extends StatelessWidget { const _StaffTable(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; // M3 Expressive: tonal surface container, 28 dp radius for large containers. return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular( AppSurfaces.of(context).containerRadius, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: const [ _StaffTableHeader(), SizedBox(height: 8), _StaffTableBody(), ], ), ); } } class _StaffTableHeader extends StatelessWidget { const _StaffTableHeader(); @override Widget build(BuildContext context) { final style = Theme.of( context, ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700); final isMobile = AppBreakpoints.isMobile(context); return Row( children: [ Expanded( flex: isMobile ? 2 : 3, child: Text('IT Staff', style: style), ), Expanded( flex: 2, child: Center(child: Text('Status', style: style)), ), if (!isMobile) Expanded( flex: 4, child: Center(child: Text('Whereabouts', style: style)), ), Expanded( flex: isMobile ? 1 : 1, child: Center( child: Text(isMobile ? 'Tix' : 'Tickets', style: style), ), ), Expanded( flex: isMobile ? 1 : 1, child: Center(child: Text(isMobile ? 'Tsk' : 'Tasks', style: style)), ), Expanded( flex: isMobile ? 1 : 1, child: Center(child: Text(isMobile ? 'Evt' : 'Events', style: style)), ), ], ); } } class _StaffTableBody extends ConsumerWidget { const _StaffTableBody(); @override Widget build(BuildContext context, WidgetRef ref) { // Only listen to the staff rows and the overall provider state to keep // rebuilds scoped to this small area. final providerState = ref.watch( dashboardMetricsProvider.select( (av) => av.when( data: (_) => 'data', loading: () => 'loading', error: (e, _) => 'error:${e.toString()}', ), ), ); final staffRows = ref.watch( dashboardMetricsProvider.select( (av) => av.when>( data: (m) => m.staffRows, loading: () => const [], error: (error, _) => const [], ), ), ); if (providerState == 'loading') { return const Text('Loading staff...'); } if (providerState.startsWith('error:')) { final err = providerState.substring(6); return Text('Failed to load staff: $err'); } if (staffRows.isEmpty) { return Text( 'No IT staff available.', style: Theme.of(context).textTheme.bodySmall, ); } return Column( children: staffRows.map((row) => _StaffRow(row: row)).toList(), ); } } class _StaffRow extends StatelessWidget { const _StaffRow({required this.row}); final StaffRowMetrics row; @override Widget build(BuildContext context) { final valueStyle = Theme.of(context).textTheme.bodySmall; final isMobile = AppBreakpoints.isMobile(context); // Team marker: use team color dot when assigned, otherwise a circular no-team image. final Widget teamMarker; if (row.teamColor != null && row.teamColor!.isNotEmpty) { final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000); teamMarker = Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ); } else { teamMarker = ClipOval( child: Image.asset( 'assets/no_team.jpg', width: 14, height: 14, fit: BoxFit.cover, ), ); } // IT Staff cell: avatar on mobile, name on desktop, with team color dot Widget staffCell; if (isMobile) { staffCell = Row( mainAxisSize: MainAxisSize.min, children: [ teamMarker, const SizedBox(width: 4), Flexible( child: Tooltip( message: row.name, child: ProfileAvatar( fullName: row.name, avatarUrl: row.avatarUrl, radius: 14, ), ), ), ], ); } else { staffCell = Row( mainAxisSize: MainAxisSize.min, children: [ teamMarker, const SizedBox(width: 6), Flexible(child: Text(row.name, style: valueStyle)), ], ); } return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded(flex: isMobile ? 2 : 3, child: staffCell), Expanded( flex: 2, child: Center(child: _PulseStatusPill(label: row.status)), ), if (!isMobile) Expanded( flex: 4, child: Center( child: Text( row.whereabouts, style: valueStyle?.copyWith( color: row.whereabouts == 'In premise' ? Colors.green : row.whereabouts == 'Outside premise' ? Colors.grey : row.whereabouts == 'Tracking off' ? Colors.grey : row.whereabouts.startsWith('Last seen') ? Colors.grey : null, fontWeight: FontWeight.w600, ), ), ), ), Expanded( flex: isMobile ? 1 : 1, child: Center( child: Text( row.ticketsRespondedToday.toString(), style: valueStyle, ), ), ), Expanded( flex: isMobile ? 1 : 1, child: Center( child: Text(row.tasksClosedToday.toString(), style: valueStyle), ), ), Expanded( flex: isMobile ? 1 : 1, child: Center( child: Text(row.eventsHandledToday.toString(), style: valueStyle), ), ), ], ), ); } } class _StaffActivityChart extends ConsumerWidget { const _StaffActivityChart(); static const _avatarDiameter = 30.0; static const _barWidth = 56.0; static const _barSpacing = 14.0; static const _chartHeight = 260.0; @override Widget build(BuildContext context, WidgetRef ref) { final metricsAsync = ref.watch(dashboardMetricsProvider); final teamsAsync = ref.watch(teamsProvider); final teamMembersAsync = ref.watch(teamMembersProvider); final cs = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular( AppSurfaces.of(context).containerRadius, ), ), child: metricsAsync.when( data: (metrics) { final staffRows = metrics.staffRows; if (staffRows.isEmpty) { return Text( 'No staff activity data available.', style: Theme.of(context).textTheme.bodySmall, ); } final teams = teamsAsync.valueOrNull ?? const []; final members = teamMembersAsync.valueOrNull ?? const []; final teamById = {for (final t in teams) t.id: t}; final teamIdByUser = {for (final m in members) m.userId: m.teamId}; // Group rows by team name in the order they appear. final teamGroups = >{}; for (final row in staffRows) { final teamId = teamIdByUser[row.userId]; final teamName = teamId != null ? teamById[teamId]?.name : null; final label = teamName ?? 'Unassigned'; teamGroups.putIfAbsent(label, () => []).add(row); } final teamColorByName = {for (final t in teams) t.name: t.color}; final flattened = <_UserBarData>[]; final teamLegend = >[]; for (final entry in teamGroups.entries) { final teamName = entry.key; final rows = entry.value; for (final row in rows) { flattened.add(_UserBarData(teamName: teamName, row: row)); } teamLegend.add(MapEntry(teamName, rows.length)); } Color? parseHexColor(String? hex) { if (hex == null || hex.isEmpty) return null; try { return Color(int.parse(hex, radix: 16) | 0xFF000000); } catch (_) { return null; } } final totals = flattened .map( (d) => d.row.ticketsRespondedToday + d.row.tasksClosedToday + d.row.eventsHandledToday, ) .toList(); // Use the actual maximum total as the display cap so bar heights // are strictly proportional to their numeric values. final capValue = totals.isEmpty ? 1 : totals.reduce((a, b) => math.max(a, b)); final totalHeight = _chartHeight - _avatarDiameter - 24; final barHeightFactor = capValue > 0 ? totalHeight / math.max(capValue, 1) : 0.0; Widget buildBar(_UserBarData data) { final tickets = data.row.ticketsRespondedToday; final tasks = data.row.tasksClosedToday; final events = data.row.eventsHandledToday; final total = tickets + tasks + events; // Linear mapping to the maximum value so heights are proportional // to actual totals. final barHeight = total * barHeightFactor; final teamHex = teamColorByName[data.teamName]; final teamColor = parseHexColor(teamHex) ?? Theme.of(context).colorScheme.primary; final segments = >[]; if (tickets > 0) { segments.add( MapEntry(tickets, teamColor.withAlpha((0.9 * 255).round())), ); } if (tasks > 0) { segments.add( MapEntry(tasks, teamColor.withAlpha((0.7 * 255).round())), ); } if (events > 0) { segments.add( MapEntry(events, teamColor.withAlpha((0.5 * 255).round())), ); } // Ensure bars never overflow the chart vertical constraints by // clamping the computed bar height and forcing the bar widget to // occupy the same overall chart height. This prevents the // RenderFlex overflow seen when a bar's intrinsic height exceeded // the available space. final maxBarContainerHeight = math.max( 0.0, _chartHeight - (_avatarDiameter * 0.5) - 12, ); final clampedBarHeight = math.min(barHeight, maxBarContainerHeight); return SizedBox( height: _chartHeight, child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Stack( alignment: Alignment.topCenter, children: [ // Bar container: fixed (clamped) height so inner // Expanded segments layout into a finite box. Container( width: _barWidth, height: clampedBarHeight, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: Theme.of( context, ).colorScheme.surfaceContainerHighest, ), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ for (final segment in segments) Expanded( flex: segment.key, child: Container( width: double.infinity, color: segment.value, ), ), ], ), ), // Avatar overlapped on the bar (top center). The // avatar is allowed to overflow slightly above the // bar — that's intentional for the visual style. Positioned( top: 0, child: Container( width: _avatarDiameter, height: _avatarDiameter, decoration: BoxDecoration( shape: BoxShape.circle, color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Theme.of(context).colorScheme.shadow .withAlpha((0.25 * 255).round()), blurRadius: 5, offset: const Offset(0, 2), ), ], ), child: Tooltip( message: data.row.name, child: ProfileAvatar( fullName: data.row.name, avatarUrl: data.row.avatarUrl, radius: _avatarDiameter / 2, ), ), ), ), if (total > 0) Positioned( bottom: 10, child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: Colors.black.withAlpha( (0.35 * 255).round(), ), borderRadius: BorderRadius.circular(8), ), child: Text( total.toString(), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, ), ), ), ), // (Overflow indicator left in place for future cap logic.) ], ), ], ), ); } return LayoutBuilder( builder: (context, constraints) { final barCount = flattened.length; final requiredWidth = barCount * (_barWidth + _barSpacing) - _barSpacing; final showScroll = requiredWidth > constraints.maxWidth; final chartRow = Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var i = 0; i < flattened.length; i++) ...[ buildBar(flattened[i]), if (i < flattened.length - 1) const SizedBox(width: _barSpacing), ], ], ); // Build a per-team legend so the X-axis has only one label per team. Widget buildTeamLegend() { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ for (var entry in teamLegend) ...[ SizedBox( width: (entry.value * _barWidth) + ((entry.value - 1) * _barSpacing), child: Text( entry.key, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 1, style: Theme.of(context).textTheme.bodySmall, ), ), const SizedBox(width: _barSpacing), ], ], ); } // Left axis width reserved for dynamic numeric labels. const leftAxisWidth = 56.0; // Build numeric ticks for the Y-axis (capValue .. 0) and remove // consecutive duplicates that occur when capValue is small. final tickCount = 4; final ticksRaw = List.generate( tickCount, (i) => ((capValue * (tickCount - 1 - i) / (tickCount - 1))) .round(), ); final ticks = []; for (final t in ticksRaw) { if (ticks.isEmpty || ticks.last != t) ticks.add(t); } if (ticks.isEmpty || ticks.last != 0) ticks.add(0); return SizedBox( height: _chartHeight + 40, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: leftAxisWidth, child: Column( children: [ // Small spacer for avatar overlap area SizedBox( height: (_avatarDiameter / 2).roundToDouble(), ), // Ticks aligned within the remaining chart area Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ for (final t in ticks) Text( t.toString(), style: Theme.of( context, ).textTheme.bodySmall, ), ], ), ), ], ), ), Expanded( child: SingleChildScrollView( scrollDirection: Axis.horizontal, physics: showScroll ? const BouncingScrollPhysics() : const NeverScrollableScrollPhysics(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: _chartHeight, child: chartRow), const SizedBox(height: 12), buildTeamLegend(), ], ), ), ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Text('Failed to load activity: $error'), ), ); } } class _UserBarData { _UserBarData({required this.teamName, required this.row}); final String teamName; final StaffRowMetrics row; } /// Status pill with attendance-specific coloring for the IT Staff Pulse table. class _PulseStatusPill extends StatelessWidget { const _PulseStatusPill({required this.label}); final String label; @override Widget build(BuildContext context) { final (Color bg, Color fg) = switch (label.toLowerCase()) { 'arrival' => (Colors.amber.shade100, Colors.amber.shade900), 'late' => (Colors.red.shade100, Colors.red.shade900), 'noon break' => (Colors.blue.shade100, Colors.blue.shade900), 'vacant' => (Colors.green.shade100, Colors.green.shade900), 'on task' => (Colors.purple.shade100, Colors.purple.shade900), 'on event' => (Colors.purple.shade100, Colors.purple.shade900), 'in triage' => (Colors.orange.shade100, Colors.orange.shade900), 'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900), 'on leave' => (Colors.teal.shade100, Colors.teal.shade900), 'absent' => (Colors.red.shade200, Colors.red.shade900), 'off duty' => (Colors.grey.shade200, Colors.grey.shade700), _ => ( Theme.of(context).colorScheme.tertiaryContainer, Theme.of(context).colorScheme.onTertiaryContainer, ), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(12), ), child: Text( label, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: fg, fontWeight: FontWeight.w700, ), ), ); } } Duration? _averageDuration(List durations) { if (durations.isEmpty) { return null; } final totalSeconds = durations .map((duration) => duration.inSeconds) .reduce((a, b) => a + b); return Duration(seconds: (totalSeconds / durations.length).round()); } DateTime? _earliestDate(DateTime? first, DateTime? second) { if (first == null) return second; if (second == null) return first; return first.isBefore(second) ? first : second; } String _formatDuration(Duration? duration) { if (duration == null) { return 'Pending'; } if (duration.inSeconds < 60) { return 'Less than a minute'; } final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); if (hours > 0) { return '${hours}h ${minutes}m'; } return '${minutes}m'; }