/// Best-effort values pulled out of OCR'd receipt text. Any field can be /// null if it couldn't be found, and the confirm screen lets the user fill /// in or correct whatever the parser got wrong. class ParsedReceipt { final double? gallons; final double? pricePerGallon; final double? totalCost; final String rawText; ParsedReceipt({ this.gallons, this.pricePerGallon, this.totalCost, required this.rawText, }); } /// Parses free-form OCR text from a fuel receipt looking for the number of /// gallons pumped, the price per gallon, and the total amount charged. /// /// Fuel receipts vary a lot between stations, so this uses a handful of /// label-based regexes rather than assuming a fixed layout, and falls back /// to deriving a missing value from the other two when exactly one is /// missing (total = gallons * price, etc). class ReceiptParser { static final _gallonsPatterns = [ RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false), ]; static final _pricePerGallonPatterns = [ RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), ]; static final _totalPatterns = [ RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), RegExp(r'(? patterns, String text) { for (final pattern in patterns) { final match = pattern.firstMatch(text); if (match != null) { return double.tryParse(match.group(1)!); } } return null; } static ({double? gallons, double? pricePerGallon, double? totalCost}) _deriveMissingValue({ required double? gallons, required double? pricePerGallon, required double? totalCost, }) { final missingCount = [gallons, pricePerGallon, totalCost].where((v) => v == null).length; // Only safe to derive when exactly one of the three is missing. if (missingCount != 1) { return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost); } if (totalCost == null && gallons != null && pricePerGallon != null) { totalCost = double.parse((gallons * pricePerGallon).toStringAsFixed(2)); } else if (gallons == null && totalCost != null && pricePerGallon != null && pricePerGallon > 0) { gallons = double.parse((totalCost / pricePerGallon).toStringAsFixed(3)); } else if (pricePerGallon == null && totalCost != null && gallons != null && gallons > 0) { pricePerGallon = double.parse((totalCost / gallons).toStringAsFixed(3)); } return (gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost); } }