/// 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 '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 _runOcr(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 _runOcr(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 _runOcr(file.path); } catch (_) { if (!mounted) return; setState(() { _error = 'Could not load the sample receipt.'; }); } } 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); } } 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) async { final items = ReceiptParser.parse(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: items, rawText: rawText, repository: widget.repository, imagePath: _imagePath, supermarketNames: names.toList(), ), ), ); 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), 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( 'Take a photo of a shopping receipt, or pick one from the gallery.\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, ), ), ); } }