192 lines
11 KiB
Markdown
192 lines
11 KiB
Markdown
# 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.
|