Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection

This commit is contained in:
2026-03-07 23:46:43 +08:00
parent 52ef36faac
commit 3dbebd4006
25 changed files with 4840 additions and 361 deletions
+52 -2
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -14,9 +16,9 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
return ReportDateRange(
start: today.subtract(const Duration(days: 7)),
start: today,
end: today.add(const Duration(days: 1)),
label: 'Last 7 Days',
label: 'Today',
);
});
@@ -95,4 +97,52 @@ class AttendanceController {
params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng},
);
}
/// Overtime check-in (no pre-existing schedule required).
/// Creates an overtime duty schedule + attendance log in one RPC call.
Future<String?> overtimeCheckIn({
required double lat,
required double lng,
String? justification,
}) async {
final data = await _client.rpc(
'overtime_check_in',
params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification},
);
return data as String?;
}
/// Upload a verification selfie and update the attendance log.
Future<void> uploadVerification({
required String attendanceId,
required Uint8List bytes,
required String fileName,
required String status, // 'verified', 'unverified'
}) async {
final userId = _client.auth.currentUser!.id;
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/$attendanceId.$ext';
await _client.storage
.from('attendance-verification')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage
.from('attendance-verification')
.getPublicUrl(path);
await _client
.from('attendance_logs')
.update({'verification_status': status, 'verification_photo_url': url})
.eq('id', attendanceId);
}
/// Mark an attendance log as skipped verification.
Future<void> skipVerification(String attendanceId) async {
await _client
.from('attendance_logs')
.update({'verification_status': 'skipped'})
.eq('id', attendanceId);
}
}
@@ -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
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/leave_of_absence.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// All visible leaves (own for standard, all for admin/dispatcher/it_staff).
final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <LeaveOfAbsence>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
final wrapper = StreamRecoveryWrapper<LeaveOfAbsence>(
stream: hasFullAccess
? client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.order('start_time', ascending: false)
: client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.eq('user_id', profile.id)
.order('start_time', ascending: false),
onPollData: () async {
final query = client.from('leave_of_absence').select();
final data = hasFullAccess
? await query.order('start_time', ascending: false)
: await query
.eq('user_id', profile.id)
.order('start_time', ascending: false);
return data.map(LeaveOfAbsence.fromMap).toList();
},
fromMap: LeaveOfAbsence.fromMap,
channelName: 'leave_of_absence',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final leaveControllerProvider = Provider<LeaveController>((ref) {
final client = ref.watch(supabaseClientProvider);
return LeaveController(client);
});
class LeaveController {
LeaveController(this._client);
final SupabaseClient _client;
/// File a leave of absence for the current user.
/// Caller controls auto-approval based on role policy.
Future<void> fileLeave({
required String leaveType,
required String justification,
required DateTime startTime,
required DateTime endTime,
required bool autoApprove,
}) async {
final uid = _client.auth.currentUser!.id;
await _client.from('leave_of_absence').insert({
'user_id': uid,
'leave_type': leaveType,
'justification': justification,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'status': autoApprove ? 'approved' : 'pending',
'filed_by': uid,
});
}
/// Approve a leave request.
Future<void> approveLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'approved'})
.eq('id', leaveId);
}
/// Reject a leave request.
Future<void> rejectLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'rejected'})
.eq('id', leaveId);
}
/// Cancel an approved leave.
Future<void> cancelLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'cancelled'})
.eq('id', leaveId);
}
}
+59
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -92,6 +93,64 @@ class ProfileController {
}
await _client.auth.updateUser(UserAttributes(password: password));
}
/// Upload a profile avatar image and update the profile record.
Future<String> uploadAvatar({
required String userId,
required Uint8List bytes,
required String fileName,
}) async {
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/avatar.$ext';
await _client.storage
.from('avatars')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage.from('avatars').getPublicUrl(path);
await _client.from('profiles').update({'avatar_url': url}).eq('id', userId);
return url;
}
/// Upload a face enrollment photo and update the profile record.
Future<String> uploadFacePhoto({
required String userId,
required Uint8List bytes,
required String fileName,
}) async {
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/face.$ext';
await _client.storage
.from('face-enrollment')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage.from('face-enrollment').getPublicUrl(path);
await _client
.from('profiles')
.update({
'face_photo_url': url,
'face_enrolled_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', userId);
return url;
}
/// Download the face enrollment photo bytes for the given user.
/// Uses Supabase authenticated storage API (works with private buckets).
Future<Uint8List?> downloadFacePhoto(String userId) async {
try {
return await _client.storage
.from('face-enrollment')
.download('$userId/face.jpg');
} catch (_) {
return null;
}
}
}
final isAdminProvider = Provider<bool>((ref) {
@@ -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);
}
}