import 'dart:async'; import 'dart:io'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/theme.dart'; import '../../services/ocr_service.dart'; /// Result returned from OCR capture screen class OcrCaptureResult { final ScanResultType type; final String text; final List? textBlocks; final List? barcodes; final String? imagePath; OcrCaptureResult({ required this.type, required this.text, 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 barcode/text detection overlay /// - Barcode-first scanning strategy (barcode → OCR → nothing) /// - Live preview scanning (optional) /// - Photo capture with scanning /// - Confirm/retake flow /// - Returns extracted data to caller class OcrCaptureScreen extends StatefulWidget { /// Enable live preview scanning (shows detected content as you scan) final bool enableLivePreview; /// Keep the captured image file (otherwise deleted after scan) final bool keepImage; const OcrCaptureScreen({ super.key, this.enableLivePreview = true, this.keepImage = false, }); @override State createState() => _OcrCaptureScreenState(); } class _OcrCaptureScreenState extends State with WidgetsBindingObserver { CameraController? _cameraController; List _cameras = []; bool _isInitializing = true; bool _isCapturing = false; bool _hasPermission = false; String? _errorMessage; // Scanner final OcrService _ocrService = OcrService(); ScanResult? _liveResult; Timer? _liveProcessingTimer; bool _isLiveProcessing = false; // Captured state String? _capturedImagePath; ScanResult? _capturedResult; bool _isProcessingCapture = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _initializeCamera(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _stopLiveProcessing(); _cameraController?.dispose(); _ocrService.dispose(); // Clean up temp image if not keeping if (!widget.keepImage && _capturedImagePath != null) { File(_capturedImagePath!).delete().ignore(); } super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (_cameraController == null || !_cameraController!.value.isInitialized) { return; } if (state == AppLifecycleState.inactive) { _stopLiveProcessing(); _cameraController?.dispose(); } else if (state == AppLifecycleState.resumed) { _initializeCamera(); } } Future _initializeCamera() async { setState(() { _isInitializing = true; _errorMessage = null; }); // Check camera permission final status = await Permission.camera.request(); if (!status.isGranted) { setState(() { _hasPermission = false; _isInitializing = false; _errorMessage = 'Camera permission is required for scanning'; }); return; } setState(() => _hasPermission = true); try { _cameras = await availableCameras(); if (_cameras.isEmpty) { setState(() { _errorMessage = 'No cameras available'; _isInitializing = false; }); return; } // Use back camera for document/barcode scanning final camera = _cameras.firstWhere( (c) => c.lensDirection == CameraLensDirection.back, orElse: () => _cameras.first, ); _cameraController = CameraController( camera, ResolutionPreset.high, enableAudio: false, imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, ); await _cameraController!.initialize(); // Start live preview if enabled if (widget.enableLivePreview) { _startLiveProcessing(); } if (mounted) { setState(() => _isInitializing = false); } } catch (e) { setState(() { _errorMessage = 'Failed to initialize camera: $e'; _isInitializing = false; }); } } void _startLiveProcessing() { if (!widget.enableLivePreview || _cameraController == null) return; // Process frames at ~2fps to avoid overloading _liveProcessingTimer = Timer.periodic( const Duration(milliseconds: 500), (_) => _processLiveFrame(), ); } void _stopLiveProcessing() { _liveProcessingTimer?.cancel(); _liveProcessingTimer = null; } Future _processLiveFrame() async { if (_isLiveProcessing || _cameraController == null || !_cameraController!.value.isInitialized || _capturedImagePath != null) { return; } _isLiveProcessing = true; try { // Capture a frame for processing final image = await _cameraController!.takePicture(); final result = await _ocrService.scanImageFile(image.path); // Clean up temp file File(image.path).delete().ignore(); if (mounted && _capturedImagePath == null) { setState(() => _liveResult = result); } } catch (e) { debugPrint('Live scan error: $e'); } finally { _isLiveProcessing = false; } } Future _captureAndProcess() async { if (_isCapturing || _cameraController == null) return; setState(() { _isCapturing = true; _isProcessingCapture = true; }); _stopLiveProcessing(); try { final image = await _cameraController!.takePicture(); _capturedImagePath = image.path; final result = await _ocrService.scanImageFile(image.path); setState(() { _capturedResult = result; _isCapturing = false; _isProcessingCapture = false; }); } catch (e) { setState(() { _errorMessage = 'Failed to capture: $e'; _isCapturing = false; _isProcessingCapture = false; }); } } void _retake() { // Clean up captured image if (_capturedImagePath != null) { File(_capturedImagePath!).delete().ignore(); } setState(() { _capturedImagePath = null; _capturedResult = null; _liveResult = null; }); // Restart live processing if (widget.enableLivePreview) { _startLiveProcessing(); } } void _confirm() { if (_capturedResult == null || !_capturedResult!.hasData) return; Navigator.of(context).pop(OcrCaptureResult.fromScanResult( _capturedResult!, imagePath: widget.keepImage ? _capturedImagePath : null, )); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, title: const Text('Scan'), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ), body: _buildBody(), ); } Widget _buildBody() { if (_isInitializing) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: AppTheme.primaryColor), SizedBox(height: 16), Text( 'Initializing camera...', style: TextStyle(color: Colors.white), ), ], ), ); } if (!_hasPermission) { return _buildPermissionDenied(); } if (_errorMessage != null) { return _buildError(); } if (_capturedImagePath != null) { return _buildCapturedPreview(); } return _buildCameraPreview(); } Widget _buildPermissionDenied() { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.camera_alt_outlined, size: 64, color: Colors.grey, ), const SizedBox(height: 16), const Text( 'Camera Permission Required', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), const Text( 'Please grant camera permission to scan barcodes and text.', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: () => openAppSettings(), icon: const Icon(Icons.settings), label: const Text('Open Settings'), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primaryColor, foregroundColor: Colors.white, ), ), ], ), ), ); } Widget _buildError() { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 16), Text( _errorMessage!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white), ), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _initializeCamera, icon: const Icon(Icons.refresh), label: const Text('Retry'), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primaryColor, foregroundColor: Colors.white, ), ), ], ), ), ); } Widget _buildCameraPreview() { if (_cameraController == null || !_cameraController!.value.isInitialized) { return const SizedBox.shrink(); } return Stack( fit: StackFit.expand, children: [ // Camera preview Center( child: AspectRatio( aspectRatio: 1 / _cameraController!.value.aspectRatio, child: CameraPreview(_cameraController!), ), ), // Live detection overlay if (_liveResult != null && _liveResult!.hasData) _buildDetectionOverlay(_liveResult!), // Scanning guide if (_liveResult == null || !_liveResult!.hasData) _buildScanningGuide(), // Live detection indicator if (_liveResult != null && _liveResult!.hasData) Positioned( top: 16, left: 16, right: 16, child: _buildDetectionBadge(_liveResult!), ), // Capture button Positioned( bottom: 32, left: 0, right: 0, child: Center( child: _buildCaptureButton(), ), ), // Hint Positioned( bottom: 120, left: 16, right: 16, child: Text( 'Point at barcode or text and tap to capture', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withOpacity(0.8), fontSize: 14, ), ), ), ], ); } 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) { 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; final offsetX = (previewSize.width - imageSize.width * scale) / 2; final offsetY = (previewSize.height - imageSize.height * scale) / 2; 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, block.boundingBox.right * scale + offsetX, block.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: AppTheme.primaryColor.withOpacity(0.8), width: 2, ), color: AppTheme.primaryColor.withOpacity(0.1), ), ), )); } } return Stack(children: overlays); }, ); } Widget _buildScanningGuide() { return Center( child: Container( width: 280, height: 180, decoration: BoxDecoration( border: Border.all( color: Colors.white.withOpacity(0.5), width: 2, ), borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.qr_code_scanner, size: 48, color: Colors.white.withOpacity(0.7), ), const SizedBox(height: 8), Text( 'Position barcode or text here', style: TextStyle( color: Colors.white.withOpacity(0.7), fontSize: 14, ), ), ], ), ), ); } Widget _buildCaptureButton() { return GestureDetector( onTap: _isCapturing ? null : _captureAndProcess, child: Container( width: 72, height: 72, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 4), ), child: Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( shape: BoxShape.circle, color: _isCapturing ? Colors.grey : Colors.white, ), child: _isCapturing ? const Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.primaryColor, ), ), ) : null, ), ), ); } Widget _buildCapturedPreview() { return Stack( fit: StackFit.expand, children: [ // Captured image if (_capturedImagePath != null) Image.file( File(_capturedImagePath!), fit: BoxFit.contain, ), // Processing indicator if (_isProcessingCapture) Container( color: Colors.black54, child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: AppTheme.primaryColor), SizedBox(height: 16), Text( 'Scanning...', style: TextStyle(color: Colors.white), ), ], ), ), ), // Detection overlay on captured image if (_capturedResult != null) _buildDetectionOverlay(_capturedResult!), // Result info bar if (_capturedResult != null && !_isProcessingCapture) Positioned( top: 16, left: 16, right: 16, child: _buildResultInfoBar(_capturedResult!), ), // Extracted data preview if (_capturedResult != null && _capturedResult!.hasData && !_isProcessingCapture) Positioned( bottom: 120, left: 16, right: 16, child: _buildDataPreview(_capturedResult!), ), // Action buttons if (!_isProcessingCapture) Positioned( bottom: 32, left: 32, right: 32, child: Row( children: [ // Retake button Expanded( child: ElevatedButton.icon( onPressed: _retake, icon: const Icon(Icons.refresh), label: const Text('Retake'), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.surfaceColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), const SizedBox(width: 16), // Confirm button Expanded( child: ElevatedButton.icon( onPressed: _capturedResult?.hasData == true ? _confirm : null, icon: const Icon(Icons.check), label: Text(_capturedResult?.hasBarcode == true ? 'Use Barcode' : 'Use Text'), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primaryColor, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey, disabledForegroundColor: Colors.white54, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), ], ), ), ], ); } 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, ), ), ), ); } }