8.8 KiB
Fuel Tax Tracker
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt photo. Vehicles and fuel entries live in a shared Google Drive folder, so multiple people can log fuel against the same pool of vehicles from their own phones; each device also keeps a local offline copy and syncs when it can.
Features
- Manage a list of vehicles (make, model, color, license plate).
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google ML Kit — no internet connection or API key required for the OCR itself.
- Review/edit the parsed gallons, price per gallon, and total cost before saving (OCR on printed receipts is usually good but not perfect).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete.
- Connect Google Drive and pick any folder you have access to — including
one someone else created and shared with you — as the shared storage
location. The app looks for (or creates) a
MO-Fuel-Tax-Backsubfolder there, so anyone pointed at the same shared parent converges on the same data automatically. - Works fully offline: writes always land locally first; a background sync pushes changes to Drive and pulls others' changes down once online.
- Once a receipt photo is uploaded to Drive, the local copy is deleted; viewing it later downloads it fresh from Drive on demand.
Project layout
lib/
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization.
Both carry `updatedAt` (for merge conflict resolution); FuelEntry
carries either a local receiptImagePath (not yet uploaded) or a
receiptDriveFileId (uploaded, local copy removed).
services/
app_state.dart In-memory state + CRUD, exposed via Provider. Stamps
updatedAt on mutations and triggers background sync.
storage_service.dart Local offline staging area: data.json + receipts/,
always at ApplicationDocumentsDirectory/FuelTaxTracker.
drive_auth_service.dart Google sign-in + builds an authenticated http.Client
for the Drive API.
drive_service.dart Raw Drive API calls: browse folders, find-or-create
the MO-Fuel-Tax-Back folder, upload/download files,
lock file operations.
drive_sync_service.dart Orchestrates one sync round: acquire the cross-device
lock, download + merge the remote data file, upload
pending receipt photos, write the merge back, release
the lock.
merge_utils.dart Pure by-ID union merge logic (independently unit-tested).
drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text.
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
confirm fuel entry, receipt viewer, settings, Drive folder browser).
Manual setup required (Google Cloud Console)
This app needs OAuth credentials you create yourself — I can't provision cloud resources on your behalf. In Google Cloud Console:
- Create/select a project, enable the Google Drive API (APIs & Services → Library).
- OAuth consent screen: set it to External, keep it in Testing
status, and add your Google account plus everyone else's you're sharing
with as test users. This avoids Google's formal verification review,
which the broad
drivescope would otherwise require — fine for a known, small group, not a public release. - Add scope
https://www.googleapis.com/auth/driveto the consent screen (shows as "restricted/sensitive" — expected, fine in Testing mode). - Credentials → Create Credentials → OAuth client ID, three times:
- Android: package name
com.courtneyarnold.fuel_tax_tracker+ the SHA-1 of your debug keystore (keytool -list -v -keystore ~/.android/debug.keystore, passwordandroid), and later your release keystore's SHA-1 too. This client ID itself is never referenced in code — it exists purely so Android's Credential Manager trusts this specific signed app. - Web application: no redirect URIs needed. Copy its Client ID —
this is what Android sign-in actually authenticates against (a
Credential Manager quirk: it needs a web client ID, passed as
serverClientId, even for a mobile app). - iOS: bundle ID matching the Xcode project. Copy its Client ID
and note the reversed form (
com.googleusercontent.apps.<...>).
- Android: package name
- Fill in
lib/services/drive_oauth_config.dart:androidServerClientId← the Web application client's ID.iosClientId← the iOS client's ID.
- Replace the placeholder in
ios/Runner/Info.plist'sCFBundleURLTypes→CFBundleURLSchemeswith your iOS client's reversed ID.
Running it
flutter pub get
flutter run # with a device/emulator connected or a simulator booted
To build release artifacts:
flutter build apk --release # Android
flutter build ios --release # iOS (requires a full Xcode install + signing setup)
This was scaffolded and verified with Flutter 3.44.9. flutter analyze and
flutter test are clean, and flutter build apk --debug has been confirmed
to produce a working APK. The Drive sign-in/sync path needs the manual
OAuth setup above before it can be exercised end-to-end.
Permissions
- Camera:
NSCameraUsageDescription(iOS,ios/Runner/Info.plist) andandroid.permission.CAMERA(Android,AndroidManifest.xml) are already set up for receipt capture. - Network/Drive: no Android manifest changes are needed for
google_sign_inwhen not usinggoogle-services.json— see the manual setup section above instead.
How sync works
Every local change (add/edit a vehicle, log a fuel entry) writes to the
local fuel_tax_data.json immediately, then triggers a best-effort
background sync — also triggered whenever connectivity comes back. Sync:
- Creates a lock file
{email}-{utcEpochMillis}.lockin the shared Drive folder, then waits until no other lock file older than its own remains (polling every second, deleting any it finds older than 10 minutes as orphaned/stale) — a simple ticket-based mutex using the Drive folder itself as the coordination point, so two devices never overwrite each other's edits to the shared data file mid-write. - Downloads the current remote data file and merges it with local changes:
vehicles and fuel entries are unioned by ID, with the newer
updatedAtwinning when a record exists on both sides. - Uploads any locally-pending receipt photos, then deletes the local copy.
- Writes the merged data back to Drive, then deletes its own lock file.
Known caveats / things to revisit
- Deletions don't propagate through the merge. If one device deletes a vehicle/entry before another device has seen that deletion, the deleting change can be resurrected by the other device's next sync. Fixing this properly needs tombstones (tracking deleted-record IDs for a retention window) — deferred for now; the by-ID union merge is intentionally simple and expected to evolve.
- Field-level conflicts aren't merged. Two people editing the same
record at the same time: whole record, newer
updatedAtwins — not a field-by-field merge. - Lock acquisition has no hard timeout beyond the 10-minute staleness reap. Fine at the scale this is built for (a handful of people); could in theory spin under many simultaneous contenders.
google_sign_inv7's Android path requires a Web OAuth client ID (serverClientId), not just the Android client's SHA-1 registration — see the manual setup section. This is a quirk of the Credential Manager-based implementation and easy to miss if you're used to oldergoogle_sign_inversions.- Receipt parsing is best-effort regex matching on the OCR'd text
(
lib/services/receipt_parser.dart), tuned against common receipt phrasing ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may parse partially or not at all — the confirm screen always lets you fill in or correct whatever wasn't found. - No automated tests exercise the OCR, camera, or real Drive API calls
(those need a real device/emulator and live credentials);
receipt_parser_test.dart,merge_utils_test.dart, anddrive_lock_test.dartcover the pure logic pieces against fixed inputs.