364 lines
11 KiB
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')),
|
|
],
|
|
);
|
|
}
|
|
}
|