Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../providers/auth_provider.dart' show sessionProvider;
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/user_offices_provider.dart';
|
||||
import '../../services/face_verification.dart' as face;
|
||||
import '../../widgets/multi_select_picker.dart';
|
||||
import '../../widgets/qr_verification_dialog.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
@@ -27,6 +31,10 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
bool _savingDetails = false;
|
||||
bool _changingPassword = false;
|
||||
bool _savingOffices = false;
|
||||
bool _uploadingAvatar = false;
|
||||
bool _enrollingFace = false;
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -72,6 +80,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
Text('My Profile', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Avatar Card ──
|
||||
_buildAvatarCard(context, profileAsync),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Face & Biometric Enrollment Card ──
|
||||
_buildFaceEnrollmentCard(context, profileAsync),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Details Card
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -253,6 +269,296 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarCard(BuildContext context, AsyncValue profileAsync) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final profile = profileAsync.valueOrNull;
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Profile Photo', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
backgroundImage: avatarUrl != null
|
||||
? NetworkImage(avatarUrl)
|
||||
: null,
|
||||
child: avatarUrl == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 48,
|
||||
color: colors.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (_uploadingAvatar)
|
||||
const Positioned.fill(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.gallery),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Upload'),
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.camera),
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Camera'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaceEnrollmentCard(
|
||||
BuildContext context,
|
||||
AsyncValue profileAsync,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final profile = profileAsync.valueOrNull;
|
||||
final hasFace = profile?.hasFaceEnrolled ?? false;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Face Verification', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enroll your face for attendance verification. '
|
||||
'A liveness check (blink detection) is required before enrollment.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Face enrollment status
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasFace ? Icons.check_circle : Icons.cancel,
|
||||
color: hasFace ? Colors.green : colors.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasFace
|
||||
? 'Face enrolled (${profile!.faceEnrolledAt != null ? _formatDate(profile.faceEnrolledAt!) : "unknown"})'
|
||||
: 'Face not enrolled',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _enrollingFace ? null : _enrollFace,
|
||||
icon: _enrollingFace
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.face),
|
||||
label: Text(hasFace ? 'Re-enroll Face' : 'Enroll Face'),
|
||||
),
|
||||
|
||||
// Test Facial Recognition
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
Text('Test Facial Recognition', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasFace
|
||||
? 'Run a liveness check and compare with your enrolled face.'
|
||||
: 'Enroll your face first to test facial recognition.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: hasFace ? _testFacialRecognition : null,
|
||||
icon: const Icon(Icons.face_retouching_natural),
|
||||
label: const Text('Test Facial Recognition'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar(ImageSource source) async {
|
||||
try {
|
||||
final xFile = await _imagePicker.pickImage(
|
||||
source: source,
|
||||
maxWidth: 512,
|
||||
maxHeight: 512,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (xFile == null) return;
|
||||
|
||||
setState(() => _uploadingAvatar = true);
|
||||
final bytes = await xFile.readAsBytes();
|
||||
final ext = xFile.name.split('.').last;
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
if (userId == null) return;
|
||||
await ref
|
||||
.read(profileControllerProvider)
|
||||
.uploadAvatar(userId: userId, bytes: bytes, fileName: 'avatar.$ext');
|
||||
ref.invalidate(currentProfileProvider);
|
||||
if (mounted) showSuccessSnackBar(context, 'Avatar updated.');
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Failed to upload avatar: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingAvatar = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Face enrollment via liveness detection (works on both mobile and web).
|
||||
/// Mobile: uses flutter_liveness_check with blink detection.
|
||||
/// Web: uses face-api.js with camera and blink detection.
|
||||
/// Falls back to QR cross-device flow if web camera is unavailable.
|
||||
Future<void> _enrollFace() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _enrollingFace = true);
|
||||
|
||||
try {
|
||||
final result = await face.runFaceLiveness(context);
|
||||
|
||||
if (result == null) {
|
||||
// Cancelled or failed — on web, offer QR fallback
|
||||
if (kIsWeb && mounted) {
|
||||
final useQr = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Camera unavailable?'),
|
||||
content: const Text(
|
||||
'If your camera did not work, you can enroll via your mobile device instead.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Use Mobile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (useQr == true && mounted) {
|
||||
final completed = await showQrVerificationDialog(
|
||||
context: context,
|
||||
ref: ref,
|
||||
type: 'enrollment',
|
||||
);
|
||||
if (completed) ref.invalidate(currentProfileProvider);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the captured face photo
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
if (userId == null) return;
|
||||
await ref
|
||||
.read(profileControllerProvider)
|
||||
.uploadFacePhoto(
|
||||
userId: userId,
|
||||
bytes: result.imageBytes,
|
||||
fileName: 'face.jpg',
|
||||
);
|
||||
ref.invalidate(currentProfileProvider);
|
||||
if (mounted) showSuccessSnackBar(context, 'Face enrolled successfully.');
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Face enrollment failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _enrollingFace = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test facial recognition: run liveness + compare with enrolled face.
|
||||
Future<void> _testFacialRecognition() async {
|
||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||
if (profile == null || !profile.hasFaceEnrolled) {
|
||||
if (mounted) {
|
||||
showWarningSnackBar(context, 'Please enroll your face first.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await face.runFaceLiveness(context);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
// Download enrolled photo via Supabase (authenticated, private bucket)
|
||||
final enrolledBytes = await ref
|
||||
.read(profileControllerProvider)
|
||||
.downloadFacePhoto(profile.id);
|
||||
if (enrolledBytes == null || !mounted) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, 'Could not load enrolled face photo.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare captured vs enrolled
|
||||
final score = await face.compareFaces(result.imageBytes, enrolledBytes);
|
||||
|
||||
if (!mounted) return;
|
||||
if (score >= 0.60) {
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
'Face matched! Similarity: ${(score * 100).toStringAsFixed(1)}%',
|
||||
);
|
||||
} else {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Face did not match. Similarity: ${(score * 100).toStringAsFixed(1)}%',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Recognition test failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSaveDetails() async {
|
||||
if (!_detailsKey.currentState!.validate()) return;
|
||||
final id = ref.read(currentUserIdProvider);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/verification_session_provider.dart';
|
||||
import '../../services/face_verification.dart' as face;
|
||||
|
||||
/// Screen opened on mobile when the user scans a QR code for cross-device
|
||||
/// face verification. Performs liveness detection and uploads the result.
|
||||
class MobileVerificationScreen extends ConsumerStatefulWidget {
|
||||
const MobileVerificationScreen({super.key, required this.sessionId});
|
||||
|
||||
final String sessionId;
|
||||
|
||||
@override
|
||||
ConsumerState<MobileVerificationScreen> createState() =>
|
||||
_MobileVerificationScreenState();
|
||||
}
|
||||
|
||||
class _MobileVerificationScreenState
|
||||
extends ConsumerState<MobileVerificationScreen> {
|
||||
bool _loading = true;
|
||||
bool _verifying = false;
|
||||
bool _done = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSession();
|
||||
}
|
||||
|
||||
Future<void> _loadSession() async {
|
||||
try {
|
||||
final controller = ref.read(verificationSessionControllerProvider);
|
||||
final session = await controller.getSession(widget.sessionId);
|
||||
if (session == null) {
|
||||
setState(() {
|
||||
_error = 'Verification session not found.';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session.isExpired) {
|
||||
setState(() {
|
||||
_error = 'This verification session has expired.';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session.isCompleted) {
|
||||
setState(() {
|
||||
_error = 'This session has already been completed.';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = false);
|
||||
|
||||
// Automatically start liveness detection
|
||||
_startLiveness();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load session: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startLiveness() async {
|
||||
if (_verifying) return;
|
||||
setState(() {
|
||||
_verifying = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await face.runFaceLiveness(context);
|
||||
|
||||
if (result == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Liveness check cancelled.';
|
||||
_verifying = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the photo and complete the session
|
||||
final controller = ref.read(verificationSessionControllerProvider);
|
||||
await controller.completeSession(
|
||||
sessionId: widget.sessionId,
|
||||
bytes: result.imageBytes,
|
||||
fileName: 'verify.jpg',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_done = true;
|
||||
_verifying = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Verification failed: $e';
|
||||
_verifying = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Face Verification')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildContent(theme, colors),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ThemeData theme, ColorScheme colors) {
|
||||
if (_loading) {
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading verification session...'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_done) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 64, color: Colors.green),
|
||||
const SizedBox(height: 16),
|
||||
Text('Verification Complete', style: theme.textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'You can close this screen and return to the web app.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: colors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Verification Error',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(color: colors.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _startLiveness,
|
||||
child: const Text('Try Again'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_verifying) {
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Processing verification...'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: prompt to start
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.face, size: 64, color: colors.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text('Ready to Verify', style: theme.textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the button below to start face liveness detection.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _startLiveness,
|
||||
icon: const Icon(Icons.face),
|
||||
label: const Text('Start Verification'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||
};
|
||||
|
||||
return ResponsiveBody(
|
||||
maxWidth: 1200,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -76,9 +77,13 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||
Map<String, Profile> profileById,
|
||||
GeofenceConfig? geofenceConfig,
|
||||
) {
|
||||
final markers = positions.map((pos) {
|
||||
final name = profileById[pos.userId]?.fullName ?? 'Unknown';
|
||||
final inPremise = pos.inPremise;
|
||||
// Only pin users who are in-premise (privacy: don't reveal off-site locations).
|
||||
final inPremisePositions = positions.where((pos) => pos.inPremise).toList();
|
||||
|
||||
final markers = inPremisePositions.map((pos) {
|
||||
final profile = profileById[pos.userId];
|
||||
final name = profile?.fullName ?? 'Unknown';
|
||||
final pinColor = _roleColor(profile?.role);
|
||||
return Marker(
|
||||
point: LatLng(pos.lat, pos.lng),
|
||||
width: 80,
|
||||
@@ -106,11 +111,7 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.location_pin,
|
||||
size: 28,
|
||||
color: inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
Icon(Icons.location_pin, size: 28, color: pinColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -167,46 +168,126 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||
) {
|
||||
if (positions.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: positions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final pos = positions[index];
|
||||
final p = profileById[pos.userId];
|
||||
final name = p?.fullName ?? 'Unknown';
|
||||
final role = p?.role ?? '-';
|
||||
final timeAgo = _timeAgo(pos.updatedAt);
|
||||
// Only include Admin, IT Staff, and Dispatcher in the legend.
|
||||
final relevantRoles = {'admin', 'dispatcher', 'it_staff'};
|
||||
final legendEntries = positions.where((pos) {
|
||||
final role = profileById[pos.userId]?.role;
|
||||
return role != null && relevantRoles.contains(role);
|
||||
}).toList();
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: pos.inPremise
|
||||
? Colors.green.shade100
|
||||
: Colors.orange.shade100,
|
||||
child: Icon(
|
||||
pos.inPremise ? Icons.check : Icons.location_off,
|
||||
size: 16,
|
||||
color: pos.inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
if (legendEntries.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Role color legend header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_legendDot(Colors.blue.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text('Admin', style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(width: 12),
|
||||
_legendDot(Colors.green.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text('IT Staff', style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(width: 12),
|
||||
_legendDot(Colors.orange.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Dispatcher',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(name),
|
||||
subtitle: Text('${_roleLabel(role)} · $timeAgo'),
|
||||
trailing: Text(
|
||||
pos.inPremise ? 'In premise' : 'Off-site',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: pos.inPremise ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
// Staff entries
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: legendEntries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final pos = legendEntries[index];
|
||||
final p = profileById[pos.userId];
|
||||
final name = p?.fullName ?? 'Unknown';
|
||||
final role = p?.role ?? '-';
|
||||
final isInPremise = pos.inPremise;
|
||||
final pinColor = _roleColor(role);
|
||||
final timeAgo = _timeAgo(pos.updatedAt);
|
||||
// Grey out outside-premise users for privacy.
|
||||
final effectiveColor = isInPremise
|
||||
? pinColor
|
||||
: Colors.grey.shade400;
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: effectiveColor.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
isInPremise ? Icons.location_pin : Icons.location_off,
|
||||
size: 16,
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: isInPremise ? null : Colors.grey,
|
||||
fontWeight: isInPremise
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${_roleLabel(role)} · ${isInPremise ? timeAgo : 'Outside premise'}',
|
||||
style: TextStyle(color: isInPremise ? null : Colors.grey),
|
||||
),
|
||||
trailing: isInPremise
|
||||
? Icon(Icons.circle, size: 10, color: pinColor)
|
||||
: Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
onTap: isInPremise
|
||||
? () =>
|
||||
_mapController.move(LatLng(pos.lat, pos.lng), 17.0)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () => _mapController.move(LatLng(pos.lat, pos.lng), 17.0),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legendDot(Color color) {
|
||||
return Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the pin color for a given role.
|
||||
static Color _roleColor(String? role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return Colors.blue.shade700;
|
||||
case 'it_staff':
|
||||
return Colors.green.shade700;
|
||||
case 'dispatcher':
|
||||
return Colors.orange.shade700;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _timeAgo(DateTime dt) {
|
||||
final diff = AppTime.now().difference(dt);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
|
||||
@@ -124,9 +124,13 @@ class _SchedulePanel extends ConsumerWidget {
|
||||
data: (allSchedules) {
|
||||
final now = AppTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
// Exclude overtime schedules – they only belong in the Logbook.
|
||||
final nonOvertime = allSchedules
|
||||
.where((s) => s.shiftType != 'overtime')
|
||||
.toList();
|
||||
final schedules = showPast
|
||||
? allSchedules
|
||||
: allSchedules
|
||||
? nonOvertime
|
||||
: nonOvertime
|
||||
.where((s) => !s.endTime.isBefore(today))
|
||||
.toList();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user