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:
2026-03-08 18:45:31 +08:00
parent f8502f01b6
commit 0f675d4274
7 changed files with 503 additions and 35 deletions
+13 -3
View File
@@ -13,8 +13,10 @@ class AttendanceLog {
this.checkOutLat,
this.checkOutLng,
this.justification,
this.checkOutJustification,
this.verificationStatus = 'pending',
this.verificationPhotoUrl,
this.checkInVerificationPhotoUrl,
this.checkOutVerificationPhotoUrl,
});
final String id;
@@ -28,8 +30,10 @@ class AttendanceLog {
final double? checkOutLat;
final double? checkOutLng;
final String? justification;
final String? checkOutJustification;
final String verificationStatus; // pending, verified, unverified, skipped
final String? verificationPhotoUrl;
final String? checkInVerificationPhotoUrl;
final String? checkOutVerificationPhotoUrl;
bool get isCheckedOut => checkOutAt != null;
bool get isVerified => verificationStatus == 'verified';
@@ -37,6 +41,8 @@ class AttendanceLog {
verificationStatus == 'unverified' || verificationStatus == 'skipped';
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(
id: map['id'] as String,
userId: map['user_id'] as String,
@@ -51,8 +57,12 @@ class AttendanceLog {
checkOutLat: (map['check_out_lat'] as num?)?.toDouble(),
checkOutLng: (map['check_out_lng'] as num?)?.toDouble(),
justification: map['justification'] as String?,
checkOutJustification: map['check_out_justification'] as String?,
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?,
);
}
}
+14 -3
View File
@@ -91,10 +91,16 @@ class AttendanceController {
required String attendanceId,
required double lat,
required double lng,
String? justification,
}) async {
await _client.rpc(
'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 String fileName,
required String status, // 'verified', 'unverified'
bool isCheckOut = false,
}) async {
final userId = _client.auth.currentUser!.id;
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
.from('attendance-verification')
.uploadBinary(
@@ -132,9 +140,12 @@ class AttendanceController {
final url = _client.storage
.from('attendance-verification')
.getPublicUrl(path);
final column = isCheckOut
? 'check_out_verification_photo_url'
: 'check_in_verification_photo_url';
await _client
.from('attendance_logs')
.update({'verification_status': status, 'verification_photo_url': url})
.update({'verification_status': status, column: url})
.eq('id', attendanceId);
}
@@ -102,12 +102,13 @@ class VerificationSessionController {
})
.eq('id', session.userId);
} 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
.from('attendance_logs')
.update({
'verification_status': 'verified',
'verification_photo_url': session.imageUrl,
'check_in_verification_photo_url': session.imageUrl,
})
.eq('id', session.contextId!);
}
+358 -27
View File
@@ -912,12 +912,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
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
.read(attendanceControllerProvider)
.checkOut(
attendanceId: log.id,
lat: position.latitude,
lng: position.longitude,
justification: checkOutJustification,
);
// Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow();
@@ -930,7 +964,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(log.id);
_performFaceVerification(log.id, isCheckOut: true);
}
} catch (e) {
if (mounted) {
@@ -960,12 +994,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
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
.read(attendanceControllerProvider)
.checkOut(
attendanceId: logId,
lat: position.latitude,
lng: position.longitude,
justification: checkOutJustification,
);
// Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow();
@@ -978,7 +1046,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(logId);
_performFaceVerification(logId, isCheckOut: true);
}
} catch (e) {
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 {
final justification = _justificationController.text.trim();
if (justification.isEmpty) {
@@ -1060,7 +1183,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
/// Face verification after check-in/out: liveness detection on mobile,
/// 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;
if (profile == null || !profile.hasFaceEnrolled) {
try {
@@ -1081,6 +1207,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
context: context,
ref: ref,
attendanceLogId: attendanceLogId,
isCheckOut: isCheckOut,
);
if (!mounted) return;
@@ -1364,7 +1491,10 @@ class _LogbookEntry {
this.logId,
this.logUserId,
this.enrolledFaceUrl,
this.verificationFaceUrl,
this.checkInVerificationFaceUrl,
this.checkOutVerificationFaceUrl,
this.justification,
this.checkOutJustification,
this.checkInLat,
this.checkInLng,
this.checkOutLat,
@@ -1385,7 +1515,10 @@ class _LogbookEntry {
final String? logId;
final String? logUserId;
final String? enrolledFaceUrl;
final String? verificationFaceUrl;
final String? checkInVerificationFaceUrl;
final String? checkOutVerificationFaceUrl;
final String? justification;
final String? checkOutJustification;
final double? checkInLat;
final double? checkInLng;
final double? checkOutLat;
@@ -1426,7 +1559,10 @@ class _LogbookEntry {
logId: log.id,
logUserId: log.userId,
enrolledFaceUrl: p?.facePhotoUrl,
verificationFaceUrl: log.verificationPhotoUrl,
checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl,
checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl,
justification: log.justification,
checkOutJustification: log.checkOutJustification,
checkInLat: log.checkInLat,
checkInLng: log.checkInLng,
checkOutLat: log.checkOutLat,
@@ -2059,6 +2195,11 @@ class _VerificationDetailsContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final hasJustification =
(entry.justification ?? '').trim().isNotEmpty ||
(entry.checkOutJustification ?? '').trim().isNotEmpty;
return DefaultTabController(
length: 2,
@@ -2073,9 +2214,14 @@ class _VerificationDetailsContent extends StatelessWidget {
Text(
'Shift: ${entry.shift}',
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 TabBar(
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});
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
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final panelWidth = constraints.maxWidth >= 760
? (constraints.maxWidth - 12) / 2
: 300.0;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
? (constraints.maxWidth - 20) / 2
: (constraints.maxWidth - 20) / 2;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
_ImagePanel(
width: panelWidth,
title: 'Enrolled Face',
imageUrl: entry.enrolledFaceUrl,
bucket: 'face-enrollment',
emptyMessage: 'No enrolled face photo found.',
icon: Icons.person,
Expanded(
child: _ImagePanel(
width: panelWidth,
title: 'Enrolled Face',
imageUrl: enrolledFaceUrl,
bucket: 'face-enrollment',
emptyMessage: 'No enrolled face photo found.',
icon: Icons.person,
),
),
const SizedBox(width: 12),
_ImagePanel(
width: panelWidth,
title: 'Attendance Verification Face',
imageUrl: entry.verificationFaceUrl,
bucket: 'attendance-verification',
emptyMessage: 'No attendance verification photo found.',
icon: Icons.verified_user,
const SizedBox(width: 8),
Expanded(
child: _ImagePanel(
width: panelWidth,
title: verificationLabel,
imageUrl: verificationUrl,
bucket: 'attendance-verification',
emptyMessage: emptyMessage,
icon: verificationIcon,
),
),
],
),
@@ -38,6 +38,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
String? attendanceLogId,
int maxAttempts = 3,
bool uploadAttendanceResult = true,
bool isCheckOut = false,
}) {
return Navigator.of(context).push<FaceVerificationResult>(
PageRouteBuilder(
@@ -46,6 +47,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
attendanceLogId: attendanceLogId,
maxAttempts: maxAttempts,
uploadAttendanceResult: uploadAttendanceResult,
isCheckOut: isCheckOut,
),
transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child);
@@ -61,11 +63,13 @@ class _FaceVerificationOverlay extends ConsumerStatefulWidget {
required this.attendanceLogId,
required this.maxAttempts,
required this.uploadAttendanceResult,
this.isCheckOut = false,
});
final String? attendanceLogId;
final int maxAttempts;
final bool uploadAttendanceResult;
final bool isCheckOut;
@override
ConsumerState<_FaceVerificationOverlay> createState() =>
@@ -250,6 +254,7 @@ class _FaceVerificationOverlayState
bytes: compressed,
fileName: 'verification.jpg',
status: status,
isCheckOut: widget.isCheckOut,
);
} catch (_) {}
}