feat(scan): Barcode-first scanning strategy

- Add google_mlkit_barcode_scanning dependency
- Scan priority: Barcode → OCR text → Nothing found
- Support UPC-A/E, EAN-8/13, QR, DataMatrix, PDF417, Code128/39
- BarcodeData class with product code detection and NDC hints
- ScanResult with type indicator (barcode|text|empty)
- Updated capture screen with barcode-specific UI:
  - Green overlay for barcodes, blue for text
  - Product code badge for UPC/EAN barcodes
  - Format-specific result display
- Backward compatible legacy API (extractText, getTextBlocks)
This commit is contained in:
Johan Jongsma 2026-01-31 19:48:38 +00:00
parent b7f079d9c6
commit 90d50aa848
4 changed files with 524 additions and 164 deletions

View File

@ -8,30 +8,63 @@ import '../../services/ocr_service.dart';
/// Result returned from OCR capture screen
class OcrCaptureResult {
final ScanResultType type;
final String text;
final List<OcrTextBlock> blocks;
final List<OcrTextBlock>? textBlocks;
final List<BarcodeData>? barcodes;
final String? imagePath;
OcrCaptureResult({
required this.type,
required this.text,
required this.blocks,
this.textBlocks,
this.barcodes,
this.imagePath,
});
/// Check if this is a barcode result
bool get isBarcode => type == ScanResultType.barcode;
/// Check if this is a text result
bool get isText => type == ScanResultType.text;
/// Get primary barcode if available
BarcodeData? get primaryBarcode =>
barcodes?.isNotEmpty == true ? barcodes!.first : null;
/// Create from ScanResult
factory OcrCaptureResult.fromScanResult(ScanResult result, {String? imagePath}) {
String text;
if (result.type == ScanResultType.barcode && result.primaryBarcode != null) {
text = result.primaryBarcode!.value;
} else {
text = result.fullText ?? '';
}
return OcrCaptureResult(
type: result.type,
text: text,
textBlocks: result.textBlocks,
barcodes: result.barcodes,
imagePath: imagePath,
);
}
}
/// OCR Camera Capture Screen
///
/// Features:
/// - Camera preview with text detection overlay
/// - Camera preview with barcode/text detection overlay
/// - Barcode-first scanning strategy (barcode OCR nothing)
/// - Live preview scanning (optional)
/// - Photo capture with OCR
/// - Photo capture with scanning
/// - Confirm/retake flow
/// - Returns extracted text to caller
/// - Returns extracted data to caller
class OcrCaptureScreen extends StatefulWidget {
/// Enable live preview scanning (shows detected text as you scan)
/// Enable live preview scanning (shows detected content as you scan)
final bool enableLivePreview;
/// Keep the captured image file (otherwise deleted after OCR)
/// Keep the captured image file (otherwise deleted after scan)
final bool keepImage;
const OcrCaptureScreen({
@ -53,15 +86,15 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
bool _hasPermission = false;
String? _errorMessage;
// OCR
// Scanner
final OcrService _ocrService = OcrService();
OcrResult? _liveResult;
ScanResult? _liveResult;
Timer? _liveProcessingTimer;
bool _isLiveProcessing = false;
// Captured state
String? _capturedImagePath;
OcrResult? _capturedResult;
ScanResult? _capturedResult;
bool _isProcessingCapture = false;
@override
@ -110,7 +143,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
setState(() {
_hasPermission = false;
_isInitializing = false;
_errorMessage = 'Camera permission is required for OCR scanning';
_errorMessage = 'Camera permission is required for scanning';
});
return;
}
@ -127,7 +160,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
return;
}
// Use back camera for document scanning
// Use back camera for document/barcode scanning
final camera = _cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => _cameras.first,
@ -188,7 +221,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
try {
// Capture a frame for processing
final image = await _cameraController!.takePicture();
final result = await _ocrService.processImageFile(image.path);
final result = await _ocrService.scanImageFile(image.path);
// Clean up temp file
File(image.path).delete().ignore();
@ -197,7 +230,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
setState(() => _liveResult = result);
}
} catch (e) {
debugPrint('Live OCR error: $e');
debugPrint('Live scan error: $e');
} finally {
_isLiveProcessing = false;
}
@ -217,7 +250,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
final image = await _cameraController!.takePicture();
_capturedImagePath = image.path;
final result = await _ocrService.processImageFile(image.path);
final result = await _ocrService.scanImageFile(image.path);
setState(() {
_capturedResult = result;
@ -252,11 +285,10 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
}
void _confirm() {
if (_capturedResult == null) return;
if (_capturedResult == null || !_capturedResult!.hasData) return;
Navigator.of(context).pop(OcrCaptureResult(
text: _capturedResult!.fullText,
blocks: _capturedResult!.blocks,
Navigator.of(context).pop(OcrCaptureResult.fromScanResult(
_capturedResult!,
imagePath: widget.keepImage ? _capturedImagePath : null,
));
}
@ -268,7 +300,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: const Text('Scan Text'),
title: const Text('Scan'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
@ -333,7 +365,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
),
const SizedBox(height: 8),
const Text(
'Please grant camera permission to scan text from documents.',
'Please grant camera permission to scan barcodes and text.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
@ -399,37 +431,21 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
),
),
// Live text overlay
if (_liveResult != null && _liveResult!.hasText)
_buildTextOverlay(_liveResult!),
// Live detection overlay
if (_liveResult != null && _liveResult!.hasData)
_buildDetectionOverlay(_liveResult!),
// Scanning guide
if (_liveResult == null || !_liveResult!.hasText)
if (_liveResult == null || !_liveResult!.hasData)
_buildScanningGuide(),
// Live text count indicator
if (_liveResult != null && _liveResult!.hasText)
// Live detection indicator
if (_liveResult != null && _liveResult!.hasData)
Positioned(
top: 16,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.text_fields, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'${_liveResult!.wordCount} words detected',
style: const TextStyle(color: Colors.white),
),
],
),
),
child: _buildDetectionBadge(_liveResult!),
),
// Capture button
@ -448,7 +464,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
left: 16,
right: 16,
child: Text(
'Point at text and tap to capture',
'Point at barcode or text and tap to capture',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
@ -460,13 +476,56 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
);
}
Widget _buildTextOverlay(OcrResult result) {
Widget _buildDetectionBadge(ScanResult result) {
final isBarcode = result.type == ScanResultType.barcode;
final color = isBarcode ? Colors.green : Colors.blue;
final icon = isBarcode ? Icons.qr_code_scanner : Icons.text_fields;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
result.description,
style: const TextStyle(color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
if (isBarcode && result.primaryBarcode?.isProductCode == true)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'PRODUCT',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
Widget _buildDetectionOverlay(ScanResult result) {
return LayoutBuilder(
builder: (context, constraints) {
// Calculate scale factors
final previewSize = Size(constraints.maxWidth, constraints.maxHeight);
final imageSize = result.imageSize;
final scaleX = previewSize.width / imageSize.width;
final scaleY = previewSize.height / imageSize.height;
final scale = scaleX < scaleY ? scaleX : scaleY;
@ -474,8 +533,42 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
final offsetX = (previewSize.width - imageSize.width * scale) / 2;
final offsetY = (previewSize.height - imageSize.height * scale) / 2;
return Stack(
children: result.blocks.map((block) {
List<Widget> overlays = [];
// Barcode overlays (green)
if (result.hasBarcode) {
for (final barcode in result.barcodes!) {
if (barcode.boundingBox != null) {
final rect = Rect.fromLTRB(
barcode.boundingBox!.left * scale + offsetX,
barcode.boundingBox!.top * scale + offsetY,
barcode.boundingBox!.right * scale + offsetX,
barcode.boundingBox!.bottom * scale + offsetY,
);
overlays.add(Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.green.withOpacity(0.9),
width: 3,
),
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
),
));
}
}
}
// Text overlays (blue/purple)
if (result.hasText && result.textBlocks != null) {
for (final block in result.textBlocks!) {
final rect = Rect.fromLTRB(
block.boundingBox.left * scale + offsetX,
block.boundingBox.top * scale + offsetY,
@ -483,7 +576,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
block.boundingBox.bottom * scale + offsetY,
);
return Positioned(
overlays.add(Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
@ -497,9 +590,11 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
color: AppTheme.primaryColor.withOpacity(0.1),
),
),
);
}).toList(),
);
));
}
}
return Stack(children: overlays);
},
);
}
@ -520,13 +615,13 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.document_scanner_outlined,
Icons.qr_code_scanner,
size: 48,
color: Colors.white.withOpacity(0.7),
),
const SizedBox(height: 8),
Text(
'Position text here',
'Position barcode or text here',
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
@ -593,7 +688,7 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
CircularProgressIndicator(color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'Extracting text...',
'Scanning...',
style: TextStyle(color: Colors.white),
),
],
@ -601,9 +696,9 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
),
),
// Text overlay on captured image
// Detection overlay on captured image
if (_capturedResult != null)
_buildTextOverlay(_capturedResult!),
_buildDetectionOverlay(_capturedResult!),
// Result info bar
if (_capturedResult != null && !_isProcessingCapture)
@ -611,62 +706,18 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
top: 16,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _capturedResult!.hasText
? Colors.green.withOpacity(0.9)
: Colors.orange.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
_capturedResult!.hasText
? Icons.check_circle
: Icons.warning,
color: Colors.white,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_capturedResult!.hasText
? '${_capturedResult!.wordCount} words extracted'
: 'No text detected. Try again?',
style: const TextStyle(color: Colors.white),
),
),
],
),
),
child: _buildResultInfoBar(_capturedResult!),
),
// Extracted text preview
// Extracted data preview
if (_capturedResult != null &&
_capturedResult!.hasText &&
_capturedResult!.hasData &&
!_isProcessingCapture)
Positioned(
bottom: 120,
left: 16,
right: 16,
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
),
child: SingleChildScrollView(
child: Text(
_capturedResult!.fullText,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
),
),
),
),
child: _buildDataPreview(_capturedResult!),
),
// Action buttons
@ -697,10 +748,9 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
// Confirm button
Expanded(
child: ElevatedButton.icon(
onPressed:
_capturedResult?.hasText == true ? _confirm : null,
onPressed: _capturedResult?.hasData == true ? _confirm : null,
icon: const Icon(Icons.check),
label: const Text('Use Text'),
label: Text(_capturedResult?.hasBarcode == true ? 'Use Barcode' : 'Use Text'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
@ -719,4 +769,85 @@ class _OcrCaptureScreenState extends State<OcrCaptureScreen>
],
);
}
Widget _buildResultInfoBar(ScanResult result) {
final Color color;
final IconData icon;
final String message;
switch (result.type) {
case ScanResultType.barcode:
color = Colors.green;
icon = Icons.qr_code_scanner;
final bc = result.primaryBarcode!;
message = bc.isProductCode
? 'Product code found: ${bc.formatName}'
: '${bc.formatName} barcode found';
break;
case ScanResultType.text:
color = Colors.blue;
icon = Icons.text_fields;
message = '${result.wordCount} words extracted';
break;
case ScanResultType.empty:
color = Colors.orange;
icon = Icons.warning;
message = 'No barcode or text detected. Try again?';
break;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(color: Colors.white),
),
),
],
),
);
}
Widget _buildDataPreview(ScanResult result) {
String previewText;
if (result.hasBarcode) {
final bc = result.primaryBarcode!;
previewText = '${bc.formatName}\n${bc.value}';
if (bc.couldBeNdc) {
previewText += '\n\n(Possible NDC/Product Code)';
}
} else if (result.hasText) {
previewText = result.fullText!;
} else {
return const SizedBox.shrink();
}
return Container(
constraints: const BoxConstraints(maxHeight: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(12),
),
child: SingleChildScrollView(
child: Text(
previewText,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
),
),
),
);
}
}

