No description
Find a file
2026-08-08 16:55:40 -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 sqlite 2026-08-08 16:55:40 -05:00
test Added sqlite 2026-08-08 16:55:40 -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 sqlite 2026-08-08 16:55:40 -05:00
pubspec.yaml Added sqlite 2026-08-08 16:55:40 -05:00
README.md Added sqlite 2026-08-08 16:55:40 -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 local SQLite database that's kept in sync with a shared Google Drive folder, so multiple people can log fuel against the same pool of vehicles from their own phones, offline or online.

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 in the local SQLite database 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 SQLite row (de)serialization
                (toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
                `deletedAt` (a soft-delete tombstone, so deletions sync too). FuelEntry
                carries either a local receiptImagePath (not yet uploaded) or a
                receiptDriveFileId (uploaded, local copy removed).
  services/
    app_state.dart          In-memory read cache + CRUD, exposed via Provider. Stamps
                             updatedAt on mutations and triggers background sync.
    database_service.dart   Owns the local SQLite database (vehicles + fuel_entries)
                             and the receipts/ folder, always at
                             ApplicationDocumentsDirectory/FuelTaxTracker.
    db_schema.dart           CREATE TABLE statements and the merge SQL — shared between
                             the app and its tests so they can never drift apart.
    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, cheap md5-based change detection.
    drive_sync_service.dart Orchestrates one sync round: acquire the cross-device
                             lock, ATTACH + merge the remote database into the local
                             one, upload pending receipt photos, push the local
                             database back up, release the lock.
    lock_coordinator.dart   The ticket-based lock-file mutex, as pure injectable
                             logic (independently unit-tested without real Drive).
    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: INTERNET and ACCESS_NETWORK_STATE are declared in the main Android manifest (needed for release builds; debug builds get INTERNET for free). No manifest changes are needed for google_sign_in itself 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 SQLite database immediately, then triggers a best-effort background sync — also triggered whenever connectivity comes back or the app starts. Every row carries updated_at (for merge resolution), deleted_at (a soft-delete tombstone — see below), and a local-only dirty flag (pending push to Drive, never itself treated as meaningful sync data). 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 ticket-based mutex using the Drive folder itself as the coordination point, so two devices never overwrite each other's edits to the shared database mid-write.
  2. Checks the remote database file's md5 checksum against the last one seen (a metadata-only call, no content download) to decide if a pull is even needed. If it changed: downloads it to a temp file, ATTACHes it to the local database, and runs one INSERT OR REPLACE ... SELECT ... WHERE local.id IS NULL OR remote.updated_at > local.updated_at per table. Rows that statement doesn't match — including this device's own not-yet-pushed edits — are left untouched, so no separate "keep local" step is needed.
  3. Uploads any locally-pending receipt photos (rows still holding a local file path), clearing that path and recording the Drive file ID instead. If any upload fails, the push step below is skipped entirely this cycle — a row is never pushed while it still holds a local-only path.
  4. Reads the local database file directly (sqflite's default journal mode isn't WAL, so the file is complete and consistent as soon as the last write's Future resolves; PRAGMA wal_checkpoint runs first anyway as cheap insurance) and uploads it as the new remote copy, then clears the dirty flag on every row now that local matches what's on Drive.
  5. Releases the lock.

Deletions propagate correctly, unlike a naive "union records" merge: deleting sets deleted_at instead of removing the row, so a deletion is just another change with its own updated_at, and rides the same newest-wins rule as any edit — no separate deletion-handling logic needed.

Known caveats / things to revisit

  • Field-level conflicts aren't merged. Two people editing the same record at the same time: whole record, newer updated_at 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, lock_coordinator_test.dart, and db_merge_test.dart (using sqflite_common_ffi to run the real merge SQL against temp SQLite files on the Dart VM) cover the pure logic pieces against fixed inputs.
  • No migration path exists from the earlier JSON-file storage format — not needed since no real data had accumulated under it yet, but flag it if that's no longer true for you.