flutter_biometric_liveness 1.0.2 copy "flutter_biometric_liveness: ^1.0.2" to clipboard
flutter_biometric_liveness: ^1.0.2 copied to clipboard

Passive face liveness detection, face template extraction, and cosine-similarity face matching for Flutter (iOS & Android).

example/lib/main.dart

// ════════════════════════════════════════════════════════════════════════════
//  Video Liveness SDK — Example Application
//  Demonstrates: enroll via camera or gallery, verify identity, face matching.
// ════════════════════════════════════════════════════════════════════════════

// ignore_for_file: use_build_context_synchronously

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:video_liveness/video_liveness_sdk.dart';

import 'enroll_success_screen.dart';
import 'match_result_screen.dart';

// ─────────────────────────────────────────────────────────────────────────────
//region RECOMMENDED THRESHOLD VALUES
// ─────────────────────────────────────────────────────────────────────────────
//
//  LIVENESS SCORE  (LivenessResult.livenessPct)
//  ─────────────────────────────────────────────
//  Value range : 0.0 – 100.0  (percentage)
//  Recommended : pass when livenessPct > 65.0
//  Meaning     : The SDK internally runs MiniFASNet anti-spoof inference.
//                Scores above 65 % indicate a real live face with high
//                confidence. Scores below 65 % suggest a spoof attempt
//                (printed photo, phone screen, 3-D mask, etc.).
//
//  FACE MATCH SCORE  (VideoLiveness.compareFaces)
//  ────────────────────────────────────────────────
//  Value range : 0.0 – 1.0  (cosine similarity mapped to [0,1])
//  Recommended : match when score ≥ 0.65
//  Zones:
//    ≥ 0.65   — Strong match  → same person (safe to approve)
//    0.55–0.64 — Borderline   → optional manual review
//    < 0.55   — No match      → different person
//  Note: these thresholds are calibrated for our FaceNet-128 model.
//        Adjust MatchResultScreen._kMatchThreshold if you need stricter rules.
//
//  TEMPLATE FORMAT
//  ────────────────
//  faceTemplate is a Uint8List of exactly 512 bytes = 128 float32 values
//  stored in little-endian order.  Store these bytes in your backend exactly
//  as-is.  Do NOT mix templates from different SDK versions or models.
//
//endregion

// ─────────────────────────────────────────────────────────────────────────────
//region APP ENTRY POINT
// ─────────────────────────────────────────────────────────────────────────────

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Lock to portrait so the liveness oval always fits correctly.
  SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: Brightness.light,
    systemNavigationBarColor: Colors.black,
    systemNavigationBarIconBrightness: Brightness.light,
  ));

  // ── SDK initialisation ────────────────────────────────────────────────────
  // Call this ONCE at startup.  It loads and decrypts the bundled FaceNet
  // model into RAM.  Takes ~300 ms on a mid-range device.
  // After this call:
  //   • VideoLiveness.launch()       — works (liveness + auto face extraction)
  //   • VideoLiveness.extractFace()  — works (gallery / offline extraction)
  //   • VideoLiveness.compareFaces() — works (in-memory, sync, ~0 ms)
  try {
    await VideoLiveness.initFaceRecognition();
    debugPrint('[App] Face recognition model loaded.');
  } catch (e) {
    debugPrint('[App] Face recognition model failed: $e');
    // The app still works for liveness-only flows even if this fails.
  }

  runApp(const ExampleApp());
}

//endregion

// ─────────────────────────────────────────────────────────────────────────────
//region APP ROOT WIDGET
// ─────────────────────────────────────────────────────────────────────────────

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Face Verification',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: const Color(0xFF07070A),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF22D37A),
          onPrimary: Colors.black,
          surface: Color(0xFF0F0F16),
          onSurface: Colors.white,
        ),
      ),
      home: const HomeScreen(),
    );
  }
}

//endregion

