Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user