classify_card 2.0.0 copy "classify_card: ^2.0.0" to clipboard
classify_card: ^2.0.0 copied to clipboard

On-device Card Gate V3 pre-check: decides whether a photo holds an identity document before you call a classify/OCR backend. Runs offline in ~20ms, fails open, Android and iOS.

example/lib/main.dart

import 'dart:io';

import 'package:classify_card/classify_card.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

void main() {
  runApp(const CardGateDemoApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Card Gate demo',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const CardGateDemoPage(),
    );
  }
}

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

  @override
  State<CardGateDemoPage> createState() => _CardGateDemoPageState();
}

class _CardGateDemoPageState extends State<CardGateDemoPage> {
  final ClassifyCard _gate = ClassifyCard();
  final ImagePicker _picker = ImagePicker();

  CardGateMode _mode = CardGateMode.enforce;
  String? _modelVersion;
  String? _photoPath;
  CardGateDecision? _decision;
  DocumentCropResult? _crop;
  String? _notice;
  bool _busy = false;

  @override
  void initState() {
    super.initState();
    _warmUp();
  }


  /// Building the interpreter takes a moment, so it is done up front rather than on the first
  /// photo. This never throws.
  Future<void> _warmUp() async {
    final modelVersion = await _gate.warmUp();
    if (!mounted) return;
    setState(() => _modelVersion = modelVersion);
  }

  Future<void> _pickAndEvaluate(ImageSource source) async {
    final photo = await _picker.pickImage(source: source);
    if (photo == null) return;

    setState(() {
      _busy = true;
      _photoPath = photo.path;
      _decision = null;
      _crop = null;
      _notice = null;
    });

    final decision = await _gate.evaluate(photo.path, mode: _mode);
    if (!mounted) return;

    setState(() {
      _busy = false;
      _decision = decision;
    });

    // This is where a real integration would decide whether to call its backend:
    //
    //   if (!decision.allow) { showRetakeGuidance(); return; }
    //   await myBackend.classify(photo.path);
  }

  /// Opens the realtime scanner. The scanner only guides the user; the photo it returns still
  /// goes through the normal still-photo path, which is what the model was calibrated against.
  Future<void> _openScanner() async {
    final capture = await Navigator.of(context).push<CardGateCapture>(
      MaterialPageRoute(
        builder: (context) => CardGateScannerView(
          onCaptured: (capture) => Navigator.of(context).pop(capture),
          onCancel: () => Navigator.of(context).pop(),
          onError: (error) => debugPrint('scanner error: $error'),
        ),
      ),
    );
    if (capture == null || !mounted) return;

    setState(() {
      _busy = true;
      _photoPath = capture.path;
      _decision = null;
      _crop = null;
      _notice = null;
    });

    final decision = await _gate.evaluate(capture.path, mode: _mode);
    if (!mounted) return;
    setState(() {
      _busy = false;
      _decision = decision;
    });
  }

  /// Crops whatever photo is on screen. Independent of the gate: no warm-up, no model, and it
  /// runs whether the photo was allowed or blocked.
  Future<void> _cropCurrentPhoto() async {
    final path = _photoPath;
    if (path == null) return;

    setState(() {
      _busy = true;
      _crop = null;
      _notice = null;
    });

    final result = await DocumentCropper().crop(path);
    if (!mounted) return;

    setState(() {
      _busy = false;
      _crop = result;
      // The path is always usable, so the only thing worth saying is whether it changed.
      _notice = result.cropped
          // ignore: unnecessary_null_comparison
          ? (result.engineNote != null
              ? 'Cropped by ${result.detector.name}. ML Kit did not run: ${result.engineNote}'
              : null)
          : 'Not cropped: ${result.reason.name}. The original photo is unchanged.'
              '${result.engineNote != null ? '\n${result.engineNote}' : ''}';
      if (result.cropped) _photoPath = result.path;
    });
  }


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Card Gate demo'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(24),
          child: Padding(
            padding: const EdgeInsets.only(bottom: 6),
            child: Text(
              _modelVersion == null
                  ? 'classifier unavailable - the gate will be bypassed'
                  : 'model $_modelVersion',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ),
        ),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          SegmentedButton<CardGateMode>(
            segments: const [
              ButtonSegment(value: CardGateMode.off, label: Text('off')),
              ButtonSegment(value: CardGateMode.shadow, label: Text('shadow')),
              ButtonSegment(
                  value: CardGateMode.enforce, label: Text('enforce')),
            ],
            selected: {_mode},
            onSelectionChanged: (selection) =>
                setState(() => _mode = selection.first),
          ),
          const SizedBox(height: 16),
          Row(
            children: [
              Expanded(
                child: FilledButton.icon(
                  onPressed:
                      _busy ? null : () => _pickAndEvaluate(ImageSource.camera),
                  icon: const Icon(Icons.photo_camera_outlined),
                  label: const Text('Camera'),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: FilledButton.tonalIcon(
                  onPressed: _busy
                      ? null
                      : () => _pickAndEvaluate(ImageSource.gallery),
                  icon: const Icon(Icons.photo_library_outlined),
                  label: const Text('Gallery'),
                ),
              ),
            ],
          ),
          const SizedBox(height: 12),
          OutlinedButton.icon(
            onPressed: _busy ? null : _openScanner,
            icon: const Icon(Icons.center_focus_strong_outlined),
            label: const Text('Live scanner (realtime)'),
          ),
          const SizedBox(height: 12),
          OutlinedButton.icon(
            onPressed: _busy || _photoPath == null ? null : _cropCurrentPhoto,
            icon: const Icon(Icons.crop_outlined),
            label: const Text('Crop (no UI)'),
          ),
          const SizedBox(height: 6),
          Text(
            'Crop takes the photo above and returns a cropped copy with no UI at all - a '
            'document-corner model runs on device.',
            style: Theme.of(context).textTheme.bodySmall,
          ),
          const SizedBox(height: 10),

          // Status FIRST. It used to sit underneath a full-height image, so the one thing worth
          // knowing - did it crop, with which engine, and if not why - was pushed off screen and
          // the app looked like it had done nothing at all.
          if (_busy)
            const Padding(
              padding: EdgeInsets.symmetric(vertical: 12),
              child: Center(child: CircularProgressIndicator()),
            ),
          if (_notice != null)
            Card(
              color: Theme.of(context).colorScheme.surfaceContainerHighest,
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Text(_notice!),
              ),
            ),
          if (_crop != null) _CropCard(result: _crop!),

