tasq/lib/screens/dashboard/dashboard_screen.dart

1254 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '../../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 '../../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<StaffRowMetrics> 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<AsyncValue<DashboardMetrics>>((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 <Ticket>[];
final tasks = tasksAsync.valueOrNull ?? const <Task>[];
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
final schedules = schedulesAsync.valueOrNull ?? const <DutySchedule>[];
final allLogs = logsAsync.valueOrNull ?? const <AttendanceLog>[];
final positions = positionsAsync.valueOrNull ?? const <LivePosition>[];
final allLeaves = leavesAsync.valueOrNull ?? const <LeaveOfAbsence>[];
final allPassSlips = passSlipsAsync.valueOrNull ?? const <PassSlip>[];
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 = <String, DateTime>{};
final lastStaffMessageByUser = <String, DateTime>{};
final respondedTicketsByUser = <String, Set<String>>{};
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, () => <String>{})
.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<Duration>()
.toList();
final triageDurationsToday = ticketsToday
.map(triageDuration)
.whereType<Duration>()
.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 = <String>{};
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 = <String, Set<String>>{};
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, () => <String>{})
.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 <ItServiceRequest>[];
final isrById = {for (final r in isrList) r.id: r};
final staffOnService = <String>{};
for (final assign
in isrAssignmentsAsync.valueOrNull ??
const <ItServiceRequestAssignment>[]) {
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 <Team>[];
final teamMembers = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
final teamById = {for (final t in teams) t.id: t};
final teamColorByUser = <String, String?>{};
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 = <String, List<DutySchedule>>{};
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 = <String, List<AttendanceLog>>{};
for (final l in allLogs) {
if (!l.checkInAt.isBefore(startOfDay)) {
todayLogsByUser.putIfAbsent(l.userId, () => []).add(l);
}
}
final positionByUser = <String, LivePosition>{};
for (final p in positions) {
positionByUser[p.userId] = p;
}
// Index today's leaves by user.
final todayLeaveByUser = <String, LeaveOfAbsence>{};
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 = <String, PassSlip>{};
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 startend), 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<dynamic> isrAssignments,
List<dynamic> 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 = <String>{};
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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@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<void>(
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 = <Widget>[
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.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<Widget> 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<String>(
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) {
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<String>(
data: (m) => valueBuilder(m),
loading: () => '',
error: (error, _) => 'Error',
),
),
);
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
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<String>(
data: (_) => 'data',
loading: () => 'loading',
error: (e, _) => 'error:${e.toString()}',
),
),
);
final staffRows = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<List<StaffRowMetrics>>(
data: (m) => m.staffRows,
loading: () => const <StaffRowMetrics>[],
error: (error, _) => const <StaffRowMetrics>[],
),
),
);
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),
),
),
],
),
);
}
}
/// 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<Duration> 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';
}