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 initFaceApi(); @JS() external JSObject createFaceContainer(JSString id); @JS() external JSPromise startWebCamera(JSString containerId); @JS() external void stopWebCamera(JSString containerId); @JS() external JSPromise runWebLiveness( JSString containerId, JSNumber requiredBlinks, ); @JS() external void cancelWebLiveness(); @JS() external JSPromise getFaceDescriptorFromDataUrl(JSString dataUrl); @JS() external JSPromise 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 runFaceLiveness( BuildContext context, { int requiredBlinks = 3, }) async { return showDialog( 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 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 _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 _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')), ], ); } }