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

723 lines
20 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 String text;
final List<OcrTextBlock> blocks;
final String? imagePath;
OcrCaptureResult({
required this.text,
required this.blocks,
this.imagePath,
});
}
/// OCR Camera Capture Screen
///
/// Features:
/// - Camera preview with text detection overlay
/// - Live preview scanning (optional)
/// - Photo capture with OCR
/// - Confirm/retake flow
/// - Returns extracted text to caller
class OcrCaptureScreen extends StatefulWidget {
/// Enable live preview scanning (shows detected text as you scan)
final bool enableLivePreview;
/// Keep the captured image file (otherwise deleted after OCR)
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;
// OCR
final OcrService _ocrService = OcrService();
OcrResult? _liveResult;
Timer? _liveProcessingTimer;
bool _isLiveProcessing = false;
// Captured state
String? _capturedImagePath;
OcrResult? _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 OCR 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 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.processImageFile(image.path);
// Clean up temp file
File(image.path).delete().ignore();
if (mounted && _capturedImagePath == null) {
setState(() => _liveResult = result);
}
} catch (e) {
debugPrint('Live OCR 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.processImageFile(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) return;
Navigator.of(context).pop(OcrCaptureResult(
text: _capturedResult!.fullText,
blocks: _capturedResult!.blocks,
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 Text'),
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 text from documents.',
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 text overlay
if (_liveResult != null && _liveResult!.hasText)
_buildTextOverlay(_liveResult!),
// Scanning guide
if (_liveResult == null || !_liveResult!.hasText)
_buildScanningGuide(),
// Live text count indicator
if (_liveResult != null && _liveResult!.hasText)
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),
),
],
),
),
),
// 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 text and tap to capture',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
),
),
),
],
);
}
Widget _buildTextOverlay(OcrResult 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;
final offsetX = (previewSize.width - imageSize.width * scale) / 2;
final offsetY = (previewSize.height - imageSize.height * scale) / 2;
return Stack(
children: result.blocks.map((block) {
final rect = Rect.fromLTRB(
block.boundingBox.left * scale + offsetX,
block.boundingBox.top * scale + offsetY,
block.boundingBox.right * scale + offsetX,
block.boundingBox.bottom * scale + offsetY,
);
return 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),
),
),
);
}).toList(),
);
},
);
}
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.document_scanner_outlined,
size: 48,
color: Colors.white.withOpacity(0.7),
),
const SizedBox(height: 8),
Text(
'Position 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(
'Extracting text...',
style: TextStyle(color: Colors.white),
),
],
),
),
),
// Text overlay on captured image
if (_capturedResult != null)
_buildTextOverlay(_capturedResult!),
// Result info bar
if (_capturedResult != null && !_isProcessingCapture)
Positioned(
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),
),
),
],
),
),
),
// Extracted text preview
if (_capturedResult != null &&
_capturedResult!.hasText &&
!_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,
),
),
),
),
),
// 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?.hasText == true ? _confirm : null,
icon: const Icon(Icons.check),
label: const Text('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),
),
),
),
),
],
),
),
],
);
}
}