| android | ||
| assets | ||
| 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 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 Refundsubfolder 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)
- 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.ohbrer.show_me_the_fuel_refund+ 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/cloud_oauth_config.dart:googleAndroidServerClientId← the Web application client's ID.googleIosClientId← the iOS client's ID.
- Replace the placeholder in
ios/Runner/Info.plist'sCFBundleURLTypes→CFBundleURLSchemeswith 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.
- Create an app at dropbox.com/developers/apps.
- 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
drivescope above. - 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). - Copy the app's App key from the Settings tab.
- Fill in
lib/services/cloud_oauth_config.dart:dropboxAppKey← the App key.dropboxRedirectUri←mofueltaxback-dropbox://oauth2redirect.
- 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.
- In Azure Portal → Microsoft Entra ID → App registrations → New registration.
- 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).
- 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). - Under API permissions, add Microsoft Graph delegated permissions
Files.ReadWrite.Allandoffline_access(the latter is required to get a refresh token back from the token endpoint). - Copy the Application (client) ID from the Overview page.
- 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) andandroid.permission.CAMERA(Android,AndroidManifest.xml) are already set up for receipt capture. - Network:
INTERNETandACCESS_NETWORK_STATEare declared in the main Android manifest (needed for release builds; debug builds getINTERNETfor free). - OAuth redirects: no manifest changes are needed for
google_sign_initself when not usinggoogle-services.json— see the Google setup section above instead. Dropbox and OneDrive's browser-based OAuth redirect is already wired up via aflutter_web_auth_2callback activity (Android) / extraCFBundleURLTypesentries (iOS) for themofueltaxback-dropbox://andmofueltaxback-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:
- Creates a lock file
{email}-{utcEpochMillis}.lockin 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. - Checks the remote database file's change-detection tag (Drive's
md5Checksum, Dropbox'scontent_hash, OneDrive'scTag, or a WebDAV resource'sETag— an opaqueversionTagas 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 oneINSERT OR REPLACE ... SELECT ... WHERE local.<key> IS NULL OR remote.updated_at > local.updated_atper table (idfor 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. - 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. - 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 the cloud. - 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 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.
mergeVehiclesSqlmerges on the hiddenid, notvin, 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_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.- 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) soattemptSilentSignIn()can restore the session after a cold restart — it re-verifies them with the same PROPFIND checksignInWithCredentialsuses 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
sharedWithMeparameter is a no-op forDropboxProvider) — 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.supportsSharedWithMeis 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 plainhttp://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, andpending_receipt_upload_test.dart(the latter two usingsqflite_common_ffito run real SQL against temp SQLite files on the Dart VM) cover the pure logic pieces against fixed inputs —receipt_parser_test.dartin 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.