// ─────────────────────────────────────────────────────────────────────────────
//region HOME SCREEN — Enroll & Verify
// ─────────────────────────────────────────────────────────────────────────────

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // ── In-memory enrollment state ────────────────────────────────────────────
  // In production: persist these securely (e.g. flutter_secure_storage or
  // your backend).  Do NOT store face templates in plain SharedPreferences.
  //
  // _enrolledTemplate — Uint8List, 512 bytes.  This is what you send to / read
  //                     from your backend when comparing faces server-side.
  // _enrolledFaceImage — JPEG bytes of the cropped face (max 200 px).
  //                      Used only for display; not needed for comparison.
  Uint8List? _enrolledTemplate;
  Uint8List? _enrolledFaceImage;

  bool _isBusy = false;

  bool get _isEnrolled => _enrolledTemplate != null;

  // ─────────────────────────────────────────────────────────────────────────
  //region RESULT LOGGER
  // ─────────────────────────────────────────────────────────────────────────

  /// Prints a clean, readable block to the console after every face scan.
  ///
  /// Shows:
  ///   • Liveness pass/fail, score, spoof score
  ///   • Face image size (JPEG bytes)
  ///   • Full 512-byte binary template as decimal integers, 16 per row
  ///     (ready to copy-paste as a List of int for testing)
  ///   • Match score + verdict (verify flow only)
  static void _logScanResult({
    required String    tag,
    required bool      livenessPassed,
    required double    livenessPct,
    required double    spoofScore,
    Uint8List?         capturedFrame,
    Uint8List?         faceTemplate,
    double?            matchScore,
    Uint8List?         enrolledTemplate,
    String?            failureReason,
  }) {
    const sep  = '──────────────────────────────────────────────────────';
    const sep2 = '══════════════════════════════════════════════════════';

    debugPrint('');
    debugPrint(sep2);
    debugPrint('  FACE SCAN RESULT  [$tag]');
    debugPrint(sep2);

    // ── Liveness ─────────────────────────────────────────────────────────
    final passStr = livenessPassed ? 'PASSED' : 'FAILED';
    debugPrint('  Liveness    : $passStr');
    debugPrint('  Score       : ${livenessPct.toStringAsFixed(1)} %   (pass if > 65.0)');
    debugPrint('  Spoof raw   : ${spoofScore.toStringAsFixed(4)}   (lower = more live)');
    if (failureReason != null) {
      debugPrint('  Reason      : $failureReason');
    }

    // ── Face image ────────────────────────────────────────────────────────
    debugPrint(sep);
    if (capturedFrame != null) {
      debugPrint('  Face image  : ${capturedFrame.lengthInBytes} bytes'
          '  (JPEG crop, max 200 px, quality 65)');
    } else {
      debugPrint('  Face image  : null');
    }

    // ── Template binary ───────────────────────────────────────────────────
    debugPrint(sep);
    if (faceTemplate != null) {
      final totalBytes  = faceTemplate.lengthInBytes;
      final totalFloats = totalBytes ~/ 4;
      debugPrint('  Template    : $totalBytes bytes  ($totalFloats x float32, little-endian, L2-normalised)');
      debugPrint('  Store these bytes as-is in your backend.');
      debugPrint('  Use VideoLiveness.compareFaces() to match against other templates.');
      debugPrint(sep);
      debugPrint('  Full binary — decimal integers, 16 values per row:');
      debugPrint(sep);

      for (int row = 0; row < faceTemplate.length; row += 16) {
        final end   = (row + 16).clamp(0, faceTemplate.length);
        final chunk = faceTemplate.sublist(row, end);
        final offset = '[${row.toString().padLeft(3)}]';
        final vals   = chunk.map((b) => b.toString().padLeft(3)).join(', ');
        debugPrint('  $offset  $vals');
      }
    } else {
      debugPrint('  Template    : null  (model not loaded or no face detected)');
    }

    // ── Match score (verify flow only) ────────────────────────────────────
    if (matchScore != null) {
      debugPrint(sep);
      final scorePct = (matchScore * 100).toStringAsFixed(2);
      final verdict  = matchScore >= 0.65 ? 'MATCH' : 'NO MATCH';
      debugPrint('  Match score : $scorePct %');
      debugPrint('  Verdict     : $verdict   (threshold >= 65.0 %)');
      debugPrint('  Enrolled    : ${enrolledTemplate?.lengthInBytes ?? 0} bytes  (from enrollment)');
    }

    debugPrint(sep2);
    debugPrint('');
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region ENROLL — choose source (camera or gallery)
  // ─────────────────────────────────────────────────────────────────────────

  Future<void> _showEnrollOptions() async {
    if (_isBusy) return;
    await showModalBottomSheet<void>(
      context: context,
      backgroundColor: const Color(0xFF0F0F16),
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
      ),
      builder: (ctx) => SafeArea(
        child: Padding(
          padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              const Text(
                'Enroll Your Face',
                style: TextStyle(
                  fontSize: 18,
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                ),
              ),
              const SizedBox(height: 4),
              const Text(
                'Choose how to capture your face.',
                style: TextStyle(color: Colors.white38, fontSize: 13),
              ),
              const SizedBox(height: 20),
              _OptionTile(
                icon: Icons.videocam_rounded,
                title: 'Use Camera',
                subtitle: 'Liveness check + face extraction',
                onTap: () {
                  Navigator.of(ctx).pop();
                  _startCameraEnrollment();
                },
              ),
              const SizedBox(height: 12),
              _OptionTile(
                icon: Icons.photo_library_rounded,
                title: 'Pick from Gallery',
                subtitle: 'Choose an existing photo',
                onTap: () {
                  Navigator.of(ctx).pop();
                  _startGalleryEnrollment();
                },
              ),
              const SizedBox(height: 8),
            ],
          ),
        ),
      ),
    );
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region ENROLL VIA CAMERA (liveness check + automatic face extraction)
  // ─────────────────────────────────────────────────────────────────────────
  //
  // Flow:
  //   1. VideoLiveness.launch() opens the full-screen liveness camera.
  //   2. User positions their face inside the oval guide.
  //   3. SDK runs passive anti-spoof (MiniFASNet) — blocks photos/screens.
  //   4. On pass: captures a still photo, crops the face region, extracts
  //      the 512-byte FaceNet embedding → returned in LivenessResult.
  //   5. We save the template and cropped face image locally.
  //
  // What you get back in LivenessResult:
  //   result.passed        — bool: did the liveness check pass?
  //   result.livenessPct   — double 0–100: confidence score (pass if > 65)
  //   result.faceTemplate  — Uint8List? 512 bytes: face embedding for matching
  //   result.capturedFrame — Uint8List? JPEG: cropped face image (max 200 px)

  Future<void> _startCameraEnrollment() async {
    if (_isBusy) return;
    setState(() => _isBusy = true);

    try {
      await VideoLiveness.launch(
        context: context,
        onResult: (LivenessResult result) async {
          // ── Result logging ─────────────────────────────────────────────
          _logScanResult(
            tag:            'ENROLL',
            livenessPassed: result.passed,
            livenessPct:    result.livenessPct,
            spoofScore:     result.avgSpoofScore,
            capturedFrame:  result.capturedFrame,
            faceTemplate:   result.faceTemplate,
            failureReason:  result.failureReason,
          );

          // Liveness failed — ask user to retry
          if (!result.passed) {
            if (!mounted) return;
            _showSnack('Liveness failed — please try again.', isError: true);
            return;
          }

          // Save the template (512 bytes) and face image locally.
          // In production: send result.faceTemplate to your backend here.
          setState(() {
            _enrolledTemplate  = result.faceTemplate;
            _enrolledFaceImage = result.capturedFrame;
          });

          if (!mounted) return;
          await Navigator.of(context).push(
            MaterialPageRoute(
              builder: (_) => EnrollSuccessScreen(
                faceImage: result.capturedFrame,
                onVerifyNow: () {
                  Navigator.of(context).pop();
                  _startVerification();
                },
              ),
            ),
          );
        },
        onError: (e) => _showSnack('Error: $e', isError: true),
      );
    } finally {
      if (mounted) setState(() => _isBusy = false);
    }
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region ENROLL VIA GALLERY (pick any photo, extract face template)
  // ─────────────────────────────────────────────────────────────────────────
  //
  // Flow:
  //   1. User picks a photo from the device gallery.
  //   2. VideoLiveness.extractFace() runs ML Kit face detection to find
  //      the largest face, crops it (50 % margin), resizes to max 200 px,
  //      and runs FaceNet to extract the 512-byte embedding.
  //
  // What you get back in FaceExtractionResult:
  //   result.faceTemplate — Uint8List 512 bytes: face embedding for matching
  //   result.croppedFace  — Uint8List JPEG: cropped face image (max 200 px)
  //
  // NOTE: gallery enrollment skips liveness — use camera enrollment for
  //       any security-critical onboarding flow.

  Future<void> _startGalleryEnrollment() async {
    if (_isBusy) return;
    setState(() => _isBusy = true);

    try {
      final picker = ImagePicker();
      final picked = await picker.pickImage(source: ImageSource.gallery);
      if (picked == null) return; // User cancelled

      final bytes = await picked.readAsBytes();

      // Extract face + generate 512-byte template from gallery image.
      // Returns null if no face is found in the image.
      final result = await VideoLiveness.extractFace(bytes);

      // ── Result logging ───────────────────────────────────────────────
      if (result == null) {
        _logScanResult(
          tag:            'GALLERY',
          livenessPassed: false,
          livenessPct:    0,
          spoofScore:     0,
          failureReason:  'No face detected in selected image',
        );
        if (mounted) _showSnack('No face found in image.', isError: true);
        return;
      }
      _logScanResult(
        tag:            'GALLERY',
        livenessPassed: true,
        livenessPct:    100,
        spoofScore:     0,
        capturedFrame:  result.croppedFace,
        faceTemplate:   result.faceTemplate,
      );

      setState(() {
        _enrolledTemplate  = result.faceTemplate;  // 512 bytes — save to backend
        _enrolledFaceImage = result.croppedFace;   // JPEG for display only
      });

      if (!mounted) return;
      await Navigator.of(context).push(
        MaterialPageRoute(
          builder: (_) => EnrollSuccessScreen(
            faceImage: result.croppedFace,
            onVerifyNow: () {
              Navigator.of(context).pop();
              _startVerification();
            },
          ),
        ),
      );
    } catch (e) {
      if (mounted) _showSnack('Error: $e', isError: true);
    } finally {
      if (mounted) setState(() => _isBusy = false);
    }
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region VERIFY & MATCH (liveness check + compare against enrolled template)
  // ─────────────────────────────────────────────────────────────────────────
  //
  // Flow:
  //   1. VideoLiveness.launch() opens the liveness camera (same as enroll).
  //   2. On pass: extracts a fresh 512-byte face template from the live face.
  //   3. VideoLiveness.compareFaces(enrolled, live) computes cosine similarity
  //      in pure Dart — synchronous, takes ~0 ms, works for 2,000+ users.
  //   4. Returns a score in [0.0, 1.0]:
  //        ≥ 0.65  →  same person  (MATCH)
  //        < 0.65  →  different person  (NO MATCH)
  //   5. MatchResultScreen shows both face images, the score, and a verdict.
  //
  // Backend matching (2,000+ users):
  //   - Fetch all enrolled templates from your backend as List<Uint8List>.
  //   - Call VideoLiveness.compareFaces(enrolled[i], liveTemplate) in a loop.
  //   - Dart completes 2,000 comparisons in under 1 ms total.

  Future<void> _startVerification() async {
    if (_isBusy || !_isEnrolled) return;
    setState(() => _isBusy = true);

    // Capture enrolled data before entering the async gap.
    final enrolledTemplate  = _enrolledTemplate!;
    final enrolledFaceImage = _enrolledFaceImage;

    try {
      await VideoLiveness.launch(
        context: context,
        onResult: (LivenessResult result) async {
          if (!mounted) return;

          // Compare the enrolled template against the fresh live template.
          // compareFaces() is synchronous — no await needed.
          // Score range: 0.0 – 1.0 (see threshold constants at top of file).
          double? matchScore;
          if (result.faceTemplate != null) {
            matchScore = VideoLiveness.compareFaces(
              enrolledTemplate,      // template from enrollment (your backend)
              result.faceTemplate!,  // fresh template from this liveness check
            );
          }

          // ── Result logging ─────────────────────────────────────────────
          _logScanResult(
            tag:              'VERIFY',
            livenessPassed:   result.passed,
            livenessPct:      result.livenessPct,
            spoofScore:       result.avgSpoofScore,
            capturedFrame:    result.capturedFrame,
            faceTemplate:     result.faceTemplate,
            matchScore:       matchScore,
            enrolledTemplate: enrolledTemplate,
            failureReason:    result.failureReason,
          );

          if (!mounted) return;
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (_) => MatchResultScreen(
                livenessResult:   result,
                enrolledFaceImage: enrolledFaceImage,
                matchScore:        matchScore,
                // matchScore ≥ 0.65 → MATCH (configured in MatchResultScreen)
              ),
            ),
          );
        },
        onError: (e) => _showSnack('Error: $e', isError: true),
      );
    } finally {
      if (mounted) setState(() => _isBusy = false);
    }
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region HELPERS
  // ─────────────────────────────────────────────────────────────────────────

  void _showSnack(String msg, {bool isError = false}) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      content: Text(msg),
      backgroundColor: isError ? Colors.redAccent : const Color(0xFF22D37A),
    ));
  }

  //endregion

  // ─────────────────────────────────────────────────────────────────────────
  //region BUILD
  // ─────────────────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // ── Header ──────────────────────────────────────────────────
              const Text(
                'Face Verification',
                style: TextStyle(
                  fontSize: 26,
                  fontWeight: FontWeight.w900,
                  letterSpacing: -0.5,
                ),
              ),
              const SizedBox(height: 4),
              const Text(
                'Enroll once, verify anytime.',
                style: TextStyle(color: Colors.white54, fontSize: 14),
              ),

              const SizedBox(height: 32),

              // ── Enrolled face preview (or placeholder) ──────────────────
              // Shows the cropped face JPEG from enrollment.
              // The actual matching uses faceTemplate (512 bytes), not this image.
              _EnrolledCard(
                faceImage: _enrolledFaceImage,
                isEnrolled: _isEnrolled,
              ),

              const Spacer(),

              // ── Verify & Match (only shown after enrollment) ─────────────
              if (_isEnrolled) ...[
                SizedBox(
                  height: 58,
                  child: FilledButton.icon(
                    onPressed: _isBusy ? null : _startVerification,
                    icon: _isBusy
                        ? const SizedBox(
                            width: 20,
                            height: 20,
                            child: CircularProgressIndicator(
                                strokeWidth: 2, color: Colors.black),
                          )
                        : const Icon(Icons.face_unlock_rounded, size: 22),
                    label: const Text(
                      'Verify & Match',
                      style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
                    ),
                    style: FilledButton.styleFrom(
                      backgroundColor: const Color(0xFF22D37A),
                      foregroundColor: Colors.black,
                      shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(16)),
                    ),
                  ),
                ),
                const SizedBox(height: 12),
              ],

              // ── Enroll / Re-enroll button ────────────────────────────────
              SizedBox(
                height: 54,
                child: _isEnrolled
                    ? OutlinedButton.icon(
                        onPressed: _isBusy ? null : _showEnrollOptions,
                        icon: const Icon(Icons.refresh_rounded, size: 20),
                        label: const Text(
                          'Re-enroll Face',
                          style: TextStyle(fontWeight: FontWeight.w700),
                        ),
                        style: OutlinedButton.styleFrom(
                          foregroundColor: Colors.white70,
                          side: const BorderSide(color: Colors.white24),
                          shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(16)),
                        ),
                      )
                    : FilledButton.icon(
                        onPressed: _isBusy ? null : _showEnrollOptions,
                        icon: _isBusy
                            ? const SizedBox(
                                width: 20,
                                height: 20,
                                child: CircularProgressIndicator(
                                    strokeWidth: 2, color: Colors.black),
                              )
                            : const Icon(Icons.person_add_rounded, size: 22),
                        label: const Text(
                          'Enroll Your Face',
                          style: TextStyle(
                              fontSize: 17, fontWeight: FontWeight.w800),
                        ),
                        style: FilledButton.styleFrom(
                          backgroundColor: const Color(0xFF22D37A),
                          foregroundColor: Colors.black,
                          shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(16)),
                        ),
                      ),
              ),

              const SizedBox(height: 8),

              // ── Step hint ────────────────────────────────────────────────
              Center(
                child: Text(
                  _isEnrolled
                      ? 'Tap "Verify & Match" to confirm your identity'
                      : 'Step 1 of 2 — enroll your face to get started',
                  style: const TextStyle(color: Colors.white38, fontSize: 12),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  //endregion
}

//endregion

// ─────────────────────────────────────────────────────────────────────────────
//region REUSABLE WIDGETS
// ─────────────────────────────────────────────────────────────────────────────

/// Bottom-sheet option tile used in the enroll source chooser.
class _OptionTile extends StatelessWidget {
  const _OptionTile({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.onTap,
  });
  final IconData    icon;
  final String      title;
  final String      subtitle;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(16),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
        decoration: BoxDecoration(
          color: Colors.white.withValues(alpha: 0.05),
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
        ),
        child: Row(
          children: [
            Container(
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: const Color(0xFF22D37A).withValues(alpha: 0.12),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(icon, color: const Color(0xFF22D37A), size: 22),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(title,
                      style: const TextStyle(
                          color: Colors.white,
                          fontSize: 15,
                          fontWeight: FontWeight.w700)),
                  const SizedBox(height: 2),
                  Text(subtitle,
                      style: const TextStyle(
                          color: Colors.white38, fontSize: 12)),
                ],
              ),
            ),
            const Icon(Icons.chevron_right_rounded,
                color: Colors.white24, size: 20),
          ],
        ),
      ),
    );
  }
}

