classify_card 1.0.0
classify_card: ^1.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;
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;
});
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);
}
@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: 16),
if (_photoPath != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(_photoPath!),
height: 220,
width: double.infinity,
fit: BoxFit.cover,
),
),
const SizedBox(height: 16),
if (_busy) const Center(child: CircularProgressIndicator()),
if (_decision != null) _DecisionCard(decision: _decision!),
],
),
);
}
}
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)),
],
),
);
}