flutter_face_nova 1.0.0+2 copy "flutter_face_nova: ^1.0.0+2" to clipboard
flutter_face_nova: ^1.0.0+2 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),
      ),
    ]);
  }
}
1
likes
120
points
48
downloads

Documentation

API reference

Publisher

verified publisherfacenova.uk

Weekly Downloads

Offline, on-device Flutter SDK for face liveness detection and identity verification. No server. No internet required.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

camera, cupertino_icons, flutter, google_mlkit_face_detection, image, onnxruntime, path_provider, permission_handler, screen_brightness

More

Packages that depend on flutter_face_nova

Packages that implement flutter_face_nova