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
+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';