          if (_photoPath != null) ...[
            const SizedBox(height: 10),
            Text(
              _crop?.cropped == true
                  ? 'CROPPED by ${_crop!.detector.name}'
                  : 'ORIGINAL photo - not cropped',
              style: Theme.of(context).textTheme.labelLarge?.copyWith(
                    color: _crop?.cropped == true
                        ? Theme.of(context).colorScheme.primary
                        : Theme.of(context).colorScheme.outline,
                  ),
            ),
            const SizedBox(height: 6),
            ClipRRect(
              borderRadius: BorderRadius.circular(12),
              // contain, never fill: fill stretches the image to the box and distorts it, which
              // made a correct crop look wrong. This has to show the real output, aspect and all.
              child: Container(
                height: 240,
                width: double.infinity,
                color: const Color(0x11000000),
                child: Image.file(File(_photoPath!), fit: BoxFit.contain),
              ),
            ),
          ],
          const SizedBox(height: 12),
          if (_decision != null) _DecisionCard(decision: _decision!),
        ],
      ),
    );
  }
}

class _CropCard extends StatelessWidget {
  const _CropCard({required this.result});

  final DocumentCropResult result;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: result.cropped
          ? Theme.of(context).colorScheme.tertiaryContainer
          : Theme.of(context).colorScheme.surfaceContainerHighest,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              result.cropped ? 'Cropped' : 'Left alone',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            _row('reason', result.reason.name),
            _row('detector', result.detector.name),
            _row('size', '${result.width}x${result.height}'),
            _row('elapsed', '${result.latencyMs.toStringAsFixed(1)} ms'),
            if (result.engineNote != null) _row('engine', result.engineNote!),
          ],
        ),
      ),
    );
  }

  Widget _row(String label, String value) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 2),
        child: Row(
          children: [
            SizedBox(width: 92, child: Text(label)),
            Expanded(child: Text(value)),
          ],
        ),
      );
}

class _DecisionCard extends StatelessWidget {
  const _DecisionCard({required this.decision});

  final CardGateDecision decision;

  @override
  Widget build(BuildContext context) {
    final result = decision.result;
    final headline = decision.allow
        ? 'Continue to the backend'
        : 'Blocked - ask for a retake';

    return Card(
      color: decision.allow
          ? Theme.of(context).colorScheme.secondaryContainer
          : Theme.of(context).colorScheme.errorContainer,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(headline, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            _row('reason', decision.reason.name),
            if (decision.reason.isFailure)
              _row('note', 'gate failed open - request still allowed'),
            if (result != null) ...[
              _row('label', result.label.name),
              _row('score', result.score.toStringAsFixed(5)),
              _row('rawScore', result.rawScore.toStringAsFixed(8)),
              _row('inference', '${result.latencyMs.toStringAsFixed(1)} ms'),
              _row('decode', '${result.decodeMs.toStringAsFixed(1)} ms'),
            ],
          ],
        ),
      ),
    );
  }

  Widget _row(String label, String value) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 2),
        child: Row(
          children: [
            SizedBox(width: 92, child: Text(label)),
            Expanded(child: Text(value)),
          ],
        ),
      );
}
0
likes
130
points
296
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

On-device Card Gate V3 pre-check: decides whether a photo holds an identity document before you call a classify/OCR backend. Runs offline in ~20ms, fails open, Android and iOS.

Repository (GitHub)
View/report issues

Topics

#ekyc #kyc #document-detection #tflite #on-device

License

unknown (license)

Dependencies

camera, flutter, onnxruntime, plugin_platform_interface

More

Packages that depend on classify_card

Packages that implement classify_card