import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/status_pill.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.ticketsRespondedToday, required this.tasksClosedToday, }); final String userId; final String name; final String status; final int ticketsRespondedToday; final int tasksClosedToday; } 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 asyncValues = [ ticketsAsync, tasksAsync, profilesAsync, assignmentsAsync, messagesAsync, ]; // 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"}', ); 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 now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); final staffProfiles = profiles .where((profile) => profile.role == 'it_staff') .toList(); final staffIds = profiles .where( (profile) => profile.role == 'admin' || 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); } const triageWindow = Duration(minutes: 1); final triageCutoff = now.subtract(triageWindow); var staffRows = staffProfiles.map((staff) { final lastMessage = lastStaffMessageByUser[staff.id]; final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0; final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0; final onTask = staffOnTask.contains(staff.id); final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff); final status = onTask ? 'On task' : inTriage ? 'In triage' : 'Vacant'; return StaffRowMetrics( userId: staff.id, name: staff.fullName.isNotEmpty ? staff.fullName : staff.id, status: status, ticketsRespondedToday: ticketsResponded, tasksClosedToday: tasksClosed, ); }).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; final bCount = b.ticketsRespondedToday + b.tasksClosedToday; 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, ), ); }); 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 showDialog( 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.isConnecting, child: LayoutBuilder( builder: (context, constraints) { final sections = [ const SizedBox(height: 16), _sectionTitle(context, 'IT Staff Pulse'), const _StaffTable(), 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.start, children: [ Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Align( alignment: Alignment.center, child: Text( 'Dashboard', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ), 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.isConnecting) Positioned.fill( child: AbsorbPointer( absorbing: true, child: Container( color: Theme.of( context, ).colorScheme.surface.withAlpha((0.35 * 255).round()), alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 36), child: SizedBox( width: 280, child: Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(12.0), child: Row( mainAxisSize: MainAxisSize.min, children: const [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ), SizedBox(width: 12), Expanded( child: Text('Reconnecting realtime…'), ), ], ), ), ), ), ), ), ), ], ); }, ), ), ); } 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), ), ); } 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); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( 'Dashboard data error: $errorText', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ); } 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) { // 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: 220), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: Theme.of( context, ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 10), // Animate only the metric text (not the whole card) for a // subtle, smooth update. AnimatedSwitcher( duration: const Duration(milliseconds: 220), transitionBuilder: (child, anim) => FadeTransition(opacity: anim, child: child), child: MonoText( value, key: ValueKey(value), style: Theme.of( context, ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), ), ), ], ), ); } } class _StaffTable extends StatelessWidget { const _StaffTable(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(AppSurfaces.of(context).cardRadius), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), 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); return Row( children: [ Expanded(flex: 3, child: Text('IT Staff', style: style)), Expanded(flex: 2, child: Text('Status', style: style)), Expanded(flex: 2, child: Text('Tickets', style: style)), Expanded(flex: 2, child: Text('Tasks', 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; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ Expanded(flex: 3, child: Text(row.name, style: valueStyle)), Expanded( flex: 2, child: Align( alignment: Alignment.centerLeft, child: StatusPill(label: row.status), ), ), Expanded( flex: 2, child: Text( row.ticketsRespondedToday.toString(), style: valueStyle, ), ), Expanded( flex: 2, child: Text(row.tasksClosedToday.toString(), style: valueStyle), ), ], ), ); } } 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'; }