Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection

This commit is contained in:
2026-03-07 23:46:43 +08:00
parent 52ef36faac
commit 3dbebd4006
25 changed files with 4840 additions and 361 deletions
+213 -8
View File
@@ -5,21 +5,30 @@ 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 '../../widgets/status_pill.dart';
import '../../utils/app_time.dart';
class DashboardMetrics {
@@ -56,6 +65,7 @@ class StaffRowMetrics {
required this.userId,
required this.name,
required this.status,
required this.whereabouts,
required this.ticketsRespondedToday,
required this.tasksClosedToday,
});
@@ -63,6 +73,7 @@ class StaffRowMetrics {
final String userId;
final String name;
final String status;
final String whereabouts;
final int ticketsRespondedToday;
final int tasksClosedToday;
}
@@ -73,6 +84,11 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
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,
@@ -80,6 +96,11 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
profilesAsync,
assignmentsAsync,
messagesAsync,
schedulesAsync,
logsAsync,
positionsAsync,
leavesAsync,
passSlipsAsync,
];
// Debug: log dependency loading/error states to diagnose full-page refreshes.
@@ -89,7 +110,11 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
'tasks=${tasksAsync.isLoading ? "loading" : "ready"} '
'profiles=${profilesAsync.isLoading ? "loading" : "ready"} '
'assignments=${assignmentsAsync.isLoading ? "loading" : "ready"} '
'messages=${messagesAsync.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)) {
@@ -117,9 +142,15 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
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')
@@ -258,22 +289,139 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
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);
final status = onTask
? 'On task'
: inTriage
? 'In triage'
: 'Vacant';
// 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,
);
@@ -670,6 +818,7 @@ class _StaffTableHeader extends StatelessWidget {
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)),
],
@@ -743,7 +892,21 @@ class _StaffRow extends StatelessWidget {
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: StatusPill(label: row.status),
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(
@@ -763,6 +926,48 @@ class _StaffRow extends StatelessWidget {
}
}
/// 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;