/// Capture or pick a receipt photo, then review parsed line items. library; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:image_picker/image_picker.dart'; import 'crop_receipt_screen.dart'; import 'models/line_item.dart'; import 'review_screen.dart'; import 'services/app_settings.dart'; import 'services/product_repository.dart'; import 'services/receipt_parser.dart'; import 'utils/constants.dart'; import 'utils/supermarkets.dart'; import 'widgets/settings_button.dart'; class ScanScreen extends StatefulWidget { const ScanScreen({super.key, required this.repository, this.onTripSaved}); final ProductRepository repository; final VoidCallback? onTripSaved; @override State createState() => _ScanScreenState(); } class _ScanScreenState extends State { final ImagePicker _picker = ImagePicker(); String _extractedText = ''; String? _imagePath; bool _busy = false; String? _error; @override void initState() { super.initState(); _recoverLostImage(); } /// Android may kill the activity while the camera app is open. Future _recoverLostImage() async { try { final response = await _picker.retrieveLostData(); if (!mounted || response.isEmpty) return; final file = response.file; if (file != null) { await _cropThenOcr(file.path); } } on UnimplementedError { // Desktop / platforms that do not support lost-data recovery. } } Future _capture(ImageSource source) async { final file = await _picker.pickImage(source: source, imageQuality: 95); if (file == null || !mounted) return; await _cropThenOcr(file.path); } Future _scanSampleReceipt() async { try { final data = await rootBundle.load(kSampleReceiptAsset); final file = File( '${Directory.systemTemp.path}/receipity_sample_receipt.jpeg', ); await file.writeAsBytes(data.buffer.asUint8List(), flush: true); if (!mounted) return; await _cropThenOcr(file.path); } catch (_) { if (!mounted) return; setState(() { _error = 'Could not load the sample receipt.'; }); } } Future _cropThenOcr(String imagePath) async { final croppedPath = await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => CropReceiptScreen(imagePath: imagePath), ), ); if (croppedPath == null || !mounted) return; await _runOcr(croppedPath); } Future _runOcr(String imagePath) async { setState(() { _busy = true; _error = null; _extractedText = ''; _imagePath = imagePath; }); final recognizer = TextRecognizer(script: TextRecognitionScript.latin); try { final inputImage = InputImage.fromFilePath(imagePath); final recognized = await recognizer.processImage(inputImage); if (!mounted) return; setState(() { _extractedText = recognized.text.trim(); if (_extractedText.isEmpty) { _error = 'No text found in this image.'; } }); } on PlatformException catch (error) { debugPrint('OCR failed: ${error.code} ${error.message}'); if (!mounted) return; setState(() { _error = _ocrFailureMessage(error.message); }); } catch (error) { debugPrint('OCR failed: $error'); if (!mounted) return; setState(() { _error = _ocrFailureMessage(error.toString()); }); } finally { await recognizer.close(); if (mounted) { setState(() => _busy = false); } } if (mounted && _extractedText.isNotEmpty) { await _openReview(_extractedText); } } Future _enterManually() async { await _openReview('', items: const []); } String _ocrFailureMessage(String? detail) { const fallback = 'Could not read text from this image.'; if (detail == null || detail.trim().isEmpty) return fallback; return '$fallback\n$detail'; } Future _openReview( String rawText, { List? items, String? imagePath, }) async { final parsedItems = items; ReceiptParseResult? parsed; if (parsedItems == null) { parsed = ReceiptParser.parseReceipt(rawText); } if (!mounted) return; final names = SettingsScope.maybeOf(context)?.supermarkets.map((s) => s.name) ?? kSupermarkets; final saved = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ReviewScreen( items: parsedItems ?? parsed!.items, rawText: rawText, repository: widget.repository, imagePath: imagePath ?? (items == null ? _imagePath : null), supermarketNames: names.toList(), needsReview: parsed != null && !parsed.validationPassed, detectedStore: parsed?.store, ), ), ); if (saved == true && mounted) { widget.onTripSaved?.call(); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: const Text(kAppName), actions: const [SettingsButton()], ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FilledButton.icon( onPressed: _busy ? null : () => _capture(ImageSource.camera), icon: const Icon(Icons.photo_camera_outlined), label: const Text('Take photo'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _busy ? null : () => _capture(ImageSource.gallery), icon: const Icon(Icons.photo_library_outlined), label: const Text('Pick from gallery'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _busy ? null : _enterManually, icon: const Icon(Icons.edit_note_outlined), label: const Text('Enter manually'), ), const SizedBox(height: 8), TextButton.icon( onPressed: _busy ? null : _scanSampleReceipt, icon: const Icon(Icons.receipt_long_outlined), label: const Text('Use sample receipt'), ), if (_extractedText.isNotEmpty) ...[ const SizedBox(height: 8), OutlinedButton( onPressed: _busy ? null : () => _openReview(_extractedText), child: const Text('Review items'), ), ], if (_busy) ...[ const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 8), Text('Reading text…', style: theme.textTheme.bodyMedium), ], const SizedBox(height: 16), Expanded( child: _ResultPane(text: _extractedText, error: _error), ), ], ), ), ), ); } } class _ResultPane extends StatelessWidget { const _ResultPane({required this.text, required this.error}); final String text; final String? error; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (error != null && text.isEmpty) { return Center( child: Text( error!, textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ); } if (text.isEmpty) { return Center( child: Text( 'Scan a receipt or enter a trip by hand.\n\nConfirmed trips show up in Shop log.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ); } return SingleChildScrollView( child: SelectableText( text, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurface, height: 1.4, ), ), ); } }