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.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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
Expanded(
|
||||
child: _ImagePanel(
|
||||
width: panelWidth,
|
||||
title: 'Enrolled Face',
|
||||
imageUrl: entry.enrolledFaceUrl,
|
||||
imageUrl: enrolledFaceUrl,
|
||||
bucket: 'face-enrollment',
|
||||
emptyMessage: 'No enrolled face photo found.',
|
||||
icon: Icons.person,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_ImagePanel(
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ImagePanel(
|
||||
width: panelWidth,
|
||||
title: 'Attendance Verification Face',
|
||||
imageUrl: entry.verificationFaceUrl,
|
||||
title: verificationLabel,
|
||||
imageUrl: verificationUrl,
|
||||
bucket: 'attendance-verification',
|
||||
emptyMessage: 'No attendance verification photo found.',
|
||||
icon: Icons.verified_user,
|
||||
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 (_) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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