tasq/lib/services/face_verification_web.dart

364 lines
11 KiB
Dart

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