View File

@ -4,8 +4,100 @@ import 'dart:ui' as ui;
import 'dart:ui' show Rect, Size;
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
/// Type of scan result
enum ScanResultType {
barcode,
text,
empty,
}
/// Alias for backward compatibility
typedef OcrResult = ScanResult;
/// Barcode data from scan
class BarcodeData {
final String value;
final String rawValue;
final BarcodeType type;
final BarcodeFormat format;
final Rect? boundingBox;
BarcodeData({
required this.value,
required this.rawValue,
required this.type,
required this.format,
this.boundingBox,
});
/// Human-readable format name
String get formatName {
switch (format) {
case BarcodeFormat.upca:
return 'UPC-A';
case BarcodeFormat.upce:
return 'UPC-E';
case BarcodeFormat.ean8:
return 'EAN-8';
case BarcodeFormat.ean13:
return 'EAN-13';
case BarcodeFormat.qrCode:
return 'QR Code';
case BarcodeFormat.dataMatrix:
return 'Data Matrix';
case BarcodeFormat.pdf417:
return 'PDF417';
case BarcodeFormat.code128:
return 'Code 128';
case BarcodeFormat.code39:
return 'Code 39';
case BarcodeFormat.code93:
return 'Code 93';
case BarcodeFormat.codabar:
return 'Codabar';
case BarcodeFormat.itf:
return 'ITF';
case BarcodeFormat.aztec:
return 'Aztec';
default:
return 'Unknown';
}
}
/// Check if this is a product barcode (UPC/EAN)
bool get isProductCode =>
format == BarcodeFormat.upca ||
format == BarcodeFormat.upce ||
format == BarcodeFormat.ean8 ||
format == BarcodeFormat.ean13;
/// Check if this could be an NDC (National Drug Code)
/// NDC is typically encoded as UPC-A or embedded in other formats
bool get couldBeNdc => isProductCode && value.length >= 10;
factory BarcodeData.fromMlKit(Barcode barcode) {
return BarcodeData(
value: barcode.displayValue ?? barcode.rawValue ?? '',
rawValue: barcode.rawValue ?? '',
type: barcode.type,
format: barcode.format,
boundingBox: barcode.boundingBox,
);
}
Map<String, dynamic> toJson() => {
'value': value,
'rawValue': rawValue,
'type': type.name,
'format': formatName,
'isProductCode': isProductCode,
'couldBeNdc': couldBeNdc,
};
}
/// Structured text block with bounding box info
class OcrTextBlock {
final String text;
@ -22,7 +114,6 @@ class OcrTextBlock {
this.recognizedLanguage,
});
/// Create from ML Kit TextBlock
factory OcrTextBlock.fromMlKit(TextBlock block) {
return OcrTextBlock(
text: block.text,
@ -87,54 +178,152 @@ class OcrTextElement {
}
}
/// Complete OCR result
class OcrResult {
final String fullText;
final List<OcrTextBlock> blocks;
/// Complete scan result - can be barcode or text
class ScanResult {
final ScanResultType type;
final List<BarcodeData>? barcodes;
final String? fullText;
final List<OcrTextBlock>? textBlocks;
final Size imageSize;
final DateTime timestamp;
final String? imagePath;
OcrResult({
required this.fullText,
required this.blocks,
ScanResult({
required this.type,
this.barcodes,
this.fullText,
this.textBlocks,
required this.imageSize,
required this.timestamp,
this.imagePath,
});
/// Check if any text was found
bool get hasText => fullText.isNotEmpty;
/// Check if any data was found
bool get hasData => type != ScanResultType.empty;
/// Get all recognized languages
/// Check if barcode was found
bool get hasBarcode => type == ScanResultType.barcode && barcodes != null && barcodes!.isNotEmpty;
/// Check if text was found
bool get hasText => type == ScanResultType.text && fullText != null && fullText!.isNotEmpty;
/// Get primary barcode (first one found)
BarcodeData? get primaryBarcode => barcodes?.isNotEmpty == true ? barcodes!.first : null;
/// Get word count for text results
int get wordCount => textBlocks?.expand((b) => b.lines).expand((l) => l.elements).length ?? 0;
/// Get all recognized languages from text
Set<String> get languages =>
blocks.where((b) => b.recognizedLanguage != null)
textBlocks
?.where((b) => b.recognizedLanguage != null)
.map((b) => b.recognizedLanguage!)
.toSet();
.toSet() ??
{};
/// Get total number of words
int get wordCount =>
blocks.expand((b) => b.lines).expand((l) => l.elements).length;
/// User-friendly description of what was found
String get description {
switch (type) {
case ScanResultType.barcode:
final bc = primaryBarcode;
if (bc != null) {
return '${bc.formatName}: ${bc.value}';
}
return 'Barcode found';
case ScanResultType.text:
return '$wordCount words detected';
case ScanResultType.empty:
return 'Nothing detected';
}
}
/// Convert to JSON-friendly map
Map<String, dynamic> toJson() => {
'type': type.name,
'data': type == ScanResultType.barcode
? barcodes?.map((b) => b.toJson()).toList()
: fullText,
'wordCount': wordCount,
'timestamp': timestamp.toIso8601String(),
};
/// Create empty result
factory ScanResult.empty(Size imageSize) => ScanResult(
type: ScanResultType.empty,
imageSize: imageSize,
timestamp: DateTime.now(),
);
/// Create barcode result
factory ScanResult.fromBarcodes(
List<BarcodeData> barcodes,
Size imageSize, {
String? imagePath,
}) =>
ScanResult(
type: ScanResultType.barcode,
barcodes: barcodes,
imageSize: imageSize,
timestamp: DateTime.now(),
imagePath: imagePath,
);
/// Create text result
factory ScanResult.fromText(
String fullText,
List<OcrTextBlock> blocks,
Size imageSize, {
String? imagePath,
}) =>
ScanResult(
type: ScanResultType.text,
fullText: fullText,
textBlocks: blocks,
imageSize: imageSize,
timestamp: DateTime.now(),
imagePath: imagePath,
);
}
/// OCR service using ML Kit Text Recognition
/// OCR service with barcode-first scanning strategy
/// Priority: Barcode OCR text Nothing found
class OcrService {
BarcodeScanner? _barcodeScanner;
TextRecognizer? _textRecognizer;
bool _isProcessing = false;
/// Barcode formats to scan for (medication-relevant)
static const List<BarcodeFormat> _barcodeFormats = [
BarcodeFormat.upca, // UPC-A (common for US products)
BarcodeFormat.upce, // UPC-E (compressed UPC)
BarcodeFormat.ean8, // EAN-8
BarcodeFormat.ean13, // EAN-13 (common for international)
BarcodeFormat.qrCode, // QR codes
BarcodeFormat.dataMatrix, // Data Matrix (common for pharma)
BarcodeFormat.pdf417, // PDF417 (driver's licenses, some pharma)
BarcodeFormat.code128, // Code 128 (logistics, pharma)
BarcodeFormat.code39, // Code 39 (older pharma)
];
/// Get or create barcode scanner
BarcodeScanner get barcodeScanner {
_barcodeScanner ??= BarcodeScanner(formats: _barcodeFormats);
return _barcodeScanner!;
}
/// Get or create text recognizer
TextRecognizer get textRecognizer {
_textRecognizer ??= TextRecognizer(script: TextRecognitionScript.latin);
return _textRecognizer!;
}
/// Whether OCR processing is currently running
/// Whether processing is currently running
bool get isProcessing => _isProcessing;
/// Process an image file and extract structured text
Future<OcrResult> processImageFile(String imagePath) async {
/// Scan an image file - barcode first, then OCR
Future<ScanResult> scanImageFile(String imagePath) async {
if (_isProcessing) {
throw OcrException('OCR processing already in progress');
throw OcrException('Scan already in progress');
}
_isProcessing = true;
@ -145,7 +334,6 @@ class OcrService {
}
final inputImage = InputImage.fromFilePath(imagePath);
final recognizedText = await textRecognizer.processImage(inputImage);
// Get image dimensions
final bytes = await file.readAsBytes();
@ -156,25 +344,42 @@ class OcrService {
frame.image.height.toDouble(),
);
return OcrResult(
fullText: recognizedText.text,
blocks: recognizedText.blocks
.map((b) => OcrTextBlock.fromMlKit(b))
.toList(),
imageSize: imageSize,
timestamp: DateTime.now(),
imagePath: imagePath,
);
// STEP 1: Try barcode scanning first
final barcodes = await barcodeScanner.processImage(inputImage);
if (barcodes.isNotEmpty) {
debugPrint('Found ${barcodes.length} barcode(s)');
return ScanResult.fromBarcodes(
barcodes.map((b) => BarcodeData.fromMlKit(b)).toList(),
imageSize,
imagePath: imagePath,
);
}
// STEP 2: No barcode found, try text OCR
final recognizedText = await textRecognizer.processImage(inputImage);
if (recognizedText.text.isNotEmpty) {
debugPrint('Found ${recognizedText.blocks.length} text block(s)');
return ScanResult.fromText(
recognizedText.text,
recognizedText.blocks.map((b) => OcrTextBlock.fromMlKit(b)).toList(),
imageSize,
imagePath: imagePath,
);
}
// STEP 3: Nothing found
debugPrint('No barcode or text found');
return ScanResult.empty(imageSize);
} catch (e) {
if (e is OcrException) rethrow;
throw OcrException('Failed to process image: $e');
throw OcrException('Failed to scan image: $e');
} finally {
_isProcessing = false;
}
}
/// Process camera image for live preview scanning
Future<OcrResult?> processCameraImage(
/// Scan camera image for live preview - barcode first, then OCR
Future<ScanResult?> scanCameraImage(
CameraImage image,
CameraDescription camera,
int sensorOrientation,
@ -188,23 +393,35 @@ class OcrService {
camera,
sensorOrientation,
);
if (inputImage == null) {
return null;
}
final recognizedText = await textRecognizer.processImage(inputImage);
final imageSize = Size(image.width.toDouble(), image.height.toDouble());
return OcrResult(
fullText: recognizedText.text,
blocks: recognizedText.blocks
.map((b) => OcrTextBlock.fromMlKit(b))
.toList(),
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
timestamp: DateTime.now(),
);
// Try barcode first
final barcodes = await barcodeScanner.processImage(inputImage);
if (barcodes.isNotEmpty) {
return ScanResult.fromBarcodes(
barcodes.map((b) => BarcodeData.fromMlKit(b)).toList(),
imageSize,
);
}
// Fall back to text OCR
final recognizedText = await textRecognizer.processImage(inputImage);
if (recognizedText.text.isNotEmpty) {
return ScanResult.fromText(
recognizedText.text,
recognizedText.blocks.map((b) => OcrTextBlock.fromMlKit(b)).toList(),
imageSize,
);
}
return ScanResult.empty(imageSize);
} catch (e) {
debugPrint('OCR processing error: $e');
debugPrint('Live scan error: $e');
return null;
} finally {
_isProcessing = false;
@ -217,11 +434,9 @@ class OcrService {
CameraDescription camera,
int sensorOrientation,
) {
// Get rotation based on platform
final rotation = _getRotation(camera, sensorOrientation);
if (rotation == null) return null;
// Get image format
final format = InputImageFormatValue.fromRawValue(image.format.raw);
if (format == null ||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
@ -229,7 +444,6 @@ class OcrService {
return null;
}
// Only single plane supported
if (image.planes.isEmpty) return null;
final plane = image.planes.first;
@ -253,7 +467,6 @@ class OcrService {
if (Platform.isIOS) {
return InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) {
// Compensate for camera and device orientation
var rotationCompensation = sensorOrientation;
if (camera.lensDirection == CameraLensDirection.front) {
@ -265,20 +478,25 @@ class OcrService {
return null;
}
// ========== Legacy API (for backward compatibility) ==========
/// Extract just the text from an image (simple API)
/// Note: This skips barcode scanning - use scanImageFile for full scan
Future<String> extractText(String imagePath) async {
final result = await processImageFile(imagePath);
return result.fullText;
final result = await scanImageFile(imagePath);
return result.fullText ?? '';
}
/// Get all text blocks from an image
Future<List<OcrTextBlock>> getTextBlocks(String imagePath) async {
final result = await processImageFile(imagePath);
return result.blocks;
final result = await scanImageFile(imagePath);
return result.textBlocks ?? [];
}
/// Cleanup resources
void dispose() {
_barcodeScanner?.close();
_barcodeScanner = null;
_textRecognizer?.close();
_textRecognizer = null;
}

View File

@ -152,6 +152,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_mlkit_barcode_scanning:
dependency: "direct main"
description:
name: google_mlkit_barcode_scanning
sha256: "5852d1daa007a05b33f99e3e3fa34227c49d9c41bc92a85d820666a57fd5c35f"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
google_mlkit_commons:
dependency: transitive
description:

View File

@ -22,6 +22,9 @@ dependencies:
# OCR (ML Kit Text Recognition)
google_mlkit_text_recognition: ^0.14.0
# Barcode Scanning (ML Kit)
google_mlkit_barcode_scanning: ^0.13.0
# Speech to Text
speech_to_text: ^7.0.0