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

129
lib/services/app_state.dart Normal file
View file

@ -0,0 +1,129 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'storage_service.dart';
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [StorageService] for persistence. Screens read from
/// this via Provider and call its mutating methods, which take care of
/// writing through to disk and notifying listeners.
class AppState extends ChangeNotifier {
final StorageService storage = StorageService();
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = [];
bool isLoading = true;
Future<void> init() async {
await storage.init();
final data = await storage.loadData();
vehicles = data.vehicles;
fuelEntries = data.entries;
isLoading = false;
notifyListeners();
}
Future<void> addVehicle({
required String make,
required String model,
required String color,
required String licensePlate,
}) async {
vehicles.add(Vehicle(
id: _uuid.v4(),
make: make,
model: model,
color: color,
licensePlate: licensePlate,
));
await _persist();
}
Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id);
if (index != -1) {
vehicles[index] = updated;
await _persist();
}
}
Future<void> deleteVehicle(String vehicleId) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
await storage.deleteReceiptImage(entry.receiptImagePath);
}
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist();
}
Future<FuelEntry> addFuelEntry({
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
required double totalCost,
File? receiptImage,
}) async {
final id = _uuid.v4();
String? storedImagePath;
if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
}
final entry = FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: storedImagePath,
);
fuelEntries.add(entry);
await _persist();
return entry;
}
Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await storage.deleteReceiptImage(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId);
await _persist();
}
List<FuelEntry> entriesForVehicle(String vehicleId) {
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
list.sort((a, b) => b.date.compareTo(a.date));
return list;
}
double totalGallonsForVehicle(String vehicleId) => fuelEntries
.where((e) => e.vehicleId == vehicleId)
.fold(0.0, (sum, e) => sum + e.gallons);
double get totalGallonsAllVehicles =>
fuelEntries.fold(0.0, (sum, e) => sum + e.gallons);
Vehicle? vehicleById(String id) {
try {
return vehicles.firstWhere((v) => v.id == id);
} catch (_) {
return null;
}
}
Future<void> changeSaveDirectory(String newPath) async {
await storage.setSaveDirectory(newPath);
notifyListeners();
}
Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
notifyListeners();
}
}

View file

@ -0,0 +1,21 @@
import 'dart:io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
/// Thin wrapper around ML Kit's on-device text recognizer so the rest of
/// the app depends on a single, easily-swappable entry point rather than
/// the ML Kit API directly.
class OcrService {
final TextRecognizer _recognizer =
TextRecognizer(script: TextRecognitionScript.latin);
Future<String> recognizeText(File imageFile) async {
final inputImage = InputImage.fromFile(imageFile);
final result = await _recognizer.processImage(inputImage);
return result.text;
}
void dispose() {
_recognizer.close();
}
}

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);
}
}

View file

@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
/// The parent directory containing both is user-configurable (see
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
class StorageService {
static const _prefsKey = 'save_directory_path';
static const _dataFileName = 'fuel_tax_data.json';
static const _receiptsFolderName = 'receipts';
late Directory _saveDirectory;
Directory get saveDirectory => _saveDirectory;
Directory get receiptsDirectory =>
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
/// Must be called once before any other method. Loads the previously
/// chosen save directory, falling back to the app's own documents
/// directory the first time the app runs.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final storedPath = prefs.getString(_prefsKey);
if (storedPath != null && await Directory(storedPath).exists()) {
_saveDirectory = Directory(storedPath);
} else {
final docsDir = await getApplicationDocumentsDirectory();
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
}
await _saveDirectory.create(recursive: true);
await receiptsDirectory.create(recursive: true);
}
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await _dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final raw = await _dataFile.readAsString();
if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
.toList();
final entries = (json['fuelEntries'] as List<dynamic>? ?? [])
.map((e) => FuelEntry.fromJson(e as Map<String, dynamic>))
.toList();
return (vehicles: vehicles, entries: entries);
}
Future<void> saveData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) async {
final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.toJson()).toList(),
};
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
}
/// Copies a captured receipt image into the receipts folder and returns
/// the path it was stored at.
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
final copied = await sourceImage.copy(destPath);
return copied.path;
}
Future<void> deleteReceiptImage(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Changes where the data file and receipt images live, moving any
/// existing data/images from the old location into the new one.
Future<void> setSaveDirectory(String newPath) async {
final newDir = Directory(newPath);
await newDir.create(recursive: true);
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
await newReceiptsDir.create(recursive: true);
final oldDataFile = _dataFile;
final oldReceiptsDir = receiptsDirectory;
if (await oldDataFile.exists() &&
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
await oldDataFile.copy(p.join(newPath, _dataFileName));
}
if (await oldReceiptsDir.exists()) {
await for (final entity in oldReceiptsDir.list()) {
if (entity is File) {
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
if (!p.equals(entity.path, destPath)) {
await entity.copy(destPath);
}
}
}
}
_saveDirectory = newDir;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, newPath);
}
}