Added Google Drive storage location
This commit is contained in:
parent
e630f2a3ec
commit
73a7c93e64
22 changed files with 1690 additions and 205 deletions
151
README.md
151
README.md
|
|
@ -3,37 +3,92 @@
|
|||
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 to a data file in a folder you choose.
|
||||
photo. Vehicles and fuel entries live in a shared Google Drive folder, so
|
||||
multiple people can log fuel against the same pool of vehicles from their
|
||||
own phones; each device also keeps a local offline copy and syncs when it
|
||||
can.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
- Settings screen to choose where receipt photos and the data file are
|
||||
stored (defaults to the app's own documents folder; existing data is
|
||||
copied over when you change it).
|
||||
- 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 locally 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 JSON (de)serialization
|
||||
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization.
|
||||
Both carry `updatedAt` (for merge conflict resolution); FuelEntry
|
||||
carries either a local receiptImagePath (not yet uploaded) or a
|
||||
receiptDriveFileId (uploaded, local copy removed).
|
||||
services/
|
||||
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider
|
||||
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path
|
||||
ocr_service.dart Thin wrapper around google_mlkit_text_recognition
|
||||
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text
|
||||
app_state.dart In-memory state + CRUD, exposed via Provider. Stamps
|
||||
updatedAt on mutations and triggers background sync.
|
||||
storage_service.dart Local offline staging area: data.json + receipts/,
|
||||
always at ApplicationDocumentsDirectory/FuelTaxTracker.
|
||||
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.
|
||||
drive_sync_service.dart Orchestrates one sync round: acquire the cross-device
|
||||
lock, download + merge the remote data file, upload
|
||||
pending receipt photos, write the merge back, release
|
||||
the lock.
|
||||
merge_utils.dart Pure by-ID union merge logic (independently unit-tested).
|
||||
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)
|
||||
confirm fuel entry, receipt viewer, settings, Drive folder browser).
|
||||
```
|
||||
|
||||
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
|
||||
subfolder of photos, both inside whatever directory Settings points at.
|
||||
## 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
|
||||
|
||||
|
|
@ -51,43 +106,61 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
|
|||
|
||||
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.
|
||||
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.
|
||||
- **Storage**: no explicit storage permission is declared. The default save
|
||||
location is inside the app's own sandbox (no permission needed). If you
|
||||
point Settings at a location outside the sandbox, the OS-native folder
|
||||
picker (via `file_picker`) is what grants access — see the caveat below.
|
||||
- **Network/Drive**: no Android manifest changes are needed for
|
||||
`google_sign_in` 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 `fuel_tax_data.json` immediately, then triggers a best-effort
|
||||
background sync — also triggered whenever connectivity comes back. 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 simple ticket-based mutex using the Drive folder
|
||||
itself as the coordination point, so two devices never overwrite each
|
||||
other's edits to the shared data file mid-write.
|
||||
2. Downloads the current remote data file and merges it with local changes:
|
||||
vehicles and fuel entries are unioned by ID, with the newer `updatedAt`
|
||||
winning when a record exists on both sides.
|
||||
3. Uploads any locally-pending receipt photos, then deletes the local copy.
|
||||
4. Writes the merged data back to Drive, then deletes its own lock file.
|
||||
|
||||
## Known caveats / things to revisit
|
||||
|
||||
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions
|
||||
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP
|
||||
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't
|
||||
actually wired up for library modules in this Flutter/AGP combination yet,
|
||||
so the plugin's own Kotlin sources never get compiled and the build fails
|
||||
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is
|
||||
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time
|
||||
you bump dependencies — check the package's CHANGELOG for when this is
|
||||
properly resolved upstream.
|
||||
- **iOS folder picking and app restarts**: `file_picker`'s directory picker
|
||||
on iOS uses `UIDocumentPickerViewController`, which hands back a
|
||||
security-scoped URL. This app does not currently persist a security-scoped
|
||||
bookmark for that URL, so if you pick a folder outside the app's own
|
||||
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after
|
||||
an app restart is not guaranteed. Picking the default in-sandbox location,
|
||||
or a folder on Android, does not have this limitation. If cross-restart
|
||||
external storage on iOS matters for your use case, this needs a proper
|
||||
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
|
||||
- **Deletions don't propagate through the merge.** If one device deletes a
|
||||
vehicle/entry before another device has seen that deletion, the deleting
|
||||
change can be resurrected by the other device's next sync. Fixing this
|
||||
properly needs tombstones (tracking deleted-record IDs for a retention
|
||||
window) — deferred for now; the by-ID union merge is intentionally simple
|
||||
and expected to evolve.
|
||||
- **Field-level conflicts aren't merged.** Two people editing the *same*
|
||||
record at the same time: whole record, newer `updatedAt` 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 or camera capture path itself (that
|
||||
requires a real device/emulator with a camera); `receipt_parser_test.dart`
|
||||
covers the parsing logic against fixed OCR text.
|
||||
- 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`,
|
||||
`merge_utils_test.dart`, and `drive_lock_test.dart` cover the pure logic
|
||||
pieces against fixed inputs.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue