classify_card 2.0.0
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)),
],
),
);
}