105 lines
3.8 KiB
Dart
105 lines
3.8 KiB
Dart
/// 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'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
|
|
caseSensitive: false),
|
|
];
|
|
|
|
static ParsedReceipt parse(String text) {
|
|
final normalized = text.replaceAll(',', '');
|
|
|
|
final gallons = _firstMatch(_gallonsPatterns, normalized);
|
|
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
|
var totalCost = _firstMatch(_totalPatterns, normalized);
|
|
|
|
final derivedTotal = _deriveMissingValue(
|
|
gallons: gallons,
|
|
pricePerGallon: pricePerGallon,
|
|
totalCost: totalCost,
|
|
);
|
|
|
|
return ParsedReceipt(
|
|
gallons: derivedTotal.gallons,
|
|
pricePerGallon: derivedTotal.pricePerGallon,
|
|
totalCost: derivedTotal.totalCost,
|
|
rawText: text,
|
|
);
|
|
}
|
|
|
|
static double? _firstMatch(List<RegExp> 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);
|
|
}
|
|
}
|