1001 lines
32 KiB
Dart
1001 lines
32 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 '../../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 '../../widgets/responsive_body.dart';
|
|
import '../../widgets/reconnect_overlay.dart';
|
|
import '../../providers/realtime_controller.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
import '../../theme/app_surfaces.dart';
|
|
import '../../widgets/mono_text.dart';
|
|
import '../../utils/app_time.dart';
|
|
|
|
class DashboardMetrics {
|
|
DashboardMetrics({
|
|
required this.newTicketsToday,
|
|
required this.closedToday,
|
|
required this.openTickets,
|
|
required this.avgResponse,
|
|
required this.avgTriage,
|
|
required this.longestResponse,
|
|
required this.tasksCreatedToday,
|
|
required this.tasksCompletedToday,
|
|
required this.openTasks,
|
|
required this.staffRows,
|
|
});
|
|
|
|
final int newTicketsToday;
|
|
final int closedToday;
|
|
final int openTickets;
|
|
|
|
final Duration? avgResponse;
|
|
final Duration? avgTriage;
|
|
final Duration? longestResponse;
|
|
|
|
final int tasksCreatedToday;
|
|
final int tasksCompletedToday;
|
|
final int openTasks;
|
|
|
|
final List<StaffRowMetrics> staffRows;
|
|
}
|
|
|
|
class StaffRowMetrics {
|
|
StaffRowMetrics({
|
|
required this.userId,
|
|
required this.name,
|
|
required this.status,
|
|
required this.whereabouts,
|
|
required this.ticketsRespondedToday,
|
|
required this.tasksClosedToday,
|
|
});
|
|
|
|
final String userId;
|
|
final String name;
|
|
final String status;
|
|
final String whereabouts;
|
|
final int ticketsRespondedToday;
|
|
final int tasksClosedToday;
|
|
}
|
|
|
|
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 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 == '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);
|
|
}
|
|
|
|
const triageWindow = Duration(minutes: 1);
|
|
final triageCutoff = now.subtract(triageWindow);
|
|
|
|
// Pre-index schedules, logs, and positions by user for efficient lookup.
|
|
final todaySchedulesByUser = <String, List<DutySchedule>>{};
|
|
for (final s in schedules) {
|
|
// Exclude overtime schedules from regular duty tracking.
|
|
if (s.shiftType != 'overtime' &&
|
|
!s.startTime.isBefore(startOfDay) &&
|
|
s.startTime.isBefore(endOfDay)) {
|
|
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;
|
|
final onTask = staffOnTask.contains(staff.id);
|
|
final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
|
|
|
|
// Whereabouts from live position.
|
|
final livePos = positionByUser[staff.id];
|
|
final whereabouts = livePos != null
|
|
? (livePos.inPremise ? 'In premise' : 'Outside premise')
|
|
: '\u2014';
|
|
|
|
// Attendance-based status.
|
|
final userSchedules = todaySchedulesByUser[staff.id] ?? const [];
|
|
final userLogs = todayLogsByUser[staff.id] ?? const [];
|
|
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/triage.
|
|
status = onTask
|
|
? 'On task'
|
|
: inTriage
|
|
? 'In triage'
|
|
: 'Off duty';
|
|
} else {
|
|
final schedule = userSchedules.first;
|
|
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 = 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.
|
|
if (isOnCall) {
|
|
// ON CALL staff don't need to be on premise or check in at a
|
|
// specific time — they only come when needed.
|
|
status = isShiftOver ? 'Off duty' : 'ON CALL';
|
|
} 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,
|
|
);
|
|
}).toList();
|
|
|
|
// Order IT staff by combined activity (tickets responded today + tasks closed today)
|
|
// descending so most-active staff appear first. Use name as a stable tiebreaker.
|
|
staffRows.sort((a, b) {
|
|
final aCount = a.ticketsRespondedToday + a.tasksClosedToday;
|
|
final bCount = b.ticketsRespondedToday + b.tasksClosedToday;
|
|
if (bCount != aCount) return bCount.compareTo(aCount);
|
|
return a.name.compareTo(b.name);
|
|
});
|
|
|
|
return AsyncData(
|
|
DashboardMetrics(
|
|
newTicketsToday: ticketsToday.length,
|
|
closedToday: closedToday.length,
|
|
openTickets: openTickets.length,
|
|
avgResponse: avgResponse,
|
|
avgTriage: avgTriage,
|
|
longestResponse: longestResponse,
|
|
tasksCreatedToday: tasksCreatedToday.length,
|
|
tasksCompletedToday: tasksCompletedToday.length,
|
|
openTasks: openTasks.length,
|
|
staffRows: staffRows,
|
|
),
|
|
);
|
|
});
|
|
|
|
class DashboardScreen extends StatefulWidget {
|
|
const DashboardScreen({super.key});
|
|
|
|
@override
|
|
State<DashboardScreen> createState() => _DashboardScreenState();
|
|
}
|
|
|
|
class _DashboardScreenState extends State<DashboardScreen> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
|
|
if (!seen) {
|
|
if (!mounted) return;
|
|
await m3ShowDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Never miss an update'),
|
|
content: const Text(
|
|
'Ensure notification sounds and vibration are enabled for important alerts.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
openAppSettings();
|
|
},
|
|
child: const Text('Open settings'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Got it'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await prefs.setBool('has_seen_notif_showcase', true);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final realtime = ProviderScope.containerOf(
|
|
context,
|
|
).read(realtimeControllerProvider);
|
|
|
|
return ResponsiveBody(
|
|
child: Skeletonizer(
|
|
enabled: realtime.isAnyStreamRecovering,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final sections = <Widget>[
|
|
const SizedBox(height: 16),
|
|
_sectionTitle(context, 'IT Staff Pulse'),
|
|
const _StaffTable(),
|
|
const SizedBox(height: 20),
|
|
_sectionTitle(context, 'Core Daily KPIs'),
|
|
_cardGrid(context, [
|
|
_MetricCard(
|
|
title: 'New tickets today',
|
|
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
|
|
),
|
|
_MetricCard(
|
|
title: 'Closed today',
|
|
valueBuilder: (metrics) => metrics.closedToday.toString(),
|
|
),
|
|
_MetricCard(
|
|
title: 'Open tickets',
|
|
valueBuilder: (metrics) => metrics.openTickets.toString(),
|
|
),
|
|
]),
|
|
const SizedBox(height: 20),
|
|
_sectionTitle(context, 'Task Flow'),
|
|
_cardGrid(context, [
|
|
_MetricCard(
|
|
title: 'Tasks created',
|
|
valueBuilder: (metrics) =>
|
|
metrics.tasksCreatedToday.toString(),
|
|
),
|
|
_MetricCard(
|
|
title: 'Tasks completed',
|
|
valueBuilder: (metrics) =>
|
|
metrics.tasksCompletedToday.toString(),
|
|
),
|
|
_MetricCard(
|
|
title: 'Open tasks',
|
|
valueBuilder: (metrics) => metrics.openTasks.toString(),
|
|
),
|
|
]),
|
|
const SizedBox(height: 20),
|
|
_sectionTitle(context, 'TAT / Response'),
|
|
_cardGrid(context, [
|
|
_MetricCard(
|
|
title: 'Avg response',
|
|
valueBuilder: (metrics) =>
|
|
_formatDuration(metrics.avgResponse),
|
|
),
|
|
_MetricCard(
|
|
title: 'Avg triage',
|
|
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
|
|
),
|
|
_MetricCard(
|
|
title: 'Longest response',
|
|
valueBuilder: (metrics) =>
|
|
_formatDuration(metrics.longestResponse),
|
|
),
|
|
]),
|
|
];
|
|
|
|
final content = Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
|
child: Align(
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'Dashboard',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const _DashboardStatusBanner(),
|
|
...sections,
|
|
],
|
|
);
|
|
|
|
return Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
padding: const EdgeInsets.only(bottom: 24),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: constraints.maxHeight,
|
|
),
|
|
child: content,
|
|
),
|
|
),
|
|
),
|
|
if (realtime.isAnyStreamRecovering)
|
|
const Positioned(
|
|
bottom: 16,
|
|
right: 16,
|
|
child: ReconnectIndicator(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sectionTitle(BuildContext context, String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _cardGrid(BuildContext context, List<Widget> cards) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
if (width < 520) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (var i = 0; i < cards.length; i++) ...[
|
|
cards[i],
|
|
if (i < cards.length - 1) const SizedBox(height: 12),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
final spacing = 12.0;
|
|
final minCardWidth = 220.0;
|
|
final totalWidth =
|
|
cards.length * minCardWidth + spacing * (cards.length - 1);
|
|
final fits = totalWidth <= width;
|
|
final cardWidth = fits
|
|
? (width - spacing * (cards.length - 1)) / cards.length
|
|
: minCardWidth;
|
|
|
|
final row = Row(
|
|
children: [
|
|
for (var i = 0; i < cards.length; i++) ...[
|
|
SizedBox(width: cardWidth, child: cards[i]),
|
|
if (i < cards.length - 1) const SizedBox(width: 12),
|
|
],
|
|
],
|
|
);
|
|
|
|
if (fits) {
|
|
return row;
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(width: totalWidth, child: row),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashboardStatusBanner extends ConsumerWidget {
|
|
const _DashboardStatusBanner();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// Watch a small derived string state so only the banner rebuilds when
|
|
// its visibility/content actually changes.
|
|
final bannerState = ref.watch(
|
|
dashboardMetricsProvider.select(
|
|
(av) => av.when<String>(
|
|
data: (_) => 'data',
|
|
loading: () => 'loading',
|
|
error: (e, _) => 'error:${e.toString()}',
|
|
),
|
|
),
|
|
);
|
|
|
|
if (bannerState == 'loading') {
|
|
return const Padding(
|
|
padding: EdgeInsets.only(bottom: 12),
|
|
child: LinearProgressIndicator(minHeight: 2),
|
|
);
|
|
}
|
|
|
|
if (bannerState.startsWith('error:')) {
|
|
final errorText = bannerState.substring(6);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text(
|
|
'Dashboard data error: $errorText',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
class _MetricCard extends ConsumerWidget {
|
|
const _MetricCard({required this.title, required this.valueBuilder});
|
|
|
|
final String title;
|
|
final String Function(DashboardMetrics metrics) valueBuilder;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
// Only watch the single string value for this card so unrelated metric
|
|
// updates don't rebuild the whole card. This makes updates feel much
|
|
// smoother and avoids full-page refreshes.
|
|
final value = ref.watch(
|
|
dashboardMetricsProvider.select(
|
|
(av) => av.when<String>(
|
|
data: (m) => valueBuilder(m),
|
|
loading: () => '—',
|
|
error: (error, _) => 'Error',
|
|
),
|
|
),
|
|
);
|
|
|
|
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 400),
|
|
curve: Curves.easeOutCubic,
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: cs.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Animate only the metric text (not the whole card) for a
|
|
// subtle, smooth update.
|
|
AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 400),
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder: (child, anim) =>
|
|
FadeTransition(opacity: anim, child: child),
|
|
child: MonoText(
|
|
value,
|
|
key: ValueKey(value),
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: cs.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StaffTable extends StatelessWidget {
|
|
const _StaffTable();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(
|
|
AppSurfaces.of(context).containerRadius,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: const [
|
|
_StaffTableHeader(),
|
|
SizedBox(height: 8),
|
|
_StaffTableBody(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StaffTableHeader extends StatelessWidget {
|
|
const _StaffTableHeader();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final style = Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
|
|
return Row(
|
|
children: [
|
|
Expanded(flex: 3, child: Text('IT Staff', style: style)),
|
|
Expanded(flex: 2, child: Text('Status', style: style)),
|
|
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
|
|
Expanded(flex: 2, child: Text('Tickets', style: style)),
|
|
Expanded(flex: 2, child: Text('Tasks', style: style)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StaffTableBody extends ConsumerWidget {
|
|
const _StaffTableBody();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
// Only listen to the staff rows and the overall provider state to keep
|
|
// rebuilds scoped to this small area.
|
|
final providerState = ref.watch(
|
|
dashboardMetricsProvider.select(
|
|
(av) => av.when<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;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: _PulseStatusPill(label: row.status),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
row.whereabouts,
|
|
style: valueStyle?.copyWith(
|
|
color: row.whereabouts == 'In premise'
|
|
? Colors.green
|
|
: row.whereabouts == 'Outside premise'
|
|
? Colors.orange
|
|
: null,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
row.ticketsRespondedToday.toString(),
|
|
style: valueStyle,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Status pill with attendance-specific coloring for the IT Staff Pulse table.
|
|
class _PulseStatusPill extends StatelessWidget {
|
|
const _PulseStatusPill({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (Color bg, Color fg) = switch (label.toLowerCase()) {
|
|
'arrival' => (Colors.amber.shade100, Colors.amber.shade900),
|
|
'late' => (Colors.red.shade100, Colors.red.shade900),
|
|
'noon break' => (Colors.blue.shade100, Colors.blue.shade900),
|
|
'vacant' => (Colors.green.shade100, Colors.green.shade900),
|
|
'on task' => (Colors.purple.shade100, Colors.purple.shade900),
|
|
'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';
|
|
}
|