1632 lines
56 KiB
Dart
1632 lines
56 KiB
Dart
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<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 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<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: 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<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);
|
||
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<String>(
|
||
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<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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 <Team>[];
|
||
final members = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
|
||
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 = <String, List<StaffRowMetrics>>{};
|
||
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 = <MapEntry<String, int>>[];
|
||
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 = <MapEntry<int, Color>>[];
|
||
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<int>.generate(
|
||
tickCount,
|
||
(i) => ((capValue * (tickCount - 1 - i) / (tickCount - 1)))
|
||
.round(),
|
||
);
|
||
final ticks = <int>[];
|
||
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<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';
|
||
}
|