first commit
This commit is contained in:
commit
e630f2a3ec
80 changed files with 3889 additions and 0 deletions
129
lib/services/app_state.dart
Normal file
129
lib/services/app_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
21
lib/services/ocr_service.dart
Normal file
21
lib/services/ocr_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
105
lib/services/receipt_parser.dart
Normal file
105
lib/services/receipt_parser.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
128
lib/services/storage_service.dart
Normal file
128
lib/services/storage_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue