first commit

This commit is contained in:
Courtney Arnold 2026-08-08 08:22:14 -05:00
commit e630f2a3ec
80 changed files with 3889 additions and 0 deletions

View file

@ -0,0 +1,105 @@
/// 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);
}
}