| android | ||
| ios | ||
| lib | ||
| test | ||
| .gitignore | ||
| .metadata | ||
| analysis_options.yaml | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
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-Backsubfolder 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:
- 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:
INTERNETandACCESS_NETWORK_STATEare declared in the main Android manifest (needed for release builds; debug builds getINTERNETfor free). No manifest changes are needed forgoogle_sign_initself when 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 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:
- 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 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. - 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 oneINSERT OR REPLACE ... SELECT ... WHERE local.id IS NULL OR remote.updated_at > local.updated_atper 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. - 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.
- 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
Futureresolves;PRAGMA wal_checkpointruns first anyway as cheap insurance) and uploads it as the new remote copy, then clears thedirtyflag on every row now that local matches what's on Drive. - 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_atwins — 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,lock_coordinator_test.dart, anddb_merge_test.dart(usingsqflite_common_ffito 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.