flutter_face_nova 1.0.0+1
flutter_face_nova: ^1.0.0+1 copied to clipboard
Offline, on-device Flutter SDK for face liveness detection and identity verification. No server. No internet required.
example/lib/main.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_face_nova/flutter_face_nova.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:uuid/uuid.dart';
import 'enrolled_face.dart';
import 'settings_screen.dart';
import 'settings_store.dart';
import 'store_preview_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Replace with your own key generated by generate_license_key.py
// ─────────────────────────────────────────────────────────────────────────────
// Android: com.liveness.liveness_app — key below (expires 2027-12-31)
// iOS: com.liveness.livenessApp — key below (expires 2027-12-31)
const _kLicenseKeyAndroid =
'LxkrQhkdAlYcLkAKcV5fVykYIx8GKxVDAjcBSW0FGxB+W3VdCSExBj0Ze0ppQxlO'
'OzUjWU1EPGomJ38Ob10FUyE3cAtCESNgBAhSLxhjQmhx';
const _kLicenseKeyIOS =
'LxkrQhkdAlYcLkAKcV5fVykYIx8GNQRDDnkDS2gfBxNhRXcQR0YiYQomRE0aRkcX'
'GzUMXk0OJnldfgA4O1pSaSQbBCY+RAFDGwV2G2d1Dhw=';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await FaceStore.init();
await SettingsStore.init();
// runApp() is called immediately so the Flutter UI appears at once.
// SDK init (ONNX model loading) runs inside HomeScreen.initState() instead,
// which prevents the Android native splash from blocking for 15-30 seconds.
await Permission.camera.request();
runApp(const ExampleApp());
}
// ─────────────────────────────────────────────────────────────────────────────
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'FaceNova',
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF0A0F1E),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF22D37A),
surface: Color(0xFF111827),
),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Home Screen
// ─────────────────────────────────────────────────────────────────────────────
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
enum _MatchStep { idle, liveness, matching }
class _HomeScreenState extends State<HomeScreen> {
List<EnrolledFace> _enrolled = [];
FaceMatchResult? _matchResult;
LivenessResult? _livenessFailResult;
_MatchStep _step = _MatchStep.idle;
bool _enrolling = false; // true while gallery image is being processed
@override
void initState() {
super.initState();
_reload();
// Defer SDK init until after the first frame is painted so the home screen
// appears immediately. OrtSession.fromBuffer() is a blocking native call
// that would otherwise stall the first render.
WidgetsBinding.instance.addPostFrameCallback((_) => _initSdk());
}
Future<void> _initSdk() async {
try {
final key = Platform.isIOS ? _kLicenseKeyIOS : _kLicenseKeyAndroid;
await FlutterFaceNova.initialize(licenseKey: key);
} catch (e) {
debugPrint('[Example] SDK init failed: $e');
}
if (mounted) setState(() {}); // refresh SDK status indicator
}
void _reload() => setState(() => _enrolled = FaceStore.getAll());
// ── Enroll from gallery ────────────────────────────────────────────────────
Future<void> _enrollFromGallery() async {
if (_step != _MatchStep.idle) return;
final picker = ImagePicker();
final xfile = await picker.pickImage(source: ImageSource.gallery, imageQuality: 80);
if (xfile == null || !mounted) return;
final bytes = await xfile.readAsBytes();
if (!mounted) return;
// Show loading spinner in the enrolled-faces area immediately after pick.
setState(() { _step = _MatchStep.liveness; _enrolling = true; });
try {
final meta = await FlutterFaceNova.generateFaceMetadata(bytes);
if (meta == null) {
_showSnack('Could not detect a face in that image.');
return;
}
final name = await _askName();
if (name == null || name.trim().isEmpty) return;
final face = EnrolledFace(
id: const Uuid().v4(),
name: name.trim(),
metadata: meta.metadata,
photo: meta.croppedImage,
);
await FaceStore.add(face);
_reload();
_showSnack('${face.name} enrolled.');
} finally {
if (mounted) setState(() { _step = _MatchStep.idle; _enrolling = false; });
}
}
Future<String?> _askName() => showDialog<String>(
context: context,
builder: (ctx) {
final ctrl = TextEditingController();
return AlertDialog(
backgroundColor: const Color(0xFF111827),
title: const Text('Enter name', style: TextStyle(color: Colors.white)),
content: TextField(
controller: ctrl,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(hintText: 'Full name'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, ctrl.text),
child: const Text('Save', style: TextStyle(color: Color(0xFF22D37A))),
),
],
);
},
);
// ── Liveness → face match (single camera, two visible steps) ─────────────
Future<void> _startFaceMatch() async {
if (_step != _MatchStep.idle) return;
if (_enrolled.isEmpty) { _showSnack('Enroll at least one face first.'); return; }
setState(() {
_step = _MatchStep.liveness;
_matchResult = null;
_livenessFailResult = null;
});
// Step 1 — camera opens, liveness runs
final liveness = await FlutterFaceNova.startLiveness(
context,
livenessThreshold: SettingsStore.livenessThreshold,
backCameraThreshold: SettingsStore.backCameraThreshold,
);
if (!mounted) return;
if (liveness == null || !liveness.isReal) {
setState(() { _step = _MatchStep.idle; _livenessFailResult = liveness; });
return;
}
// Step 2 — liveness passed; show progress dialog, match on same image (no camera)
setState(() => _step = _MatchStep.matching);
bool dialogOpen = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const _VerifyingDialog(),
).then((_) => dialogOpen = false);
final entries = _enrolled.map((e) => FaceEntry(
id: e.id, name: e.name, metadata: e.metadata, photo: e.photo,
)).toList();
final result = await FlutterFaceNova.matchFaceFromImage(
liveness.imageBytes!,
enrolledFaces: entries,
matchThreshold: SettingsStore.matchThreshold,
);
if (!mounted) return;
if (dialogOpen) Navigator.of(context, rootNavigator: true).pop();
setState(() {
_step = _MatchStep.idle;
_matchResult = (result != null && result.isMatch) ? result : null;
});
final livePct = (liveness.score * 100).toStringAsFixed(1);
if (result == null || !result.isMatch) {
if (result != null && result.matchedFace != null) {
_showSnack(
'Liveness: $livePct% · No match — closest: ${result.matchedFace!.name} (${result.score.toStringAsFixed(1)}%). Please try again.',
);
} else {
_showSnack('Liveness: $livePct% · No face detected — please try again.');
}
} else {
_showSnack('Liveness: $livePct% · Match: ${result.score.toStringAsFixed(1)}% — Identity verified.');
}
}
void _showSnack(String msg, {Duration duration = const Duration(seconds: 4)}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
behavior: SnackBarBehavior.floating,
duration: duration,
),
);
}
Future<void> _removeEnrolled(EnrolledFace face) async {
await FaceStore.remove(face.id);
_reload();
_showSnack('${face.name} removed.');
}
@override
Widget build(BuildContext context) {
final busy = _step != _MatchStep.idle;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 28),
// Header
Row(children: [
// App logo
Container(
width: 48, height: 48,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: const LinearGradient(
colors: [Color(0xFF1A6FFF), Color(0xFF22D37A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF22D37A).withValues(alpha: 0.30),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(Icons.fingerprint_rounded,
color: Colors.white, size: 28),
),
const SizedBox(width: 14),
const Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('FaceNova',
style: TextStyle(color: Colors.white, fontSize: 18,
fontWeight: FontWeight.w700)),
Text('100% offline · on-device AI',
style: TextStyle(color: Colors.white38, fontSize: 12)),
]),
),
Row(children: [
GestureDetector(
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const StorePreviewScreen())),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: const Icon(Icons.storefront_rounded, color: Colors.white70, size: 20),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () async {
await Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen()));
setState(() {});
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12),
),
child: const Icon(Icons.tune_rounded, color: Colors.white70, size: 20),
),
),
]),
]),
const SizedBox(height: 28),
// Enrolled faces section
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Enrolled Faces',
style: TextStyle(color: Colors.white70, fontSize: 13,
fontWeight: FontWeight.w600, letterSpacing: 0.5)),
TextButton.icon(
onPressed: busy || !FlutterFaceNova.isInitialized ? null : _enrollFromGallery,
icon: const Icon(Icons.add_photo_alternate_rounded,
size: 16, color: Color(0xFF22D37A)),
label: const Text('Add from Gallery',
style: TextStyle(color: Color(0xFF22D37A), fontSize: 13)),
),
]),
const SizedBox(height: 10),
if (_enrolling)
Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white12),
),
child: const Row(children: [
SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF22D37A)),
),
SizedBox(width: 12),
Text('Detecting face…',
style: TextStyle(color: Colors.white54, fontSize: 13)),
]),
)
else if (_enrolled.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white12),
),
child: const Row(children: [
Icon(Icons.person_add_alt_1_rounded, color: Colors.white24, size: 20),
SizedBox(width: 12),
Text('No faces enrolled yet',
style: TextStyle(color: Colors.white38, fontSize: 13)),
]),
)
else
SizedBox(
height: 100,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _enrolled.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, i) => _EnrolledCard(
face: _enrolled[i],
onRemove: () => _removeEnrolled(_enrolled[i]),
),
),
),
const SizedBox(height: 28),
// Result / status area
if (_livenessFailResult != null)
_LivenessFailCard(result: _livenessFailResult!)
else if (_matchResult != null)
_MatchResultCard(result: _matchResult!),
const Spacer(),
// Single action button
_ActionButton(
label: busy
? 'Running…'
: !FlutterFaceNova.isInitialized
? 'Loading SDK…'
: 'Start Face Match',
icon: Icons.manage_accounts_rounded,
color: const Color(0xFF22D37A),
disabled: busy || !FlutterFaceNova.isInitialized,
onTap: _startFaceMatch,
),
const SizedBox(height: 12),
_SdkStatus(),
const SizedBox(height: 24),
],
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Widgets
// ─────────────────────────────────────────────────────────────────────────────
class _VerifyingDialog extends StatelessWidget {
const _VerifyingDialog();
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: const Color(0xFF111827),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// Step 1 — done
Row(children: [
Container(
width: 32, height: 32,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFF22D37A)),
child: const Icon(Icons.check_rounded, color: Colors.black, size: 18),
),
const SizedBox(width: 14),
const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Step 1', style: TextStyle(color: Colors.white38, fontSize: 11)),
Text('Liveness Passed', style: TextStyle(
color: Color(0xFF22D37A), fontSize: 15, fontWeight: FontWeight.w700)),
]),
]),
const SizedBox(height: 6),
Container(width: 2, height: 20, color: Colors.white12, margin: const EdgeInsets.only(left: 15)),
const SizedBox(height: 6),
// Step 2 — in progress
Row(children: [
const SizedBox(width: 32, height: 32,
child: CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF4D9EFF))),
const SizedBox(width: 14),
const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Step 2', style: TextStyle(color: Colors.white38, fontSize: 11)),
Text('Comparing Face…', style: TextStyle(
color: Color(0xFF4D9EFF), fontSize: 15, fontWeight: FontWeight.w700)),
]),
]),
]),
),
);
}
}
class _LivenessFailCard extends StatelessWidget {
final LivenessResult result;
const _LivenessFailCard({required this.result});
@override
Widget build(BuildContext context) {
const color = Color(0xFFFF5C5C);
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.30)),
),
child: Row(children: [
if (result.imageBytes != null) ...[
Container(
width: 60, height: 60,
decoration: const BoxDecoration(
shape: BoxShape.circle,
border: Border.fromBorderSide(BorderSide(color: color, width: 2)),
),
child: ClipOval(child: Image.memory(result.imageBytes!, fit: BoxFit.cover)),
),
const SizedBox(width: 14),
],
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Icon(Icons.block_rounded, color: color, size: 18),
SizedBox(width: 6),
Text('Liveness Check Failed',
style: TextStyle(color: color, fontSize: 14, fontWeight: FontWeight.w700)),
]),
const SizedBox(height: 4),
Text('Score: ${(result.score * 100).toStringAsFixed(1)}% · Please try again',
style: const TextStyle(color: Colors.white54, fontSize: 12)),
])),
]),
);
}
}
class _MatchResultCard extends StatelessWidget {
final FaceMatchResult result;
const _MatchResultCard({required this.result});
@override
Widget build(BuildContext context) {
final color = result.isMatch ? const Color(0xFF22D37A) : const Color(0xFFFF5C5C);
final enrolled = result.matchedFace?.photo;
final captured = result.capturedImage;
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.30)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title row
Row(children: [
Icon(result.isMatch ? Icons.how_to_reg_rounded : Icons.person_off_rounded,
color: color, size: 18),
const SizedBox(width: 8),
Flexible(child: Text(
result.isMatch
? 'Identity Verified · ${result.matchedFace?.name ?? ''}'
: 'No Match Found',
style: TextStyle(color: color, fontSize: 14, fontWeight: FontWeight.w700),
)),
]),
const SizedBox(height: 4),
Text('Confidence: ${result.score.toStringAsFixed(1)}%',
style: const TextStyle(color: Colors.white54, fontSize: 12)),
// Photo comparison — only when we have both images
if (enrolled != null && captured != null) ...[
const SizedBox(height: 14),
Row(
children: [
// Enrolled (original)
Expanded(child: _FacePhoto(
bytes: enrolled,
label: 'Enrolled',
borderColor: Colors.white24,
)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Icon(
result.isMatch ? Icons.check_circle_rounded : Icons.cancel_rounded,
color: color, size: 28,
),
),
// Captured (live)
Expanded(child: _FacePhoto(
bytes: captured,
label: 'Captured',
borderColor: color,
)),
],
),
],
],
),
);
}
}
class _FacePhoto extends StatelessWidget {
final Uint8List bytes;
final String label;
final Color borderColor;
const _FacePhoto({required this.bytes, required this.label, required this.borderColor});
@override
Widget build(BuildContext context) {
return Column(children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: borderColor, width: 2),
),
child: ClipOval(child: Image.memory(bytes, fit: BoxFit.cover)),
),
const SizedBox(height: 5),
Text(label,
style: const TextStyle(color: Colors.white38, fontSize: 11,
fontWeight: FontWeight.w500)),
]);
}
}
class _EnrolledCard extends StatelessWidget {
final EnrolledFace face;
final VoidCallback onRemove;
const _EnrolledCard({required this.face, required this.onRemove});
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Stack(children: [
Container(
width: 64, height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF22D37A), width: 2),
),
child: ClipOval(
child: Image.memory(face.photo, fit: BoxFit.cover),
),
),
Positioned(
top: 0, right: 0,
child: GestureDetector(
onTap: onRemove,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFF5C5C),
),
child: const Icon(Icons.close, size: 12, color: Colors.white),
),
),
),
]),
const SizedBox(height: 6),
SizedBox(
width: 68,
child: Text(face.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 11)),
),
]);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool disabled;
final VoidCallback onTap;
const _ActionButton({
required this.label, required this.icon, required this.color,
required this.disabled, required this.onTap,
});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: disabled ? 0.45 : 1.0,
child: FilledButton.icon(
onPressed: disabled ? null : onTap,
icon: Icon(icon, color: Colors.black, size: 20),
label: Text(label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700,
color: Colors.black)),
style: FilledButton.styleFrom(
backgroundColor: color,
disabledBackgroundColor: color,
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
);
}
}
class _SdkStatus extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ok = FlutterFaceNova.isInitialized;
return Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(ok ? Icons.check_circle_rounded : Icons.cancel_rounded,
size: 12,
color: ok ? const Color(0xFF22D37A) : Colors.redAccent),
const SizedBox(width: 5),
Text(
ok ? 'SDK initialized · offline' : 'SDK not initialized — check license key',
style: TextStyle(fontSize: 11,
color: ok ? Colors.white30 : Colors.redAccent),
),
]);
}
}