diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0013493e..2d6797b9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -39,6 +39,15 @@ android:scheme="io.supabase.tasq" android:host="login-callback" /> + + + + + + diff --git a/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt b/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt index cc94f81f..c3f544e6 100644 --- a/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/tasq/MainActivity.kt @@ -1,5 +1,5 @@ package com.example.tasq -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity : FlutterActivity() +class MainActivity : FlutterFragmentActivity() diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart index b6d937c4..3060be34 100644 --- a/lib/models/attendance_log.dart +++ b/lib/models/attendance_log.dart @@ -11,6 +11,9 @@ class AttendanceLog { this.checkOutAt, this.checkOutLat, this.checkOutLng, + this.justification, + this.verificationStatus = 'pending', + this.verificationPhotoUrl, }); final String id; @@ -22,8 +25,14 @@ class AttendanceLog { final DateTime? checkOutAt; final double? checkOutLat; final double? checkOutLng; + final String? justification; + final String verificationStatus; // pending, verified, unverified, skipped + final String? verificationPhotoUrl; bool get isCheckedOut => checkOutAt != null; + bool get isVerified => verificationStatus == 'verified'; + bool get isUnverified => + verificationStatus == 'unverified' || verificationStatus == 'skipped'; factory AttendanceLog.fromMap(Map map) { return AttendanceLog( @@ -38,6 +47,9 @@ class AttendanceLog { : AppTime.parse(map['check_out_at'] as String), checkOutLat: (map['check_out_lat'] as num?)?.toDouble(), checkOutLng: (map['check_out_lng'] as num?)?.toDouble(), + justification: map['justification'] as String?, + verificationStatus: map['verification_status'] as String? ?? 'pending', + verificationPhotoUrl: map['verification_photo_url'] as String?, ); } } diff --git a/lib/models/leave_of_absence.dart b/lib/models/leave_of_absence.dart new file mode 100644 index 00000000..6de59f9e --- /dev/null +++ b/lib/models/leave_of_absence.dart @@ -0,0 +1,54 @@ +import '../utils/app_time.dart'; + +class LeaveOfAbsence { + LeaveOfAbsence({ + required this.id, + required this.userId, + required this.leaveType, + required this.justification, + required this.startTime, + required this.endTime, + required this.status, + required this.filedBy, + required this.createdAt, + }); + + final String id; + final String userId; + final String leaveType; + final String justification; + final DateTime startTime; + final DateTime endTime; + final String status; + final String filedBy; + final DateTime createdAt; + + factory LeaveOfAbsence.fromMap(Map map) { + return LeaveOfAbsence( + id: map['id'] as String, + userId: map['user_id'] as String, + leaveType: map['leave_type'] as String, + justification: map['justification'] as String, + startTime: AppTime.parse(map['start_time'] as String), + endTime: AppTime.parse(map['end_time'] as String), + status: map['status'] as String? ?? 'pending', + filedBy: map['filed_by'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } + + String get leaveTypeLabel { + switch (leaveType) { + case 'emergency_leave': + return 'Emergency Leave'; + case 'parental_leave': + return 'Parental Leave'; + case 'sick_leave': + return 'Sick Leave'; + case 'vacation_leave': + return 'Vacation Leave'; + default: + return leaveType; + } + } +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart index b9ca0d93..a7a7aae5 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -5,6 +5,9 @@ class Profile { required this.fullName, this.religion = 'catholic', this.allowTracking = false, + this.avatarUrl, + this.facePhotoUrl, + this.faceEnrolledAt, }); final String id; @@ -12,6 +15,11 @@ class Profile { final String fullName; final String religion; final bool allowTracking; + final String? avatarUrl; + final String? facePhotoUrl; + final DateTime? faceEnrolledAt; + + bool get hasFaceEnrolled => facePhotoUrl != null && faceEnrolledAt != null; factory Profile.fromMap(Map map) { return Profile( @@ -20,6 +28,11 @@ class Profile { fullName: map['full_name'] as String? ?? '', religion: map['religion'] as String? ?? 'catholic', allowTracking: map['allow_tracking'] as bool? ?? false, + avatarUrl: map['avatar_url'] as String?, + facePhotoUrl: map['face_photo_url'] as String?, + faceEnrolledAt: map['face_enrolled_at'] == null + ? null + : DateTime.tryParse(map['face_enrolled_at'] as String), ); } } diff --git a/lib/models/verification_session.dart b/lib/models/verification_session.dart new file mode 100644 index 00000000..32599d02 --- /dev/null +++ b/lib/models/verification_session.dart @@ -0,0 +1,54 @@ +import '../utils/app_time.dart'; + +/// A cross-device face verification session. +/// +/// Created on web when no camera is detected. The web client generates a QR +/// code containing the session ID. The mobile client scans the QR, performs +/// liveness detection, uploads the photo, and marks the session completed. +class VerificationSession { + VerificationSession({ + required this.id, + required this.userId, + required this.type, + this.contextId, + this.status = 'pending', + this.imageUrl, + required this.createdAt, + required this.expiresAt, + }); + + final String id; + final String userId; + final String type; // 'enrollment' or 'verification' + final String? contextId; // e.g. attendance_log_id + final String status; // 'pending', 'completed', 'expired' + final String? imageUrl; + final DateTime createdAt; + final DateTime expiresAt; + + bool get isPending => status == 'pending'; + bool get isCompleted => status == 'completed'; + bool get isExpired => + status == 'expired' || DateTime.now().toUtc().isAfter(expiresAt); + + factory VerificationSession.fromMap(Map map) { + return VerificationSession( + id: map['id'] as String, + userId: map['user_id'] as String, + type: map['type'] as String, + contextId: map['context_id'] as String?, + status: (map['status'] as String?) ?? 'pending', + imageUrl: map['image_url'] as String?, + createdAt: AppTime.parse(map['created_at'] as String), + expiresAt: AppTime.parse(map['expires_at'] as String), + ); + } + + Map toJson() { + return { + 'user_id': userId, + 'type': type, + if (contextId != null) 'context_id': contextId, + }; + } +} diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 4f87863e..1a6766e2 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -14,9 +16,9 @@ final attendanceDateRangeProvider = StateProvider((ref) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); return ReportDateRange( - start: today.subtract(const Duration(days: 7)), + start: today, end: today.add(const Duration(days: 1)), - label: 'Last 7 Days', + label: 'Today', ); }); @@ -95,4 +97,52 @@ class AttendanceController { params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng}, ); } + + /// Overtime check-in (no pre-existing schedule required). + /// Creates an overtime duty schedule + attendance log in one RPC call. + Future overtimeCheckIn({ + required double lat, + required double lng, + String? justification, + }) async { + final data = await _client.rpc( + 'overtime_check_in', + params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification}, + ); + return data as String?; + } + + /// Upload a verification selfie and update the attendance log. + Future uploadVerification({ + required String attendanceId, + required Uint8List bytes, + required String fileName, + required String status, // 'verified', 'unverified' + }) async { + final userId = _client.auth.currentUser!.id; + final ext = fileName.split('.').last.toLowerCase(); + final path = '$userId/$attendanceId.$ext'; + await _client.storage + .from('attendance-verification') + .uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = _client.storage + .from('attendance-verification') + .getPublicUrl(path); + await _client + .from('attendance_logs') + .update({'verification_status': status, 'verification_photo_url': url}) + .eq('id', attendanceId); + } + + /// Mark an attendance log as skipped verification. + Future skipVerification(String attendanceId) async { + await _client + .from('attendance_logs') + .update({'verification_status': 'skipped'}) + .eq('id', attendanceId); + } } diff --git a/lib/providers/debug_settings_provider.dart b/lib/providers/debug_settings_provider.dart new file mode 100644 index 00000000..d8ef938e --- /dev/null +++ b/lib/providers/debug_settings_provider.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Debug-only settings for development and testing. +/// Only functional in debug builds (kDebugMode). +class DebugSettings { + final bool bypassGeofence; + + const DebugSettings({this.bypassGeofence = false}); + + DebugSettings copyWith({bool? bypassGeofence}) { + return DebugSettings(bypassGeofence: bypassGeofence ?? this.bypassGeofence); + } +} + +class DebugSettingsNotifier extends StateNotifier { + DebugSettingsNotifier() : super(const DebugSettings()); + + void toggleGeofenceBypass() { + if (kDebugMode) { + state = state.copyWith(bypassGeofence: !state.bypassGeofence); + } + } + + void setGeofenceBypass(bool value) { + if (kDebugMode) { + state = state.copyWith(bypassGeofence: value); + } + } +} + +final debugSettingsProvider = + StateNotifierProvider( + (ref) => DebugSettingsNotifier(), + ); diff --git a/lib/providers/leave_provider.dart b/lib/providers/leave_provider.dart new file mode 100644 index 00000000..b1c7c1e1 --- /dev/null +++ b/lib/providers/leave_provider.dart @@ -0,0 +1,105 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/leave_of_absence.dart'; +import 'profile_provider.dart'; +import 'supabase_provider.dart'; +import 'stream_recovery.dart'; +import 'realtime_controller.dart'; + +/// All visible leaves (own for standard, all for admin/dispatcher/it_staff). +final leavesProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profile = profileAsync.valueOrNull; + if (profile == null) return Stream.value(const []); + + final hasFullAccess = + profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff'; + + final wrapper = StreamRecoveryWrapper( + stream: hasFullAccess + ? client + .from('leave_of_absence') + .stream(primaryKey: ['id']) + .order('start_time', ascending: false) + : client + .from('leave_of_absence') + .stream(primaryKey: ['id']) + .eq('user_id', profile.id) + .order('start_time', ascending: false), + onPollData: () async { + final query = client.from('leave_of_absence').select(); + final data = hasFullAccess + ? await query.order('start_time', ascending: false) + : await query + .eq('user_id', profile.id) + .order('start_time', ascending: false); + return data.map(LeaveOfAbsence.fromMap).toList(); + }, + fromMap: LeaveOfAbsence.fromMap, + channelName: 'leave_of_absence', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); +}); + +final leaveControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return LeaveController(client); +}); + +class LeaveController { + LeaveController(this._client); + + final SupabaseClient _client; + + /// File a leave of absence for the current user. + /// Caller controls auto-approval based on role policy. + Future fileLeave({ + required String leaveType, + required String justification, + required DateTime startTime, + required DateTime endTime, + required bool autoApprove, + }) async { + final uid = _client.auth.currentUser!.id; + await _client.from('leave_of_absence').insert({ + 'user_id': uid, + 'leave_type': leaveType, + 'justification': justification, + 'start_time': startTime.toIso8601String(), + 'end_time': endTime.toIso8601String(), + 'status': autoApprove ? 'approved' : 'pending', + 'filed_by': uid, + }); + } + + /// Approve a leave request. + Future approveLeave(String leaveId) async { + await _client + .from('leave_of_absence') + .update({'status': 'approved'}) + .eq('id', leaveId); + } + + /// Reject a leave request. + Future rejectLeave(String leaveId) async { + await _client + .from('leave_of_absence') + .update({'status': 'rejected'}) + .eq('id', leaveId); + } + + /// Cancel an approved leave. + Future cancelLeave(String leaveId) async { + await _client + .from('leave_of_absence') + .update({'status': 'cancelled'}) + .eq('id', leaveId); + } +} diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index 63ff2909..7c6a5c5d 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -92,6 +93,64 @@ class ProfileController { } await _client.auth.updateUser(UserAttributes(password: password)); } + + /// Upload a profile avatar image and update the profile record. + Future uploadAvatar({ + required String userId, + required Uint8List bytes, + required String fileName, + }) async { + final ext = fileName.split('.').last.toLowerCase(); + final path = '$userId/avatar.$ext'; + await _client.storage + .from('avatars') + .uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = _client.storage.from('avatars').getPublicUrl(path); + await _client.from('profiles').update({'avatar_url': url}).eq('id', userId); + return url; + } + + /// Upload a face enrollment photo and update the profile record. + Future uploadFacePhoto({ + required String userId, + required Uint8List bytes, + required String fileName, + }) async { + final ext = fileName.split('.').last.toLowerCase(); + final path = '$userId/face.$ext'; + await _client.storage + .from('face-enrollment') + .uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = _client.storage.from('face-enrollment').getPublicUrl(path); + await _client + .from('profiles') + .update({ + 'face_photo_url': url, + 'face_enrolled_at': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', userId); + return url; + } + + /// Download the face enrollment photo bytes for the given user. + /// Uses Supabase authenticated storage API (works with private buckets). + Future downloadFacePhoto(String userId) async { + try { + return await _client.storage + .from('face-enrollment') + .download('$userId/face.jpg'); + } catch (_) { + return null; + } + } } final isAdminProvider = Provider((ref) { diff --git a/lib/providers/verification_session_provider.dart b/lib/providers/verification_session_provider.dart new file mode 100644 index 00000000..45f8acef --- /dev/null +++ b/lib/providers/verification_session_provider.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/verification_session.dart'; +import 'supabase_provider.dart'; + +/// Provider for the verification session controller. +final verificationSessionControllerProvider = + Provider((ref) { + final client = ref.watch(supabaseClientProvider); + return VerificationSessionController(client); + }); + +/// Controller for creating, completing, and listening to verification sessions. +class VerificationSessionController { + VerificationSessionController(this._client); + + final SupabaseClient _client; + + /// Create a new verification session and return it. + Future createSession({ + required String type, + String? contextId, + }) async { + final userId = _client.auth.currentUser!.id; + final data = await _client + .from('verification_sessions') + .insert({'user_id': userId, 'type': type, 'context_id': contextId}) + .select() + .single(); + return VerificationSession.fromMap(data); + } + + /// Fetch a session by ID. + Future getSession(String sessionId) async { + final data = await _client + .from('verification_sessions') + .select() + .eq('id', sessionId) + .maybeSingle(); + return data == null ? null : VerificationSession.fromMap(data); + } + + /// Listen for realtime changes on a specific session (used by web to detect + /// when mobile completes the verification). + Stream watchSession(String sessionId) { + return _client + .from('verification_sessions') + .stream(primaryKey: ['id']) + .eq('id', sessionId) + .map( + (rows) => rows.isEmpty + ? throw Exception('Session not found') + : VerificationSession.fromMap(rows.first), + ); + } + + /// Complete a session: upload the face photo and mark the session as + /// completed. Called from the mobile verification screen. + Future completeSession({ + required String sessionId, + required Uint8List bytes, + required String fileName, + }) async { + final userId = _client.auth.currentUser!.id; + final ext = fileName.split('.').last.toLowerCase(); + final path = '$userId/$sessionId.$ext'; + + // Upload to face-enrollment bucket (same bucket used for face photos) + await _client.storage + .from('face-enrollment') + .uploadBinary( + path, + bytes, + fileOptions: const FileOptions(upsert: true), + ); + final url = _client.storage.from('face-enrollment').getPublicUrl(path); + + // Mark session completed with the image URL + await _client + .from('verification_sessions') + .update({'status': 'completed', 'image_url': url}) + .eq('id', sessionId); + } + + /// After a session completes, apply the result based on the session type. + /// For 'enrollment': update the user's face photo. + /// For 'verification': update the attendance log. + Future applySessionResult(VerificationSession session) async { + if (session.imageUrl == null) return; + + if (session.type == 'enrollment') { + // Update profile face photo + await _client + .from('profiles') + .update({ + 'face_photo_url': session.imageUrl, + 'face_enrolled_at': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', session.userId); + } else if (session.type == 'verification' && session.contextId != null) { + // Update attendance log verification status + await _client + .from('attendance_logs') + .update({ + 'verification_status': 'verified', + 'verification_photo_url': session.imageUrl, + }) + .eq('id', session.contextId!); + } + } + + /// Expire a session (called when dialog is closed prematurely). + Future expireSession(String sessionId) async { + await _client + .from('verification_sessions') + .update({'status': 'expired'}) + .eq('id', sessionId); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 169422cf..4d947451 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -14,6 +14,7 @@ import '../screens/admin/geofence_test_screen.dart'; import '../screens/dashboard/dashboard_screen.dart'; import '../screens/notifications/notifications_screen.dart'; import '../screens/profile/profile_screen.dart'; +import '../screens/shared/mobile_verification_screen.dart'; import '../screens/shared/under_development_screen.dart'; import '../screens/shared/permissions_screen.dart'; import '../screens/reports/reports_screen.dart'; @@ -85,6 +86,12 @@ final appRouterProvider = Provider((ref) { path: '/signup', builder: (context, state) => const SignUpScreen(), ), + GoRoute( + path: '/verify/:sessionId', + builder: (context, state) => MobileVerificationScreen( + sessionId: state.pathParameters['sessionId'] ?? '', + ), + ), ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 09278490..7a9ec421 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -1,13 +1,18 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../../models/attendance_log.dart'; import '../../models/duty_schedule.dart'; +import '../../models/leave_of_absence.dart'; import '../../models/pass_slip.dart'; import '../../models/profile.dart'; import '../../providers/attendance_provider.dart'; -import '../../providers/notifications_provider.dart'; +import '../../providers/debug_settings_provider.dart'; +import '../../providers/leave_provider.dart'; import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/reports_provider.dart'; @@ -15,7 +20,10 @@ import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; +import '../../widgets/face_verification_overlay.dart'; import '../../utils/snackbar.dart'; +import '../../widgets/gemini_animated_text_field.dart'; +import '../../widgets/gemini_button.dart'; import '../../widgets/responsive_body.dart'; class AttendanceScreen extends ConsumerStatefulWidget { @@ -26,17 +34,25 @@ class AttendanceScreen extends ConsumerStatefulWidget { } class _AttendanceScreenState extends ConsumerState - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late TabController _tabController; + bool _fabMenuOpen = false; @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 4, vsync: this); + _tabController.addListener(_onTabChanged); + } + + void _onTabChanged() { + if (_fabMenuOpen) setState(() => _fabMenuOpen = false); + setState(() {}); // rebuild for FAB visibility } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } @@ -44,40 +60,185 @@ class _AttendanceScreenState extends ConsumerState @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = ref.watch(currentProfileProvider).valueOrNull; + final showFab = _tabController.index >= 2; // Pass Slip or Leave tabs return ResponsiveBody( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Row( - children: [ - Expanded( - child: Text( - 'Attendance', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + maxWidth: 1200, + child: Scaffold( + backgroundColor: Colors.transparent, + floatingActionButton: showFab && profile != null + ? _buildFabMenu(context, theme, colors, profile) + : null, + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Attendance', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), - ), + ], + ), + ), + TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: const [ + Tab(text: 'Check In'), + Tab(text: 'Logbook'), + Tab(text: 'Pass Slip'), + Tab(text: 'Leave'), ], ), - ), - TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Check In'), - Tab(text: 'Logbook'), - Tab(text: 'Pass Slip'), - ], - ), - Expanded( - child: TabBarView( - controller: _tabController, - children: const [_CheckInTab(), _LogbookTab(), _PassSlipTab()], + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + _CheckInTab(), + _LogbookTab(), + _PassSlipTab(), + _LeaveTab(), + ], + ), ), + ], + ), + ), + ); + } + + Widget _buildFabMenu( + BuildContext context, + ThemeData theme, + ColorScheme colors, + Profile profile, + ) { + final isAdmin = profile.role == 'admin'; + final canFileLeave = + profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff'; + + if (!_fabMenuOpen) { + return M3ExpandedFab( + heroTag: 'attendance_fab', + onPressed: () => setState(() => _fabMenuOpen = true), + icon: const Icon(Icons.add), + label: const Text('Actions'), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Leave option + if (canFileLeave) ...[ + _FabMenuItem( + heroTag: 'fab_leave', + label: 'File Leave', + icon: Icons.event_busy, + color: colors.tertiaryContainer, + onColor: colors.onTertiaryContainer, + onTap: () { + setState(() => _fabMenuOpen = false); + _showLeaveDialog(context, isAdmin); + }, ), + const SizedBox(height: 12), ], + // Pass Slip option + _FabMenuItem( + heroTag: 'fab_slip', + label: 'Request Slip', + icon: Icons.receipt_long, + color: colors.secondaryContainer, + onColor: colors.onSecondaryContainer, + onTap: () { + setState(() => _fabMenuOpen = false); + _showPassSlipDialog(context, profile); + }, + ), + const SizedBox(height: 12), + // Close button + FloatingActionButton( + heroTag: 'fab_close', + onPressed: () => setState(() => _fabMenuOpen = false), + child: const Icon(Icons.close), + ), + ], + ); + } + + void _showLeaveDialog(BuildContext context, bool isAdmin) { + m3ShowDialog( + context: context, + builder: (ctx) => _FileLeaveDialog( + isAdmin: isAdmin, + onSubmitted: () { + if (mounted) { + showSuccessSnackBar( + context, + isAdmin + ? 'Leave filed and auto-approved.' + : 'Leave application submitted for approval.', + ); + } + }, + ), + ); + } + + void _showPassSlipDialog(BuildContext context, Profile profile) { + final isAdmin = profile.role == 'admin'; + if (isAdmin) { + showWarningSnackBar(context, 'Admins cannot file pass slips.'); + return; + } + + final activeSlip = ref.read(activePassSlipProvider); + if (activeSlip != null) { + showWarningSnackBar(context, 'You already have an active pass slip.'); + return; + } + + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + final todaySchedule = schedules.where((s) { + final sDay = DateTime( + s.startTime.year, + s.startTime.month, + s.startTime.day, + ); + return s.userId == profile.id && + sDay == today && + s.shiftType != 'overtime'; + }).toList(); + + if (todaySchedule.isEmpty) { + showWarningSnackBar(context, 'No schedule found for today.'); + return; + } + + m3ShowDialog( + context: context, + builder: (ctx) => _PassSlipDialog( + scheduleId: todaySchedule.first.id, + onSubmitted: () { + if (mounted) { + showSuccessSnackBar(context, 'Pass slip requested.'); + } + }, ), ); } @@ -96,6 +257,88 @@ class _CheckInTab extends ConsumerStatefulWidget { class _CheckInTabState extends ConsumerState<_CheckInTab> { bool _loading = false; + final _justCheckedIn = {}; + final _checkInLogIds = {}; + String? _overtimeLogId; + + final _justificationController = TextEditingController(); + bool _isGeminiProcessing = false; + + // Animated clock + Timer? _clockTimer; + DateTime _currentTime = AppTime.now(); + + // Geofence state + bool _insideGeofence = false; + bool _checkingGeofence = true; + + @override + void initState() { + super.initState(); + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _currentTime = AppTime.now()); + }); + _checkGeofenceStatus(); + } + + @override + void dispose() { + _clockTimer?.cancel(); + _justificationController.dispose(); + super.dispose(); + } + + Future _checkGeofenceStatus() async { + final debugBypass = + kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; + if (debugBypass) { + if (mounted) { + setState(() { + _insideGeofence = true; + _checkingGeofence = false; + }); + } + return; + } + try { + final geoCfg = await ref.read(geofenceProvider.future); + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + bool inside = true; + if (geoCfg != null) { + 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 (mounted) { + setState(() { + _insideGeofence = inside; + _checkingGeofence = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _insideGeofence = false; + _checkingGeofence = false; + }); + } + } + } @override Widget build(BuildContext context) { @@ -110,10 +353,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { return const Center(child: CircularProgressIndicator()); } - final now = AppTime.now(); + final now = _currentTime; final today = DateTime(now.year, now.month, now.day); + final noon = DateTime(now.year, now.month, now.day, 12, 0); + final onePM = DateTime(now.year, now.month, now.day, 13, 0); - // Find today's schedule for the current user + // Find today's schedule for the current user. + // Exclude overtime schedules – they only belong in the Logbook. final schedules = schedulesAsync.valueOrNull ?? []; final todaySchedule = schedules.where((s) { final sDay = DateTime( @@ -121,7 +367,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { s.startTime.month, s.startTime.day, ); - return s.userId == profile.id && sDay == today; + return s.userId == profile.id && + sDay == today && + s.shiftType != 'overtime'; }).toList(); // Find active attendance log (checked in but not out) @@ -129,6 +377,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { final activeLog = logs .where((l) => l.userId == profile.id && !l.isCheckedOut) .toList(); + final activeOvertimeLog = activeLog + .where((l) => (l.justification ?? '').trim().isNotEmpty) + .toList(); return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -162,8 +413,152 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ), ), ), + const SizedBox(height: 8), + + // Debug: Geofence bypass toggle (only in debug mode) + if (kDebugMode) + Card( + color: colors.errorContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + Icon( + Icons.bug_report, + size: 20, + color: colors.onErrorContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'DEBUG: Bypass Geofence', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + Switch( + value: ref.watch(debugSettingsProvider).bypassGeofence, + onChanged: (v) => ref + .read(debugSettingsProvider.notifier) + .setGeofenceBypass(v), + ), + ], + ), + ), + ), const SizedBox(height: 16), + // ── Animated Clock ── + () { + // Calculate lateness color from schedule + Color timeColor = colors.onSurface; + if (todaySchedule.isNotEmpty) { + final scheduleStart = todaySchedule.first.startTime; + final diff = scheduleStart.difference(now); + if (diff.isNegative) { + timeColor = colors.error; + } else if (diff.inMinutes <= 5) { + timeColor = colors.error; + } else if (diff.inMinutes <= 15) { + timeColor = Colors.orange; + } else if (diff.inMinutes <= 30) { + timeColor = colors.tertiary; + } + } + return Center( + child: Column( + children: [ + AnimatedDefaultTextStyle( + duration: M3Motion.standard, + curve: M3Motion.standard_, + style: theme.textTheme.displayMedium!.copyWith( + fontWeight: FontWeight.w300, + color: timeColor, + letterSpacing: 2, + ), + child: Text(AppTime.formatTime(now)), + ), + const SizedBox(height: 4), + Text( + AppTime.formatDate(now), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + AnimatedSwitcher( + duration: M3Motion.short, + child: _checkingGeofence + ? Row( + mainAxisSize: MainAxisSize.min, + key: const ValueKey('checking'), + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.outline, + ), + ), + const SizedBox(width: 8), + Text( + 'Checking location...', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.outline, + ), + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.min, + key: ValueKey(_insideGeofence), + children: [ + Icon( + _insideGeofence + ? Icons.check_circle + : Icons.cancel, + size: 16, + color: _insideGeofence + ? Colors.green + : colors.error, + ), + const SizedBox(width: 6), + Text( + _insideGeofence + ? 'Within geofence' + : 'Outside geofence', + style: theme.textTheme.bodySmall?.copyWith( + color: _insideGeofence + ? Colors.green + : colors.error, + fontWeight: FontWeight.w500, + ), + ), + if (!_insideGeofence) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + setState(() => _checkingGeofence = true); + _checkGeofenceStatus(); + }, + child: const Text('Refresh'), + ), + ], + ], + ), + ), + ], + ), + ); + }(), + const SizedBox(height: 24), + // Today's schedule Text( "Today's Schedule", @@ -173,32 +568,57 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ), const SizedBox(height: 8), - if (todaySchedule.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Text( - 'No schedule assigned for today.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ), - ), - ) + if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null) + _buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog) + else if (todaySchedule.isEmpty && + activeLog.isEmpty && + _overtimeLogId == null) + _buildOvertimeCard(context, theme, colors) + else if (todaySchedule.isEmpty && + (activeLog.isNotEmpty || _overtimeLogId != null)) + _buildActiveOvertimeCard(context, theme, colors, activeLog) else ...todaySchedule.map((schedule) { - final hasCheckedIn = schedule.checkInAt != null; - final isActive = activeLog.any( - (l) => l.dutyScheduleId == schedule.id, - ); - final completedLog = logs - .where( - (l) => l.dutyScheduleId == schedule.id && l.isCheckedOut, - ) + // All logs for this schedule. + final scheduleLogs = logs + .where((l) => l.dutyScheduleId == schedule.id) .toList(); - final isCompleted = completedLog.isNotEmpty; + final realActiveLog = scheduleLogs + .where((l) => !l.isCheckedOut) + .toList(); + final completedLogs = scheduleLogs + .where((l) => l.isCheckedOut) + .toList(); + + final hasActiveLog = realActiveLog.isNotEmpty; + final isLocallyCheckedIn = _justCheckedIn.contains(schedule.id); + final showCheckOut = hasActiveLog || isLocallyCheckedIn; + + final isShiftOver = !now.isBefore(schedule.endTime); + final isFullDay = + schedule.endTime.difference(schedule.startTime).inHours >= 6; + final isNoonBreakWindow = + isFullDay && !now.isBefore(noon) && now.isBefore(onePM); + + // Determine status label. + String statusLabel; + if (showCheckOut) { + statusLabel = 'On Duty'; + } else if (isShiftOver) { + statusLabel = scheduleLogs.isEmpty ? 'Absent' : 'Completed'; + } else if (completedLogs.isNotEmpty && isNoonBreakWindow) { + statusLabel = 'Noon Break'; + } else if (completedLogs.isNotEmpty) { + statusLabel = 'Early Out'; + } else { + statusLabel = 'Scheduled'; + } + + final canCheckIn = + !showCheckOut && + !isShiftOver && + _insideGeofence && + !_checkingGeofence; return Card( child: Padding( @@ -218,16 +638,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { ), ), ), - _statusChip( - context, - isCompleted - ? 'Completed' - : isActive - ? 'On Duty' - : hasCheckedIn - ? 'Checked In' - : 'Scheduled', - ), + _statusChip(context, statusLabel), ], ), const SizedBox(height: 8), @@ -235,63 +646,149 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { '${AppTime.formatTime(schedule.startTime)} – ${AppTime.formatTime(schedule.endTime)}', style: theme.textTheme.bodyMedium, ), - const SizedBox(height: 12), - if (!hasCheckedIn && !isCompleted) - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _loading - ? null - : () => _handleCheckIn(schedule), - icon: _loading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.login), - label: const Text('Check In'), + + // Session history — show each completed check-in/out pair. + if (completedLogs.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'Sessions', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, ), - ) - else if (isActive) - SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - onPressed: _loading - ? null - : () => _handleCheckOut( - activeLog.firstWhere( - (l) => l.dutyScheduleId == schedule.id, + ), + const SizedBox(height: 4), + ...completedLogs.map((log) { + final dur = log.checkOutAt!.difference(log.checkInAt); + final hours = dur.inHours; + final mins = dur.inMinutes.remainder(60); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 14, + color: colors.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '${AppTime.formatTime(log.checkInAt)} – ${AppTime.formatTime(log.checkOutAt!)} (${hours}h ${mins}m)', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, ), ), - icon: _loading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.logout), - label: const Text('Check Out'), + ), + ], + ), + ); + }), + ], + + const SizedBox(height: 20), + + // Action button — check-in or check-out (centered, enlarged). + if (canCheckIn) + Center( + child: SizedBox( + width: 220, + height: 56, + child: FilledButton.icon( + onPressed: _loading + ? null + : () => _handleCheckIn(schedule), + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.login, size: 24), + label: Text( + 'Check In', + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), ), ) - else if (isCompleted) + else if (!showCheckOut && + !isShiftOver && + !_insideGeofence && + !_checkingGeofence) + Center( + child: SizedBox( + width: 220, + height: 56, + child: FilledButton.icon( + onPressed: null, + icon: const Icon(Icons.location_off, size: 24), + label: Text( + 'Check In', + style: theme.textTheme.titleMedium, + ), + ), + ), + ) + else if (showCheckOut) + Center( + child: SizedBox( + width: 220, + height: 56, + child: FilledButton.tonalIcon( + onPressed: _loading + ? null + : () { + if (realActiveLog.isNotEmpty) { + _handleCheckOut( + realActiveLog.first, + scheduleId: schedule.id, + ); + } else { + final logId = + _checkInLogIds[schedule.id]; + if (logId != null) { + _handleCheckOutById( + logId, + scheduleId: schedule.id, + ); + } + } + }, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.logout, size: 24), + label: Text( + 'Check Out', + style: theme.textTheme.titleMedium, + ), + ), + ), + ) + else if (statusLabel == 'Absent') Row( children: [ Icon( - Icons.check_circle, + Icons.cancel_outlined, size: 16, - color: colors.primary, + color: colors.error, ), const SizedBox(width: 6), Expanded( child: Text( - 'Checked out at ${AppTime.formatTime(completedLog.first.checkOutAt!)}', + 'No check-in recorded for this shift.', style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, + color: colors.error, ), ), ), @@ -316,8 +813,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { accuracy: LocationAccuracy.high, ), ); - // Client-side geofence check - if (geoCfg != null) { + // Client-side geofence check (can be bypassed in debug mode) + final debugBypass = + kDebugMode && ref.read(debugSettingsProvider).bypassGeofence; + if (geoCfg != null && !debugBypass) { bool inside = false; if (geoCfg.hasPolygon) { inside = geoCfg.containsPolygon( @@ -337,8 +836,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { showWarningSnackBar(context, 'You are outside the geofence area.'); return; } + } else if (debugBypass && mounted) { + showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed'); } - await ref + final logId = await ref .read(attendanceControllerProvider) .checkIn( dutyScheduleId: schedule.id, @@ -346,7 +847,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { lng: position.longitude, ); if (mounted) { - showSuccessSnackBar(context, 'Checked in successfully.'); + setState(() { + _justCheckedIn.add(schedule.id); + if (logId != null) _checkInLogIds[schedule.id] = logId; + }); + showSuccessSnackBar(context, 'Checked in! Running verification...'); + if (logId != null) _performFaceVerification(logId); } } catch (e) { if (mounted) { @@ -357,7 +863,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { } } - Future _handleCheckOut(AttendanceLog log) async { + Future _handleCheckOut(AttendanceLog log, {String? scheduleId}) async { setState(() => _loading = true); try { final position = await Geolocator.getCurrentPosition( @@ -373,7 +879,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { lng: position.longitude, ); if (mounted) { - showSuccessSnackBar(context, 'Checked out successfully.'); + setState(() { + if (scheduleId != null) { + _justCheckedIn.remove(scheduleId); + _checkInLogIds.remove(scheduleId); + } + _overtimeLogId = null; + }); + showSuccessSnackBar(context, 'Checked out! Running verification...'); + _performFaceVerification(log.id); } } catch (e) { if (mounted) { @@ -384,6 +898,340 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { } } + Future _handleCheckOutById(String logId, {String? scheduleId}) async { + setState(() => _loading = true); + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + await ref + .read(attendanceControllerProvider) + .checkOut( + attendanceId: logId, + lat: position.latitude, + lng: position.longitude, + ); + if (mounted) { + setState(() { + if (scheduleId != null) { + _justCheckedIn.remove(scheduleId); + _checkInLogIds.remove(scheduleId); + } + _overtimeLogId = null; + }); + showSuccessSnackBar(context, 'Checked out! Running verification...'); + _performFaceVerification(logId); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Check-out failed: $e'); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _handleOvertimeCheckIn() async { + final justification = _justificationController.text.trim(); + if (justification.isEmpty) { + showWarningSnackBar( + context, + 'Please provide a justification for overtime.', + ); + return; + } + setState(() => _loading = true); + try { + final geoCfg = await ref.read(geofenceProvider.future); + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + 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) { + showWarningSnackBar(context, 'You are outside the geofence area.'); + return; + } + } else if (debugBypass && mounted) { + showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed'); + } + final logId = await ref + .read(attendanceControllerProvider) + .overtimeCheckIn( + lat: position.latitude, + lng: position.longitude, + justification: justification, + ); + if (mounted) { + setState(() { + _overtimeLogId = logId; + _justificationController.clear(); + }); + showSuccessSnackBar( + context, + 'Overtime check-in! Running verification...', + ); + if (logId != null) _performFaceVerification(logId); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Overtime check-in failed: $e'); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// 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 { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null || !profile.hasFaceEnrolled) { + try { + await ref + .read(attendanceControllerProvider) + .skipVerification(attendanceLogId); + } catch (_) {} + if (mounted) { + showInfoSnackBar( + context, + 'Face not enrolled — verification skipped. Enroll in Profile.', + ); + } + return; + } + try { + final result = await showFaceVerificationOverlay( + context: context, + ref: ref, + attendanceLogId: attendanceLogId, + ); + + if (!mounted) return; + if (result == null || !result.verified) { + final score = result?.matchScore; + if (score != null) { + showWarningSnackBar( + context, + 'Face did not match (${(score * 100).toStringAsFixed(0)}%). Flagged for review.', + ); + } else { + showWarningSnackBar( + context, + 'Verification skipped — flagged as unverified.', + ); + } + } else { + final score = result.matchScore ?? 0; + showSuccessSnackBar( + context, + 'Face verified (${(score * 100).toStringAsFixed(0)}% match).', + ); + } + } catch (e) { + try { + await ref + .read(attendanceControllerProvider) + .skipVerification(attendanceLogId); + } catch (_) {} + if (mounted) { + showWarningSnackBar(context, 'Verification failed — flagged.'); + } + } + } + + /// Card shown when user has no schedule — offers overtime check-in. + Widget _buildOvertimeCard( + BuildContext context, + ThemeData theme, + ColorScheme colors, + ) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'No schedule assigned for today.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Overtime Check-in', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'You can check in as overtime. A justification is required.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: _justificationController, + labelText: 'Justification for overtime', + maxLines: 4, + enabled: !_loading && _insideGeofence && !_checkingGeofence, + isProcessing: _isGeminiProcessing, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: _justificationController, + onTextUpdated: (text) { + setState(() { + _justificationController.text = text; + }); + }, + onProcessingStateChanged: (processing) { + setState(() => _isGeminiProcessing = processing); + }, + tooltip: 'Translate/Enhance with AI', + promptBuilder: (_) => + 'Translate this sentence to clear professional English ' + 'if needed, and enhance grammar/clarity while preserving ' + 'the original meaning. Return ONLY the improved text, ' + 'with no explanations, no recommendations, and no extra context.', + ), + ), + ], + ), + const SizedBox(height: 12), + Center( + child: SizedBox( + width: 220, + height: 56, + child: FilledButton.icon( + onPressed: + (_loading || + _isGeminiProcessing || + !_insideGeofence || + _checkingGeofence) + ? null + : _handleOvertimeCheckIn, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.more_time, size: 24), + label: Text( + 'Overtime Check In', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// Card shown when user is actively in an overtime session. + Widget _buildActiveOvertimeCard( + BuildContext context, + ThemeData theme, + ColorScheme colors, + List activeLog, + ) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.more_time, size: 20, color: colors.tertiary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Overtime', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + _statusChip(context, 'On Duty'), + ], + ), + const SizedBox(height: 8), + if (activeLog.isNotEmpty) + Text( + 'Checked in at ${AppTime.formatTime(activeLog.first.checkInAt)}', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: _loading + ? null + : () { + if (activeLog.isNotEmpty) { + _handleCheckOut(activeLog.first); + } else if (_overtimeLogId != null) { + _handleCheckOutById(_overtimeLogId!); + } + }, + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.logout), + label: const Text('Check Out'), + ), + ), + ], + ), + ), + ); + } + Widget _statusChip(BuildContext context, String label) { final colors = Theme.of(context).colorScheme; Color bg; @@ -398,6 +1246,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { case 'Checked In': bg = colors.secondaryContainer; fg = colors.onSecondaryContainer; + case 'Early Out': + bg = Colors.orange.shade100; + fg = Colors.orange.shade900; + case 'Noon Break': + bg = Colors.blue.shade100; + fg = Colors.blue.shade900; + case 'Absent': + bg = colors.errorContainer; + fg = colors.onErrorContainer; default: bg = colors.surfaceContainerHighest; fg = colors.onSurface; @@ -429,6 +1286,87 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { } } +// ──────────────────────────────────────────────── +// Unified logbook entry (real log or absent schedule) +// ──────────────────────────────────────────────── + +class _LogbookEntry { + _LogbookEntry({ + required this.name, + required this.date, + required this.checkIn, + required this.checkOut, + required this.duration, + required this.status, + required this.isAbsent, + this.isLeave = false, + this.leaveType, + this.verificationStatus, + this.logId, + this.logUserId, + }); + + final String name; + final DateTime date; + final String checkIn; + final String checkOut; + final String duration; + final String status; + final bool isAbsent; + final bool isLeave; + final String? leaveType; + final String? verificationStatus; + final String? logId; + final String? logUserId; + + /// Whether this entry can be re-verified (within 10 min of check-in). + bool canReverify(String currentUserId) { + if (logId == null || logUserId != currentUserId) return false; + if (verificationStatus != 'unverified' && verificationStatus != 'skipped') { + return false; + } + final elapsed = AppTime.now().difference(date); + return elapsed.inMinutes <= 10; + } + + factory _LogbookEntry.fromLog(AttendanceLog log, Map byId) { + final p = byId[log.userId]; + return _LogbookEntry( + name: p?.fullName ?? log.userId, + date: log.checkInAt, + checkIn: AppTime.formatTime(log.checkInAt), + checkOut: log.isCheckedOut ? AppTime.formatTime(log.checkOutAt!) : '—', + duration: log.isCheckedOut + ? _fmtDur(log.checkOutAt!.difference(log.checkInAt)) + : 'On duty', + status: log.isCheckedOut ? 'Completed' : 'On duty', + isAbsent: false, + verificationStatus: log.verificationStatus, + logId: log.id, + logUserId: log.userId, + ); + } + + factory _LogbookEntry.absent(DutySchedule s, Map byId) { + final p = byId[s.userId]; + return _LogbookEntry( + name: p?.fullName ?? s.userId, + date: s.startTime, + checkIn: '—', + checkOut: '—', + duration: '—', + status: 'Absent', + isAbsent: true, + ); + } + + static String _fmtDur(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60); + return '${h}h ${m}m'; + } +} + // ──────────────────────────────────────────────── // Tab 2 – Logbook // ──────────────────────────────────────────────── @@ -443,11 +1381,15 @@ class _LogbookTab extends ConsumerWidget { final range = ref.watch(attendanceDateRangeProvider); final logsAsync = ref.watch(attendanceLogsProvider); final profilesAsync = ref.watch(profilesProvider); + final schedulesAsync = ref.watch(dutySchedulesProvider); + final leavesAsync = ref.watch(leavesProvider); final Map profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; + final now = AppTime.now(); + return Column( children: [ // Date filter card @@ -494,7 +1436,56 @@ class _LogbookTab extends ConsumerWidget { log.checkInAt.isBefore(range.end); }).toList(); - if (filtered.isEmpty) { + // Build absent entries from past schedules with no logs. + final allSchedules = schedulesAsync.valueOrNull ?? []; + final logScheduleIds = logs.map((l) => l.dutyScheduleId).toSet(); + final absentSchedules = allSchedules.where((s) { + // Only include schedules whose shift has ended, within + // the selected date range, and with no matching logs. + // Exclude overtime schedules – they only belong in the + // Logbook via their attendance_log entry. + return s.shiftType != 'overtime' && + s.endTime.isBefore(now) && + !s.startTime.isBefore(range.start) && + s.startTime.isBefore(range.end) && + !logScheduleIds.contains(s.id); + }).toList(); + + // Build combined entries: _LogbookEntry sealed type. + // Include leave entries within the date range. + final leaves = leavesAsync.valueOrNull ?? []; + final leaveEntries = leaves + .where((l) { + return l.status == 'approved' && + !l.startTime.isBefore(range.start) && + l.startTime.isBefore(range.end); + }) + .map((l) { + final p = profileById[l.userId]; + return _LogbookEntry( + name: p?.fullName ?? l.userId, + date: l.startTime, + checkIn: AppTime.formatTime(l.startTime), + checkOut: AppTime.formatTime(l.endTime), + duration: '—', + status: 'On Leave', + isAbsent: false, + isLeave: true, + leaveType: l.leaveType, + ); + }); + + final List<_LogbookEntry> entries = [ + ...filtered.map((l) => _LogbookEntry.fromLog(l, profileById)), + ...absentSchedules.map( + (s) => _LogbookEntry.absent(s, profileById), + ), + ...leaveEntries, + ]; + // Sort by date descending. + entries.sort((a, b) => b.date.compareTo(a.date)); + + if (entries.isEmpty) { return Center( child: Text( 'No attendance logs for this period.', @@ -505,12 +1496,24 @@ class _LogbookTab extends ConsumerWidget { ); } + final currentUserId = ref.read(currentUserIdProvider) ?? ''; + return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth >= 700) { - return _buildDataTable(context, filtered, profileById); + return _buildDataTable( + context, + entries, + currentUserId: currentUserId, + onReverify: (logId) => _reverify(context, ref, logId), + ); } - return _buildLogList(context, filtered, profileById); + return _buildLogList( + context, + entries, + currentUserId: currentUserId, + onReverify: (logId) => _reverify(context, ref, logId), + ); }, ); }, @@ -522,6 +1525,34 @@ class _LogbookTab extends ConsumerWidget { ); } + void _reverify(BuildContext context, WidgetRef ref, String logId) async { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null || !profile.hasFaceEnrolled) { + showInfoSnackBar( + context, + 'Face not enrolled \u2014 enroll in Profile first.', + ); + return; + } + final result = await showFaceVerificationOverlay( + context: context, + ref: ref, + attendanceLogId: logId, + ); + if (!context.mounted) return; + if (result != null && result.verified) { + showSuccessSnackBar( + context, + 'Re-verification successful (${((result.matchScore ?? 0) * 100).toStringAsFixed(0)}% match).', + ); + } else if (result != null) { + showWarningSnackBar( + context, + 'Re-verification failed. Still flagged as unverified.', + ); + } + } + void _showDateFilterDialog(BuildContext context, WidgetRef ref) { m3ShowDialog( context: context, @@ -536,51 +1567,172 @@ class _LogbookTab extends ConsumerWidget { Widget _buildDataTable( BuildContext context, - List logs, - Map profileById, - ) { + List<_LogbookEntry> entries, { + required String currentUserId, + required void Function(String logId) onReverify, + }) { + // Group entries by date. + final grouped = _groupByDate(entries); return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: grouped.entries.map((group) { + return _DateGroupTile( + dateLabel: group.key, + entries: group.value, + useTable: true, + currentUserId: currentUserId, + onReverify: onReverify, + ); + }).toList(), + ), + ); + } + + Widget _buildLogList( + BuildContext context, + List<_LogbookEntry> entries, { + required String currentUserId, + required void Function(String logId) onReverify, + }) { + // Group entries by date. + final grouped = _groupByDate(entries); + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: grouped.entries.map((group) { + return _DateGroupTile( + dateLabel: group.key, + entries: group.value, + useTable: false, + currentUserId: currentUserId, + onReverify: onReverify, + ); + }).toList(), + ); + } + + /// Group sorted entries by formatted date string (preserving order). + static Map> _groupByDate( + List<_LogbookEntry> entries, + ) { + final map = >{}; + for (final e in entries) { + final key = AppTime.formatDate(e.date); + map.putIfAbsent(key, () => []).add(e); + } + return map; + } +} + +// ──────────────────────────────────────────────── +// Collapsible date-group tile for Logbook +// ──────────────────────────────────────────────── + +class _DateGroupTile extends StatelessWidget { + const _DateGroupTile({ + required this.dateLabel, + required this.entries, + required this.useTable, + required this.currentUserId, + required this.onReverify, + }); + + final String dateLabel; + final List<_LogbookEntry> entries; + final bool useTable; + final String currentUserId; + final void Function(String logId) onReverify; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: ExpansionTile( + initiallyExpanded: true, + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + dateLabel, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + subtitle: Text( + '${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}', + style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + children: [if (useTable) _buildTable(context) else _buildList(context)], + ), + ); + } + + Widget _buildTable(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, child: DataTable( columns: const [ DataColumn(label: Text('Staff')), - DataColumn(label: Text('Role')), - DataColumn(label: Text('Date')), DataColumn(label: Text('Check In')), DataColumn(label: Text('Check Out')), DataColumn(label: Text('Duration')), DataColumn(label: Text('Status')), + DataColumn(label: Text('Verified')), ], - rows: logs.map((log) { - final p = profileById[log.userId]; - final name = p?.fullName ?? log.userId; - final role = p?.role ?? '-'; - final date = AppTime.formatDate(log.checkInAt); - final checkIn = AppTime.formatTime(log.checkInAt); - final checkOut = log.isCheckedOut - ? AppTime.formatTime(log.checkOutAt!) - : '—'; - final duration = log.isCheckedOut - ? _formatDuration(log.checkOutAt!.difference(log.checkInAt)) - : 'On duty'; - final status = log.isCheckedOut ? 'Completed' : 'On duty'; + rows: entries.map((entry) { + final Color statusColor; + if (entry.isLeave) { + statusColor = Colors.teal; + } else if (entry.isAbsent) { + statusColor = Colors.red; + } else if (entry.status == 'On duty') { + statusColor = Colors.orange; + } else { + statusColor = Colors.green; + } + + final statusText = entry.isLeave + ? 'On Leave${entry.leaveType != null ? ' (${_leaveLabel(entry.leaveType!)})' : ''}' + : entry.status; return DataRow( cells: [ - DataCell(Text(name)), - DataCell(Text(_roleLabel(role))), - DataCell(Text(date)), - DataCell(Text(checkIn)), - DataCell(Text(checkOut)), - DataCell(Text(duration)), + DataCell(Text(entry.name)), + DataCell(Text(entry.checkIn)), + DataCell(Text(entry.checkOut)), + DataCell(Text(entry.duration)), DataCell( Text( - status, + statusText, style: TextStyle( - color: log.isCheckedOut ? Colors.green : Colors.orange, + color: statusColor, + fontWeight: (entry.isAbsent || entry.isLeave) + ? FontWeight.w600 + : null, ), ), ), + DataCell( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _verificationBadge(context, entry), + if (entry.canReverify(currentUserId)) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.refresh, size: 16), + tooltip: 'Re-verify', + onPressed: () => onReverify(entry.logId!), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + visualDensity: VisualDensity.compact, + ), + ], + ], + ), + ), ], ); }).toList(), @@ -588,62 +1740,121 @@ class _LogbookTab extends ConsumerWidget { ); } - Widget _buildLogList( - BuildContext context, - List logs, - Map profileById, - ) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: logs.length, - itemBuilder: (context, index) { - final log = logs[index]; - final p = profileById[log.userId]; - final name = p?.fullName ?? log.userId; - final role = p?.role ?? '-'; - - return Card( - child: ListTile( - title: Text(name), - subtitle: Text( - '${_roleLabel(role)} · ${AppTime.formatDate(log.checkInAt)}\n' - 'In: ${AppTime.formatTime(log.checkInAt)}' - '${log.isCheckedOut ? " · Out: ${AppTime.formatTime(log.checkOutAt!)}" : " · On duty"}', - ), - isThreeLine: true, - trailing: log.isCheckedOut - ? Text( - _formatDuration(log.checkOutAt!.difference(log.checkInAt)), - style: Theme.of(context).textTheme.bodySmall, - ) - : Chip( - label: const Text('On duty'), - backgroundColor: Theme.of( - context, - ).colorScheme.tertiaryContainer, + Widget _buildList(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + children: entries.map((entry) { + return ListTile( + title: Row( + children: [ + Expanded(child: Text(entry.name)), + _verificationBadge(context, entry), + if (entry.canReverify(currentUserId)) + IconButton( + icon: Icon( + Icons.refresh, + size: 16, + color: Theme.of(context).colorScheme.primary, ), + tooltip: 'Re-verify', + onPressed: () => onReverify(entry.logId!), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + visualDensity: VisualDensity.compact, + ), + ], ), + subtitle: Text( + entry.isLeave + ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' + : entry.isAbsent + ? 'Absent — no check-in recorded' + : 'In: ${entry.checkIn}${entry.checkOut != "—" ? " · Out: ${entry.checkOut}" : " · On duty"}', + ), + trailing: entry.isLeave + ? Chip( + label: const Text('On Leave'), + backgroundColor: Colors.teal.withValues(alpha: 0.15), + ) + : entry.isAbsent + ? Chip( + label: const Text('Absent'), + backgroundColor: colors.errorContainer, + ) + : entry.status == 'On duty' + ? Chip( + label: const Text('On duty'), + backgroundColor: colors.tertiaryContainer, + ) + : Text( + entry.duration, + style: Theme.of(context).textTheme.bodySmall, + ), ); - }, + }).toList(), ); } - String _formatDuration(Duration d) { - final hours = d.inHours; - final minutes = d.inMinutes.remainder(60); - return '${hours}h ${minutes}m'; + /// Verification badge for logbook entries. + Widget _verificationBadge(BuildContext context, _LogbookEntry entry) { + if (entry.isAbsent || entry.isLeave || entry.verificationStatus == null) { + return const SizedBox.shrink(); + } + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + IconData icon; + Color color; + String tooltip; + switch (entry.verificationStatus) { + case 'verified': + icon = Icons.verified; + color = Colors.green; + tooltip = 'Verified'; + case 'unverified' || 'skipped': + icon = Icons.warning_amber_rounded; + color = colors.error; + tooltip = 'Unverified'; + default: + icon = Icons.hourglass_bottom; + color = Colors.orange; + tooltip = 'Pending'; + } + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text(tooltip, style: textTheme.labelSmall?.copyWith(color: color)), + ], + ), + ), + ); } - String _roleLabel(String role) { - switch (role) { - case 'admin': - return 'Admin'; - case 'dispatcher': - return 'Dispatcher'; - case 'it_staff': - return 'IT Staff'; + static String _leaveLabel(String leaveType) { + switch (leaveType) { + case 'emergency_leave': + return 'Emergency'; + case 'parental_leave': + return 'Parental'; + case 'sick_leave': + return 'Sick'; + case 'vacation_leave': + return 'Vacation'; default: - return 'Standard'; + return leaveType; } } } @@ -963,7 +2174,7 @@ class _AttendanceDateFilterDialogState widget.onApply( ReportDateRange( start: _customStart!, - end: _customEnd!, + end: _customEnd!.add(const Duration(days: 1)), label: 'Custom', ), ); @@ -998,22 +2209,14 @@ class _PassSlipTab extends ConsumerStatefulWidget { } class _PassSlipTabState extends ConsumerState<_PassSlipTab> { - final _reasonController = TextEditingController(); bool _submitting = false; - @override - void dispose() { - _reasonController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; final slipsAsync = ref.watch(passSlipsProvider); - final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final activeSlip = ref.watch(activePassSlipProvider); final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher'; @@ -1026,19 +2229,6 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { return const Center(child: CircularProgressIndicator()); } - // Find today's schedule for passing to request form - final now = AppTime.now(); - final today = DateTime(now.year, now.month, now.day); - final schedules = schedulesAsync.valueOrNull ?? []; - final todaySchedule = schedules.where((s) { - final sDay = DateTime( - s.startTime.year, - s.startTime.month, - s.startTime.day, - ); - return s.userId == profile.id && sDay == today; - }).toList(); - return slipsAsync.when( data: (slips) { return ListView( @@ -1109,54 +2299,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { const SizedBox(height: 16), ], - // Request form (only for non-admin staff with a schedule today) - if (!isAdmin && activeSlip == null && todaySchedule.isNotEmpty) ...[ - Text( - 'Request Pass Slip', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _reasonController, - decoration: const InputDecoration( - labelText: 'Reason', - hintText: 'Brief reason for pass slip', - ), - maxLines: 2, - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.tonalIcon( - onPressed: _submitting - ? null - : () => _requestSlip(todaySchedule.first.id), - icon: _submitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.send), - label: const Text('Submit Request'), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - ], + // Request form removed — use FAB instead // Pending slips for admin approval if (isAdmin) ...[ @@ -1322,51 +2465,6 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { ); } - Future _requestSlip(String scheduleId) async { - final reason = _reasonController.text.trim(); - if (reason.isEmpty) { - showWarningSnackBar(context, 'Please enter a reason.'); - return; - } - setState(() => _submitting = true); - try { - await ref - .read(passSlipControllerProvider) - .requestSlip(dutyScheduleId: scheduleId, reason: reason); - _reasonController.clear(); - - // Notify all admin users via push notification - final profiles = ref.read(profilesProvider).valueOrNull ?? []; - final adminIds = profiles - .where((p) => p.role == 'admin') - .map((p) => p.id) - .toList(); - final currentProfile = ref.read(currentProfileProvider).valueOrNull; - final actorName = currentProfile?.fullName ?? 'A staff member'; - if (adminIds.isNotEmpty && currentProfile != null) { - ref - .read(notificationsControllerProvider) - .createNotification( - userIds: adminIds, - type: 'pass_slip_request', - actorId: currentProfile.id, - pushTitle: 'Pass Slip Request', - pushBody: '$actorName requested a pass slip: $reason', - ); - } - - if (mounted) { - showSuccessSnackBar(context, 'Pass slip request submitted.'); - } - } catch (e) { - if (mounted) { - showErrorSnackBar(context, 'Failed: $e'); - } - } finally { - if (mounted) setState(() => _submitting = false); - } - } - Future _approveSlip(String slipId) async { setState(() => _submitting = true); try { @@ -1415,3 +2513,805 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { } } } + +// ──────────────────────────────────────────────── +// Tab 4 – Leave of Absence +// ──────────────────────────────────────────────── + +class _LeaveTab extends ConsumerStatefulWidget { + const _LeaveTab(); + + @override + ConsumerState<_LeaveTab> createState() => _LeaveTabState(); +} + +class _LeaveTabState extends ConsumerState<_LeaveTab> { + bool _submitting = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = ref.watch(currentProfileProvider).valueOrNull; + final leavesAsync = ref.watch(leavesProvider); + final profilesAsync = ref.watch(profilesProvider); + + if (profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + final isAdmin = profile.role == 'admin'; + + final profiles = profilesAsync.valueOrNull ?? []; + final profileById = {for (final p in profiles) p.id: p}; + + return leavesAsync.when( + data: (leaves) { + final myLeaves = leaves.where((l) => l.userId == profile.id).toList(); + final pendingApprovals = isAdmin + ? leaves + .where((l) => l.status == 'pending' && l.userId != profile.id) + .toList() + : []; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Pending Approvals (admin only) ── + if (isAdmin) ...[ + Text( + 'Pending Approvals', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + if (pendingApprovals.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No pending leave requests.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ...pendingApprovals.map( + (leave) => _buildLeaveCard( + context, + leave, + profileById, + showApproval: true, + ), + ), + const SizedBox(height: 24), + ], + + // ── My Leave Applications ── + Text( + 'My Leave Applications', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + if (myLeaves.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'You have no leave applications.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ...myLeaves + .take(50) + .map( + (leave) => _buildLeaveCard( + context, + leave, + profileById, + showApproval: false, + ), + ), + + // ── All Leave History (admin only) ── + if (isAdmin) ...[ + const SizedBox(height: 24), + Text( + 'All Leave History', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...leaves + .where((l) => l.status != 'pending' && l.userId != profile.id) + .take(50) + .map( + (leave) => _buildLeaveCard( + context, + leave, + profileById, + showApproval: false, + ), + ), + if (leaves + .where((l) => l.status != 'pending' && l.userId != profile.id) + .isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No leave history from other staff.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + const SizedBox(height: 80), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Failed to load leaves: $e')), + ); + } + + Widget _buildLeaveCard( + BuildContext context, + LeaveOfAbsence leave, + Map profileById, { + required bool showApproval, + }) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final p = profileById[leave.userId]; + final name = p?.fullName ?? leave.userId; + + Color statusColor; + switch (leave.status) { + case 'approved': + statusColor = Colors.teal; + case 'rejected': + statusColor = colors.error; + case 'cancelled': + statusColor = colors.onSurfaceVariant; + default: + statusColor = Colors.orange; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(name, style: theme.textTheme.titleSmall)), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + leave.status.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + leave.leaveTypeLabel, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text(leave.justification, style: theme.textTheme.bodyMedium), + Text( + '${AppTime.formatDate(leave.startTime)} ' + '${AppTime.formatTime(leave.startTime)} – ' + '${AppTime.formatTime(leave.endTime)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + // Approve / Reject for admins on pending leaves + if (showApproval && leave.status == 'pending') ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting + ? null + : () => _rejectLeave(leave.id), + child: Text( + 'Reject', + style: TextStyle(color: colors.error), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _submitting + ? null + : () => _approveLeave(leave.id), + child: const Text('Approve'), + ), + ], + ), + ], + // Cancel future approved leaves: + // - user can cancel own + // - admin can cancel anyone + if (!showApproval && _canCancelFutureApproved(leave)) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _submitting ? null : () => _cancelLeave(leave.id), + child: Text('Cancel', style: TextStyle(color: colors.error)), + ), + ), + ], + ], + ), + ), + ); + } + + bool _canCancelFutureApproved(LeaveOfAbsence leave) { + if (leave.status != 'approved' || !leave.startTime.isAfter(AppTime.now())) { + return false; + } + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null) return false; + final isAdmin = profile.role == 'admin'; + return isAdmin || leave.userId == profile.id; + } + + Future _approveLeave(String leaveId) async { + setState(() => _submitting = true); + try { + await ref.read(leaveControllerProvider).approveLeave(leaveId); + if (mounted) { + showSuccessSnackBar(context, 'Leave approved.'); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Failed: $e'); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _rejectLeave(String leaveId) async { + setState(() => _submitting = true); + try { + await ref.read(leaveControllerProvider).rejectLeave(leaveId); + if (mounted) { + showSuccessSnackBar(context, 'Leave rejected.'); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Failed: $e'); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _cancelLeave(String leaveId) async { + setState(() => _submitting = true); + try { + await ref.read(leaveControllerProvider).cancelLeave(leaveId); + if (mounted) { + showSuccessSnackBar(context, 'Leave cancelled.'); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Failed: $e'); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} + +// ─── FAB Menu Item ────────────────────────────────────────────── +class _FabMenuItem extends StatelessWidget { + const _FabMenuItem({ + required this.heroTag, + required this.label, + required this.icon, + required this.color, + required this.onColor, + required this.onTap, + }); + + final String heroTag; + final String label; + final IconData icon; + final Color color; + final Color onColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: color, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: onColor), + ), + ), + ), + const SizedBox(width: 12), + FloatingActionButton.small( + heroTag: heroTag, + backgroundColor: color, + foregroundColor: onColor, + onPressed: onTap, + child: Icon(icon), + ), + ], + ); + } +} + +// ─── Pass Slip Dialog (with Gemini) ───────────────────────────── +class _PassSlipDialog extends ConsumerStatefulWidget { + const _PassSlipDialog({required this.scheduleId, required this.onSubmitted}); + final String scheduleId; + final VoidCallback onSubmitted; + + @override + ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState(); +} + +class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { + final _reasonController = TextEditingController(); + bool _submitting = false; + bool _isGeminiProcessing = false; + + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + Future _submit() async { + final reason = _reasonController.text.trim(); + if (reason.isEmpty) { + showWarningSnackBar(context, 'Please enter a reason.'); + return; + } + setState(() => _submitting = true); + try { + await ref + .read(passSlipControllerProvider) + .requestSlip(dutyScheduleId: widget.scheduleId, reason: reason); + if (mounted) { + Navigator.of(context).pop(); + widget.onSubmitted(); + } + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Failed: $e'); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Request Pass Slip', style: theme.textTheme.headlineSmall), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: _reasonController, + labelText: 'Reason', + maxLines: 3, + enabled: !_submitting, + isProcessing: _isGeminiProcessing, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: _reasonController, + onTextUpdated: (text) { + setState(() => _reasonController.text = text); + }, + onProcessingStateChanged: (processing) { + setState(() => _isGeminiProcessing = processing); + }, + tooltip: 'Translate/Enhance with AI', + promptBuilder: (_) => + 'Translate this sentence to clear professional English ' + 'if needed, and enhance grammar/clarity while preserving ' + 'the original meaning. Return ONLY the improved text, ' + 'with no explanations, no recommendations, and no extra context.', + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Submit'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +// ─── File Leave Dialog ────────────────────────────────────────── +class _FileLeaveDialog extends ConsumerStatefulWidget { + const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted}); + final bool isAdmin; + final VoidCallback onSubmitted; + + @override + ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState(); +} + +class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> { + final _justificationController = TextEditingController(); + bool _submitting = false; + bool _isGeminiProcessing = false; + + String _leaveType = 'emergency_leave'; + DateTime? _startDate; + TimeOfDay? _startTime; + TimeOfDay? _endTime; + + static const _leaveTypes = { + 'emergency_leave': 'Emergency Leave', + 'parental_leave': 'Parental Leave', + 'sick_leave': 'Sick Leave', + 'vacation_leave': 'Vacation Leave', + }; + + @override + void dispose() { + _justificationController.dispose(); + super.dispose(); + } + + void _autoFillShiftTimes(List schedules, String userId) { + if (_startDate == null) return; + final day = DateTime(_startDate!.year, _startDate!.month, _startDate!.day); + final match = schedules.where((s) { + final sDay = DateTime( + s.startTime.year, + s.startTime.month, + s.startTime.day, + ); + return s.userId == userId && sDay == day; + }).toList(); + if (match.isNotEmpty) { + setState(() { + _startTime = TimeOfDay.fromDateTime(match.first.startTime); + _endTime = TimeOfDay.fromDateTime(match.first.endTime); + }); + } + } + + Future _pickDate() async { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + final picked = await showDatePicker( + context: context, + initialDate: _startDate ?? today, + firstDate: today, + lastDate: today.add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() => _startDate = picked); + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile != null) { + final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + _autoFillShiftTimes(schedules, profile.id); + } + } + } + + Future _pickStartTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _startTime ?? const TimeOfDay(hour: 8, minute: 0), + ); + if (picked != null) setState(() => _startTime = picked); + } + + Future _pickEndTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _endTime ?? const TimeOfDay(hour: 17, minute: 0), + ); + if (picked != null) setState(() => _endTime = picked); + } + + Future _submit() async { + if (_startDate == null) { + showWarningSnackBar(context, 'Please select a date.'); + return; + } + if (_startTime == null || _endTime == null) { + showWarningSnackBar(context, 'Please set start and end times.'); + return; + } + if (_justificationController.text.trim().isEmpty) { + showWarningSnackBar(context, 'Please enter a justification.'); + return; + } + + final startDt = DateTime( + _startDate!.year, + _startDate!.month, + _startDate!.day, + _startTime!.hour, + _startTime!.minute, + ); + final endDt = DateTime( + _startDate!.year, + _startDate!.month, + _startDate!.day, + _endTime!.hour, + _endTime!.minute, + ); + + if (!endDt.isAfter(startDt)) { + showWarningSnackBar(context, 'End time must be after start time.'); + return; + } + + setState(() => _submitting = true); + try { + await ref + .read(leaveControllerProvider) + .fileLeave( + leaveType: _leaveType, + justification: _justificationController.text.trim(), + startTime: startDt, + endTime: endDt, + autoApprove: widget.isAdmin, + ); + if (mounted) { + Navigator.of(context).pop(); + widget.onSubmitted(); + } + } catch (e) { + if (mounted) { + showErrorSnackBar(context, 'Failed to file leave: $e'); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'File Leave of Absence', + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 16), + + // Leave type + DropdownButtonFormField( + // ignore: deprecated_member_use + value: _leaveType, + decoration: const InputDecoration(labelText: 'Leave Type'), + items: _leaveTypes.entries + .map( + (e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _leaveType = v); + }, + ), + const SizedBox(height: 12), + + // Date picker + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today), + title: Text( + _startDate == null + ? 'Select Date' + : AppTime.formatDate(_startDate!), + ), + subtitle: const Text('Current or future dates only'), + onTap: _pickDate, + ), + + // Time range + Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time), + title: Text( + _startTime == null + ? 'Start Time' + : _startTime!.format(context), + ), + onTap: _pickStartTime, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.arrow_forward), + ), + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time), + title: Text( + _endTime == null + ? 'End Time' + : _endTime!.format(context), + ), + subtitle: const Text('From shift schedule'), + onTap: _pickEndTime, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Justification with AI + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: _justificationController, + labelText: 'Justification', + maxLines: 3, + enabled: !_submitting, + isProcessing: _isGeminiProcessing, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: _justificationController, + onTextUpdated: (text) { + setState(() { + _justificationController.text = text; + }); + }, + onProcessingStateChanged: (processing) { + setState(() => _isGeminiProcessing = processing); + }, + tooltip: 'Translate/Enhance with AI', + promptBuilder: (_) => + 'Translate this sentence to clear professional English ' + 'if needed, and enhance grammar/clarity while preserving ' + 'the original meaning. Return ONLY the improved text, ' + 'with no explanations, no recommendations, and no extra context.', + ), + ), + ], + ), + const SizedBox(height: 16), + + if (widget.isAdmin) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'As admin, your leave will be auto-approved.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.primary, + ), + ), + ), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.event_busy), + label: const Text('File Leave'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index c1da4015..75bf3fd7 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -5,21 +5,30 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/attendance_log.dart'; +import '../../models/duty_schedule.dart'; +import '../../models/leave_of_absence.dart'; +import '../../models/live_position.dart'; +import '../../models/pass_slip.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; +import '../../providers/attendance_provider.dart'; +import '../../providers/leave_provider.dart'; +import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; +import '../../providers/whereabouts_provider.dart'; +import '../../providers/workforce_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/mono_text.dart'; -import '../../widgets/status_pill.dart'; import '../../utils/app_time.dart'; class DashboardMetrics { @@ -56,6 +65,7 @@ class StaffRowMetrics { required this.userId, required this.name, required this.status, + required this.whereabouts, required this.ticketsRespondedToday, required this.tasksClosedToday, }); @@ -63,6 +73,7 @@ class StaffRowMetrics { final String userId; final String name; final String status; + final String whereabouts; final int ticketsRespondedToday; final int tasksClosedToday; } @@ -73,6 +84,11 @@ final dashboardMetricsProvider = Provider>((ref) { final profilesAsync = ref.watch(profilesProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); final messagesAsync = ref.watch(ticketMessagesAllProvider); + final schedulesAsync = ref.watch(dutySchedulesProvider); + final logsAsync = ref.watch(attendanceLogsProvider); + final positionsAsync = ref.watch(livePositionsProvider); + final leavesAsync = ref.watch(leavesProvider); + final passSlipsAsync = ref.watch(passSlipsProvider); final asyncValues = [ ticketsAsync, @@ -80,6 +96,11 @@ final dashboardMetricsProvider = Provider>((ref) { profilesAsync, assignmentsAsync, messagesAsync, + schedulesAsync, + logsAsync, + positionsAsync, + leavesAsync, + passSlipsAsync, ]; // Debug: log dependency loading/error states to diagnose full-page refreshes. @@ -89,7 +110,11 @@ final dashboardMetricsProvider = Provider>((ref) { 'tasks=${tasksAsync.isLoading ? "loading" : "ready"} ' 'profiles=${profilesAsync.isLoading ? "loading" : "ready"} ' 'assignments=${assignmentsAsync.isLoading ? "loading" : "ready"} ' - 'messages=${messagesAsync.isLoading ? "loading" : "ready"}', + 'messages=${messagesAsync.isLoading ? "loading" : "ready"} ' + 'schedules=${schedulesAsync.isLoading ? "loading" : "ready"} ' + 'logs=${logsAsync.isLoading ? "loading" : "ready"} ' + 'positions=${positionsAsync.isLoading ? "loading" : "ready"} ' + 'leaves=${leavesAsync.isLoading ? "loading" : "ready"}', ); if (asyncValues.any((value) => value.hasError)) { @@ -117,9 +142,15 @@ final dashboardMetricsProvider = Provider>((ref) { final profiles = profilesAsync.valueOrNull ?? const []; final assignments = assignmentsAsync.valueOrNull ?? const []; final messages = messagesAsync.valueOrNull ?? const []; + final schedules = schedulesAsync.valueOrNull ?? const []; + final allLogs = logsAsync.valueOrNull ?? const []; + final positions = positionsAsync.valueOrNull ?? const []; + final allLeaves = leavesAsync.valueOrNull ?? const []; + final allPassSlips = passSlipsAsync.valueOrNull ?? const []; final now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); + final endOfDay = startOfDay.add(const Duration(days: 1)); final staffProfiles = profiles .where((profile) => profile.role == 'it_staff') @@ -258,22 +289,139 @@ final dashboardMetricsProvider = Provider>((ref) { const triageWindow = Duration(minutes: 1); final triageCutoff = now.subtract(triageWindow); + // Pre-index schedules, logs, and positions by user for efficient lookup. + final todaySchedulesByUser = >{}; + for (final s in schedules) { + // Exclude overtime schedules from regular duty tracking. + if (s.shiftType != 'overtime' && + !s.startTime.isBefore(startOfDay) && + s.startTime.isBefore(endOfDay)) { + todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s); + } + } + final todayLogsByUser = >{}; + for (final l in allLogs) { + if (!l.checkInAt.isBefore(startOfDay)) { + todayLogsByUser.putIfAbsent(l.userId, () => []).add(l); + } + } + final positionByUser = {}; + for (final p in positions) { + positionByUser[p.userId] = p; + } + + // Index today's leaves by user. + final todayLeaveByUser = {}; + for (final l in allLeaves) { + if (l.status == 'approved' && + !l.startTime.isAfter(now) && + l.endTime.isAfter(now)) { + todayLeaveByUser[l.userId] = l; + } + } + + // Index active pass slips by user. + final activePassSlipByUser = {}; + for (final slip in allPassSlips) { + if (slip.isActive) { + activePassSlipByUser[slip.userId] = slip; + } + } + + final noon = DateTime(now.year, now.month, now.day, 12, 0); + final onePM = DateTime(now.year, now.month, now.day, 13, 0); + var staffRows = staffProfiles.map((staff) { final lastMessage = lastStaffMessageByUser[staff.id]; final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0; final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0; final onTask = staffOnTask.contains(staff.id); final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff); - final status = onTask - ? 'On task' - : inTriage - ? 'In triage' - : 'Vacant'; + + // Whereabouts from live position. + final livePos = positionByUser[staff.id]; + final whereabouts = livePos != null + ? (livePos.inPremise ? 'In premise' : 'Outside premise') + : '\u2014'; + + // Attendance-based status. + final userSchedules = todaySchedulesByUser[staff.id] ?? const []; + final userLogs = todayLogsByUser[staff.id] ?? const []; + final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull; + final completedLogs = userLogs.where((l) => l.isCheckedOut).toList(); + + String status; + // Check leave first — overrides all schedule-based statuses. + if (todayLeaveByUser.containsKey(staff.id)) { + status = 'On leave'; + } else if (activePassSlipByUser.containsKey(staff.id)) { + // Active pass slip — user is temporarily away from duty. + status = 'PASS SLIP'; + } else if (userSchedules.isEmpty) { + // No schedule today — off duty unless actively on task/triage. + status = onTask + ? 'On task' + : inTriage + ? 'In triage' + : 'Off duty'; + } else { + final schedule = userSchedules.first; + final isShiftOver = !now.isBefore(schedule.endTime); + final isFullDay = + schedule.endTime.difference(schedule.startTime).inHours >= 6; + final isNoonBreakWindow = + isFullDay && !now.isBefore(noon) && now.isBefore(onePM); + final isOnCall = schedule.shiftType == 'on_call'; + + if (activeLog != null) { + // Currently checked in — on-duty, can be overridden. + status = onTask + ? 'On task' + : inTriage + ? 'In triage' + : isOnCall + ? 'On duty' + : 'Vacant'; + } else if (completedLogs.isNotEmpty) { + // Has checked out at least once. + if (isNoonBreakWindow) { + status = 'Noon break'; + } else if (isShiftOver) { + status = 'Off duty'; + } else { + // Checked out before shift end and not noon break → Early Out. + status = 'Early out'; + } + } else { + // Not checked in yet, no completed logs. + if (isOnCall) { + // ON CALL staff don't need to be on premise or check in at a + // specific time — they only come when needed. + status = isShiftOver ? 'Off duty' : 'ON CALL'; + } else if (isShiftOver) { + // Shift ended with no check-in at all → Absent. + status = 'Absent'; + } else { + final oneHourBefore = schedule.startTime.subtract( + const Duration(hours: 1), + ); + if (!now.isBefore(oneHourBefore) && + now.isBefore(schedule.startTime)) { + status = 'Arrival'; + } else if (!now.isBefore(schedule.startTime)) { + status = 'Late'; + } else { + status = 'Off duty'; + } + } + } + } return StaffRowMetrics( userId: staff.id, name: staff.fullName.isNotEmpty ? staff.fullName : staff.id, status: status, + whereabouts: whereabouts, ticketsRespondedToday: ticketsResponded, tasksClosedToday: tasksClosed, ); @@ -670,6 +818,7 @@ class _StaffTableHeader extends StatelessWidget { children: [ Expanded(flex: 3, child: Text('IT Staff', style: style)), Expanded(flex: 2, child: Text('Status', style: style)), + Expanded(flex: 2, child: Text('Whereabouts', style: style)), Expanded(flex: 2, child: Text('Tickets', style: style)), Expanded(flex: 2, child: Text('Tasks', style: style)), ], @@ -743,7 +892,21 @@ class _StaffRow extends StatelessWidget { flex: 2, child: Align( alignment: Alignment.centerLeft, - child: StatusPill(label: row.status), + child: _PulseStatusPill(label: row.status), + ), + ), + Expanded( + flex: 2, + child: Text( + row.whereabouts, + style: valueStyle?.copyWith( + color: row.whereabouts == 'In premise' + ? Colors.green + : row.whereabouts == 'Outside premise' + ? Colors.orange + : null, + fontWeight: FontWeight.w600, + ), ), ), Expanded( @@ -763,6 +926,48 @@ class _StaffRow extends StatelessWidget { } } +/// Status pill with attendance-specific coloring for the IT Staff Pulse table. +class _PulseStatusPill extends StatelessWidget { + const _PulseStatusPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final (Color bg, Color fg) = switch (label.toLowerCase()) { + 'arrival' => (Colors.amber.shade100, Colors.amber.shade900), + 'late' => (Colors.red.shade100, Colors.red.shade900), + 'noon break' => (Colors.blue.shade100, Colors.blue.shade900), + 'vacant' => (Colors.green.shade100, Colors.green.shade900), + 'on task' => (Colors.purple.shade100, Colors.purple.shade900), + 'in triage' => (Colors.orange.shade100, Colors.orange.shade900), + 'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900), + 'on leave' => (Colors.teal.shade100, Colors.teal.shade900), + 'absent' => (Colors.red.shade200, Colors.red.shade900), + 'off duty' => (Colors.grey.shade200, Colors.grey.shade700), + _ => ( + Theme.of(context).colorScheme.tertiaryContainer, + Theme.of(context).colorScheme.onTertiaryContainer, + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + Duration? _averageDuration(List durations) { if (durations.isEmpty) { return null; diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index e8c2a70a..fc07a3ad 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -1,12 +1,16 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import '../../models/office.dart'; import '../../providers/auth_provider.dart' show sessionProvider; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/user_offices_provider.dart'; +import '../../services/face_verification.dart' as face; import '../../widgets/multi_select_picker.dart'; +import '../../widgets/qr_verification_dialog.dart'; import '../../widgets/responsive_body.dart'; import '../../utils/snackbar.dart'; @@ -27,6 +31,10 @@ class _ProfileScreenState extends ConsumerState { bool _savingDetails = false; bool _changingPassword = false; bool _savingOffices = false; + bool _uploadingAvatar = false; + bool _enrollingFace = false; + + final _imagePicker = ImagePicker(); @override void dispose() { @@ -72,6 +80,14 @@ class _ProfileScreenState extends ConsumerState { Text('My Profile', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 12), + // ── Avatar Card ── + _buildAvatarCard(context, profileAsync), + const SizedBox(height: 12), + + // ── Face & Biometric Enrollment Card ── + _buildFaceEnrollmentCard(context, profileAsync), + const SizedBox(height: 12), + // Details Card Card( child: Padding( @@ -253,6 +269,296 @@ class _ProfileScreenState extends ConsumerState { ); } + Widget _buildAvatarCard(BuildContext context, AsyncValue profileAsync) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = profileAsync.valueOrNull; + final avatarUrl = profile?.avatarUrl; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Profile Photo', style: theme.textTheme.titleMedium), + const SizedBox(height: 16), + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 56, + backgroundColor: colors.surfaceContainerHighest, + backgroundImage: avatarUrl != null + ? NetworkImage(avatarUrl) + : null, + child: avatarUrl == null + ? Icon( + Icons.person, + size: 48, + color: colors.onSurfaceVariant, + ) + : null, + ), + if (_uploadingAvatar) + const Positioned.fill( + child: Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.tonalIcon( + onPressed: _uploadingAvatar + ? null + : () => _pickAvatar(ImageSource.gallery), + icon: const Icon(Icons.photo_library), + label: const Text('Upload'), + ), + if (!kIsWeb) ...[ + const SizedBox(width: 12), + FilledButton.tonalIcon( + onPressed: _uploadingAvatar + ? null + : () => _pickAvatar(ImageSource.camera), + icon: const Icon(Icons.camera_alt), + label: const Text('Camera'), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildFaceEnrollmentCard( + BuildContext context, + AsyncValue profileAsync, + ) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final profile = profileAsync.valueOrNull; + final hasFace = profile?.hasFaceEnrolled ?? false; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Face Verification', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Enroll your face for attendance verification. ' + 'A liveness check (blink detection) is required before enrollment.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + + // Face enrollment status + Row( + children: [ + Icon( + hasFace ? Icons.check_circle : Icons.cancel, + color: hasFace ? Colors.green : colors.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + hasFace + ? 'Face enrolled (${profile!.faceEnrolledAt != null ? _formatDate(profile.faceEnrolledAt!) : "unknown"})' + : 'Face not enrolled', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _enrollingFace ? null : _enrollFace, + icon: _enrollingFace + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.face), + label: Text(hasFace ? 'Re-enroll Face' : 'Enroll Face'), + ), + + // Test Facial Recognition + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 12), + Text('Test Facial Recognition', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text( + hasFace + ? 'Run a liveness check and compare with your enrolled face.' + : 'Enroll your face first to test facial recognition.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: hasFace ? _testFacialRecognition : null, + icon: const Icon(Icons.face_retouching_natural), + label: const Text('Test Facial Recognition'), + ), + ], + ), + ), + ); + } + + String _formatDate(DateTime dt) { + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } + + Future _pickAvatar(ImageSource source) async { + try { + final xFile = await _imagePicker.pickImage( + source: source, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + if (xFile == null) return; + + setState(() => _uploadingAvatar = true); + final bytes = await xFile.readAsBytes(); + final ext = xFile.name.split('.').last; + final userId = ref.read(currentUserIdProvider); + if (userId == null) return; + await ref + .read(profileControllerProvider) + .uploadAvatar(userId: userId, bytes: bytes, fileName: 'avatar.$ext'); + ref.invalidate(currentProfileProvider); + if (mounted) showSuccessSnackBar(context, 'Avatar updated.'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Failed to upload avatar: $e'); + } finally { + if (mounted) setState(() => _uploadingAvatar = false); + } + } + + /// Face enrollment via liveness detection (works on both mobile and web). + /// Mobile: uses flutter_liveness_check with blink detection. + /// Web: uses face-api.js with camera and blink detection. + /// Falls back to QR cross-device flow if web camera is unavailable. + Future _enrollFace() async { + if (!mounted) return; + setState(() => _enrollingFace = true); + + try { + final result = await face.runFaceLiveness(context); + + if (result == null) { + // Cancelled or failed — on web, offer QR fallback + if (kIsWeb && mounted) { + final useQr = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Camera unavailable?'), + content: const Text( + 'If your camera did not work, you can enroll via your mobile device instead.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Use Mobile'), + ), + ], + ), + ); + if (useQr == true && mounted) { + final completed = await showQrVerificationDialog( + context: context, + ref: ref, + type: 'enrollment', + ); + if (completed) ref.invalidate(currentProfileProvider); + } + } + return; + } + + // Upload the captured face photo + final userId = ref.read(currentUserIdProvider); + if (userId == null) return; + await ref + .read(profileControllerProvider) + .uploadFacePhoto( + userId: userId, + bytes: result.imageBytes, + fileName: 'face.jpg', + ); + ref.invalidate(currentProfileProvider); + if (mounted) showSuccessSnackBar(context, 'Face enrolled successfully.'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Face enrollment failed: $e'); + } finally { + if (mounted) setState(() => _enrollingFace = false); + } + } + + /// Test facial recognition: run liveness + compare with enrolled face. + Future _testFacialRecognition() async { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null || !profile.hasFaceEnrolled) { + if (mounted) { + showWarningSnackBar(context, 'Please enroll your face first.'); + } + return; + } + + try { + final result = await face.runFaceLiveness(context); + if (result == null || !mounted) return; + + // Download enrolled photo via Supabase (authenticated, private bucket) + final enrolledBytes = await ref + .read(profileControllerProvider) + .downloadFacePhoto(profile.id); + if (enrolledBytes == null || !mounted) { + if (mounted) { + showErrorSnackBar(context, 'Could not load enrolled face photo.'); + } + return; + } + + // Compare captured vs enrolled + final score = await face.compareFaces(result.imageBytes, enrolledBytes); + + if (!mounted) return; + if (score >= 0.60) { + showSuccessSnackBar( + context, + 'Face matched! Similarity: ${(score * 100).toStringAsFixed(1)}%', + ); + } else { + showWarningSnackBar( + context, + 'Face did not match. Similarity: ${(score * 100).toStringAsFixed(1)}%', + ); + } + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Recognition test failed: $e'); + } + } + Future _onSaveDetails() async { if (!_detailsKey.currentState!.validate()) return; final id = ref.read(currentUserIdProvider); diff --git a/lib/screens/shared/mobile_verification_screen.dart b/lib/screens/shared/mobile_verification_screen.dart new file mode 100644 index 00000000..c03919ff --- /dev/null +++ b/lib/screens/shared/mobile_verification_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/verification_session_provider.dart'; +import '../../services/face_verification.dart' as face; + +/// Screen opened on mobile when the user scans a QR code for cross-device +/// face verification. Performs liveness detection and uploads the result. +class MobileVerificationScreen extends ConsumerStatefulWidget { + const MobileVerificationScreen({super.key, required this.sessionId}); + + final String sessionId; + + @override + ConsumerState createState() => + _MobileVerificationScreenState(); +} + +class _MobileVerificationScreenState + extends ConsumerState { + bool _loading = true; + bool _verifying = false; + bool _done = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadSession(); + } + + Future _loadSession() async { + try { + final controller = ref.read(verificationSessionControllerProvider); + final session = await controller.getSession(widget.sessionId); + if (session == null) { + setState(() { + _error = 'Verification session not found.'; + _loading = false; + }); + return; + } + if (session.isExpired) { + setState(() { + _error = 'This verification session has expired.'; + _loading = false; + }); + return; + } + if (session.isCompleted) { + setState(() { + _error = 'This session has already been completed.'; + _loading = false; + }); + return; + } + setState(() => _loading = false); + + // Automatically start liveness detection + _startLiveness(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to load session: $e'; + _loading = false; + }); + } + } + } + + Future _startLiveness() async { + if (_verifying) return; + setState(() { + _verifying = true; + _error = null; + }); + + try { + final result = await face.runFaceLiveness(context); + + if (result == null) { + if (mounted) { + setState(() { + _error = 'Liveness check cancelled.'; + _verifying = false; + }); + } + return; + } + + // Upload the photo and complete the session + final controller = ref.read(verificationSessionControllerProvider); + await controller.completeSession( + sessionId: widget.sessionId, + bytes: result.imageBytes, + fileName: 'verify.jpg', + ); + + if (mounted) { + setState(() { + _done = true; + _verifying = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = 'Verification failed: $e'; + _verifying = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Face Verification')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildContent(theme, colors), + ), + ), + ); + } + + Widget _buildContent(ThemeData theme, ColorScheme colors) { + if (_loading) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading verification session...'), + ], + ); + } + + if (_done) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, size: 64, color: Colors.green), + const SizedBox(height: 16), + Text('Verification Complete', style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + 'You can close this screen and return to the web app.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('Close'), + ), + ], + ); + } + + if (_error != null) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: colors.error), + const SizedBox(height: 16), + Text( + 'Verification Error', + style: theme.textTheme.headlineSmall?.copyWith(color: colors.error), + ), + const SizedBox(height: 8), + Text( + _error!, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _startLiveness, + child: const Text('Try Again'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('Cancel'), + ), + ], + ); + } + + if (_verifying) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Processing verification...'), + ], + ); + } + + // Fallback: prompt to start + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.face, size: 64, color: colors.primary), + const SizedBox(height: 16), + Text('Ready to Verify', style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + 'Tap the button below to start face liveness detection.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _startLiveness, + icon: const Icon(Icons.face), + label: const Text('Start Verification'), + ), + ], + ); + } +} diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart index f666d6bd..a42ddfa3 100644 --- a/lib/screens/whereabouts/whereabouts_screen.dart +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -33,6 +33,7 @@ class _WhereaboutsScreenState extends ConsumerState { }; return ResponsiveBody( + maxWidth: 1200, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -76,9 +77,13 @@ class _WhereaboutsScreenState extends ConsumerState { Map profileById, GeofenceConfig? geofenceConfig, ) { - final markers = positions.map((pos) { - final name = profileById[pos.userId]?.fullName ?? 'Unknown'; - final inPremise = pos.inPremise; + // Only pin users who are in-premise (privacy: don't reveal off-site locations). + final inPremisePositions = positions.where((pos) => pos.inPremise).toList(); + + final markers = inPremisePositions.map((pos) { + final profile = profileById[pos.userId]; + final name = profile?.fullName ?? 'Unknown'; + final pinColor = _roleColor(profile?.role); return Marker( point: LatLng(pos.lat, pos.lng), width: 80, @@ -106,11 +111,7 @@ class _WhereaboutsScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ), - Icon( - Icons.location_pin, - size: 28, - color: inPremise ? Colors.green : Colors.orange, - ), + Icon(Icons.location_pin, size: 28, color: pinColor), ], ), ); @@ -167,46 +168,126 @@ class _WhereaboutsScreenState extends ConsumerState { ) { if (positions.isEmpty) return const SizedBox.shrink(); - return Container( - constraints: const BoxConstraints(maxHeight: 180), - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: positions.length, - itemBuilder: (context, index) { - final pos = positions[index]; - final p = profileById[pos.userId]; - final name = p?.fullName ?? 'Unknown'; - final role = p?.role ?? '-'; - final timeAgo = _timeAgo(pos.updatedAt); + // Only include Admin, IT Staff, and Dispatcher in the legend. + final relevantRoles = {'admin', 'dispatcher', 'it_staff'}; + final legendEntries = positions.where((pos) { + final role = profileById[pos.userId]?.role; + return role != null && relevantRoles.contains(role); + }).toList(); - return ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: pos.inPremise - ? Colors.green.shade100 - : Colors.orange.shade100, - child: Icon( - pos.inPremise ? Icons.check : Icons.location_off, - size: 16, - color: pos.inPremise ? Colors.green : Colors.orange, - ), + if (legendEntries.isEmpty) return const SizedBox.shrink(); + + return Container( + constraints: const BoxConstraints(maxHeight: 220), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role color legend header + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + _legendDot(Colors.blue.shade700), + const SizedBox(width: 4), + Text('Admin', style: Theme.of(context).textTheme.labelSmall), + const SizedBox(width: 12), + _legendDot(Colors.green.shade700), + const SizedBox(width: 4), + Text('IT Staff', style: Theme.of(context).textTheme.labelSmall), + const SizedBox(width: 12), + _legendDot(Colors.orange.shade700), + const SizedBox(width: 4), + Text( + 'Dispatcher', + style: Theme.of(context).textTheme.labelSmall, + ), + ], ), - title: Text(name), - subtitle: Text('${_roleLabel(role)} · $timeAgo'), - trailing: Text( - pos.inPremise ? 'In premise' : 'Off-site', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: pos.inPremise ? Colors.green : Colors.orange, - ), + ), + // Staff entries + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + itemCount: legendEntries.length, + itemBuilder: (context, index) { + final pos = legendEntries[index]; + final p = profileById[pos.userId]; + final name = p?.fullName ?? 'Unknown'; + final role = p?.role ?? '-'; + final isInPremise = pos.inPremise; + final pinColor = _roleColor(role); + final timeAgo = _timeAgo(pos.updatedAt); + // Grey out outside-premise users for privacy. + final effectiveColor = isInPremise + ? pinColor + : Colors.grey.shade400; + + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: effectiveColor.withValues(alpha: 0.2), + child: Icon( + isInPremise ? Icons.location_pin : Icons.location_off, + size: 16, + color: effectiveColor, + ), + ), + title: Text( + name, + style: TextStyle( + color: isInPremise ? null : Colors.grey, + fontWeight: isInPremise + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + subtitle: Text( + '${_roleLabel(role)} · ${isInPremise ? timeAgo : 'Outside premise'}', + style: TextStyle(color: isInPremise ? null : Colors.grey), + ), + trailing: isInPremise + ? Icon(Icons.circle, size: 10, color: pinColor) + : Icon( + Icons.circle, + size: 10, + color: Colors.grey.shade300, + ), + onTap: isInPremise + ? () => + _mapController.move(LatLng(pos.lat, pos.lng), 17.0) + : null, + ); + }, ), - onTap: () => _mapController.move(LatLng(pos.lat, pos.lng), 17.0), - ); - }, + ), + ], ), ); } + Widget _legendDot(Color color) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } + + /// Returns the pin color for a given role. + static Color _roleColor(String? role) { + switch (role) { + case 'admin': + return Colors.blue.shade700; + case 'it_staff': + return Colors.green.shade700; + case 'dispatcher': + return Colors.orange.shade700; + default: + return Colors.grey; + } + } + String _timeAgo(DateTime dt) { final diff = AppTime.now().difference(dt); if (diff.inMinutes < 1) return 'Just now'; diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 9634cd6d..7e37bd91 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -124,9 +124,13 @@ class _SchedulePanel extends ConsumerWidget { data: (allSchedules) { final now = AppTime.now(); final today = DateTime(now.year, now.month, now.day); + // Exclude overtime schedules – they only belong in the Logbook. + final nonOvertime = allSchedules + .where((s) => s.shiftType != 'overtime') + .toList(); final schedules = showPast - ? allSchedules - : allSchedules + ? nonOvertime + : nonOvertime .where((s) => !s.endTime.isBefore(today)) .toList(); diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index 543edb01..d12b4afd 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -54,7 +54,7 @@ void callbackDispatcher() { /// Initialize Workmanager and register periodic background location task. Future initBackgroundLocationService() async { - await Workmanager().initialize(callbackDispatcher, isInDebugMode: false); + await Workmanager().initialize(callbackDispatcher); } /// Register a periodic task to report location every ~15 minutes @@ -65,7 +65,7 @@ Future startBackgroundLocationUpdates() async { _taskName, frequency: const Duration(minutes: 15), constraints: Constraints(networkType: NetworkType.connected), - existingWorkPolicy: ExistingWorkPolicy.keep, + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, ); } diff --git a/lib/services/face_verification.dart b/lib/services/face_verification.dart new file mode 100644 index 00000000..f030322d --- /dev/null +++ b/lib/services/face_verification.dart @@ -0,0 +1,3 @@ +export 'face_verification_stub.dart' + if (dart.library.io) 'face_verification_mobile.dart' + if (dart.library.js_interop) 'face_verification_web.dart'; diff --git a/lib/services/face_verification_mobile.dart b/lib/services/face_verification_mobile.dart new file mode 100644 index 00000000..b8b80ac9 --- /dev/null +++ b/lib/services/face_verification_mobile.dart @@ -0,0 +1,172 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_liveness_check/flutter_liveness_check.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; + +/// Result from a face liveness check. +class FaceLivenessResult { + final Uint8List imageBytes; + final String? imagePath; + FaceLivenessResult({required this.imageBytes, this.imagePath}); +} + +/// Run face liveness detection on mobile using flutter_liveness_check. +/// Navigates to the LivenessCheckScreen and returns the captured photo. +Future runFaceLiveness( + BuildContext context, { + int requiredBlinks = 3, +}) async { + String? capturedPath; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => LivenessCheckScreen( + config: LivenessCheckConfig( + callbacks: LivenessCheckCallbacks( + onPhotoTaken: (path) { + capturedPath = path; + // Package never calls onSuccess in v1.0.3 — pop here + // so the screen doesn't hang after photo capture. + Navigator.of(ctx).pop(); + }, + // Don't pop in onCancel/onError — the package's AppBar + // already calls Navigator.pop() after invoking these. + ), + settings: LivenessCheckSettings( + requiredBlinkCount: requiredBlinks, + requireSmile: false, + autoNavigateOnSuccess: false, + ), + ), + ), + ), + ); + + if (capturedPath == null) return null; + + final file = File(capturedPath!); + if (!await file.exists()) return null; + + final bytes = await file.readAsBytes(); + return FaceLivenessResult(imageBytes: bytes, imagePath: capturedPath); +} + +/// Compare a captured face photo with enrolled face photo bytes. +/// Uses Google ML Kit face contour comparison. +/// Returns similarity score 0.0 (no match) to 1.0 (perfect match). +Future compareFaces( + Uint8List capturedBytes, + Uint8List enrolledBytes, +) async { + final detector = FaceDetector( + options: FaceDetectorOptions( + enableContours: true, + performanceMode: FaceDetectorMode.accurate, + ), + ); + + try { + // Save both to temp files for ML Kit + final tempDir = Directory.systemTemp; + final capturedFile = File('${tempDir.path}/face_captured_temp.jpg'); + await capturedFile.writeAsBytes(capturedBytes); + + final enrolledFile = File('${tempDir.path}/face_enrolled_temp.jpg'); + await enrolledFile.writeAsBytes(enrolledBytes); + + // Process both images + final capturedInput = InputImage.fromFilePath(capturedFile.path); + final enrolledInput = InputImage.fromFilePath(enrolledFile.path); + + final capturedFaces = await detector.processImage(capturedInput); + final enrolledFaces = await detector.processImage(enrolledInput); + + // Cleanup temp files + await capturedFile.delete().catchError((_) => capturedFile); + await enrolledFile.delete().catchError((_) => enrolledFile); + + if (capturedFaces.isEmpty || enrolledFaces.isEmpty) return 0.0; + + return _compareContours(capturedFaces.first, enrolledFaces.first); + } catch (_) { + return 0.0; + } finally { + await detector.close(); + } +} + +double _compareContours(Face face1, Face face2) { + const contourTypes = [ + FaceContourType.face, + FaceContourType.leftEye, + FaceContourType.rightEye, + FaceContourType.noseBridge, + FaceContourType.noseBottom, + FaceContourType.upperLipTop, + FaceContourType.lowerLipBottom, + ]; + + double totalScore = 0; + int validComparisons = 0; + + for (final type in contourTypes) { + final c1 = face1.contours[type]; + final c2 = face2.contours[type]; + + if (c1 != null && + c2 != null && + c1.points.isNotEmpty && + c2.points.isNotEmpty) { + final score = _comparePointSets(c1.points, c2.points); + totalScore += score; + validComparisons++; + } + } + + if (validComparisons == 0) return 0.0; + return totalScore / validComparisons; +} + +double _comparePointSets(List> points1, List> points2) { + final norm1 = _normalizePoints(points1); + final norm2 = _normalizePoints(points2); + + final n = min(norm1.length, norm2.length); + if (n == 0) return 0.0; + + double totalDist = 0; + for (int i = 0; i < n; i++) { + final dx = norm1[i].x - norm2[i].x; + final dy = norm1[i].y - norm2[i].y; + totalDist += sqrt(dx * dx + dy * dy); + } + + final avgDist = totalDist / n; + // Convert distance to similarity: 0 distance → 1.0 score + return max(0.0, 1.0 - avgDist * 2.5); +} + +List> _normalizePoints(List> points) { + if (points.isEmpty) return []; + + double minX = double.infinity, minY = double.infinity; + double maxX = double.negativeInfinity, maxY = double.negativeInfinity; + + for (final p in points) { + minX = min(minX, p.x.toDouble()); + minY = min(minY, p.y.toDouble()); + maxX = max(maxX, p.x.toDouble()); + maxY = max(maxY, p.y.toDouble()); + } + + final w = maxX - minX; + final h = maxY - minY; + if (w == 0 || h == 0) return []; + + return points + .map((p) => Point((p.x - minX) / w, (p.y - minY) / h)) + .toList(); +} diff --git a/lib/services/face_verification_stub.dart b/lib/services/face_verification_stub.dart new file mode 100644 index 00000000..8d83fb77 --- /dev/null +++ b/lib/services/face_verification_stub.dart @@ -0,0 +1,29 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +/// Result from a face liveness check. +class FaceLivenessResult { + final Uint8List imageBytes; + final String? imagePath; + FaceLivenessResult({required this.imageBytes, this.imagePath}); +} + +/// Run face liveness detection. Returns captured photo or null if cancelled. +/// Stub implementation for unsupported platforms. +Future runFaceLiveness( + BuildContext context, { + int requiredBlinks = 3, +}) async { + return null; +} + +/// Compare a captured face photo with enrolled face photo bytes. +/// Returns similarity score 0.0 (no match) to 1.0 (perfect match). +/// Stub returns 0.0. +Future compareFaces( + Uint8List capturedBytes, + Uint8List enrolledBytes, +) async { + return 0.0; +} diff --git a/lib/services/face_verification_web.dart b/lib/services/face_verification_web.dart new file mode 100644 index 00000000..d1878a8e --- /dev/null +++ b/lib/services/face_verification_web.dart @@ -0,0 +1,363 @@ +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/material.dart'; + +// ─── JS interop bindings ─────────────────────────────────────────────────── + +@JS() +external JSPromise initFaceApi(); + +@JS() +external JSObject createFaceContainer(JSString id); + +@JS() +external JSPromise startWebCamera(JSString containerId); + +@JS() +external void stopWebCamera(JSString containerId); + +@JS() +external JSPromise runWebLiveness( + JSString containerId, + JSNumber requiredBlinks, +); + +@JS() +external void cancelWebLiveness(); + +@JS() +external JSPromise getFaceDescriptorFromDataUrl(JSString dataUrl); + +@JS() +external JSPromise getFaceDescriptorFromUrl(JSString url); + +@JS() +external JSNumber compareFaceDescriptors(JSAny desc1, JSAny desc2); + +// ─── JS result type ──────────────────────────────────────────────────────── + +extension type _LivenessJSResult(JSObject _) implements JSObject { + external JSString get dataUrl; + external JSNumber get blinkCount; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/// Result from a face liveness check. +class FaceLivenessResult { + final Uint8List imageBytes; + final String? imagePath; + FaceLivenessResult({required this.imageBytes, this.imagePath}); +} + +/// Run face liveness detection on web using face-api.js. +/// Shows a dialog with camera preview and blink detection. +Future runFaceLiveness( + BuildContext context, { + int requiredBlinks = 3, +}) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _WebLivenessDialog(requiredBlinks: requiredBlinks), + ); +} + +/// Compare a captured face photo with enrolled face photo bytes. +/// Uses face-api.js face descriptors on web. +/// Returns similarity score 0.0 (no match) to 1.0 (perfect match). +Future compareFaces( + Uint8List capturedBytes, + Uint8List enrolledBytes, +) async { + try { + final capturedDataUrl = + 'data:image/jpeg;base64,${base64Encode(capturedBytes)}'; + final enrolledDataUrl = + 'data:image/jpeg;base64,${base64Encode(enrolledBytes)}'; + + final desc1Result = await getFaceDescriptorFromDataUrl( + capturedDataUrl.toJS, + ).toDart; + final desc2Result = await getFaceDescriptorFromDataUrl( + enrolledDataUrl.toJS, + ).toDart; + + if (desc1Result == null || desc2Result == null) return 0.0; + + final distance = compareFaceDescriptors( + desc1Result, + desc2Result, + ).toDartDouble; + + // face-api.js distance: 0 = identical, ~0.6 = threshold, 1+ = very different + // Convert to similarity score: 1.0 = perfect match, 0.0 = no match + return (1.0 - distance).clamp(0.0, 1.0); + } catch (_) { + return 0.0; + } +} + +// ─── Web Liveness Dialog ──────────────────────────────────────────────────── + +bool _viewFactoryRegistered = false; + +class _WebLivenessDialog extends StatefulWidget { + final int requiredBlinks; + const _WebLivenessDialog({required this.requiredBlinks}); + + @override + State<_WebLivenessDialog> createState() => _WebLivenessDialogState(); +} + +enum _WebLivenessState { loading, cameraStarting, detecting, error } + +class _WebLivenessDialogState extends State<_WebLivenessDialog> { + late final String _containerId; + late final String _viewType; + _WebLivenessState _state = _WebLivenessState.loading; + String _statusText = 'Loading face detection models...'; + int _blinkCount = 0; + String? _errorText; + bool _popped = false; + + @override + void initState() { + super.initState(); + _containerId = 'face-cam-${DateTime.now().millisecondsSinceEpoch}'; + _viewType = 'web-face-cam-$_containerId'; + + // Register a unique platform view factory for this dialog instance + if (!_viewFactoryRegistered) { + _viewFactoryRegistered = true; + } + ui_web.platformViewRegistry.registerViewFactory(_viewType, ( + int viewId, { + Object? params, + }) { + return createFaceContainer(_containerId.toJS); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) => _initialize()); + } + + Future _initialize() async { + try { + // Load face-api.js models + final loaded = await initFaceApi().toDart; + if (!loaded.toDart) { + _setError('Failed to load face detection models.'); + return; + } + + if (!mounted) return; + setState(() { + _state = _WebLivenessState.cameraStarting; + _statusText = 'Starting camera...'; + }); + + // Give the platform view a moment to render + await Future.delayed(const Duration(milliseconds: 500)); + + // Start camera + final cameraStarted = await startWebCamera(_containerId.toJS).toDart; + if (!cameraStarted.toDart) { + _setError( + 'Camera access denied or unavailable.\n' + 'Please allow camera access and try again.', + ); + return; + } + + if (!mounted) return; + setState(() { + _state = _WebLivenessState.detecting; + _statusText = 'Look at the camera and blink naturally'; + _blinkCount = 0; + }); + + // Start liveness detection + _runLiveness(); + } catch (e) { + _setError('Initialization failed: $e'); + } + } + + Future _runLiveness() async { + try { + final result = await runWebLiveness( + _containerId.toJS, + widget.requiredBlinks.toJS, + ).toDart; + + if (result == null) { + // Cancelled — _cancel() may have already popped + if (mounted && !_popped) { + _popped = true; + Navigator.of(context).pop(null); + } + return; + } + + final jsResult = result as _LivenessJSResult; + final dataUrl = jsResult.dataUrl.toDart; + + // Convert data URL to bytes + final base64Data = dataUrl.split(',')[1]; + final bytes = base64Decode(base64Data); + + if (mounted && !_popped) { + _popped = true; + Navigator.of( + context, + ).pop(FaceLivenessResult(imageBytes: Uint8List.fromList(bytes))); + } + } catch (e) { + _setError('Liveness detection failed: $e'); + } + } + + void _setError(String message) { + if (!mounted) return; + setState(() { + _state = _WebLivenessState.error; + _errorText = message; + }); + } + + void _cancel() { + if (_popped) return; + _popped = true; + cancelWebLiveness(); + stopWebCamera(_containerId.toJS); + Navigator.of(context).pop(null); + } + + void _retry() { + setState(() { + _state = _WebLivenessState.loading; + _statusText = 'Loading face detection models...'; + _errorText = null; + _blinkCount = 0; + }); + _initialize(); + } + + @override + void dispose() { + cancelWebLiveness(); + stopWebCamera(_containerId.toJS); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + title: Row( + children: [ + Icon(Icons.face, color: colors.primary), + const SizedBox(width: 12), + const Expanded(child: Text('Face Verification')), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Camera preview + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 320, + height: 240, + child: _state == _WebLivenessState.error + ? Container( + color: colors.errorContainer, + child: Center( + child: Icon( + Icons.videocam_off, + size: 48, + color: colors.onErrorContainer, + ), + ), + ) + : HtmlElementView(viewType: _viewType), + ), + ), + const SizedBox(height: 16), + + // Status + if (_state == _WebLivenessState.loading || + _state == _WebLivenessState.cameraStarting) + Column( + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 8), + Text( + _statusText, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ) + else if (_state == _WebLivenessState.detecting) + Column( + children: [ + Text( + _statusText, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: _blinkCount / widget.requiredBlinks, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 4), + Text( + 'Blinks: $_blinkCount / ${widget.requiredBlinks}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ) + else if (_state == _WebLivenessState.error) + Column( + children: [ + Icon(Icons.error_outline, color: colors.error, size: 32), + const SizedBox(height: 8), + Text( + _errorText ?? 'An error occurred.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.error, + ), + textAlign: TextAlign.center, + ), + ], + ), + ], + ), + ), + actions: [ + if (_state == _WebLivenessState.error) ...[ + TextButton(onPressed: _cancel, child: const Text('Cancel')), + FilledButton(onPressed: _retry, child: const Text('Retry')), + ] else + TextButton(onPressed: _cancel, child: const Text('Cancel')), + ], + ); + } +} diff --git a/lib/widgets/face_verification_overlay.dart b/lib/widgets/face_verification_overlay.dart new file mode 100644 index 00000000..78a847a1 --- /dev/null +++ b/lib/widgets/face_verification_overlay.dart @@ -0,0 +1,431 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/attendance_provider.dart'; +import '../providers/profile_provider.dart'; +import '../services/face_verification.dart' as face; +import '../theme/m3_motion.dart'; +import '../widgets/qr_verification_dialog.dart'; + +/// Phases of the full-screen face verification overlay. +enum _Phase { liveness, downloading, matching, success, failed, cancelled } + +/// Result returned from the overlay. +class FaceVerificationResult { + final bool verified; + final double? matchScore; + FaceVerificationResult({required this.verified, this.matchScore}); +} + +/// Shows a full-screen animated face verification overlay with up to +/// [maxAttempts] retries. Returns whether the user was verified. +Future showFaceVerificationOverlay({ + required BuildContext context, + required WidgetRef ref, + required String attendanceLogId, + int maxAttempts = 3, +}) { + return Navigator.of(context).push( + PageRouteBuilder( + opaque: true, + pageBuilder: (ctx, anim, secAnim) => _FaceVerificationOverlay( + attendanceLogId: attendanceLogId, + maxAttempts: maxAttempts, + ), + transitionsBuilder: (ctx, anim, secAnim, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: M3Motion.standard, + reverseTransitionDuration: M3Motion.short, + ), + ); +} + +class _FaceVerificationOverlay extends ConsumerStatefulWidget { + const _FaceVerificationOverlay({ + required this.attendanceLogId, + required this.maxAttempts, + }); + + final String attendanceLogId; + final int maxAttempts; + + @override + ConsumerState<_FaceVerificationOverlay> createState() => + _FaceVerificationOverlayState(); +} + +class _FaceVerificationOverlayState + extends ConsumerState<_FaceVerificationOverlay> + with TickerProviderStateMixin { + _Phase _phase = _Phase.liveness; + int _attempt = 1; + String _statusText = 'Preparing liveness check...'; + late AnimationController _scanCtrl; + late AnimationController _pulseCtrl; + late Animation _pulseAnim; + + @override + void initState() { + super.initState(); + _scanCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _pulseAnim = Tween( + begin: 0.85, + end: 1.0, + ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); + + WidgetsBinding.instance.addPostFrameCallback((_) => _runAttempt()); + } + + @override + void dispose() { + _scanCtrl.dispose(); + _pulseCtrl.dispose(); + super.dispose(); + } + + Future _runAttempt() async { + if (!mounted) return; + setState(() { + _phase = _Phase.liveness; + _statusText = + 'Attempt $_attempt of ${widget.maxAttempts}\nPerforming liveness check...'; + }); + + // 1. Liveness check + final result = await face.runFaceLiveness(context); + if (result == null) { + if (!mounted) return; + // Cancelled: on web offer QR, otherwise mark cancelled + if (kIsWeb) { + final completed = await showQrVerificationDialog( + context: context, + ref: ref, + type: 'verification', + contextId: widget.attendanceLogId, + ); + if (mounted) { + Navigator.of( + context, + ).pop(FaceVerificationResult(verified: completed)); + } + } else { + setState(() { + _phase = _Phase.cancelled; + _statusText = 'Verification cancelled.'; + }); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) { + Navigator.of(context).pop(FaceVerificationResult(verified: false)); + } + } + return; + } + + // 2. Download enrolled photo + if (!mounted) return; + setState(() { + _phase = _Phase.downloading; + _statusText = 'Fetching enrolled face data...'; + }); + + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null || !profile.hasFaceEnrolled) { + _skipVerification('No enrolled face found.'); + return; + } + + final enrolledBytes = await ref + .read(profileControllerProvider) + .downloadFacePhoto(profile.id); + if (enrolledBytes == null) { + _skipVerification('Could not download enrolled face.'); + return; + } + + // 3. Face matching + if (!mounted) return; + setState(() { + _phase = _Phase.matching; + _statusText = 'Analyzing face match...'; + }); + + final score = await face.compareFaces(result.imageBytes, enrolledBytes); + + if (score >= 0.60) { + // Success! + if (!mounted) return; + setState(() { + _phase = _Phase.success; + _statusText = + 'Face verified!\n${(score * 100).toStringAsFixed(0)}% match'; + }); + await _uploadResult(result.imageBytes, 'verified'); + await Future.delayed(const Duration(milliseconds: 1200)); + if (mounted) { + Navigator.of( + context, + ).pop(FaceVerificationResult(verified: true, matchScore: score)); + } + } else { + // Failed attempt + if (_attempt < widget.maxAttempts) { + if (!mounted) return; + setState(() { + _phase = _Phase.failed; + _statusText = + 'No match (${(score * 100).toStringAsFixed(0)}%)\n' + 'Attempt $_attempt of ${widget.maxAttempts}\n' + 'Retrying...'; + }); + await Future.delayed(const Duration(milliseconds: 1500)); + _attempt++; + _runAttempt(); + } else { + // All attempts exhausted + if (!mounted) return; + setState(() { + _phase = _Phase.failed; + _statusText = + 'Face did not match after ${widget.maxAttempts} attempts\n' + '${(score * 100).toStringAsFixed(0)}% similarity'; + }); + await _uploadResult(result.imageBytes, 'unverified'); + await Future.delayed(const Duration(milliseconds: 1500)); + if (mounted) { + Navigator.of( + context, + ).pop(FaceVerificationResult(verified: false, matchScore: score)); + } + } + } + } + + Future _uploadResult(Uint8List bytes, String status) async { + try { + await ref + .read(attendanceControllerProvider) + .uploadVerification( + attendanceId: widget.attendanceLogId, + bytes: bytes, + fileName: 'verification.jpg', + status: status, + ); + } catch (_) {} + } + + Future _skipVerification(String reason) async { + try { + await ref + .read(attendanceControllerProvider) + .skipVerification(widget.attendanceLogId); + } catch (_) {} + if (!mounted) return; + setState(() { + _phase = _Phase.cancelled; + _statusText = reason; + }); + await Future.delayed(const Duration(milliseconds: 1200)); + if (mounted) { + Navigator.of(context).pop(FaceVerificationResult(verified: false)); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFaceIndicator(colors), + const SizedBox(height: 32), + AnimatedSwitcher( + duration: M3Motion.short, + child: Text( + _statusText, + key: ValueKey('$_phase-$_attempt'), + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith( + color: _phase == _Phase.success + ? Colors.green + : _phase == _Phase.failed + ? colors.error + : colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + if (_phase != _Phase.success && _phase != _Phase.cancelled) + _buildProgressIndicator(colors), + const SizedBox(height: 24), + _buildAttemptDots(colors), + ], + ), + ), + ), + ), + ); + } + + /// Animated face icon with scanning line and pulse. + Widget _buildFaceIndicator(ColorScheme colors) { + final isActive = + _phase == _Phase.liveness || + _phase == _Phase.downloading || + _phase == _Phase.matching; + final isSuccess = _phase == _Phase.success; + final isFailed = _phase == _Phase.failed; + + final Color ringColor; + if (isSuccess) { + ringColor = Colors.green; + } else if (isFailed) { + ringColor = colors.error; + } else { + ringColor = colors.primary; + } + + return SizedBox( + width: 200, + height: 200, + child: Stack( + alignment: Alignment.center, + children: [ + // Pulsing ring + ScaleTransition( + scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0), + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ringColor.withValues(alpha: 0.3), + width: 3, + ), + ), + ), + ), + // Inner circle with icon + AnimatedContainer( + duration: M3Motion.standard, + curve: M3Motion.standard_, + width: 140, + height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ringColor.withValues(alpha: 0.08), + border: Border.all(color: ringColor, width: 2), + ), + child: AnimatedSwitcher( + duration: M3Motion.short, + child: Icon( + isSuccess + ? Icons.check_circle_rounded + : isFailed + ? Icons.error_rounded + : Icons.face_rounded, + key: ValueKey(_phase), + size: 64, + color: ringColor, + ), + ), + ), + // Scanning line (only during active phases) + if (isActive) + AnimatedBuilder( + animation: _scanCtrl, + builder: (context, child) { + final yOffset = sin(_scanCtrl.value * 2 * pi) * 60; + return Transform.translate( + offset: Offset(0, yOffset), + child: Container( + width: 120, + height: 2, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colors.primary.withValues(alpha: 0.0), + colors.primary.withValues(alpha: 0.6), + colors.primary.withValues(alpha: 0.0), + ], + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildProgressIndicator(ColorScheme colors) { + if (_phase == _Phase.failed) { + return LinearProgressIndicator( + value: null, + backgroundColor: colors.error.withValues(alpha: 0.12), + color: colors.error, + borderRadius: BorderRadius.circular(4), + ); + } + return LinearProgressIndicator( + value: null, + backgroundColor: colors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + ); + } + + /// Dots showing attempt progress. + Widget _buildAttemptDots(ColorScheme colors) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.maxAttempts, (i) { + final attemptNum = i + 1; + final bool isDone = attemptNum < _attempt; + final bool isCurrent = attemptNum == _attempt; + final bool isSuccess_ = isCurrent && _phase == _Phase.success; + + Color dotColor; + if (isSuccess_) { + dotColor = Colors.green; + } else if (isDone) { + dotColor = colors.error.withValues(alpha: 0.5); + } else if (isCurrent) { + dotColor = colors.primary; + } else { + dotColor = colors.outlineVariant; + } + + return AnimatedContainer( + duration: M3Motion.short, + curve: M3Motion.standard_, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isCurrent ? 12 : 8, + height: isCurrent ? 12 : 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: dotColor), + ); + }), + ); + } +} diff --git a/lib/widgets/qr_verification_dialog.dart b/lib/widgets/qr_verification_dialog.dart new file mode 100644 index 00000000..915b899a --- /dev/null +++ b/lib/widgets/qr_verification_dialog.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../models/verification_session.dart'; +import '../providers/profile_provider.dart'; +import '../providers/verification_session_provider.dart'; +import '../utils/snackbar.dart'; + +/// Shows a dialog with a QR code for cross-device face verification. +/// +/// On web, when the camera is unavailable, this dialog is shown so the user +/// can scan the QR with the TasQ mobile app to perform liveness detection. +/// The dialog listens via Supabase Realtime for the session to complete. +/// +/// Returns `true` if the verification was completed successfully. +Future showQrVerificationDialog({ + required BuildContext context, + required WidgetRef ref, + required String type, + String? contextId, +}) async { + final controller = ref.read(verificationSessionControllerProvider); + + // Create the verification session + final session = await controller.createSession( + type: type, + contextId: contextId, + ); + + if (!context.mounted) return false; + + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _QrVerificationDialog(session: session), + ); + + // If dismissed without completion, expire the session + if (result != true) { + await controller.expireSession(session.id); + } + + return result == true; +} + +class _QrVerificationDialog extends ConsumerStatefulWidget { + const _QrVerificationDialog({required this.session}); + + final VerificationSession session; + + @override + ConsumerState<_QrVerificationDialog> createState() => + _QrVerificationDialogState(); +} + +class _QrVerificationDialogState extends ConsumerState<_QrVerificationDialog> { + StreamSubscription? _subscription; + bool _completed = false; + bool _expired = false; + late Timer _expiryTimer; + + @override + void initState() { + super.initState(); + + // Listen for session completion via realtime + final controller = ref.read(verificationSessionControllerProvider); + _subscription = controller.watchSession(widget.session.id).listen(( + session, + ) { + if (session.isCompleted && !_completed) { + _onCompleted(session); + } + }, onError: (_) {}); + + // Auto-expire after the session's TTL + final remaining = widget.session.expiresAt.difference( + DateTime.now().toUtc(), + ); + _expiryTimer = Timer(remaining.isNegative ? Duration.zero : remaining, () { + if (mounted && !_completed) { + setState(() => _expired = true); + } + }); + } + + Future _onCompleted(VerificationSession session) async { + _completed = true; + if (!mounted) return; + + // Apply the result (update profile face photo or attendance log) + final controller = ref.read(verificationSessionControllerProvider); + await controller.applySessionResult(session); + + // Invalidate profile so UI refreshes + ref.invalidate(currentProfileProvider); + + if (mounted) { + Navigator.of(context).pop(true); + showSuccessSnackBar( + context, + session.type == 'enrollment' + ? 'Face enrolled successfully via mobile.' + : 'Face verification completed via mobile.', + ); + } + } + + @override + void dispose() { + _subscription?.cancel(); + _expiryTimer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final qrData = 'tasq://verify/${widget.session.id}'; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + title: Row( + children: [ + Icon(Icons.qr_code_2, color: colors.primary), + const SizedBox(width: 12), + const Expanded(child: Text('Scan with Mobile')), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.session.type == 'enrollment' + ? 'Open the TasQ app on your phone and scan this QR code to enroll your face with liveness detection.' + : 'Open the TasQ app on your phone and scan this QR code to verify your face with liveness detection.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + if (_expired) + _buildExpiredState(theme, colors) + else + _buildQrCode(theme, colors, qrData), + const SizedBox(height: 16), + if (!_expired) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Waiting for mobile verification...', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ], + ); + } + + Widget _buildQrCode(ThemeData theme, ColorScheme colors, String qrData) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outlineVariant), + ), + child: QrImageView( + data: qrData, + version: QrVersions.auto, + size: 220, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black87, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black87, + ), + ), + ); + } + + Widget _buildExpiredState(ThemeData theme, ColorScheme colors) { + return Column( + children: [ + Icon(Icons.timer_off, size: 48, color: colors.error), + const SizedBox(height: 12), + Text( + 'Session expired', + style: theme.textTheme.titleMedium?.copyWith(color: colors.error), + ), + const SizedBox(height: 4), + Text( + 'Close this dialog and try again.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ); + } +}