inou-mobile/lib/features/input/ocr_capture_screen.dart

854 lines
24 KiB
Dart

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<OcrTextBlock>? textBlocks;
final List<BarcodeData>? 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<OcrCaptureScreen> createState() => _OcrCaptureScreenState();
}
class _OcrCaptureScreenState extends State<OcrCaptureScreen>
with WidgetsBindingObserver {
CameraController? _cameraController;
List<CameraDescription> _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<void> _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<void> _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<void> _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<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,
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,
),
),
),
);
}
}