Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user