selectable_ocr 1.0.0
selectable_ocr: ^1.0.0 copied to clipboard
A powerful Flutter widget that lets users select and crop any area of the UI and automatically extracts text using on-device OCR or custom backends.
import 'package:flutter/material.dart';
import 'package:selectable_ocr/selectable_ocr.dart';
void main() {
runApp(const SelectableOcrExampleApp());
}
class FieldData {
final String label;
final String placeholder;
final TextEditingController controller;
bool isValidated;
FieldData({
required this.label,
required this.placeholder,
required this.controller,
this.isValidated = false,
});
}
class SelectableOcrExampleApp extends StatelessWidget {
const SelectableOcrExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Selectable OCR',
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF090D16),
cardColor: const Color(0xFF131B2E),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF3B82F6),
secondary: Color(0xFF10B981),
surface: Color(0xFF131B2E),
),
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF0F172A),
elevation: 0,
),
),
home: const DocumentScannerScreen(),
);
}
}
class DocumentScannerScreen extends StatefulWidget {
const DocumentScannerScreen({super.key});
@override
State<DocumentScannerScreen> createState() => _DocumentScannerScreenState();
}
class _DocumentScannerScreenState extends State<DocumentScannerScreen> {
int _activeFieldIndex = 0;
bool _isProcessing = false;
String _statusMessage = 'Drag a box around the active field on the passport.';
String _lastLatency = '';
late final List<FieldData> _fields;
@override
void initState() {
super.initState();
_fields = [
FieldData(
label: 'Passport No',
placeholder: 'e.g. E87239104',
controller: TextEditingController(),
),
FieldData(
label: 'Surname',
placeholder: 'e.g. ROBINSON',
controller: TextEditingController(),
),
FieldData(
label: 'Given Names',
placeholder: 'e.g. ALEXANDER DAVID',
controller: TextEditingController(),
),
FieldData(
label: 'Nationality',
placeholder: 'e.g. UNITED STATES OF AMERICA',
controller: TextEditingController(),
),
FieldData(
label: 'Date of Birth',
placeholder: 'e.g. 14 JUL 1992',
controller: TextEditingController(),
),
FieldData(
label: 'Place of Birth',
placeholder: 'e.g. CALIFORNIA, USA',
controller: TextEditingController(),
),
FieldData(
label: 'Date of Expiry',
placeholder: 'e.g. 09 AUG 2032',
controller: TextEditingController(),
),
];
}
@override
void dispose() {
for (final f in _fields) {
f.controller.dispose();
}
super.dispose();
}
void _handleOcrResult(OcrResult result) {
final extracted = result.cleanText;
final activeField = _fields[_activeFieldIndex];
setState(() {
activeField.controller.text = extracted;
activeField.isValidated = extracted.isNotEmpty;
_lastLatency = '${result.processingTime.inMilliseconds} ms';
_statusMessage = 'Captured "$extracted" into ${activeField.label}';
// Auto advance to the next empty field
int nextIndex = (_activeFieldIndex + 1) % _fields.length;
for (int i = 0; i < _fields.length; i++) {
final checkIndex = (_activeFieldIndex + 1 + i) % _fields.length;
if (!_fields[checkIndex].isValidated) {
nextIndex = checkIndex;
break;
}
}
_activeFieldIndex = nextIndex;
});
}
int get _validatedCount => _fields.where((f) => f.isValidated).length;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withAlpha(40),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.crop_free,
color: Color(0xFF3B82F6),
size: 20,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Selectable OCR',
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
),
],
),
actions: [
if (_lastLatency.isNotEmpty)
Center(
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withAlpha(30),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF10B981), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.bolt, size: 13, color: Color(0xFF10B981)),
const SizedBox(width: 3),
Text(
_lastLatency,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF10B981),
fontWeight: FontWeight.bold,
),
),
],
),
),
),
IconButton(
tooltip: 'Reset Fields',
icon: const Icon(Icons.refresh, size: 20),
onPressed: () {
setState(() {
for (final f in _fields) {
f.controller.clear();
f.isValidated = false;
}
_activeFieldIndex = 0;
_statusMessage = 'All fields reset. Select a field to begin.';
_lastLatency = '';
});
},
),
],
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 850;
if (isDesktop) {
return _buildDesktopLayout();
} else {
return _buildMobileLayout();
}
},
),
),
);
}
// --- Mobile Layout: Optimized for portrait touch screen with keyboard safety ---
Widget _buildMobileLayout() {
return Column(
children: [
// Top Active Field Guide Banner
_buildActiveFieldHeader(),
// Document Viewer (Fixed height proportion to prevent layout shift)
Expanded(
flex: 5,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1E293B)),
boxShadow: const [
BoxShadow(
color: Colors.black45,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: _buildSelectableDocument(),
),
),
// Bottom Extracted Fields List (Scrollable & keyboard-safe)
Expanded(
flex: 4,
child: Container(
margin: const EdgeInsets.fromLTRB(12, 4, 12, 8),
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1E293B)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Extracted Fields',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withAlpha(40),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$_validatedCount / ${_fields.length} Done',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF60A5FA),
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: _fields.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) => _buildFieldRow(index),
),
),
],
),
),
),
],
);
}
// --- Desktop / Tablet Layout: Dual-pane side-by-side ---
Widget _buildDesktopLayout() {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: Column(
children: [
_buildActiveFieldHeader(),
const SizedBox(height: 12),
Expanded(
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF1E293B)),
boxShadow: const [
BoxShadow(
color: Colors.black45,
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: _buildSelectableDocument(),
),
),
],
),
),
const SizedBox(width: 20),
SizedBox(
width: 380,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF1E293B)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Extracted Data',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'$_validatedCount of ${_fields.length} Extracted',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF60A5FA),
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
Text(
_statusMessage,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF94A3B8),
),
),
const Divider(color: Color(0xFF1E293B), height: 24),
Expanded(
child: ListView.separated(
itemCount: _fields.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 10),
itemBuilder: (context, index) => _buildFieldRow(index),
),
),
],
),
),
),
],
),
);
}
Widget _buildActiveFieldHeader() {
final activeField = _fields[_activeFieldIndex];
return Container(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 2),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF3B82F6).withAlpha(120),
width: 1.5,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withAlpha(50),
shape: BoxShape.circle,
),
child: const Icon(
Icons.touch_app,
color: Color(0xFF60A5FA),
size: 16,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text(
'ACTIVE TARGET: ',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Color(0xFF94A3B8),
letterSpacing: 1,
),
),
Text(
activeField.label.toUpperCase(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w900,
color: Color(0xFF60A5FA),
),
),
],
),
Text(
'Draw a box over "${activeField.label}" on the passport image',
style: const TextStyle(
fontSize: 11,
color: Colors.white70,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
if (_isProcessing)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF3B82F6),
),
),
],
),
);
}
Widget _buildSelectableDocument() {
return Center(
child: SelectableOcr(
style: const SelectionStyle(
strokeColor: Color(0xFF3B82F6),
fillColor: Color(0x383B82F6),
strokeWidth: 2.5,
cornerRadius: 4.0,
loadingText: 'Extracting on-device...',
loadingBadgeColor: Color(0xFF3B82F6),
loadingBadgeBackground: Color(0xE60F172A),
),
onLoading: (loading) => setState(() => _isProcessing = loading),
onOcrResult: _handleOcrResult,
onError: (err) {
setState(() {
_statusMessage = 'Error: $err';
});
},
child: Image.asset(
'assets/images/sample_passport.jpg',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load asset image: $error',
style: const TextStyle(color: Colors.redAccent),
),
);
},
),
),
);
}
Widget _buildFieldRow(int index) {
final field = _fields[index];
final isActive = _activeFieldIndex == index;
return InkWell(
onTap: () {
setState(() {
_activeFieldIndex = index;
_statusMessage = 'Selected "${field.label}". Now draw a box over it.';
});
},
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF3B82F6).withAlpha(30)
: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isActive
? const Color(0xFF3B82F6)
: (field.isValidated
? const Color(0xFF10B981).withAlpha(100)
: const Color(0xFF1E293B)),
width: isActive ? 1.5 : 1.0,
),
),
child: Row(
children: [
// Validation indicator or index badge
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: field.isValidated
? const Color(0xFF10B981).withAlpha(40)
: (isActive
? const Color(0xFF3B82F6).withAlpha(40)
: const Color(0xFF1E293B)),
shape: BoxShape.circle,
),
child: Center(
child: field.isValidated
? const Icon(
Icons.check,
size: 14,
color: Color(0xFF10B981),
)
: Text(
'${index + 1}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isActive
? const Color(0xFF60A5FA)
: Colors.white54,
),
),
),
),
const SizedBox(width: 10),
// Field Label and Value
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
field.label,
style: TextStyle(
fontSize: 11,
fontWeight:
isActive ? FontWeight.bold : FontWeight.w500,
color: isActive
? const Color(0xFF60A5FA)
: const Color(0xFF94A3B8),
),
),
const SizedBox(height: 2),
Text(
field.controller.text.isEmpty
? field.placeholder
: field.controller.text,
style: TextStyle(
fontSize: 13,
fontFamily: field.controller.text.isNotEmpty
? 'monospace'
: null,
fontWeight: field.controller.text.isNotEmpty
? FontWeight.bold
: FontWeight.normal,
color: field.controller.text.isNotEmpty
? Colors.white
: const Color(0xFF475569),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isActive)
const Icon(
Icons.arrow_left,
color: Color(0xFF3B82F6),
size: 20,
),
],
),
),
);
}
}