diff --git a/lib/features/input/ocr_capture_screen.dart b/lib/features/input/ocr_capture_screen.dart index 70593c4..cd4c3d1 100644 --- a/lib/features/input/ocr_capture_screen.dart +++ b/lib/features/input/ocr_capture_screen.dart @@ -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 blocks; + final List? textBlocks; + final List? 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 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 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 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 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 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 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 } 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 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 ), 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 ), ), - // 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 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 ); } - 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 final offsetX = (previewSize.width - imageSize.width * scale) / 2; final offsetY = (previewSize.height - imageSize.height * scale) / 2; - return Stack( - children: result.blocks.map((block) { + List 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 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 color: AppTheme.primaryColor.withOpacity(0.1), ), ), - ); - }).toList(), - ); + )); + } + } + + return Stack(children: overlays); }, ); } @@ -520,13 +615,13 @@ class _OcrCaptureScreenState extends State 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 CircularProgressIndicator(color: AppTheme.primaryColor), SizedBox(height: 16), Text( - 'Extracting text...', + 'Scanning...', style: TextStyle(color: Colors.white), ), ], @@ -601,9 +696,9 @@ class _OcrCaptureScreenState extends State ), ), - // 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 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 // 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 ], ); } + + 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, + ), + ), + ), + ); + } } diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart index 021e876..215456e 100644 --- a/lib/services/ocr_service.dart +++ b/lib/services/ocr_service.dart @@ -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 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 blocks; +/// Complete scan result - can be barcode or text +class ScanResult { + final ScanResultType type; + final List? barcodes; + final String? fullText; + final List? 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 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 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 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 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 _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 processImageFile(String imagePath) async { + /// Scan an image file - barcode first, then OCR + Future 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 processCameraImage( + /// Scan camera image for live preview - barcode first, then OCR + Future 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 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> 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; } diff --git a/pubspec.lock b/pubspec.lock index 0772fa9..5636be4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 495a367..d128554 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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