/// Two-letter USPS abbreviation to full name, for the 50 states + DC — /// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular /// shows up on real receipts as a "Visa" abbreviation, and including it as /// a valid state code would misfire on that. const usStateNames = { 'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas', 'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware', 'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho', 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas', 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland', 'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', 'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska', 'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey', 'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina', 'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah', 'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington', 'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming', 'DC': 'District of Columbia', }; /// 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; /// The transaction date/time printed on the receipt, if found. Null /// means the confirm screen should fall back to manual entry (it /// defaults to "now" and lets the user pick a different date/time). final DateTime? date; /// Two-letter state abbreviation of the station's address, if found /// (e.g. "MO", "TX"). Null means no state could be confidently /// identified — callers should treat that as "unknown", not "Missouri". final String? state; final String rawText; ParsedReceipt({ this.gallons, this.pricePerGallon, this.totalCost, this.date, this.state, 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 { // These use [ \t]* rather than \s* between a label and its value: \s // matches newlines too, which let a number on one line match a // completely unrelated label several lines further down (e.g. a price // value followed, many lines later, by an incidental "Gallons" heading // for a different column) — a real bug this surfaced against an actual // receipt where "$1.999" ended up matching "...Gallons" two lines below // it. A label and its own value are always on the same line. static final _gallonsPatterns = [ RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false), ]; static final _pricePerGallonPatterns = [ RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), ]; static final _totalPatterns = [ RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false), RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false), // Allows for words between TOTAL and the amount ("Total Sale $120.72", // "Fuel Total: $44.44"), not just a bare colon/dash. RegExp(r'(? 12 || day < 1 || day > 31) return null; if (year < 100) year += 2000; final windowEnd = (dateMatch.end + 40).clamp(0, text.length); final nearbyText = text.substring(dateMatch.end, windowEnd); final timeMatch = _timePattern.firstMatch(nearbyText); var hour = 0; var minute = 0; if (timeMatch != null) { hour = int.tryParse(timeMatch.group(1)!) ?? 0; minute = int.tryParse(timeMatch.group(2)!) ?? 0; final meridiem = timeMatch.group(3)?.toUpperCase(); if (meridiem == 'PM' && hour != 12) hour += 12; if (meridiem == 'AM' && hour == 12) hour = 0; if (hour > 23 || minute > 59) { hour = 0; minute = 0; } } try { return DateTime(year, month, day, hour, minute); } catch (_) { return null; } } static double? _firstMatch(List 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); } }