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
File diff suppressed because it is too large Load Diff
+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;
+306
View File
@@ -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'),
),
],
);
}
}
+122 -41
View File
@@ -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';
+6 -2
View File
@@ -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();