MO-Fuel-Tax-Back/README.md

383 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `MO-Fuel-Tax-Back` subfolder there, 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](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/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](https://www.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](https://portal.azure.com/) → **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, `ATTACH`es 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.