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:
parent
b7f079d9c6
commit
90d50aa848
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue