/// Shared pick → crop → OCR → parse flow for adding a receipt to a trip. library; import 'dart:io'; import 'dart:ui' as ui; 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 'receipt_parser.dart'; class CapturedReceiptParse { const CapturedReceiptParse({ required this.items, required this.rawText, this.imagePath, this.needsReview = false, this.detectedStore, }); final List items; final String rawText; final String? imagePath; final bool needsReview; final String? detectedStore; } /// Asks camera vs gallery, crops, runs OCR, and returns parsed items. /// /// Returns null if the user cancels at any step. Future captureAndParseReceipt( BuildContext context, { ImagePicker? picker, }) async { final source = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.photo_camera_outlined), title: const Text('Take photo'), onTap: () => Navigator.pop(context, ImageSource.camera), ), ListTile( leading: const Icon(Icons.photo_library_outlined), title: const Text('Pick from gallery'), onTap: () => Navigator.pop(context, ImageSource.gallery), ), ], ), ); }, ); if (source == null || !context.mounted) return null; final imagePicker = picker ?? ImagePicker(); final file = await imagePicker.pickImage(source: source, imageQuality: 95); if (file == null || !context.mounted) return null; final croppedPath = await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => CropReceiptScreen(imagePath: file.path), ), ); if (croppedPath == null || !context.mounted) return null; return _runOcrAndParse(context, croppedPath); } Future _runOcrAndParse( BuildContext context, String imagePath, ) async { showDialog( context: context, barrierDismissible: false, builder: (context) { return const PopScope( canPop: false, child: Center( child: Card( child: Padding( padding: EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Reading receipt…'), ], ), ), ), ), ); }, ); final recognizer = TextRecognizer(script: TextRecognitionScript.latin); try { final bytes = await File(imagePath).readAsBytes(); final codec = await ui.instantiateImageCodec(bytes); final frame = await codec.getNextFrame(); final imageWidth = frame.image.width.toDouble(); final imageHeight = frame.image.height.toDouble(); frame.image.dispose(); final recognized = await recognizer.processImage( InputImage.fromFilePath(imagePath), ); final rawText = recognized.text.trim(); if (rawText.isEmpty) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No text found in this image.')), ); } return null; } final parsed = ReceiptParser.parseRecognizedText( recognized, imageWidth: imageWidth, imageHeight: imageHeight, ); if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); } if (parsed.items.isEmpty) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No products found on this receipt.')), ); } return null; } // Fresh ids so merged trips never collide with existing item keys. final stamp = DateTime.now().microsecondsSinceEpoch; final items = [ for (var i = 0; i < parsed.items.length; i++) parsed.items[i].copyWith(id: 'merged-$stamp-$i'), ]; return CapturedReceiptParse( items: items, rawText: rawText, imagePath: imagePath, needsReview: !parsed.validationPassed, detectedStore: parsed.store, ); } on PlatformException catch (error) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Could not read text from this image.' '${error.message == null || error.message!.isEmpty ? '' : '\n${error.message}'}', ), ), ); } return null; } catch (error) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not read text from this image.\n$error')), ); } return null; } finally { await recognizer.close(); } }