Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection
This commit is contained in:
parent
52ef36faac
commit
3dbebd4006
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
lib/models/leave_of_absence.dart
Normal file
54
lib/models/leave_of_absence.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
lib/models/verification_session.dart
Normal file
54
lib/models/verification_session.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
lib/providers/debug_settings_provider.dart
Normal file
35
lib/providers/debug_settings_provider.dart
Normal 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(),
|
||||
);
|
||||
105
lib/providers/leave_provider.dart
Normal file
105
lib/providers/leave_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
123
lib/providers/verification_session_provider.dart
Normal file
123
lib/providers/verification_session_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
234
lib/screens/shared/mobile_verification_screen.dart
Normal file
234
lib/screens/shared/mobile_verification_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
3
lib/services/face_verification.dart
Normal file
3
lib/services/face_verification.dart
Normal 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';
|
||||
172
lib/services/face_verification_mobile.dart
Normal file
172
lib/services/face_verification_mobile.dart
Normal 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();
|
||||
}
|
||||
29
lib/services/face_verification_stub.dart
Normal file
29
lib/services/face_verification_stub.dart
Normal 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;
|
||||
}
|
||||
363
lib/services/face_verification_web.dart
Normal file
363
lib/services/face_verification_web.dart
Normal 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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
431
lib/widgets/face_verification_overlay.dart
Normal file
431
lib/widgets/face_verification_overlay.dart
Normal 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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
230
lib/widgets/qr_verification_dialog.dart
Normal file
230
lib/widgets/qr_verification_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user