MO-Fuel-Tax-Back/README.md

23 KiB
Raw Blame History

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 folder on Google Drive, Dropbox, OneDrive, or your own WebDAV server (your choice), 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, identified by VIN (required, and unique across active vehicles — but editable, e.g. to fix a typo from a misread scan; scan it from a photo of the door-jamb sticker or dashboard plate instead of typing all 17 characters). An optional nickname ("Mom's Car", "Red Ford F-150") is what's shown as the primary label everywhere; without one, the VIN is shown instead.
  • Capture a fuel receipt photo per vehicle — from the camera or an existing photo in your library — and OCR it on-device with Google ML Kit — no internet connection or API key required for the OCR itself.
  • If a receipt's address shows a state other than Missouri, a warning explains that the fuel tax refund only covers Missouri purchases and lets you choose whether to log the entry anyway.
  • Review/edit the parsed gallons, price per gallon, total cost, and date/time before saving (OCR on printed receipts is usually good but not perfect; the date/time picker is always available as a manual fallback when a date can't be found on the receipt).
  • Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete.
  • Connect Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV server (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV endpoint) 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 Show Me The Fuel Refund subfolder there (using it directly, without nesting, if the folder you picked is already named that), so anyone pointed at the same shared parent converges on the same data automatically, regardless of which of the four providers each person is using. WebDAV is the only one of the four that needs no developer-console setup at all — just a server URL, username, and password.
  • Works fully offline: writes always land in the local SQLite database first; a background sync pushes changes to the cloud and pulls others' changes down once online.
  • Settings lets you choose whether a receipt photo's local copy is deleted once it's safely uploaded to the cloud (the default — keeps the phone's storage footprint small) or kept on the phone for offline viewing.
  • Settings → Advanced lets you tune the sync lock's staleness timeout (how long before another device's abandoned lock is cleared so sync isn't stuck waiting forever), from 1–60 minutes.

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). Vehicle's
                `id` is a hidden, generated, immutable primary key — never shown in the
                UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
                required, unique-among-active-vehicles, but user-editable field, and
                `nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
                `receiptDriveFileId`, or both at once if "keep photos on this phone" is
                on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
                distinction.
  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.
    cloud/
      cloud_storage_provider.dart  The CloudStorageProvider/CloudStorageSession
                             interface every backend implements (auth, folder
                             browsing, file upload/download, lock file ops) —
                             this is what cloud_sync_service.dart and the rest
                             of the app talk to; they never know which of the
                             three providers below is actually active.
      google_drive_provider.dart  Google sign-in + the googleapis DriveApi client.
      dropbox_provider.dart       Hand-rolled OAuth2 PKCE + Dropbox's path-addressed
                             REST API (files/list_folder, files/upload, etc).
      onedrive_provider.dart      Hand-rolled OAuth2 PKCE + Microsoft Graph
                             (/me/drive/...), ID-addressed like Drive.
      webdav_provider.dart        Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE)
                             over HTTP Basic Auth — no OAuth, no developer
                             console, just a server URL + username + password.
                             For self-hosted servers (Nextcloud, ownCloud, a
                             Synology NAS, etc). Path-addressed like Dropbox;
                             uses the resource's ETag as its versionTag.
      oauth_pkce.dart        PKCE code_verifier/code_challenge generation, shared
                             by the Dropbox and OneDrive providers (Google's own
                             SDK handles its OAuth flow itself, and WebDAV uses
                             plain Basic Auth, so neither needs this).
    cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever
                             provider(s) you want to use — see setup below.
    cloud_sync_service.dart Orchestrates one sync round against whichever
                             CloudStorageProvider is active: 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 a real backend).
    ocr_service.dart        Thin wrapper around google_mlkit_text_recognition.
    receipt_parser.dart     Regex-based extraction of gallons/price/total/date from OCR
                             text — falls back to manual entry (date picker, or the
                             gallons*price derivation) for whatever isn't found.
  screens/      One file per screen (vehicle list, add/edit vehicle, vehicle detail,
                confirm fuel entry, receipt viewer, settings, cloud folder browser).

Manual setup required (cloud storage)

You only need to complete setup for whichever provider(s) you actually want to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same shape: I can't provision cloud resources on your behalf, so each needs an app/OAuth client you create yourself in that provider's own developer console, with the resulting IDs pasted into lib/services/cloud_oauth_config.dart — Settings only shows a "Connect" button for one of these three once its config fields are filled in with real values. WebDAV needs none of this — see its section below.

Google Drive (Google Cloud Console)

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.ohbrer.show_me_the_fuel_refund + 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/cloud_oauth_config.dart:
    • googleAndroidServerClientId ← the Web application client's ID.
    • googleIosClientId ← the iOS client's ID.
  6. Replace the placeholder in ios/Runner/Info.plist's CFBundleURLTypes → CFBundleURLSchemes with your iOS client's reversed ID.

Dropbox (Dropbox App Console)

Dropbox has no official Flutter sign-in SDK, so this uses a hand-built OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a public/mobile app) via flutter_web_auth_2, which opens the system browser and catches the redirect through a custom URL scheme already registered in AndroidManifest.xml / Info.plist.

  1. Create an app at dropbox.com/developers/apps.
  2. Access type: Full Dropbox (not "App folder") — needed because browsing to and reusing a folder someone else created and shared requires seeing the whole account, the same reasoning as Google's full drive scope above.
  3. Under OAuth 2 → Redirect URIs, add mofueltaxback-dropbox://oauth2redirect (this exact scheme is already wired up in the Android manifest and iOS Info.plist; use a different one only if you also update those two files to match).
  4. Copy the app's App key from the Settings tab.
  5. Fill in lib/services/cloud_oauth_config.dart:
    • dropboxAppKey ← the App key.
    • dropboxRedirectUri ← mofueltaxback-dropbox://oauth2redirect.
  6. While the app is in Development status, only your own Dropbox account can sign in; add teammates under the app's Permissions / member-access settings, or apply for Production status, once you're ready to share it with others.

OneDrive (Azure Portal / Microsoft Graph)

Same PKCE approach as Dropbox, against the Microsoft identity platform and Microsoft Graph.

  1. In Azure Portal → Microsoft Entra ID → App registrations → New registration.
  2. Supported account types: Personal Microsoft accounts only (or "any organizational directory and personal Microsoft accounts" if you also want work/school accounts to be able to sign in — those may additionally need their tenant admin's consent for the scopes below).
  3. Under Authentication → Add a platform → Mobile and desktop applications, add the redirect URI mofueltaxback-onedrive://auth (this exact scheme is already wired up in the Android manifest and iOS Info.plist; use a different one only if you also update those two files to match).
  4. Under API permissions, add Microsoft Graph delegated permissions Files.ReadWrite.All and offline_access (the latter is required to get a refresh token back from the token endpoint).
  5. Copy the Application (client) ID from the Overview page.
  6. Fill in lib/services/cloud_oauth_config.dart:
    • oneDriveClientId ← the Application (client) ID.
    • oneDriveRedirectUri ← mofueltaxback-onedrive://auth.

WebDAV (self-hosted — no developer console needed)

This is the option for a personal cloud server you already run — Nextcloud, ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere; tapping "Connect WebDAV" in Settings just opens a form asking for:

  • Server URL — the full WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/yourusername/ for Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you. https:// is assumed if you omit the scheme.
  • Username and Password — for servers that support app-specific passwords (Nextcloud: Settings → Security → "Create new app password"), use one of those instead of your real account password, so this app can be revoked independently later.

The app verifies the URL and credentials with a harmless PROPFIND request before treating you as signed in, so a typo or wrong password fails immediately with a clear error rather than surfacing later during sync. Nothing needs to be filled in cloud_oauth_config.dart or the Android/iOS manifest files for this provider.

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. None of the three providers' sign-in/sync paths can be exercised end-to-end until you've done that provider's manual OAuth setup above.

Permissions

  • Camera: NSCameraUsageDescription (iOS, ios/Runner/Info.plist) and android.permission.CAMERA (Android, AndroidManifest.xml) are already set up for receipt capture.
  • Network: INTERNET and ACCESS_NETWORK_STATE are declared in the main Android manifest (needed for release builds; debug builds get INTERNET for free).
  • OAuth redirects: no manifest changes are needed for google_sign_in itself when not using google-services.json — see the Google setup section above instead. Dropbox and OneDrive's browser-based OAuth redirect is already wired up via a flutter_web_auth_2 callback activity (Android) / extra CFBundleURLTypes entries (iOS) for the mofueltaxback-dropbox:// and mofueltaxback-onedrive:// schemes — you only need to register the matching redirect URI in each provider's own console (see setup above), not touch these files, unless you deliberately choose different scheme names.

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 cloud folder, then waits until no other lock file older than its own remains (polling every second, deleting any it finds older than the configured staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as orphaned/stale) — a ticket-based mutex using the cloud folder itself as the coordination point, so two devices never overwrite each other's edits to the shared database mid-write. This logic (lock_coordinator.dart) is identical regardless of which of the four providers is active.
  2. Checks the remote database file's change-detection tag (Drive's md5Checksum, Dropbox's content_hash, OneDrive's cTag, or a WebDAV resource's ETag — an opaque versionTag as far as the sync engine is concerned) 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.<key> IS NULL OR remote.updated_at > local.updated_at per table (id for both tables — vehicles' hidden generated id, not the user-editable VIN). 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 receipt photos still needing it (a local path but no cloud file ID yet — see FuelEntry.needsReceiptUpload), recording the cloud file ID. The local copy is then deleted or kept depending on the Settings "keep photos on this phone" toggle. 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, not-yet-uploaded 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 the cloud.
  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 configurable staleness reap (Settings → Advanced). Fine at the scale this is built for (a handful of people); could in theory spin under many simultaneous contenders.
  • VIN uniqueness is checked, but not race-proof across devices. Editing or adding a vehicle checks for an existing active vehicle with the same VIN before saving, but two offline devices could still each independently create (or rename into) the same VIN before either has seen the other's change — same category of accepted limitation as "field-level conflicts aren't merged" above. mergeVehiclesSql merges on the hidden id, not vin, specifically so this doesn't corrupt the merge itself if it happens — you'd just end up with two vehicle rows sharing a VIN until someone notices and fixes it by hand.
  • 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.
  • Dropbox and OneDrive's sign-in isn't restored across a cold app restart. Their OAuth refresh tokens are kept in memory only for now (Google's own SDK handles its own persistent session separately, and WebDAV persists its credentials to OS-encrypted storage — see below — so this only affects these two hand-built OAuth providers) — attemptSilentSignIn() always returns false for them, so you'll need to reconnect once per app launch until refresh-token persistence is added.
  • WebDAV credentials are stored via flutter_secure_storage (Keystore on Android, Keychain on iOS) so attemptSilentSignIn() can restore the session after a cold restart — it re-verifies them with the same PROPFIND check signInWithCredentials uses rather than trusting the stored values blindly, since the server or password could have changed since. A wrong or revoked password just silently fails the restore (same as no connection ever having been made); there's no proactive UI nudge to reconnect beyond the sync error that shows up on the next sync attempt.
  • No automatic retry-on-401 for Dropbox/OneDrive. Each session checks the access token's expiry before every call and refreshes proactively, but a token revoked or invalidated out-of-band (e.g. from that provider's own "manage app access" page) surfaces as a failed sync (visible in Settings as "Last sync failed") rather than prompting a fresh sign-in automatically.
  • Dropbox has no distinct "Shared with me" tab in the folder browser (the interface's sharedWithMe parameter is a no-op for DropboxProvider) — Dropbox auto-mounts accepted shares directly into the account's normal folder tree, so they already show up under "My Files" in the common case. Revisit if an unmounted/pending share needs to be browsable directly.
  • WebDAV has no "Shared with me" concept at all (it's not part of the base WebDAV protocol) — WebDavProvider.supportsSharedWithMe is false, same as Dropbox, and it's up to the server/user to point the app at whatever path a share is mounted under. WebDAV credentials are also sent as HTTP Basic Auth on every request, so an HTTPS server URL is effectively required — the app doesn't block a plain http:// URL, but it would send the password in the clear.
  • 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/gallery picker, or real Drive API calls (those need a real device/emulator and live credentials); receipt_parser_test.dart, lock_coordinator_test.dart, db_merge_test.dart, and pending_receipt_upload_test.dart (the latter two using sqflite_common_ffi to run real SQL against temp SQLite files on the Dart VM) cover the pure logic pieces against fixed inputs — receipt_parser_test.dart in particular is transcribed from 13 real photographed receipts across different gas station chains.
  • 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.