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,
+ ),
+ ),
+ ],
+ );
+ }
+}