No description
Find a file
2026-08-08 14:59:18 -05:00
android Added Google Drive storage location 2026-08-08 14:59:18 -05:00
ios Added Google Drive storage location 2026-08-08 14:59:18 -05:00
lib Added Google Drive storage location 2026-08-08 14:59:18 -05:00
test Added Google Drive storage location 2026-08-08 14:59:18 -05:00
.gitignore first commit 2026-08-08 08:22:14 -05:00
.metadata first commit 2026-08-08 08:22:14 -05:00
analysis_options.yaml first commit 2026-08-08 08:22:14 -05:00
pubspec.lock Added Google Drive storage location 2026-08-08 14:59:18 -05:00
pubspec.yaml Added Google Drive storage location 2026-08-08 14:59:18 -05:00
README.md Added Google Drive storage location 2026-08-08 14:59:18 -05:00

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-Back subfolder 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:

  1. Create/select a project, enable the Google Drive API (APIs & Services → Library).
  2. 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 drive scope would otherwise require — fine for a known, small group, not a public release.
  3. Add scope https://www.googleapis.com/auth/drive to the consent screen (shows as "restricted/sensitive" — expected, fine in Testing mode).
  4. 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, password android), 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.<...>).
  5. Fill in lib/services/drive_oauth_config.dart:
    • androidServerClientId ← the Web application client's ID.
    • iosClientId ← the iOS client's ID.
  6. Replace the placeholder in ios/Runner/Info.plist's CFBundleURLTypes → CFBundleURLSchemes with 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) and android.permission.CAMERA (Android, AndroidManifest.xml) are already set up for receipt capture.
  • Network/Drive: no Android manifest changes are needed for google_sign_in when not using google-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:

  1. Creates a lock file {email}-{utcEpochMillis}.lock in 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.
  2. Downloads the current remote data file and merges it with local changes: vehicles and fuel entries are unioned by ID, with the newer updatedAt winning when a record exists on both sides.
  3. Uploads any locally-pending receipt photos, then deletes the local copy.
  4. 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 updatedAt wins — 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_in v7'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 older google_sign_in versions.
  • 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, and drive_lock_test.dart cover the pure logic pieces against fixed inputs.