Added sqlite
This commit is contained in:
parent
73a7c93e64
commit
f01186df79
14 changed files with 784 additions and 382 deletions
102
README.md
102
README.md
|
|
@ -3,10 +3,9 @@
|
|||
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 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.
|
||||
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
|
||||
|
||||
|
|
@ -22,8 +21,9 @@ can.
|
|||
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.
|
||||
- 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.
|
||||
|
||||
|
|
@ -31,25 +31,30 @@ can.
|
|||
|
||||
```
|
||||
lib/
|
||||
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization.
|
||||
Both carry `updatedAt` (for merge conflict resolution); FuelEntry
|
||||
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 state + CRUD, exposed via Provider. Stamps
|
||||
app_state.dart In-memory read cache + 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.
|
||||
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.
|
||||
lock file operations, cheap md5-based change detection.
|
||||
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).
|
||||
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.
|
||||
|
|
@ -114,38 +119,55 @@ OAuth setup above before it can be exercised end-to-end.
|
|||
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
||||
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
||||
set up for receipt capture.
|
||||
- **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.
|
||||
- **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 `fuel_tax_data.json` immediately, then triggers a best-effort
|
||||
background sync — also triggered whenever connectivity comes back. Sync:
|
||||
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 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.
|
||||
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
|
||||
|
||||
- **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
|
||||
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
|
||||
|
|
@ -162,5 +184,9 @@ background sync — also triggered whenever connectivity comes back. Sync:
|
|||
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`,
|
||||
`merge_utils_test.dart`, and `drive_lock_test.dart` cover the pure logic
|
||||
pieces against fixed inputs.
|
||||
`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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue