Attendance log now record both check in and out photos and allow IT Staffs, Dispatchers and Admins to view for verification
This commit is contained in:
parent
f8502f01b6
commit
0f675d4274
|
|
@ -13,8 +13,10 @@ class AttendanceLog {
|
||||||
this.checkOutLat,
|
this.checkOutLat,
|
||||||
this.checkOutLng,
|
this.checkOutLng,
|
||||||
this.justification,
|
this.justification,
|
||||||
|
this.checkOutJustification,
|
||||||
this.verificationStatus = 'pending',
|
this.verificationStatus = 'pending',
|
||||||
this.verificationPhotoUrl,
|
this.checkInVerificationPhotoUrl,
|
||||||
|
this.checkOutVerificationPhotoUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -28,8 +30,10 @@ class AttendanceLog {
|
||||||
final double? checkOutLat;
|
final double? checkOutLat;
|
||||||
final double? checkOutLng;
|
final double? checkOutLng;
|
||||||
final String? justification;
|
final String? justification;
|
||||||
|
final String? checkOutJustification;
|
||||||
final String verificationStatus; // pending, verified, unverified, skipped
|
final String verificationStatus; // pending, verified, unverified, skipped
|
||||||
final String? verificationPhotoUrl;
|
final String? checkInVerificationPhotoUrl;
|
||||||
|
final String? checkOutVerificationPhotoUrl;
|
||||||
|
|
||||||
bool get isCheckedOut => checkOutAt != null;
|
bool get isCheckedOut => checkOutAt != null;
|
||||||
bool get isVerified => verificationStatus == 'verified';
|
bool get isVerified => verificationStatus == 'verified';
|
||||||
|
|
@ -37,6 +41,8 @@ class AttendanceLog {
|
||||||
verificationStatus == 'unverified' || verificationStatus == 'skipped';
|
verificationStatus == 'unverified' || verificationStatus == 'skipped';
|
||||||
|
|
||||||
factory AttendanceLog.fromMap(Map<String, dynamic> map) {
|
factory AttendanceLog.fromMap(Map<String, dynamic> map) {
|
||||||
|
// Support both old single-column and new dual-column schemas.
|
||||||
|
final legacyUrl = map['verification_photo_url'] as String?;
|
||||||
return AttendanceLog(
|
return AttendanceLog(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
userId: map['user_id'] as String,
|
userId: map['user_id'] as String,
|
||||||
|
|
@ -51,8 +57,12 @@ class AttendanceLog {
|
||||||
checkOutLat: (map['check_out_lat'] as num?)?.toDouble(),
|
checkOutLat: (map['check_out_lat'] as num?)?.toDouble(),
|
||||||
checkOutLng: (map['check_out_lng'] as num?)?.toDouble(),
|
checkOutLng: (map['check_out_lng'] as num?)?.toDouble(),
|
||||||
justification: map['justification'] as String?,
|
justification: map['justification'] as String?,
|
||||||
|
checkOutJustification: map['check_out_justification'] as String?,
|
||||||
verificationStatus: map['verification_status'] as String? ?? 'pending',
|
verificationStatus: map['verification_status'] as String? ?? 'pending',
|
||||||
verificationPhotoUrl: map['verification_photo_url'] as String?,
|
checkInVerificationPhotoUrl:
|
||||||
|
map['check_in_verification_photo_url'] as String? ?? legacyUrl,
|
||||||
|
checkOutVerificationPhotoUrl:
|
||||||
|
map['check_out_verification_photo_url'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,16 @@ class AttendanceController {
|
||||||
required String attendanceId,
|
required String attendanceId,
|
||||||
required double lat,
|
required double lat,
|
||||||
required double lng,
|
required double lng,
|
||||||
|
String? justification,
|
||||||
}) async {
|
}) async {
|
||||||
await _client.rpc(
|
await _client.rpc(
|
||||||
'attendance_check_out',
|
'attendance_check_out',
|
||||||
params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng},
|
params: {
|
||||||
|
'p_attendance_id': attendanceId,
|
||||||
|
'p_lat': lat,
|
||||||
|
'p_lng': lng,
|
||||||
|
if (justification != null) 'p_justification': justification,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,10 +124,12 @@ class AttendanceController {
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
required String status, // 'verified', 'unverified'
|
required String status, // 'verified', 'unverified'
|
||||||
|
bool isCheckOut = false,
|
||||||
}) async {
|
}) async {
|
||||||
final userId = _client.auth.currentUser!.id;
|
final userId = _client.auth.currentUser!.id;
|
||||||
final ext = fileName.split('.').last.toLowerCase();
|
final ext = fileName.split('.').last.toLowerCase();
|
||||||
final path = '$userId/$attendanceId.$ext';
|
final prefix = isCheckOut ? 'checkout' : 'checkin';
|
||||||
|
final path = '$userId/${prefix}_$attendanceId.$ext';
|
||||||
await _client.storage
|
await _client.storage
|
||||||
.from('attendance-verification')
|
.from('attendance-verification')
|
||||||
.uploadBinary(
|
.uploadBinary(
|
||||||
|
|
@ -132,9 +140,12 @@ class AttendanceController {
|
||||||
final url = _client.storage
|
final url = _client.storage
|
||||||
.from('attendance-verification')
|
.from('attendance-verification')
|
||||||
.getPublicUrl(path);
|
.getPublicUrl(path);
|
||||||
|
final column = isCheckOut
|
||||||
|
? 'check_out_verification_photo_url'
|
||||||
|
: 'check_in_verification_photo_url';
|
||||||
await _client
|
await _client
|
||||||
.from('attendance_logs')
|
.from('attendance_logs')
|
||||||
.update({'verification_status': status, 'verification_photo_url': url})
|
.update({'verification_status': status, column: url})
|
||||||
.eq('id', attendanceId);
|
.eq('id', attendanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,13 @@ class VerificationSessionController {
|
||||||
})
|
})
|
||||||
.eq('id', session.userId);
|
.eq('id', session.userId);
|
||||||
} else if (session.type == 'verification' && session.contextId != null) {
|
} else if (session.type == 'verification' && session.contextId != null) {
|
||||||
// Update attendance log verification status
|
// Update attendance log verification status.
|
||||||
|
// Cross-device verification defaults to check-in photo column.
|
||||||
await _client
|
await _client
|
||||||
.from('attendance_logs')
|
.from('attendance_logs')
|
||||||
.update({
|
.update({
|
||||||
'verification_status': 'verified',
|
'verification_status': 'verified',
|
||||||
'verification_photo_url': session.imageUrl,
|
'check_in_verification_photo_url': session.imageUrl,
|
||||||
})
|
})
|
||||||
.eq('id', session.contextId!);
|
.eq('id', session.contextId!);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -912,12 +912,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if outside geofence — require justification if so
|
||||||
|
String? checkOutJustification;
|
||||||
|
final geoCfg = await ref.read(geofenceProvider.future);
|
||||||
|
final debugBypass =
|
||||||
|
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||||
|
if (geoCfg != null && !debugBypass) {
|
||||||
|
bool inside = false;
|
||||||
|
if (geoCfg.hasPolygon) {
|
||||||
|
inside = geoCfg.containsPolygon(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
} else if (geoCfg.hasCircle) {
|
||||||
|
final dist = Geolocator.distanceBetween(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
geoCfg.lat!,
|
||||||
|
geoCfg.lng!,
|
||||||
|
);
|
||||||
|
inside = dist <= (geoCfg.radiusMeters ?? 0);
|
||||||
|
}
|
||||||
|
if (!inside && mounted) {
|
||||||
|
checkOutJustification = await _showCheckOutJustificationDialog(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
if (checkOutJustification == null) {
|
||||||
|
// User cancelled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(attendanceControllerProvider)
|
.read(attendanceControllerProvider)
|
||||||
.checkOut(
|
.checkOut(
|
||||||
attendanceId: log.id,
|
attendanceId: log.id,
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
|
justification: checkOutJustification,
|
||||||
);
|
);
|
||||||
// Update live position immediately on check-out
|
// Update live position immediately on check-out
|
||||||
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
|
|
@ -930,7 +964,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
_overtimeLogId = null;
|
_overtimeLogId = null;
|
||||||
});
|
});
|
||||||
showSuccessSnackBar(context, 'Checked out! Running verification...');
|
showSuccessSnackBar(context, 'Checked out! Running verification...');
|
||||||
_performFaceVerification(log.id);
|
_performFaceVerification(log.id, isCheckOut: true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -960,12 +994,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if outside geofence — require justification if so
|
||||||
|
String? checkOutJustification;
|
||||||
|
final geoCfg = await ref.read(geofenceProvider.future);
|
||||||
|
final debugBypass =
|
||||||
|
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||||
|
if (geoCfg != null && !debugBypass) {
|
||||||
|
bool inside = false;
|
||||||
|
if (geoCfg.hasPolygon) {
|
||||||
|
inside = geoCfg.containsPolygon(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
} else if (geoCfg.hasCircle) {
|
||||||
|
final dist = Geolocator.distanceBetween(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
geoCfg.lat!,
|
||||||
|
geoCfg.lng!,
|
||||||
|
);
|
||||||
|
inside = dist <= (geoCfg.radiusMeters ?? 0);
|
||||||
|
}
|
||||||
|
if (!inside && mounted) {
|
||||||
|
checkOutJustification = await _showCheckOutJustificationDialog(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
if (checkOutJustification == null) {
|
||||||
|
// User cancelled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(attendanceControllerProvider)
|
.read(attendanceControllerProvider)
|
||||||
.checkOut(
|
.checkOut(
|
||||||
attendanceId: logId,
|
attendanceId: logId,
|
||||||
lat: position.latitude,
|
lat: position.latitude,
|
||||||
lng: position.longitude,
|
lng: position.longitude,
|
||||||
|
justification: checkOutJustification,
|
||||||
);
|
);
|
||||||
// Update live position immediately on check-out
|
// Update live position immediately on check-out
|
||||||
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||||
|
|
@ -978,7 +1046,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
_overtimeLogId = null;
|
_overtimeLogId = null;
|
||||||
});
|
});
|
||||||
showSuccessSnackBar(context, 'Checked out! Running verification...');
|
showSuccessSnackBar(context, 'Checked out! Running verification...');
|
||||||
_performFaceVerification(logId);
|
_performFaceVerification(logId, isCheckOut: true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -989,6 +1057,61 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog asking for justification when checking out outside geofence.
|
||||||
|
/// Returns the justification text, or null if the user cancelled.
|
||||||
|
Future<String?> _showCheckOutJustificationDialog(BuildContext context) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final result = await m3ShowDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
final colors = Theme.of(ctx).colorScheme;
|
||||||
|
final textTheme = Theme.of(ctx).textTheme;
|
||||||
|
return AlertDialog(
|
||||||
|
icon: Icon(Icons.location_off, color: colors.error),
|
||||||
|
title: const Text('Outside Geofence'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'You are checking out outside the designated area. '
|
||||||
|
'Please provide a justification.',
|
||||||
|
style: textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Justification',
|
||||||
|
hintText:
|
||||||
|
'Explain why you are checking out outside the geofence...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(null),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final text = controller.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
Navigator.of(ctx).pop(text);
|
||||||
|
},
|
||||||
|
child: const Text('Submit & Check Out'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleOvertimeCheckIn() async {
|
Future<void> _handleOvertimeCheckIn() async {
|
||||||
final justification = _justificationController.text.trim();
|
final justification = _justificationController.text.trim();
|
||||||
if (justification.isEmpty) {
|
if (justification.isEmpty) {
|
||||||
|
|
@ -1060,7 +1183,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
|
|
||||||
/// Face verification after check-in/out: liveness detection on mobile,
|
/// Face verification after check-in/out: liveness detection on mobile,
|
||||||
/// camera/gallery on web. Uploads selfie and updates attendance log.
|
/// camera/gallery on web. Uploads selfie and updates attendance log.
|
||||||
Future<void> _performFaceVerification(String attendanceLogId) async {
|
Future<void> _performFaceVerification(
|
||||||
|
String attendanceLogId, {
|
||||||
|
bool isCheckOut = false,
|
||||||
|
}) async {
|
||||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||||
if (profile == null || !profile.hasFaceEnrolled) {
|
if (profile == null || !profile.hasFaceEnrolled) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1081,6 +1207,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
context: context,
|
context: context,
|
||||||
ref: ref,
|
ref: ref,
|
||||||
attendanceLogId: attendanceLogId,
|
attendanceLogId: attendanceLogId,
|
||||||
|
isCheckOut: isCheckOut,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -1364,7 +1491,10 @@ class _LogbookEntry {
|
||||||
this.logId,
|
this.logId,
|
||||||
this.logUserId,
|
this.logUserId,
|
||||||
this.enrolledFaceUrl,
|
this.enrolledFaceUrl,
|
||||||
this.verificationFaceUrl,
|
this.checkInVerificationFaceUrl,
|
||||||
|
this.checkOutVerificationFaceUrl,
|
||||||
|
this.justification,
|
||||||
|
this.checkOutJustification,
|
||||||
this.checkInLat,
|
this.checkInLat,
|
||||||
this.checkInLng,
|
this.checkInLng,
|
||||||
this.checkOutLat,
|
this.checkOutLat,
|
||||||
|
|
@ -1385,7 +1515,10 @@ class _LogbookEntry {
|
||||||
final String? logId;
|
final String? logId;
|
||||||
final String? logUserId;
|
final String? logUserId;
|
||||||
final String? enrolledFaceUrl;
|
final String? enrolledFaceUrl;
|
||||||
final String? verificationFaceUrl;
|
final String? checkInVerificationFaceUrl;
|
||||||
|
final String? checkOutVerificationFaceUrl;
|
||||||
|
final String? justification;
|
||||||
|
final String? checkOutJustification;
|
||||||
final double? checkInLat;
|
final double? checkInLat;
|
||||||
final double? checkInLng;
|
final double? checkInLng;
|
||||||
final double? checkOutLat;
|
final double? checkOutLat;
|
||||||
|
|
@ -1426,7 +1559,10 @@ class _LogbookEntry {
|
||||||
logId: log.id,
|
logId: log.id,
|
||||||
logUserId: log.userId,
|
logUserId: log.userId,
|
||||||
enrolledFaceUrl: p?.facePhotoUrl,
|
enrolledFaceUrl: p?.facePhotoUrl,
|
||||||
verificationFaceUrl: log.verificationPhotoUrl,
|
checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl,
|
||||||
|
checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl,
|
||||||
|
justification: log.justification,
|
||||||
|
checkOutJustification: log.checkOutJustification,
|
||||||
checkInLat: log.checkInLat,
|
checkInLat: log.checkInLat,
|
||||||
checkInLng: log.checkInLng,
|
checkInLng: log.checkInLng,
|
||||||
checkOutLat: log.checkOutLat,
|
checkOutLat: log.checkOutLat,
|
||||||
|
|
@ -2059,6 +2195,11 @@ class _VerificationDetailsContent extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final hasJustification =
|
||||||
|
(entry.justification ?? '').trim().isNotEmpty ||
|
||||||
|
(entry.checkOutJustification ?? '').trim().isNotEmpty;
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
|
|
@ -2073,9 +2214,14 @@ class _VerificationDetailsContent extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
'Shift: ${entry.shift}',
|
'Shift: ${entry.shift}',
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: colors.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Justification section
|
||||||
|
if (hasJustification) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_JustificationSection(entry: entry),
|
||||||
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const TabBar(
|
const TabBar(
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|
@ -2109,38 +2255,223 @@ class _VerificationDetailsContent extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FaceVerificationTab extends StatelessWidget {
|
/// Displays justification notes for overtime check-in and/or off-site checkout.
|
||||||
|
class _JustificationSection extends StatelessWidget {
|
||||||
|
const _JustificationSection({required this.entry});
|
||||||
|
|
||||||
|
final _LogbookEntry entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final overtimeJustification = (entry.justification ?? '').trim();
|
||||||
|
final checkOutJustification = (entry.checkOutJustification ?? '').trim();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.tertiaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.notes_rounded, size: 16, color: colors.tertiary),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Justification',
|
||||||
|
style: textTheme.labelMedium?.copyWith(
|
||||||
|
color: colors.tertiary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (overtimeJustification.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Overtime:',
|
||||||
|
style: textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(overtimeJustification, style: textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
if (checkOutJustification.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Check-out (outside geofence):',
|
||||||
|
style: textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(checkOutJustification, style: textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FaceVerificationTab extends StatefulWidget {
|
||||||
const _FaceVerificationTab({required this.entry});
|
const _FaceVerificationTab({required this.entry});
|
||||||
|
|
||||||
final _LogbookEntry entry;
|
final _LogbookEntry entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FaceVerificationTab> createState() => _FaceVerificationTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FaceVerificationTabState extends State<_FaceVerificationTab> {
|
||||||
|
late final PageController _pageController;
|
||||||
|
int _currentPage = 0;
|
||||||
|
|
||||||
|
static const _labels = ['Check-In Verification', 'Check-Out Verification'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageController = PageController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final entry = widget.entry;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: (i) => setState(() => _currentPage = i),
|
||||||
|
children: [
|
||||||
|
// Page 1: Enrolled Face + Check-In Verification
|
||||||
|
_SideBySidePanel(
|
||||||
|
enrolledFaceUrl: entry.enrolledFaceUrl,
|
||||||
|
verificationUrl: entry.checkInVerificationFaceUrl,
|
||||||
|
verificationLabel: 'Check-In Verification',
|
||||||
|
verificationIcon: Icons.login_rounded,
|
||||||
|
emptyMessage: 'No check-in verification photo.',
|
||||||
|
),
|
||||||
|
// Page 2: Enrolled Face + Check-Out Verification
|
||||||
|
_SideBySidePanel(
|
||||||
|
enrolledFaceUrl: entry.enrolledFaceUrl,
|
||||||
|
verificationUrl: entry.checkOutVerificationFaceUrl,
|
||||||
|
verificationLabel: 'Check-Out Verification',
|
||||||
|
verificationIcon: Icons.logout_rounded,
|
||||||
|
emptyMessage: 'No check-out verification photo.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Label
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: M3Motion.micro,
|
||||||
|
child: Text(
|
||||||
|
_labels[_currentPage],
|
||||||
|
key: ValueKey(_currentPage),
|
||||||
|
style: textTheme.labelMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Page indicator dots
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(_labels.length, (i) {
|
||||||
|
final isActive = i == _currentPage;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _pageController.animateToPage(
|
||||||
|
i,
|
||||||
|
duration: M3Motion.standard,
|
||||||
|
curve: M3Motion.standard_,
|
||||||
|
),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: M3Motion.short,
|
||||||
|
curve: M3Motion.standard_,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
width: isActive ? 24 : 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive
|
||||||
|
? colors.primary
|
||||||
|
: colors.onSurfaceVariant.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Side-by-side panel: enrolled face on the left, verification photo on the right.
|
||||||
|
class _SideBySidePanel extends StatelessWidget {
|
||||||
|
const _SideBySidePanel({
|
||||||
|
required this.enrolledFaceUrl,
|
||||||
|
required this.verificationUrl,
|
||||||
|
required this.verificationLabel,
|
||||||
|
required this.verificationIcon,
|
||||||
|
required this.emptyMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? enrolledFaceUrl;
|
||||||
|
final String? verificationUrl;
|
||||||
|
final String verificationLabel;
|
||||||
|
final IconData verificationIcon;
|
||||||
|
final String emptyMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final panelWidth = constraints.maxWidth >= 760
|
final panelWidth = constraints.maxWidth >= 760
|
||||||
? (constraints.maxWidth - 12) / 2
|
? (constraints.maxWidth - 20) / 2
|
||||||
: 300.0;
|
: (constraints.maxWidth - 20) / 2;
|
||||||
return SingleChildScrollView(
|
return Padding(
|
||||||
scrollDirection: Axis.horizontal,
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ImagePanel(
|
Expanded(
|
||||||
width: panelWidth,
|
child: _ImagePanel(
|
||||||
title: 'Enrolled Face',
|
width: panelWidth,
|
||||||
imageUrl: entry.enrolledFaceUrl,
|
title: 'Enrolled Face',
|
||||||
bucket: 'face-enrollment',
|
imageUrl: enrolledFaceUrl,
|
||||||
emptyMessage: 'No enrolled face photo found.',
|
bucket: 'face-enrollment',
|
||||||
icon: Icons.person,
|
emptyMessage: 'No enrolled face photo found.',
|
||||||
|
icon: Icons.person,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
_ImagePanel(
|
Expanded(
|
||||||
width: panelWidth,
|
child: _ImagePanel(
|
||||||
title: 'Attendance Verification Face',
|
width: panelWidth,
|
||||||
imageUrl: entry.verificationFaceUrl,
|
title: verificationLabel,
|
||||||
bucket: 'attendance-verification',
|
imageUrl: verificationUrl,
|
||||||
emptyMessage: 'No attendance verification photo found.',
|
bucket: 'attendance-verification',
|
||||||
icon: Icons.verified_user,
|
emptyMessage: emptyMessage,
|
||||||
|
icon: verificationIcon,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
|
||||||
String? attendanceLogId,
|
String? attendanceLogId,
|
||||||
int maxAttempts = 3,
|
int maxAttempts = 3,
|
||||||
bool uploadAttendanceResult = true,
|
bool uploadAttendanceResult = true,
|
||||||
|
bool isCheckOut = false,
|
||||||
}) {
|
}) {
|
||||||
return Navigator.of(context).push<FaceVerificationResult>(
|
return Navigator.of(context).push<FaceVerificationResult>(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
|
|
@ -46,6 +47,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
|
||||||
attendanceLogId: attendanceLogId,
|
attendanceLogId: attendanceLogId,
|
||||||
maxAttempts: maxAttempts,
|
maxAttempts: maxAttempts,
|
||||||
uploadAttendanceResult: uploadAttendanceResult,
|
uploadAttendanceResult: uploadAttendanceResult,
|
||||||
|
isCheckOut: isCheckOut,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (ctx, anim, secAnim, child) {
|
transitionsBuilder: (ctx, anim, secAnim, child) {
|
||||||
return FadeTransition(opacity: anim, child: child);
|
return FadeTransition(opacity: anim, child: child);
|
||||||
|
|
@ -61,11 +63,13 @@ class _FaceVerificationOverlay extends ConsumerStatefulWidget {
|
||||||
required this.attendanceLogId,
|
required this.attendanceLogId,
|
||||||
required this.maxAttempts,
|
required this.maxAttempts,
|
||||||
required this.uploadAttendanceResult,
|
required this.uploadAttendanceResult,
|
||||||
|
this.isCheckOut = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? attendanceLogId;
|
final String? attendanceLogId;
|
||||||
final int maxAttempts;
|
final int maxAttempts;
|
||||||
final bool uploadAttendanceResult;
|
final bool uploadAttendanceResult;
|
||||||
|
final bool isCheckOut;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_FaceVerificationOverlay> createState() =>
|
ConsumerState<_FaceVerificationOverlay> createState() =>
|
||||||
|
|
@ -250,6 +254,7 @@ class _FaceVerificationOverlayState
|
||||||
bytes: compressed,
|
bytes: compressed,
|
||||||
fileName: 'verification.jpg',
|
fileName: 'verification.jpg',
|
||||||
status: status,
|
status: status,
|
||||||
|
isCheckOut: widget.isCheckOut,
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
-- Split single verification_photo_url into separate check-in and check-out columns.
|
||||||
|
-- Also add check_out_justification for off-site checkout.
|
||||||
|
|
||||||
|
-- Add new columns
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'attendance_logs' AND column_name = 'check_in_verification_photo_url'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE attendance_logs ADD COLUMN check_in_verification_photo_url TEXT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'attendance_logs' AND column_name = 'check_out_verification_photo_url'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE attendance_logs ADD COLUMN check_out_verification_photo_url TEXT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'attendance_logs' AND column_name = 'check_out_justification'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE attendance_logs ADD COLUMN check_out_justification TEXT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Migrate existing data: copy verification_photo_url → check_in_verification_photo_url
|
||||||
|
-- (the old column stored the last verification photo, which was overwritten by checkout;
|
||||||
|
-- for rows that were only checked in, this is the check-in photo)
|
||||||
|
UPDATE attendance_logs
|
||||||
|
SET check_in_verification_photo_url = verification_photo_url
|
||||||
|
WHERE verification_photo_url IS NOT NULL
|
||||||
|
AND check_in_verification_photo_url IS NULL;
|
||||||
|
|
||||||
|
-- Update checkout RPC to accept optional justification
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||||
|
p_attendance_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision,
|
||||||
|
p_justification text DEFAULT NULL
|
||||||
|
) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log attendance_logs%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Attendance log not found';
|
||||||
|
END IF;
|
||||||
|
IF v_log.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your attendance log';
|
||||||
|
END IF;
|
||||||
|
IF v_log.check_out_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Already checked out';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE attendance_logs
|
||||||
|
SET check_out_at = now(),
|
||||||
|
check_out_lat = p_lat,
|
||||||
|
check_out_lng = p_lng,
|
||||||
|
check_out_justification = p_justification
|
||||||
|
WHERE id = p_attendance_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- ───────────────────────────────────────────────────────────
|
||||||
|
-- Fix storage SELECT policies so admin, dispatcher, and it_staff
|
||||||
|
-- can view any user's face-enrollment and attendance-verification photos.
|
||||||
|
-- Regular users can still only view their own.
|
||||||
|
-- ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- face-enrollment: owner OR privileged roles can view
|
||||||
|
DROP POLICY IF EXISTS "Users can view own face" ON storage.objects;
|
||||||
|
CREATE POLICY "Users can view own face"
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
USING (
|
||||||
|
bucket_id = 'face-enrollment'
|
||||||
|
AND (
|
||||||
|
(storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND role IN ('admin', 'dispatcher', 'it_staff')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- attendance-verification: owner OR privileged roles can view
|
||||||
|
DROP POLICY IF EXISTS "Users and admins can view verification photos" ON storage.objects;
|
||||||
|
CREATE POLICY "Users and admins can view verification photos"
|
||||||
|
ON storage.objects FOR SELECT
|
||||||
|
USING (
|
||||||
|
bucket_id = 'attendance-verification'
|
||||||
|
AND (
|
||||||
|
(storage.foldername(name))[1] = auth.uid()::text
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND role IN ('admin', 'dispatcher', 'it_staff')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user