diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart index 122bbeeb..f610a8fc 100644 --- a/lib/models/attendance_log.dart +++ b/lib/models/attendance_log.dart @@ -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 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?, ); } } diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 1a6766e2..2312d7ea 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -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); } diff --git a/lib/providers/verification_session_provider.dart b/lib/providers/verification_session_provider.dart index 45f8acef..80427b64 100644 --- a/lib/providers/verification_session_provider.dart +++ b/lib/providers/verification_session_provider.dart @@ -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!); } diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 5c1d5866..4617afbe 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -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 _showCheckOutJustificationDialog(BuildContext context) async { + final controller = TextEditingController(); + final result = await m3ShowDialog( + 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 _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 _performFaceVerification(String attendanceLogId) async { + Future _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, + ), ), ], ), diff --git a/lib/widgets/face_verification_overlay.dart b/lib/widgets/face_verification_overlay.dart index 793a56c1..b34d9253 100644 --- a/lib/widgets/face_verification_overlay.dart +++ b/lib/widgets/face_verification_overlay.dart @@ -38,6 +38,7 @@ Future showFaceVerificationOverlay({ String? attendanceLogId, int maxAttempts = 3, bool uploadAttendanceResult = true, + bool isCheckOut = false, }) { return Navigator.of(context).push( PageRouteBuilder( @@ -46,6 +47,7 @@ Future 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 (_) {} } diff --git a/supabase/migrations/20260308160000_split_verification_photo_urls.sql b/supabase/migrations/20260308160000_split_verification_photo_urls.sql new file mode 100644 index 00000000..51bd14e2 --- /dev/null +++ b/supabase/migrations/20260308160000_split_verification_photo_urls.sql @@ -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; +$$; diff --git a/supabase/migrations/20260308161000_fix_verification_photo_view_policies.sql b/supabase/migrations/20260308161000_fix_verification_photo_view_policies.sql new file mode 100644 index 00000000..aa166575 --- /dev/null +++ b/supabase/migrations/20260308161000_fix_verification_photo_view_policies.sql @@ -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') + ) + ) + );