/// Card showing the currently enrolled face image (or a placeholder).
/// The card border turns green once a face is enrolled.
class _EnrolledCard extends StatelessWidget {
  const _EnrolledCard({required this.faceImage, required this.isEnrolled});
  final Uint8List? faceImage;
  final bool       isEnrolled;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white.withValues(alpha: 0.04),
        borderRadius: BorderRadius.circular(24),
        border: Border.all(
          color: isEnrolled
              ? const Color(0xFF22D37A).withValues(alpha: 0.35)
              : Colors.white12,
        ),
      ),
      child: Row(
        children: [
          // Face avatar — shows cropped JPEG from enrollment (display only)
          Container(
            width: 72,
            height: 72,
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: Colors.white.withValues(alpha: 0.06),
              border: Border.all(
                color: isEnrolled ? const Color(0xFF22D37A) : Colors.white24,
                width: 2,
              ),
            ),
            child: ClipOval(
              child: faceImage != null
                  ? Image.memory(faceImage!, fit: BoxFit.cover)
                  : const Icon(Icons.person_outline_rounded,
                      color: Colors.white24, size: 36),
            ),
          ),
          const SizedBox(width: 16),
          // Status text
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Container(
                      width: 8,
                      height: 8,
                      decoration: BoxDecoration(
                        shape: BoxShape.circle,
                        color: isEnrolled
                            ? const Color(0xFF22D37A)
                            : Colors.white24,
                      ),
                    ),
                    const SizedBox(width: 6),
                    Text(
                      isEnrolled ? 'Face Enrolled' : 'No Face Enrolled',
                      style: TextStyle(
                        color: isEnrolled
                            ? const Color(0xFF22D37A)
                            : Colors.white38,
                        fontWeight: FontWeight.w700,
                        fontSize: 14,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 4),
                Text(
                  isEnrolled
                      ? 'Template saved. Ready for identity verification.'
                      : 'Enroll your face below to enable identity verification.',
                  style: const TextStyle(
                      color: Colors.white54, fontSize: 12, height: 1.4),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

//endregion
1
likes
130
points
0
downloads

Documentation

API reference

Publisher

verified publisherfacenova.uk

Weekly Downloads

Passive face liveness detection, face template extraction, and cosine-similarity face matching for Flutter (iOS & Android).

Repository (GitHub)

License

unknown (license)

Dependencies

camera, cupertino_icons, flutter, flutter_tts, google_mlkit_face_detection, image, package_info_plus, permission_handler, shared_preferences, url_launcher

More

Packages that depend on flutter_biometric_liveness

Packages that implement flutter_biometric_liveness