Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection

This commit is contained in:
Marc Rejohn Castillano 2026-03-07 23:46:43 +08:00
parent 52ef36faac
commit 3dbebd4006
25 changed files with 4840 additions and 361 deletions

View File

@ -39,6 +39,15 @@
android:scheme="io.supabase.tasq"
android:host="login-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="tasq"
android:host="verify"
android:pathPrefix="/" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -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()

View File

@ -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<String, dynamic> 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?,
);
}
}

View File

@ -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<String, dynamic> 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;
}
}
}

View File

@ -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<String, dynamic> 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),
);
}
}

View File

@ -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<String, dynamic> 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<String, dynamic> toJson() {
return {
'user_id': userId,
'type': type,
if (contextId != null) 'context_id': contextId,
};
}
}

View File

@ -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<ReportDateRange>((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<String?> 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<void> 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<void> skipVerification(String attendanceId) async {
await _client
.from('attendance_logs')
.update({'verification_status': 'skipped'})
.eq('id', attendanceId);
}
}

View File

@ -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<DebugSettings> {
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<DebugSettingsNotifier, DebugSettings>(
(ref) => DebugSettingsNotifier(),
);

View File

@ -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<List<LeaveOfAbsence>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <LeaveOfAbsence>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
final wrapper = StreamRecoveryWrapper<LeaveOfAbsence>(
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<LeaveController>((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<void> 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<void> approveLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'approved'})
.eq('id', leaveId);
}
/// Reject a leave request.
Future<void> rejectLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'rejected'})
.eq('id', leaveId);
}
/// Cancel an approved leave.
Future<void> cancelLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'cancelled'})
.eq('id', leaveId);
}
}

View File

@ -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<String> 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<String> 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<Uint8List?> downloadFacePhoto(String userId) async {
try {
return await _client.storage
.from('face-enrollment')
.download('$userId/face.jpg');
} catch (_) {
return null;
}
}
}
final isAdminProvider = Provider<bool>((ref) {

View File

@ -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<VerificationSessionController>((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<VerificationSession> 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<VerificationSession?> 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<VerificationSession> 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<void> 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<void> 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<void> expireSession(String sessionId) async {
await _client
.from('verification_sessions')
.update({'status': 'expired'})
.eq('id', sessionId);
}
}

View File

@ -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<GoRouter>((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: [

File diff suppressed because it is too large Load Diff

View File

@ -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<AsyncValue<DashboardMetrics>>((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<AsyncValue<DashboardMetrics>>((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<AsyncValue<DashboardMetrics>>((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<AsyncValue<DashboardMetrics>>((ref) {
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
final schedules = schedulesAsync.valueOrNull ?? const <DutySchedule>[];
final allLogs = logsAsync.valueOrNull ?? const <AttendanceLog>[];
final positions = positionsAsync.valueOrNull ?? const <LivePosition>[];
final allLeaves = leavesAsync.valueOrNull ?? const <LeaveOfAbsence>[];
final allPassSlips = passSlipsAsync.valueOrNull ?? const <PassSlip>[];
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<AsyncValue<DashboardMetrics>>((ref) {
const triageWindow = Duration(minutes: 1);
final triageCutoff = now.subtract(triageWindow);
// Pre-index schedules, logs, and positions by user for efficient lookup.
final todaySchedulesByUser = <String, List<DutySchedule>>{};
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 = <String, List<AttendanceLog>>{};
for (final l in allLogs) {
if (!l.checkInAt.isBefore(startOfDay)) {
todayLogsByUser.putIfAbsent(l.userId, () => []).add(l);
}
}
final positionByUser = <String, LivePosition>{};
for (final p in positions) {
positionByUser[p.userId] = p;
}
// Index today's leaves by user.
final todayLeaveByUser = <String, LeaveOfAbsence>{};
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 = <String, PassSlip>{};
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<Duration> durations) {
if (durations.isEmpty) {
return null;

View File

@ -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<ProfileScreen> {
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<ProfileScreen> {
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<ProfileScreen> {
);
}
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<void> _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<void> _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<bool>(
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<void> _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<void> _onSaveDetails() async {
if (!_detailsKey.currentState!.validate()) return;
final id = ref.read(currentUserIdProvider);

View File

@ -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<MobileVerificationScreen> createState() =>
_MobileVerificationScreenState();
}
class _MobileVerificationScreenState
extends ConsumerState<MobileVerificationScreen> {
bool _loading = true;
bool _verifying = false;
bool _done = false;
String? _error;
@override
void initState() {
super.initState();
_loadSession();
}
Future<void> _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<void> _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'),
),
],
);
}
}

View File

@ -33,6 +33,7 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
};
return ResponsiveBody(
maxWidth: 1200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -76,9 +77,13 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
Map<String, Profile> 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<WhereaboutsScreen> {
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<WhereaboutsScreen> {
) {
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';

View File

@ -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();

View File

@ -54,7 +54,7 @@ void callbackDispatcher() {
/// Initialize Workmanager and register periodic background location task.
Future<void> 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<void> startBackgroundLocationUpdates() async {
_taskName,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.keep,
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
);
}

View File

@ -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';

View File

@ -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<FaceLivenessResult?> 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<double> 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<Point<int>> points1, List<Point<int>> 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<Point<double>> _normalizePoints(List<Point<int>> 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<double>((p.x - minX) / w, (p.y - minY) / h))
.toList();
}

View File

@ -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<FaceLivenessResult?> 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<double> compareFaces(
Uint8List capturedBytes,
Uint8List enrolledBytes,
) async {
return 0.0;
}

View File

@ -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<JSBoolean> initFaceApi();
@JS()
external JSObject createFaceContainer(JSString id);
@JS()
external JSPromise<JSBoolean> startWebCamera(JSString containerId);
@JS()
external void stopWebCamera(JSString containerId);
@JS()
external JSPromise<JSAny?> runWebLiveness(
JSString containerId,
JSNumber requiredBlinks,
);
@JS()
external void cancelWebLiveness();
@JS()
external JSPromise<JSAny?> getFaceDescriptorFromDataUrl(JSString dataUrl);
@JS()
external JSPromise<JSAny?> 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<FaceLivenessResult?> runFaceLiveness(
BuildContext context, {
int requiredBlinks = 3,
}) async {
return showDialog<FaceLivenessResult>(
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<double> 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<void> _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<void> _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')),
],
);
}
}

View File

@ -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<FaceVerificationResult?> showFaceVerificationOverlay({
required BuildContext context,
required WidgetRef ref,
required String attendanceLogId,
int maxAttempts = 3,
}) {
return Navigator.of(context).push<FaceVerificationResult>(
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<double> _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<double>(
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<void> _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<void> _uploadResult(Uint8List bytes, String status) async {
try {
await ref
.read(attendanceControllerProvider)
.uploadVerification(
attendanceId: widget.attendanceLogId,
bytes: bytes,
fileName: 'verification.jpg',
status: status,
);
} catch (_) {}
}
Future<void> _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),
);
}),
);
}
}

View File

@ -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<bool> 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<bool>(
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<VerificationSession>? _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<void> _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,
),
),
],
);
}
}