# 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](https://console.cloud.google.com/): 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, `ATTACH`es 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.