Files
tasq/lib/screens/dashboard/dashboard_screen.dart
T
redz1029 57377b1b21 UI: Cyber/IT design overhaul with semantic status tokens (Material 3)
Re-theme TasQ with a refined enterprise "Cyber/IT" identity on Material 3.
Functionality is unchanged — only color/shape tokens and a few state widgets.

Foundation:
- Hand-tuned light + dark ColorSchemes (deep-slate surfaces, azure/teal/violet
  accents), tightened 12-16dp shape language, flat hairline-outlined cards.
- New AppStatusColors theme extension (success/warning/danger/info/neutral,
  per-brightness) replacing scattered raw Colors.green/orange/red/grey so status
  color is consistent and legible in dark mode.
- New TechChip mono primitive; StatusPill.kind; M3Card hairline variants;
  tightened AppSurfaces radii.

Per-screen:
- Tokenized status colors + radii across dashboard, tickets, tasks, IT service
  requests, attendance, whereabouts, network map, reports charts, auth, and
  shared/admin screens. Added _ticketStatusKind/_taskStatusKind/_isrStatusKind
  mappers so status carries meaning consistently.
- Fixed white pie-slice borders for dark mode; dropped ad-hoc dialog radius
  overrides in favor of the theme default. Categorical palettes (leave-type
  icons, team color picker, distribution charts) intentionally preserved.

Verified: flutter analyze lib clean; flutter build web green; visual QA in
light and dark (dashboard, tickets, attendance).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 15:13:47 +08:00

1688 lines
58 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/foundation.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.model.dart';
import '../../models/duty_schedule.model.dart';
import '../../models/leave_of_absence.model.dart';
import '../../models/live_position.dart';
import '../../models/pass_slip.model.dart';
import '../../models/profile.model.dart';
import '../../models/task.model.dart';
import '../../models/task_assignment.dart';
import '../../models/ticket.model.dart';
import '../../models/ticket_message.dart';
import '../../models/it_service_request.model.dart';
import '../../models/it_service_request_assignment.model.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.model.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 '../../theme/app_status_colors.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 ago = AppTime.relative(livePos.updatedAt);
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 {
// Sound/vibration settings and openAppSettings() don't apply on web.
if (kIsWeb) return;
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(),
for (int i = 0; i < sections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 5) * 40),
child: sections[i],
),
],
);
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) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 3,
height: 16,
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: cs.onSurface,
),
),
],
),
);
}
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',
),
),
);
final isDark = cs.brightness == Brightness.dark;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).cardRadius,
),
border: Border.all(color: cs.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
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;
final isDark = cs.brightness == Brightness.dark;
// Cyber/IT panel: flat tonal fill + hairline outline.
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
border: Border.all(color: cs.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);
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 _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
}
if (providerState.startsWith('error:')) {
final err = providerState.substring(6);
return _panelHint(
context,
Icons.error_outline_rounded,
'Failed to load staff: $err',
);
}
if (staffRows.isEmpty) {
return _panelHint(
context,
Icons.groups_outlined,
'No IT staff available.',
);
}
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: name (ellipsized on narrow screens) with team color dot.
// Mobile used initials-only avatars, which are ambiguous and need a
// long-press tooltip to identify anyone.
final staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
teamMarker,
const SizedBox(width: 6),
Flexible(
child: Text(
row.name,
style: valueStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
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: Builder(
builder: (context) {
final status = AppStatusColors.of(context);
final Color? color = row.whereabouts == 'In premise'
? status.success.color
: (row.whereabouts == 'Outside premise' ||
row.whereabouts == 'Tracking off' ||
row.whereabouts.startsWith('Last seen'))
? status.neutral.color
: null;
return Text(
row.whereabouts,
style: valueStyle?.copyWith(
color: color,
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;
final isDark = cs.brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
border: Border.all(color: cs.outlineVariant),
),
child: metricsAsync.when(
data: (metrics) {
final staffRows = metrics.staffRows;
if (staffRows.isEmpty) {
return _panelHint(
context,
Icons.bar_chart_outlined,
'No staff activity data available.',
);
}
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: cs.inverseSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).chipRadius,
),
),
child: MonoText(
total.toString(),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: cs.onInverseSurface,
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: () =>
_panelHint(context, Icons.hourglass_empty_rounded, 'Loading activity…'),
error: (error, stack) => _panelHint(
context,
Icons.error_outline_rounded,
'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 cs = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
final (Color bg, Color fg) = switch (label.toLowerCase()) {
'arrival' => (s.success.container, s.success.onContainer),
'late' => (s.warning.container, s.warning.onContainer),
'noon break' => (s.info.container, s.info.onContainer),
'vacant' => (s.neutral.container, s.neutral.onContainer),
'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
'in triage' => (s.warning.container, s.warning.onContainer),
'early out' => (s.warning.container, s.warning.onContainer),
'on leave' => (s.info.container, s.info.onContainer),
'absent' => (s.danger.container, s.danger.onContainer),
'off duty' => (s.neutral.container, s.neutral.onContainer),
_ => (cs.tertiaryContainer, cs.onTertiaryContainer),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
),
),
);
}
}
/// Compact, muted, centered hint for empty/loading/error states inside a
/// bounded dashboard panel (too small for the full-screen AppEmptyView).
Widget _panelHint(BuildContext context, IconData icon, String message) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
),
],
),
);
}
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';
}