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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user