Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
329
README.md
329
README.md
|
|
@ -4,28 +4,48 @@ 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.
|
||||
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 (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).
|
||||
- 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 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.
|
||||
- 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 Drive and pulls others'
|
||||
first; a background sync pushes changes to the cloud 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.
|
||||
- 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
|
||||
|
||||
|
|
@ -33,9 +53,14 @@ against the same pool of vehicles from their own phones, offline or online.
|
|||
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).
|
||||
`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.
|
||||
|
|
@ -44,28 +69,59 @@ lib/
|
|||
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.
|
||||
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 real Drive).
|
||||
drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below.
|
||||
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 from OCR text.
|
||||
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, Drive folder browser).
|
||||
confirm fuel entry, receipt viewer, settings, cloud folder browser).
|
||||
```
|
||||
|
||||
## Manual setup required (Google Cloud Console)
|
||||
## Manual setup required (cloud storage)
|
||||
|
||||
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/):
|
||||
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).
|
||||
|
|
@ -89,12 +145,84 @@ cloud resources on your behalf. In [Google Cloud Console](https://console.cloud.
|
|||
`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.
|
||||
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
|
||||
|
||||
```
|
||||
|
|
@ -111,19 +239,27 @@ 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. The Drive sign-in/sync path needs the manual
|
||||
OAuth setup above before it can be exercised end-to-end.
|
||||
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/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.
|
||||
- **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
|
||||
|
||||
|
|
@ -134,29 +270,37 @@ 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
|
||||
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 10 minutes as
|
||||
orphaned/stale) — a ticket-based mutex using the Drive folder itself as
|
||||
(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.
|
||||
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.
|
||||
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 Drive.
|
||||
`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:
|
||||
|
|
@ -169,24 +313,71 @@ newest-wins rule as any edit — no separate deletion-handling logic needed.
|
|||
- **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.
|
||||
- **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, 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 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.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,22 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Dropbox & OneDrive OAuth: flutter_web_auth_2 captures the
|
||||
browser redirect via this activity + custom URL scheme(s).
|
||||
These scheme values must exactly match whatever is set as
|
||||
dropboxRedirectUri/oneDriveRedirectUri in
|
||||
lib/services/cloud_oauth_config.dart. -->
|
||||
<activity
|
||||
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="flutter_web_auth_2">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mofueltaxback-dropbox" />
|
||||
<data android:scheme="mofueltaxback-onedrive" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fuel Tax Tracker</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
|
@ -84,6 +86,25 @@
|
|||
<string>com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Dropbox & OneDrive OAuth redirects (flutter_web_auth_2). These
|
||||
scheme values must exactly match dropboxRedirectUri /
|
||||
oneDriveRedirectUri in lib/services/cloud_oauth_config.dart. -->
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-dropbox</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-onedrive</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,32 @@ class AppRoot extends StatelessWidget {
|
|||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (appState.initError != null) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not start the app:\n${appState.initError}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => appState.init(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const HomeScreen();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
class FuelEntry {
|
||||
final String id;
|
||||
|
||||
/// References [Vehicle.id] (the hidden, immutable identifier) — not the
|
||||
/// VIN, which is user-editable and so unsuitable as a foreign key.
|
||||
final String vehicleId;
|
||||
|
||||
final DateTime date;
|
||||
final double gallons;
|
||||
final double pricePerGallon;
|
||||
|
|
@ -26,16 +30,21 @@ class FuelEntry {
|
|||
this.deletedAt,
|
||||
});
|
||||
|
||||
/// True once the receipt photo has been uploaded to Drive and the local
|
||||
/// copy removed. Viewing it then requires downloading it on demand.
|
||||
bool get isReceiptUploadedToDrive =>
|
||||
receiptImagePath == null && receiptDriveFileId != null;
|
||||
/// True once the receipt photo has been uploaded to Drive, regardless of
|
||||
/// whether a local copy is also being kept (see the "keep photos on this
|
||||
/// phone" setting). Viewing it locally is preferred when a local copy
|
||||
/// exists; otherwise it requires downloading from Drive on demand.
|
||||
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
|
||||
|
||||
/// True while a receipt photo exists only locally and still needs to be
|
||||
/// uploaded next sync. A row in this state is never pushed to Drive as-is
|
||||
/// (a local file path is meaningless on another device) — sync uploads
|
||||
/// the image first, which clears this.
|
||||
bool get hasPendingLocalReceipt => receiptImagePath != null;
|
||||
/// True while a receipt photo still needs to be uploaded to Drive: a
|
||||
/// local file exists but hasn't made it there yet. Once
|
||||
/// [receiptDriveFileId] is set this is false — even if a local copy is
|
||||
/// also being kept — since that field alone is what marks "no longer
|
||||
/// needs uploading", independent of local retention. A row in this state
|
||||
/// is never pushed to Drive as data (a local file path is meaningless on
|
||||
/// another device) — sync uploads the image first, which sets
|
||||
/// [receiptDriveFileId] and clears this.
|
||||
bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null;
|
||||
|
||||
FuelEntry copyWith({
|
||||
String? receiptImagePath,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
class Vehicle {
|
||||
/// Hidden, immutable, generated primary key — never shown in the UI and
|
||||
/// never editable. This is what fuel entries actually reference and what
|
||||
/// sync merges on, so that [vin] itself is free to be edited without
|
||||
/// breaking those references or losing sync history.
|
||||
final String id;
|
||||
final String make;
|
||||
final String model;
|
||||
final String color;
|
||||
final String licensePlate;
|
||||
|
||||
/// The vehicle's identification number. Required and must be unique
|
||||
/// among active (non-deleted) vehicles, but — unlike [id] — the user can
|
||||
/// edit it later (e.g. to fix a typo from a misread VIN scan).
|
||||
final String vin;
|
||||
|
||||
/// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional —
|
||||
/// when absent, screens fall back to other identifying info instead.
|
||||
final String? nickname;
|
||||
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Soft-delete tombstone: null means active. Deleting sets this instead
|
||||
|
|
@ -15,30 +25,35 @@ class Vehicle {
|
|||
|
||||
Vehicle({
|
||||
required this.id,
|
||||
required this.make,
|
||||
required this.model,
|
||||
required this.color,
|
||||
required this.licensePlate,
|
||||
required this.vin,
|
||||
required this.updatedAt,
|
||||
this.nickname,
|
||||
this.deletedAt,
|
||||
});
|
||||
|
||||
String get displayName => '$color $make $model ($licensePlate)';
|
||||
/// What screens should show as the vehicle's primary label: the nickname
|
||||
/// if one was given, otherwise the VIN itself.
|
||||
String get displayLabel {
|
||||
final trimmedNickname = nickname?.trim();
|
||||
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
|
||||
return trimmedNickname;
|
||||
}
|
||||
return vin;
|
||||
}
|
||||
|
||||
/// Note: [id] is intentionally not overridable here — it's the hidden
|
||||
/// identifier, not an editable field.
|
||||
Vehicle copyWith({
|
||||
String? make,
|
||||
String? model,
|
||||
String? color,
|
||||
String? licensePlate,
|
||||
String? vin,
|
||||
String? nickname,
|
||||
bool clearNickname = false,
|
||||
DateTime? updatedAt,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return Vehicle(
|
||||
id: id,
|
||||
make: make ?? this.make,
|
||||
model: model ?? this.model,
|
||||
color: color ?? this.color,
|
||||
licensePlate: licensePlate ?? this.licensePlate,
|
||||
vin: vin ?? this.vin,
|
||||
nickname: clearNickname ? null : (nickname ?? this.nickname),
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
|
|
@ -46,20 +61,16 @@ class Vehicle {
|
|||
|
||||
Map<String, Object?> toMap() => {
|
||||
'id': id,
|
||||
'make': make,
|
||||
'model': model,
|
||||
'color': color,
|
||||
'license_plate': licensePlate,
|
||||
'vin': vin,
|
||||
'nickname': nickname,
|
||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
|
||||
id: map['id'] as String,
|
||||
make: map['make'] as String,
|
||||
model: map['model'] as String,
|
||||
color: map['color'] as String,
|
||||
licensePlate: map['license_plate'] as String,
|
||||
vin: map['vin'] as String,
|
||||
nickname: map['nickname'] as String?,
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
||||
deletedAt: map['deleted_at'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/ocr_service.dart';
|
||||
import '../services/vin_parser.dart';
|
||||
import '../widgets/image_source_sheet.dart';
|
||||
|
||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||
/// omit it to create a new one.
|
||||
|
|
@ -17,52 +23,107 @@ class AddEditVehicleScreen extends StatefulWidget {
|
|||
|
||||
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _makeController;
|
||||
late final TextEditingController _modelController;
|
||||
late final TextEditingController _colorController;
|
||||
late final TextEditingController _plateController;
|
||||
late final TextEditingController _nicknameController;
|
||||
late final TextEditingController _vinController;
|
||||
|
||||
bool _saving = false;
|
||||
bool _scanningVin = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEditing => widget.vehicle != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
|
||||
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
|
||||
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
|
||||
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
|
||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_makeController.dispose();
|
||||
_modelController.dispose();
|
||||
_colorController.dispose();
|
||||
_plateController.dispose();
|
||||
_nicknameController.dispose();
|
||||
_vinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _scanVin() async {
|
||||
final source = await chooseImageSource(context);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? photo;
|
||||
try {
|
||||
photo = await picker.pickImage(source: source, imageQuality: 85);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open $sourceLabel: $e')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (photo == null) return;
|
||||
|
||||
setState(() => _scanningVin = true);
|
||||
|
||||
final ocrService = OcrService();
|
||||
String recognizedText = '';
|
||||
try {
|
||||
recognizedText = await ocrService.recognizeText(File(photo.path));
|
||||
} catch (_) {
|
||||
recognizedText = '';
|
||||
} finally {
|
||||
ocrService.dispose();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final vin = VinParser.parse(recognizedText);
|
||||
setState(() => _scanningVin = false);
|
||||
|
||||
if (vin != null) {
|
||||
_vinController.text = vin;
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
final nickname = _nicknameController.text.trim();
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
vin: _vinController.text.trim(),
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
clearNickname: nickname.isEmpty,
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(
|
||||
vin: _vinController.text.trim(),
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} on DuplicateVinException catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
|
|
@ -72,7 +133,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
title: const Text('Delete vehicle?'),
|
||||
content: Text(
|
||||
'This will also delete all fuel entries and receipt photos logged for '
|
||||
'${widget.vehicle!.displayName}. This cannot be undone.',
|
||||
'${widget.vehicle!.displayLabel}. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
||||
|
|
@ -114,36 +175,48 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _makeController,
|
||||
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
|
||||
controller: _nicknameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nickname (optional)',
|
||||
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _modelController,
|
||||
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _colorController,
|
||||
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _plateController,
|
||||
decoration: const InputDecoration(labelText: 'License Plate'),
|
||||
controller: _vinController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'VIN *',
|
||||
hintText: 'Vehicle Identification Number',
|
||||
helperText: 'Required — tap the camera to scan it from a photo',
|
||||
suffixIcon: _scanningVin
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
tooltip: 'Scan VIN from a photo',
|
||||
onPressed: _scanVin,
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _save,
|
||||
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,32 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/drive_service.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
|
||||
enum _BrowseRoot { myDrive, sharedWithMe }
|
||||
enum _BrowseRoot { myFiles, sharedWithMe }
|
||||
|
||||
/// Lets the user navigate their Google Drive — either "My Drive" or
|
||||
/// folders others have shared with them — and pick a parent location.
|
||||
/// Confirming looks for (or creates) the `MO-Fuel-Tax-Back` folder under
|
||||
/// that location, so two people pointing at the same shared parent
|
||||
/// converge on the same app folder.
|
||||
class DriveFolderBrowserScreen extends StatefulWidget {
|
||||
const DriveFolderBrowserScreen({super.key});
|
||||
/// Lets the user navigate whichever cloud storage provider is connected —
|
||||
/// their own files, and (if the provider supports it) files others have
|
||||
/// shared with them — and pick a parent location. Confirming looks for (or
|
||||
/// creates) the `MO-Fuel-Tax-Back` folder under that location, so two
|
||||
/// people pointing at the same shared parent converge on the same app
|
||||
/// folder, regardless of which provider each of them is using.
|
||||
class CloudFolderBrowserScreen extends StatefulWidget {
|
||||
const CloudFolderBrowserScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DriveFolderBrowserScreen> createState() => _DriveFolderBrowserScreenState();
|
||||
State<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
|
||||
}
|
||||
|
||||
class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
||||
late final http.Client _client;
|
||||
late final DriveService _drive;
|
||||
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
|
||||
late final CloudStorageSession _session;
|
||||
|
||||
_BrowseRoot _root = _BrowseRoot.myDrive;
|
||||
final List<DriveFolder> _pathStack = [];
|
||||
_BrowseRoot _root = _BrowseRoot.myFiles;
|
||||
final List<CloudFolder> _pathStack = [];
|
||||
|
||||
List<DriveFolder>? _folders;
|
||||
List<CloudFolder>? _folders;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _confirming = false;
|
||||
|
|
@ -34,25 +33,24 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_client = context.read<AppState>().driveAuth.authenticatedHttpClient();
|
||||
_drive = DriveService(_client);
|
||||
_session = context.read<AppState>().activeProvider!.beginSession();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_client.close();
|
||||
_session.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _rootLabel => _root == _BrowseRoot.myDrive ? 'My Drive' : 'Shared with me';
|
||||
String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me';
|
||||
|
||||
/// The folder ID that "Use This Folder" would act on, or null if the
|
||||
/// current view is a virtual listing (top-level "Shared with me") rather
|
||||
/// than an actual folder.
|
||||
String? get _currentFolderId {
|
||||
if (_pathStack.isNotEmpty) return _pathStack.last.id;
|
||||
if (_root == _BrowseRoot.myDrive) return 'root';
|
||||
if (_root == _BrowseRoot.myFiles) return 'root';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -66,13 +64,13 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
|||
});
|
||||
|
||||
try {
|
||||
List<DriveFolder> folders;
|
||||
List<CloudFolder> folders;
|
||||
if (_pathStack.isNotEmpty) {
|
||||
folders = await _drive.listFolders(parentId: _pathStack.last.id);
|
||||
} else if (_root == _BrowseRoot.myDrive) {
|
||||
folders = await _drive.listFolders(parentId: 'root');
|
||||
folders = await _session.listFolders(parentId: _pathStack.last.id);
|
||||
} else if (_root == _BrowseRoot.myFiles) {
|
||||
folders = await _session.listFolders(parentId: 'root');
|
||||
} else {
|
||||
folders = await _drive.listFolders(sharedWithMe: true);
|
||||
folders = await _session.listFolders(sharedWithMe: true);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
@ -97,7 +95,7 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
|||
_load();
|
||||
}
|
||||
|
||||
void _openFolder(DriveFolder folder) {
|
||||
void _openFolder(CloudFolder folder) {
|
||||
setState(() => _pathStack.add(folder));
|
||||
_load();
|
||||
}
|
||||
|
|
@ -120,7 +118,7 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
|||
|
||||
setState(() => _confirming = true);
|
||||
try {
|
||||
await context.read<AppState>().chooseDriveFolder(
|
||||
await context.read<AppState>().chooseCloudFolder(
|
||||
parentId: parentId,
|
||||
breadcrumbPath: _breadcrumbPath,
|
||||
);
|
||||
|
|
@ -138,20 +136,22 @@ class _DriveFolderBrowserScreenState extends State<DriveFolderBrowserScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final providerName = context.read<AppState>().activeProvider!.displayName;
|
||||
final canUseCurrentFolder = _currentFolderId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Choose Drive Folder')),
|
||||
appBar: AppBar(title: Text('Choose $providerName Folder')),
|
||||
body: Column(
|
||||
children: [
|
||||
SegmentedButton<_BrowseRoot>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BrowseRoot.myDrive, label: Text('My Drive')),
|
||||
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
|
||||
],
|
||||
selected: {_root},
|
||||
onSelectionChanged: (selection) => _switchRoot(selection.first),
|
||||
),
|
||||
if (_session.supportsSharedWithMe)
|
||||
SegmentedButton<_BrowseRoot>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')),
|
||||
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
|
||||
],
|
||||
selected: {_root},
|
||||
onSelectionChanged: (selection) => _switchRoot(selection.first),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
|
|
@ -33,12 +33,16 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
late final TextEditingController _gallonsController;
|
||||
late final TextEditingController _priceController;
|
||||
late final TextEditingController _totalController;
|
||||
DateTime _date = DateTime.now();
|
||||
late DateTime _date;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Prefer the date/time printed on the receipt; fall back to now, same
|
||||
// as before, when it couldn't be parsed — the date picker below is
|
||||
// always available either way for manual entry/correction.
|
||||
_date = widget.parsed.date ?? DateTime.now();
|
||||
_gallonsController = TextEditingController(
|
||||
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
||||
);
|
||||
|
|
@ -69,11 +73,20 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
// initialDate must fall within [firstDate, lastDate] or the picker
|
||||
// throws — widen the bounds to cover _date in case a misread date from
|
||||
// the receipt landed outside the normal 5-years-back-to-today window.
|
||||
final today = DateTime.now();
|
||||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
||||
? _date
|
||||
: today.subtract(const Duration(days: 365 * 5));
|
||||
final lastDate = _date.isAfter(today) ? _date : today;
|
||||
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
|
||||
lastDate: DateTime.now(),
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
);
|
||||
if (pickedDate == null || !mounted) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -59,18 +59,20 @@ class _VehicleCard extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
|
||||
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
|
||||
),
|
||||
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
|
||||
subtitle: Text(
|
||||
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
|
||||
),
|
||||
isThreeLine: true,
|
||||
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
|
||||
title: Text(vehicle.displayLabel),
|
||||
// Only repeat the VIN here when the title is already showing the
|
||||
// nickname instead — otherwise the title (falling back to the VIN
|
||||
// when there's no nickname) would show it twice.
|
||||
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
|
||||
isThreeLine: hasNickname,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/drive_service.dart';
|
||||
|
||||
/// Full-screen, pinch-zoomable view of a receipt photo. Pass
|
||||
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
|
||||
/// one already uploaded to Drive and removed locally — in which case it's
|
||||
/// downloaded on demand (no local caching afterward).
|
||||
/// one already uploaded to the cloud and removed locally — in which case
|
||||
/// it's downloaded on demand (no local caching afterward).
|
||||
class ReceiptImageScreen extends StatefulWidget {
|
||||
final String? localImagePath;
|
||||
final String? driveFileId;
|
||||
|
|
@ -42,11 +41,18 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
|||
_error = null;
|
||||
});
|
||||
|
||||
final authService = context.read<AppState>().driveAuth;
|
||||
final client = authService.authenticatedHttpClient();
|
||||
final provider = context.read<AppState>().activeProvider;
|
||||
if (provider == null) {
|
||||
setState(() {
|
||||
_error = 'Not connected to a cloud storage provider.';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final session = provider.beginSession();
|
||||
try {
|
||||
final drive = DriveService(client);
|
||||
final bytes = await drive.downloadFileBytes(widget.driveFileId!);
|
||||
final bytes = await session.downloadFileBytes(widget.driveFileId!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_downloadedBytes = Uint8List.fromList(bytes);
|
||||
|
|
@ -59,7 +65,7 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
|||
_loading = false;
|
||||
});
|
||||
} finally {
|
||||
client.close();
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import 'package:intl/intl.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import 'drive_folder_browser_screen.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
import 'cloud_folder_browser_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -16,13 +17,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _connect() async {
|
||||
Future<void> _connect(CloudProviderId id) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectDrive();
|
||||
await context.read<AppState>().connectProvider(id);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
|
|
@ -30,10 +31,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _connectManual(CloudProviderId id, String providerName) async {
|
||||
final credentials = await showDialog<_WebDavCredentials>(
|
||||
context: context,
|
||||
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
|
||||
);
|
||||
if (credentials == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProviderWithCredentials(
|
||||
id,
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectDrive();
|
||||
await context.read<AppState>().disconnectCloud();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
|
@ -41,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()),
|
||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,12 +95,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Google Drive', style: Theme.of(context).textTheme.titleMedium),
|
||||
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (!appState.isDriveConnected) ...[
|
||||
if (!appState.isCloudConnected) ...[
|
||||
Text(
|
||||
'Connect Google Drive to share vehicles and fuel receipts with '
|
||||
'other people, and to keep a backup off this device.',
|
||||
'Connect a cloud storage account to share vehicles and fuel '
|
||||
'receipts with other people, and to keep a backup off this device.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -82,18 +108,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _connect,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect Google Drive'),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final provider in appState.availableProviders)
|
||||
FilledButton.icon(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => provider is ManualCredentialCloudStorageProvider
|
||||
? _connectManual(provider.id, provider.displayName)
|
||||
: _connect(provider.id),
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text('Connect ${provider.displayName}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text('Signed in as ${appState.driveAccountEmail}'),
|
||||
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appState.driveFolderPath == null
|
||||
appState.cloudFolderPath == null
|
||||
? 'No folder selected yet.'
|
||||
: 'Folder: ${appState.driveFolderPath}',
|
||||
: 'Folder: ${appState.cloudFolderPath}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -104,12 +141,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _chooseFolder,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: Text(appState.driveFolderPath == null
|
||||
label: Text(appState.cloudFolderPath == null
|
||||
? 'Choose Folder'
|
||||
: 'Change Folder'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: (_busy || appState.driveFolderPath == null)
|
||||
onPressed: (_busy || appState.cloudFolderPath == null)
|
||||
? null
|
||||
: _syncNow,
|
||||
icon: appState.isSyncing
|
||||
|
|
@ -147,6 +184,62 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep photos on this phone after syncing'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, a receipt photo is removed from this device once '
|
||||
"it's safely uploaded to the cloud.",
|
||||
),
|
||||
value: appState.keepReceiptPhotosLocally,
|
||||
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep max quality images'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, receipt photos are downscaled to a reasonable size '
|
||||
'before storing — smaller cloud storage and faster syncs, with '
|
||||
'no loss of legibility for a printed receipt.',
|
||||
),
|
||||
value: appState.keepMaxQualityReceiptPhotos,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"If another device disconnects mid-sync without releasing its lock, "
|
||||
"this is how long to wait before treating it as abandoned and clearing "
|
||||
"it so sync can continue.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Slider(
|
||||
value: appState.staleLockMinutes.toDouble(),
|
||||
min: minStaleLockMinutes.toDouble(),
|
||||
max: maxStaleLockMinutes.toDouble(),
|
||||
divisions: maxStaleLockMinutes - minStaleLockMinutes,
|
||||
label: '${appState.staleLockMinutes} min',
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setStaleLockMinutes(value.round()),
|
||||
),
|
||||
Text('${appState.staleLockMinutes} minute(s)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -167,3 +260,93 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebDavCredentials {
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
|
||||
}
|
||||
|
||||
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
|
||||
/// needs, since (unlike the OAuth providers) there's no browser flow to
|
||||
/// gather these instead.
|
||||
class _WebDavCredentialsDialog extends StatefulWidget {
|
||||
final String providerName;
|
||||
const _WebDavCredentialsDialog({required this.providerName});
|
||||
|
||||
@override
|
||||
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
|
||||
}
|
||||
|
||||
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Navigator.of(context).pop(_WebDavCredentials(
|
||||
serverUrl: _serverController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Connect ${widget.providerName}'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,42 @@ import '../models/fuel_entry.dart';
|
|||
import '../services/app_state.dart';
|
||||
import '../services/ocr_service.dart';
|
||||
import '../services/receipt_parser.dart';
|
||||
import '../widgets/image_source_sheet.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'confirm_fuel_entry_screen.dart';
|
||||
import 'receipt_image_screen.dart';
|
||||
|
||||
/// Long edge and JPEG quality a receipt photo is downscaled to unless
|
||||
/// "keep max quality" is on — a receipt is a photo of small printed text,
|
||||
/// not something that benefits from a multi-megapixel original, and this
|
||||
/// keeps typical files in the low hundreds of KB instead of several MB.
|
||||
const receiptImageMaxDimension = 1600.0;
|
||||
const receiptImageQuality = 70;
|
||||
|
||||
class VehicleDetailScreen extends StatelessWidget {
|
||||
final String vehicleId;
|
||||
|
||||
const VehicleDetailScreen({super.key, required this.vehicleId});
|
||||
|
||||
Future<void> _captureReceipt(BuildContext context) async {
|
||||
final source = await chooseImageSource(context);
|
||||
if (source == null || !context.mounted) return;
|
||||
|
||||
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
|
||||
final picker = ImagePicker();
|
||||
XFile? photo;
|
||||
try {
|
||||
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
||||
photo = await picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: keepMaxQuality ? null : receiptImageQuality,
|
||||
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open camera: $e')),
|
||||
SnackBar(content: Text('Could not open $sourceLabel: $e')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -54,8 +72,16 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
|
||||
final parsed = ReceiptParser.parse(recognizedText);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // close the OCR loading spinner
|
||||
|
||||
final state = parsed.state;
|
||||
if (state != null && state != 'MO') {
|
||||
final proceed = await _confirmNonMissouriPurchase(context, state);
|
||||
if (!proceed || !context.mounted) return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConfirmFuelEntryScreen(
|
||||
|
|
@ -68,6 +94,30 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
|
||||
/// warn if the receipt's address is somewhere else, and let the user
|
||||
/// decide whether to log it anyway (e.g. it might still be worth
|
||||
/// tracking for other reasons even if it won't qualify for a refund).
|
||||
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
|
||||
final stateName = usStateNames[stateCode] ?? stateCode;
|
||||
final proceed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Non-Missouri Purchase'),
|
||||
content: Text(
|
||||
'This receipt looks like it\'s from a gas station in $stateName. '
|
||||
'The Missouri fuel tax refund only applies to fuel purchased in '
|
||||
'Missouri.\n\nDo you still want to add this entry?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
|
||||
],
|
||||
),
|
||||
);
|
||||
return proceed ?? false;
|
||||
}
|
||||
|
||||
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
|
|
@ -136,7 +186,7 @@ class VehicleDetailScreen extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(vehicle.displayName),
|
||||
title: Text(vehicle.displayLabel),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
|
|
|
|||
|
|
@ -8,19 +8,42 @@ import 'package:uuid/uuid.dart';
|
|||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'cloud/dropbox_provider.dart';
|
||||
import 'cloud/google_drive_provider.dart';
|
||||
import 'cloud/onedrive_provider.dart';
|
||||
import 'cloud/webdav_provider.dart';
|
||||
import 'cloud_sync_service.dart';
|
||||
import 'database_service.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_sync_service.dart';
|
||||
|
||||
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
|
||||
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
||||
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
||||
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
||||
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
||||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
const maxStaleLockMinutes = 60;
|
||||
|
||||
/// Thrown by [AppState.addVehicle] when the given VIN already belongs to
|
||||
/// an active vehicle — VIN is the primary/unique identifier, so adding a
|
||||
/// duplicate should be rejected rather than silently overwriting it.
|
||||
class DuplicateVinException implements Exception {
|
||||
final String vin;
|
||||
DuplicateVinException(this.vin);
|
||||
|
||||
@override
|
||||
String toString() => 'A vehicle with VIN "$vin" already exists.';
|
||||
}
|
||||
|
||||
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
|
||||
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
|
||||
/// read from this via Provider and call its mutating methods, which write
|
||||
/// through to the local database immediately and kick off a best-effort
|
||||
/// background sync to Drive.
|
||||
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
|
||||
/// [CloudStorageProvider] the user has connected. Screens read from this
|
||||
/// via Provider and call its mutating methods, which write through to the
|
||||
/// local database immediately and kick off a best-effort background sync.
|
||||
///
|
||||
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
|
||||
/// database, refreshed after every mutation and after every sync (since
|
||||
|
|
@ -28,27 +51,71 @@ const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
|||
/// handing back updated Dart objects).
|
||||
class AppState extends ChangeNotifier {
|
||||
final DatabaseService database = DatabaseService();
|
||||
final DriveAuthService driveAuth = DriveAuthService();
|
||||
late final DriveSyncService driveSync =
|
||||
DriveSyncService(authService: driveAuth, databaseService: database);
|
||||
|
||||
/// Every storage backend the user can choose from in Settings.
|
||||
final List<CloudStorageProvider> availableProviders = [
|
||||
GoogleDriveProvider(),
|
||||
DropboxProvider(),
|
||||
OneDriveProvider(),
|
||||
WebDavProvider(),
|
||||
];
|
||||
|
||||
CloudStorageProvider? activeProvider;
|
||||
CloudSyncService? cloudSync;
|
||||
|
||||
final _uuid = const Uuid();
|
||||
|
||||
List<Vehicle> vehicles = [];
|
||||
List<FuelEntry> fuelEntries = [];
|
||||
bool isLoading = true;
|
||||
|
||||
bool isDriveConnected = false;
|
||||
String? driveAccountEmail;
|
||||
String? driveFolderPath;
|
||||
String? cloudFolderPath;
|
||||
bool isSyncing = false;
|
||||
DateTime? lastSyncedAt;
|
||||
Object? lastSyncError;
|
||||
|
||||
bool keepReceiptPhotosLocally = false;
|
||||
int staleLockMinutes = defaultStaleLockMinutes;
|
||||
|
||||
/// When false (default), a newly captured receipt photo is downscaled
|
||||
/// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality]
|
||||
/// before it's stored — receipts are just photos of small printed text,
|
||||
/// so a full-resolution original (often several MB on a modern phone
|
||||
/// camera) buys nothing but cloud storage and sync bandwidth. When true,
|
||||
/// the original camera/gallery image is kept as-is.
|
||||
bool keepMaxQualityReceiptPhotos = false;
|
||||
|
||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||
/// of spinning forever — an unhandled exception here previously left
|
||||
/// `isLoading` stuck at true with no feedback at all.
|
||||
Object? initError;
|
||||
|
||||
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
Future<void> init() async {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
isLoading = true;
|
||||
initError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
||||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||
keepMaxQualityReceiptPhotos =
|
||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -58,7 +125,7 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
});
|
||||
|
||||
unawaited(_restoreDriveConnection());
|
||||
unawaited(_restoreCloudConnection());
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -72,18 +139,34 @@ class AppState extends ChangeNotifier {
|
|||
fuelEntries = await database.getFuelEntries();
|
||||
}
|
||||
|
||||
Future<void> _restoreDriveConnection() async {
|
||||
final signedIn = await driveAuth.attemptSilentSignIn();
|
||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||
for (final provider in availableProviders) {
|
||||
if (provider.id == id) return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _restoreCloudConnection() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedProviderName = prefs.getString(_prefsKeyActiveProviderId);
|
||||
if (storedProviderName == null) return;
|
||||
|
||||
final matchingId = CloudProviderId.values
|
||||
.where((id) => id.name == storedProviderName)
|
||||
.firstOrNull;
|
||||
final provider = matchingId == null ? null : _providerById(matchingId);
|
||||
if (provider == null) return;
|
||||
|
||||
final signedIn = await provider.attemptSilentSignIn();
|
||||
if (!signedIn) return;
|
||||
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = driveAuth.currentAccountEmail;
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final folderId = prefs.getString(_prefsKeyDriveFolderId);
|
||||
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
|
||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
||||
if (folderId != null) {
|
||||
driveSync.configure(folderId);
|
||||
cloudSync!.configure(folderId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -92,48 +175,91 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> connectDrive() async {
|
||||
final email = await driveAuth.signIn();
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = email;
|
||||
notifyListeners();
|
||||
}
|
||||
Future<void> connectProvider(CloudProviderId id) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null) return;
|
||||
|
||||
Future<void> disconnectDrive() async {
|
||||
await driveAuth.signOut();
|
||||
driveSync.clearConfiguration();
|
||||
isDriveConnected = false;
|
||||
driveAccountEmail = null;
|
||||
driveFolderPath = null;
|
||||
await provider.signIn();
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyDriveFolderId);
|
||||
await prefs.remove(_prefsKeyDriveFolderPath);
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseDriveFolder({
|
||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
||||
/// (currently just WebDAV) that needs a server URL/username/password
|
||||
/// instead of an OAuth browser flow. The caller (Settings) is responsible
|
||||
/// for collecting those from the user first.
|
||||
Future<void> connectProviderWithCredentials(
|
||||
CloudProviderId id, {
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
|
||||
|
||||
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disconnectCloud() async {
|
||||
final provider = activeProvider;
|
||||
if (provider == null) return;
|
||||
|
||||
await provider.signOut();
|
||||
cloudSync?.clearConfiguration();
|
||||
activeProvider = null;
|
||||
cloudSync = null;
|
||||
cloudFolderPath = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyActiveProviderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseCloudFolder({
|
||||
required String parentId,
|
||||
required String breadcrumbPath,
|
||||
}) async {
|
||||
final folderId = await driveSync.selectAppFolder(parentId);
|
||||
driveFolderPath = breadcrumbPath;
|
||||
final sync = cloudSync;
|
||||
if (sync == null) return;
|
||||
|
||||
final folderId = await sync.selectAppFolder(parentId);
|
||||
cloudFolderPath = breadcrumbPath;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyDriveFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
|
||||
await prefs.setString(_prefsKeyCloudFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
|
||||
notifyListeners();
|
||||
|
||||
unawaited(syncNow());
|
||||
}
|
||||
|
||||
Future<void> syncNow() async {
|
||||
if (isSyncing || !driveSync.isConfigured) return;
|
||||
final sync = cloudSync;
|
||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
||||
|
||||
isSyncing = true;
|
||||
notifyListeners();
|
||||
|
||||
final result = await driveSync.syncNow();
|
||||
final result = await sync.syncNow(
|
||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
||||
);
|
||||
|
||||
if (result.ranSync) {
|
||||
await _refreshFromDatabase();
|
||||
|
|
@ -147,36 +273,71 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepReceiptPhotosLocally(bool value) async {
|
||||
keepReceiptPhotosLocally = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
|
||||
keepMaxQualityReceiptPhotos = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||
/// Settings "Advanced" section, which restricts the picker to that range
|
||||
/// anyway; this is a defensive backstop for any other caller.
|
||||
Future<void> setStaleLockMinutes(int minutes) async {
|
||||
staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [vin] already belongs to another
|
||||
/// active vehicle — VIN must stay unique even though it's editable, so
|
||||
/// silently letting a duplicate through would be a real correctness bug,
|
||||
/// not just a UX wrinkle.
|
||||
Future<void> addVehicle({
|
||||
required String make,
|
||||
required String model,
|
||||
required String color,
|
||||
required String licensePlate,
|
||||
required String vin,
|
||||
String? nickname,
|
||||
}) async {
|
||||
if (await database.vinExists(vin)) {
|
||||
throw DuplicateVinException(vin);
|
||||
}
|
||||
final vehicle = Vehicle(
|
||||
id: _uuid.v4(),
|
||||
make: make,
|
||||
model: model,
|
||||
color: color,
|
||||
licensePlate: licensePlate,
|
||||
vin: vin,
|
||||
nickname: nickname,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await database.saveVehicle(vehicle);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [updated]'s VIN now collides with
|
||||
/// another active vehicle's — this is the check [addVehicle] does for a
|
||||
/// new vehicle, but here it also has to exclude the vehicle being edited
|
||||
/// itself (its VIN obviously still matches its own prior value if it
|
||||
/// wasn't changed).
|
||||
Future<void> updateVehicle(Vehicle updated) async {
|
||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
||||
throw DuplicateVinException(updated.vin);
|
||||
}
|
||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
Future<void> deleteVehicle(String id) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
||||
for (final path in orphanedLocalPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
await database.softDeleteVehicle(vehicleId, now);
|
||||
await database.softDeleteVehicle(id, now);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +352,8 @@ class AppState extends ChangeNotifier {
|
|||
final id = _uuid.v4();
|
||||
String? storedImagePath;
|
||||
if (receiptImage != null) {
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id);
|
||||
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
|
||||
}
|
||||
|
||||
final entry = FuelEntry(
|
||||
|
|
@ -243,3 +405,7 @@ class AppState extends ChangeNotifier {
|
|||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
|
|
|||
153
lib/services/cloud/cloud_storage_provider.dart
Normal file
153
lib/services/cloud/cloud_storage_provider.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:io';
|
||||
|
||||
/// Shared naming convention across all providers: whichever cloud storage
|
||||
/// backend is active, the app looks for (or creates) a folder with this
|
||||
/// exact name wherever the user points it, so two devices pointed at the
|
||||
/// same shared parent location converge on the same data regardless of
|
||||
/// which provider they're using.
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_tracker.db';
|
||||
|
||||
enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav }
|
||||
|
||||
class CloudFolder {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
CloudFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class CloudFileInfo {
|
||||
final String id;
|
||||
|
||||
/// Opaque change-detection signal — Drive's md5Checksum, Dropbox's
|
||||
/// content_hash, OneDrive's cTag all satisfy "did this change since I
|
||||
/// last looked", which is the only thing callers need from it.
|
||||
final String? versionTag;
|
||||
|
||||
CloudFileInfo({required this.id, required this.versionTag});
|
||||
}
|
||||
|
||||
class CloudLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
CloudLockFile({required this.id, required this.username, required this.createdAtUtc});
|
||||
}
|
||||
|
||||
/// Thrown when an operation needs authorization that isn't currently
|
||||
/// available without prompting the user, e.g. during a background sync
|
||||
/// with an expired/revoked token.
|
||||
class CloudNotAuthorizedException implements Exception {
|
||||
final String providerName;
|
||||
CloudNotAuthorizedException(this.providerName);
|
||||
|
||||
@override
|
||||
String toString() => '$providerName access is not currently authorized.';
|
||||
}
|
||||
|
||||
/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive).
|
||||
/// Owns account-level identity/auth; per-sync operations go through a
|
||||
/// [CloudStorageSession] obtained via [beginSession].
|
||||
abstract class CloudStorageProvider {
|
||||
CloudProviderId get id;
|
||||
String get displayName;
|
||||
|
||||
bool get isSignedIn;
|
||||
String? get accountLabel;
|
||||
|
||||
/// Attempts to restore a previous sign-in without any UI. Returns true
|
||||
/// if signed in and authorized.
|
||||
Future<bool> attemptSilentSignIn();
|
||||
|
||||
/// Interactive sign-in. Must be called from a user-initiated action
|
||||
/// (e.g. a button press). Returns a label to display (email/username).
|
||||
Future<String> signIn();
|
||||
|
||||
Future<void> signOut();
|
||||
|
||||
/// Starts one session's worth of operations (roughly: one sync round,
|
||||
/// or one folder-browsing screen visit). The caller owns its lifecycle —
|
||||
/// call [CloudStorageSession.close] when done with it.
|
||||
CloudStorageSession beginSession();
|
||||
}
|
||||
|
||||
/// Raw operations against one cloud storage backend, scoped to a single
|
||||
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
|
||||
/// opaque per-provider — for most providers a real ID, but for a
|
||||
/// path-addressed API (Dropbox) a session may internally treat the path
|
||||
/// itself as the "id". Callers never need to know which.
|
||||
abstract class CloudStorageSession {
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
|
||||
/// provider supports it) top-level folders shared with the signed-in
|
||||
/// account regardless of parent. Providers that don't have a meaningful
|
||||
/// separate "shared with me" concept may just ignore [sharedWithMe] and
|
||||
/// always list under [parentId].
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
|
||||
|
||||
/// True if this provider has a distinct "Shared with me" browsing mode
|
||||
/// worth showing as a separate tab in the folder picker UI.
|
||||
bool get supportsSharedWithMe;
|
||||
|
||||
/// Finds a folder named [name] directly under [parentId], or creates one
|
||||
/// if none exists. If duplicates exist, the earliest-created one wins.
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name});
|
||||
|
||||
/// Looks up a file's ID + versionTag by name within [folderId] without
|
||||
/// downloading its content, or null if no such file exists yet.
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name});
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId);
|
||||
|
||||
/// Creates the file if [existingFileId] is null, otherwise overwrites
|
||||
/// its content. Returns the (possibly new) file ID and fresh versionTag.
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
});
|
||||
|
||||
Future<void> deleteFile(String fileId);
|
||||
|
||||
Future<String> createLockFile({required String folderId, required String name});
|
||||
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId);
|
||||
|
||||
/// Releases any resources (e.g. closes an underlying HTTP client).
|
||||
void close();
|
||||
}
|
||||
|
||||
/// Implemented by providers that need the user to type in connection
|
||||
/// details (server URL, username, password) instead of completing an
|
||||
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
|
||||
/// central authorization server to redirect to. The Settings screen checks
|
||||
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
|
||||
/// "Connect" opens a small credentials form instead of calling
|
||||
/// [CloudStorageProvider.signIn] directly.
|
||||
abstract class ManualCredentialCloudStorageProvider {
|
||||
Future<String> signInWithCredentials({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
});
|
||||
}
|
||||
|
||||
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
|
||||
/// every provider's [CloudStorageSession.listLockFiles] implementation
|
||||
/// rather than duplicated, since the lock file naming convention itself
|
||||
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
|
||||
(String, DateTime)? parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
364
lib/services/cloud/dropbox_provider.dart
Normal file
364
lib/services/cloud/dropbox_provider.dart
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
import 'oauth_pkce.dart';
|
||||
|
||||
/// Dropbox implementation of [CloudStorageProvider].
|
||||
///
|
||||
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
|
||||
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
|
||||
/// folder's own Dropbox path (e.g. "/MO-Fuel-Tax-Back") as its "id" for
|
||||
/// purposes of the generic [CloudStorageSession] interface — an
|
||||
/// implementation detail entirely inside this file, invisible to
|
||||
/// [CloudSyncService].
|
||||
class DropboxProvider implements CloudStorageProvider {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
DateTime? _accessTokenExpiry;
|
||||
String? _accountLabel;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.dropbox;
|
||||
|
||||
@override
|
||||
String get displayName => 'Dropbox';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _refreshToken != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _accountLabel;
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
// The refresh token isn't persisted across app launches in this first
|
||||
// pass (kept in memory only) — see README's Known limitations. Silent
|
||||
// restore always fails; the user reconnects once per cold start until
|
||||
// that's added.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
final redirectUri = CloudOAuthConfig.dropboxRedirectUri;
|
||||
if (appKey == null || redirectUri == null) {
|
||||
throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', {
|
||||
'client_id': appKey,
|
||||
'response_type': 'code',
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'redirect_uri': redirectUri,
|
||||
'token_access_type': 'offline',
|
||||
});
|
||||
|
||||
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
|
||||
final resultUrl = await FlutterWebAuth2.authenticate(
|
||||
url: authUrl.toString(),
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
final code = Uri.parse(resultUrl).queryParameters['code'];
|
||||
if (code == null) {
|
||||
throw StateError('Dropbox sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
appKey: appKey,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String appKey,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': appKey,
|
||||
'code_verifier': codeVerifier,
|
||||
'redirect_uri': redirectUri,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox token exchange failed: ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String?;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/users/get_current_account'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'Dropbox account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['email'] as String? ?? 'Dropbox account';
|
||||
}
|
||||
|
||||
/// Refreshes the access token if it's missing or close to expiring.
|
||||
/// Never prompts for UI — suitable for background sync — so throws
|
||||
/// [CloudNotAuthorizedException] if there's no refresh token to use.
|
||||
Future<String> _freshAccessToken() async {
|
||||
final stillValid = _accessToken != null &&
|
||||
_accessTokenExpiry != null &&
|
||||
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
|
||||
if (stillValid) return _accessToken!;
|
||||
|
||||
final refreshToken = _refreshToken;
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
if (refreshToken == null || appKey == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'client_id': appKey,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return DropboxSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class DropboxSession implements CloudStorageSession {
|
||||
final DropboxProvider _provider;
|
||||
DropboxSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
/// Dropbox's root path is `""`, not `"/"` — our generic interface uses
|
||||
/// the literal string `'root'` for "the top of the tree" (matching
|
||||
/// Google Drive's convention), so translate that here.
|
||||
String _normalizePath(String id) => id == 'root' ? '' : id;
|
||||
|
||||
String _childPath(String parentId, String name) {
|
||||
final parent = _normalizePath(parentId);
|
||||
return '$parent/$name';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, Map<String, dynamic> body) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', path),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// True if a Dropbox API error response's `.tag` chain indicates "the
|
||||
/// path doesn't exist" — Dropbox reports this as a normal 409 response
|
||||
/// with a structured error body, not a 404, so it needs its own check
|
||||
/// rather than a status-code check.
|
||||
bool _isPathNotFoundError(http.Response response) {
|
||||
if (response.statusCode != 409) return false;
|
||||
try {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return jsonEncode(body['error']).contains('not_found');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final path = _normalizePath(parentId ?? 'root');
|
||||
final json = await _post('/2/files/list_folder', {'path': path});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
return entries
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((e) => e['.tag'] == 'folder')
|
||||
.map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childPath = _childPath(parentId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': childPath}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] == 'folder') return childPath;
|
||||
} else if (!_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
await _post('/2/files/create_folder_v2', {'path': childPath});
|
||||
return childPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final filePath = _childPath(folderId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': filePath}),
|
||||
);
|
||||
if (_isPathNotFoundError(response)) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] != 'file') return null;
|
||||
return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/download'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': fileId}),
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final targetPath = existingFileId ?? _childPath(folderId, name);
|
||||
final bytes = await localFile.readAsBytes();
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(
|
||||
id: json['path_display'] as String? ?? targetPath,
|
||||
versionTag: json['content_hash'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/delete_v2'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': fileId}),
|
||||
);
|
||||
if (response.statusCode != 200 && !_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final path = _childPath(folderId, name);
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries.cast<Map<String, dynamic>>()) {
|
||||
if (entry['.tag'] != 'file') continue;
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['path_display'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
263
lib/services/cloud/google_drive_provider.dart
Normal file
263
lib/services/cloud/google_drive_provider.dart
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:googleapis/drive/v3.dart' as drive;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
|
||||
/// Full Drive access is required (not the narrower `drive.file` scope)
|
||||
/// because users need to browse to and reuse folders that someone else
|
||||
/// created and shared with them, not just folders/files this app itself
|
||||
/// created. See the plan doc for the tradeoffs (this requires Google
|
||||
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
|
||||
/// a full OAuth verification review).
|
||||
const _driveScopes = <String>['https://www.googleapis.com/auth/drive'];
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
/// Google Drive implementation of [CloudStorageProvider], via
|
||||
/// `google_sign_in` for auth and the `googleapis` `DriveApi` client for
|
||||
/// everything else.
|
||||
class GoogleDriveProvider implements CloudStorageProvider {
|
||||
bool _initialized = false;
|
||||
GoogleSignInAccount? _account;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.googleDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'Google Drive';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _account?.email;
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
await GoogleSignIn.instance.initialize(
|
||||
clientId: Platform.isIOS ? CloudOAuthConfig.googleIosClientId : null,
|
||||
serverClientId: Platform.isAndroid ? CloudOAuthConfig.googleAndroidServerClientId : null,
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
|
||||
_account = account;
|
||||
if (account == null) return false;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient.authorizationForScopes(_driveScopes);
|
||||
return authorization != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes);
|
||||
_account = account;
|
||||
await account.authorizationClient.authorizeScopes(_driveScopes);
|
||||
return account.email;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
_account = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
final account = _account;
|
||||
if (account == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final client = _GoogleAuthHttpClient(account.authorizationClient);
|
||||
return GoogleDriveSession(client);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoogleAuthHttpClient extends http.BaseClient {
|
||||
final GoogleSignInAuthorizationClient _authClient;
|
||||
final http.Client _inner = http.Client();
|
||||
|
||||
_GoogleAuthHttpClient(this._authClient);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final headers =
|
||||
await _authClient.authorizationHeaders(_driveScopes, promptIfNecessary: false);
|
||||
if (headers == null) {
|
||||
throw CloudNotAuthorizedException('Google Drive');
|
||||
}
|
||||
request.headers.addAll(headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleDriveSession implements CloudStorageSession {
|
||||
final http.Client _client;
|
||||
final drive.DriveApi _api;
|
||||
|
||||
GoogleDriveSession(this._client) : _api = drive.DriveApi(_client);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => true;
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final query = sharedWithMe
|
||||
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
|
||||
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
|
||||
|
||||
final result = await _api.files.list(
|
||||
q: query,
|
||||
orderBy: 'name',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
return (result.files ?? [])
|
||||
.where((f) => f.id != null && f.name != null)
|
||||
.map((f) => CloudFolder(id: f.id!, name: f.name!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final existing = await _api.files.list(
|
||||
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
|
||||
orderBy: 'createdTime',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final files = existing.files ?? <drive.File>[];
|
||||
if (files.isNotEmpty && files.first.id != null) return files.first.id!;
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..mimeType = _folderMimeType
|
||||
..parents = [parentId],
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$folderId' in parents and trashed=false and name='$name'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name,md5Checksum)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
final files = result.files ?? <drive.File>[];
|
||||
if (files.isEmpty || files.first.id == null) return null;
|
||||
return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
return _collectBytes(media.stream);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final length = await localFile.length();
|
||||
final media = drive.Media(localFile.openRead(), length, contentType: contentType);
|
||||
|
||||
if (existingFileId != null) {
|
||||
final updated = await _api.files.update(
|
||||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return CloudFileInfo(id: updated.id ?? existingFileId, versionTag: updated.md5Checksum);
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [folderId],
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return CloudFileInfo(id: created.id!, versionTag: created.md5Checksum);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
try {
|
||||
await _api.files.delete(fileId);
|
||||
} on drive.DetailedApiRequestError catch (e) {
|
||||
// Already gone (e.g. deleted by another device) — not an error for
|
||||
// our purposes.
|
||||
if (e.status != 404) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [folderId],
|
||||
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$folderId' in parents and trashed=false and name contains '.lock'",
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final f in result.files ?? <drive.File>[]) {
|
||||
final parsed = parseLockFileName(f.name);
|
||||
if (f.id != null && parsed != null) {
|
||||
locks.add(CloudLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => _client.close();
|
||||
}
|
||||
31
lib/services/cloud/oauth_pkce.dart
Normal file
31
lib/services/cloud/oauth_pkce.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's
|
||||
/// OAuth2 Authorization Code flow — proves to the token endpoint that the
|
||||
/// app completing the exchange is the same one that started the browser
|
||||
/// redirect, without needing an embedded client secret (appropriate for a
|
||||
/// public/mobile client, since a secret can't actually be kept secret in
|
||||
/// a distributed app binary).
|
||||
class PkcePair {
|
||||
final String codeVerifier;
|
||||
final String codeChallenge;
|
||||
|
||||
PkcePair._(this.codeVerifier, this.codeChallenge);
|
||||
|
||||
factory PkcePair.generate() {
|
||||
final verifier = _randomUrlSafeString(64);
|
||||
final challenge =
|
||||
base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', '');
|
||||
return PkcePair._(verifier, challenge);
|
||||
}
|
||||
|
||||
static String _randomUrlSafeString(int length) {
|
||||
// RFC 7636's unreserved character set for a code_verifier.
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final random = Random.secure();
|
||||
return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join();
|
||||
}
|
||||
}
|
||||
350
lib/services/cloud/onedrive_provider.dart
Normal file
350
lib/services/cloud/onedrive_provider.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
import 'oauth_pkce.dart';
|
||||
|
||||
const _graphScopes = 'offline_access Files.ReadWrite.All';
|
||||
|
||||
/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph.
|
||||
/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the
|
||||
/// interface directly with no path-based workaround.
|
||||
class OneDriveProvider implements CloudStorageProvider {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
DateTime? _accessTokenExpiry;
|
||||
String? _accountLabel;
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.oneDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'OneDrive';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _refreshToken != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => _accountLabel;
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
// As with Dropbox, the refresh token is kept in memory only in this
|
||||
// first pass — see README's Known limitations.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
final redirectUri = CloudOAuthConfig.oneDriveRedirectUri;
|
||||
if (clientId == null || redirectUri == null) {
|
||||
throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https(
|
||||
'login.microsoftonline.com',
|
||||
'/common/oauth2/v2.0/authorize',
|
||||
{
|
||||
'client_id': clientId,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': redirectUri,
|
||||
'response_mode': 'query',
|
||||
'scope': _graphScopes,
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
},
|
||||
);
|
||||
|
||||
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
|
||||
final resultUrl = await FlutterWebAuth2.authenticate(
|
||||
url: authUrl.toString(),
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
final code = Uri.parse(resultUrl).queryParameters['code'];
|
||||
if (code == null) {
|
||||
throw StateError('OneDrive sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
clientId: clientId,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirectUri,
|
||||
'code_verifier': codeVerifier,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive token exchange failed: ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String?;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.get(
|
||||
Uri.https('graph.microsoft.com', '/v1.0/me'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'OneDrive account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (json['mail'] as String?) ??
|
||||
(json['userPrincipalName'] as String?) ??
|
||||
'OneDrive account';
|
||||
}
|
||||
|
||||
Future<String> _freshAccessToken() async {
|
||||
final stillValid = _accessToken != null &&
|
||||
_accessTokenExpiry != null &&
|
||||
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
|
||||
if (stillValid) return _accessToken!;
|
||||
|
||||
final refreshToken = _refreshToken;
|
||||
final clientId = CloudOAuthConfig.oneDriveClientId;
|
||||
if (refreshToken == null || clientId == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
|
||||
body: {
|
||||
'client_id': clientId,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'scope': _graphScopes,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String? ?? _refreshToken;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return OneDriveSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class OneDriveSession implements CloudStorageSession {
|
||||
final OneDriveProvider _provider;
|
||||
OneDriveSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => true;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path');
|
||||
|
||||
/// The interface's `'root'` sentinel (matching Google's convention) maps
|
||||
/// to Graph's own `/me/drive/root` special item.
|
||||
String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id';
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final uri = sharedWithMe
|
||||
? _graph('/me/drive/sharedWithMe')
|
||||
: _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children');
|
||||
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final folders = <CloudFolder>[];
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] == null) continue;
|
||||
// "Shared with me" items carry the shared item's own id under
|
||||
// `remoteItem`, not the top-level entry id.
|
||||
final remoteItem = entry['remoteItem'] as Map<String, dynamic>?;
|
||||
final id = (remoteItem?['id'] ?? entry['id']) as String?;
|
||||
final name = entry['name'] as String?;
|
||||
if (id != null && name != null) {
|
||||
folders.add(CloudFolder(id: id, name: name));
|
||||
}
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children');
|
||||
final listResponse = await http.get(childrenUri, headers: await _authHeader());
|
||||
if (listResponse.statusCode != 200) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}');
|
||||
}
|
||||
final listJson = jsonDecode(listResponse.body) as Map<String, dynamic>;
|
||||
final entries = (listJson['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
for (final entry in entries) {
|
||||
if (entry['folder'] != null && entry['name'] == name) {
|
||||
return entry['id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
final createResponse = await http.post(
|
||||
childrenUri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'folder': <String, dynamic>{},
|
||||
'@microsoft.graph.conflictBehavior': 'fail',
|
||||
}),
|
||||
);
|
||||
if (createResponse.statusCode != 201) {
|
||||
throw StateError(
|
||||
'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}');
|
||||
}
|
||||
final created = jsonDecode(createResponse.body) as Map<String, dynamic>;
|
||||
return created['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
|
||||
final response = await http.get(uri, headers: await _authHeader());
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response =
|
||||
await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader());
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final bytes = await localFile.readAsBytes();
|
||||
// Graph's simple upload endpoint (content < 4MB, which every receipt
|
||||
// photo and the sqlite data file comfortably are) — no upload session
|
||||
// needed.
|
||||
final uri = existingFileId != null
|
||||
? _graph('/me/drive/items/$existingFileId/content')
|
||||
: _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response =
|
||||
await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader());
|
||||
if (response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {...await _authHeader(), 'Content-Type': 'text/plain'},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['id'] as String;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final response = await http.get(
|
||||
_graph('/me/drive/${_itemSegment(folderId)}/children'),
|
||||
headers: await _authHeader(),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries) {
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['id'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
390
lib/services/cloud/webdav_provider.dart
Normal file
390
lib/services/cloud/webdav_provider.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import 'cloud_storage_provider.dart';
|
||||
|
||||
const _secureStorageKeyServerUrl = 'webdav_server_url';
|
||||
const _secureStorageKeyUsername = 'webdav_username';
|
||||
const _secureStorageKeyPassword = 'webdav_password';
|
||||
|
||||
const _propfindRequestBody = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:propfind>''';
|
||||
|
||||
/// One `<D:response>` entry from a WebDAV PROPFIND multistatus response.
|
||||
/// Not private, and [parseWebDavMultistatus] is a free function, purely so
|
||||
/// the XML parsing can be unit-tested directly against sample responses
|
||||
/// from different server implementations — real WebDAV servers vary in
|
||||
/// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all),
|
||||
/// which is exactly the kind of real-world format variance this app has
|
||||
/// been burned by before with format-specific assumptions.
|
||||
class WebDavEntry {
|
||||
final String path;
|
||||
final bool isCollection;
|
||||
final String? etag;
|
||||
WebDavEntry({required this.path, required this.isCollection, required this.etag});
|
||||
}
|
||||
|
||||
/// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with
|
||||
/// each entry's href resolved to an absolute path against [baseUrl].
|
||||
/// Matches elements by local name only (ignoring namespace prefix), since
|
||||
/// that's the part that varies across server implementations.
|
||||
List<WebDavEntry> parseWebDavMultistatus(String xmlBody, Uri baseUrl) {
|
||||
final document = XmlDocument.parse(xmlBody);
|
||||
return _byLocalName(document, 'response').map((responseEl) {
|
||||
final href = _byLocalName(responseEl, 'href').first.innerText;
|
||||
final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty;
|
||||
final etagEls = _byLocalName(responseEl, 'getetag').toList();
|
||||
return WebDavEntry(
|
||||
path: baseUrl.resolve(href).path,
|
||||
isCollection: isCollection,
|
||||
etag: etagEls.isEmpty ? null : etagEls.first.innerText,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Iterable<XmlElement> _byLocalName(XmlNode node, String localName) =>
|
||||
node.descendants.whereType<XmlElement>().where((e) => e.name.local == localName);
|
||||
|
||||
class _RawResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
final Map<String, String> headers;
|
||||
_RawResponse({required this.statusCode, required this.body, required this.headers});
|
||||
}
|
||||
|
||||
/// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that
|
||||
/// `package:http`'s GET/PUT/DELETE convenience functions don't support.
|
||||
Future<_RawResponse> _send(String method, Uri uri, {Map<String, String>? headers, Object? body}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final request = http.Request(method, uri);
|
||||
if (headers != null) request.headers.addAll(headers);
|
||||
if (body is String) request.body = body;
|
||||
if (body is List<int>) request.bodyBytes = body;
|
||||
final streamed = await client.send(request);
|
||||
final responseBody = await streamed.stream.bytesToString();
|
||||
return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _basicAuthHeader(String username, String password) =>
|
||||
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
|
||||
String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path;
|
||||
|
||||
String _nameFromPath(String path) {
|
||||
final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty);
|
||||
return segments.isEmpty ? '' : Uri.decodeComponent(segments.last);
|
||||
}
|
||||
|
||||
/// WebDAV implementation of [CloudStorageProvider], for self-hosted
|
||||
/// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any
|
||||
/// generic WebDAV server) rather than a named commercial provider. Unlike
|
||||
/// the OAuth-based providers, there's no browser sign-in flow and no
|
||||
/// developer-console app to register ahead of time — the user supplies a
|
||||
/// server URL, username, and password (an app-specific password is
|
||||
/// recommended on servers that support one, e.g. Nextcloud's Security
|
||||
/// settings) directly via [signInWithCredentials].
|
||||
class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
Uri? _baseUrl;
|
||||
String? _username;
|
||||
String? _password;
|
||||
|
||||
WebDavProvider({FlutterSecureStorage? secureStorage})
|
||||
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.webdav;
|
||||
|
||||
@override
|
||||
String get displayName => 'WebDAV';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => _baseUrl != null;
|
||||
|
||||
@override
|
||||
String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null;
|
||||
|
||||
/// Restores a session from credentials saved on a previous
|
||||
/// [signInWithCredentials] call (OS-encrypted storage — Keystore on
|
||||
/// Android, Keychain on iOS), re-verifying them with the same PROPFIND
|
||||
/// check rather than trusting them blindly, since the server config or
|
||||
/// password could have changed since. Any failure here — wrong/expired
|
||||
/// credentials, no network, nothing stored yet — just means "not signed
|
||||
/// in"; this never throws; a real error from a user-initiated attempt
|
||||
/// belongs to [signInWithCredentials], not this silent path.
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
try {
|
||||
final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl);
|
||||
final username = await _secureStorage.read(key: _secureStorageKeyUsername);
|
||||
final password = await _secureStorage.read(key: _secureStorageKeyPassword);
|
||||
if (serverUrl == null || username == null || password == null) return false;
|
||||
|
||||
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() {
|
||||
throw UnsupportedError(
|
||||
'WebDAV needs a server URL and credentials — use signInWithCredentials instead.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signInWithCredentials({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final label =
|
||||
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
|
||||
|
||||
await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString());
|
||||
await _secureStorage.write(key: _secureStorageKeyUsername, value: username);
|
||||
await _secureStorage.write(key: _secureStorageKeyPassword, value: password);
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
/// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes
|
||||
/// the URL, does a side-effect-free PROPFIND on the root to confirm the
|
||||
/// URL and credentials actually work, and — only once that's confirmed —
|
||||
/// sets this instance's session fields.
|
||||
Future<String> _verifiedSignIn({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
var normalized = serverUrl.trim();
|
||||
if (!normalized.contains('://')) normalized = 'https://$normalized';
|
||||
if (!normalized.endsWith('/')) normalized += '/';
|
||||
final baseUrl = Uri.parse(normalized);
|
||||
|
||||
final response = await _send('PROPFIND', baseUrl, headers: {
|
||||
'Authorization': _basicAuthHeader(username, password),
|
||||
'Depth': '0',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}, body: _propfindRequestBody);
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
throw StateError('WebDAV sign-in failed: invalid username or password.');
|
||||
}
|
||||
if (response.statusCode != 207 && response.statusCode != 200) {
|
||||
throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
_baseUrl = baseUrl;
|
||||
_username = username;
|
||||
_password = password;
|
||||
return accountLabel!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_baseUrl = null;
|
||||
_username = null;
|
||||
_password = null;
|
||||
await _secureStorage.delete(key: _secureStorageKeyServerUrl);
|
||||
await _secureStorage.delete(key: _secureStorageKeyUsername);
|
||||
await _secureStorage.delete(key: _secureStorageKeyPassword);
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return WebDavSession(this);
|
||||
}
|
||||
|
||||
String get _authHeader => _basicAuthHeader(_username!, _password!);
|
||||
}
|
||||
|
||||
class _WebDavNotFoundException implements Exception {}
|
||||
|
||||
class WebDavSession implements CloudStorageSession {
|
||||
final WebDavProvider _provider;
|
||||
WebDavSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id);
|
||||
|
||||
Uri _childUri(Uri parent, String name) {
|
||||
final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/');
|
||||
return parentUri.resolve(Uri.encodeComponent(name));
|
||||
}
|
||||
|
||||
Future<List<WebDavEntry>> _propfind(Uri uri, {required String depth}) async {
|
||||
final response = await _send('PROPFIND', uri, headers: {
|
||||
'Authorization': _provider._authHeader,
|
||||
'Depth': depth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
}, body: _propfindRequestBody);
|
||||
|
||||
if (response.statusCode == 404) throw _WebDavNotFoundException();
|
||||
if (response.statusCode != 207) {
|
||||
throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return parseWebDavMultistatus(response.body, _provider._baseUrl!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final uri = _uriFor(parentId ?? 'root');
|
||||
final selfPath = _normalizedPath(uri.path);
|
||||
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(uri, depth: '1');
|
||||
} on _WebDavNotFoundException {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries
|
||||
.where((e) => e.isCollection && _normalizedPath(e.path) != selfPath)
|
||||
.map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childUri = _childUri(_uriFor(parentId), name);
|
||||
|
||||
try {
|
||||
final entries = await _propfind(childUri, depth: '0');
|
||||
if (entries.isNotEmpty && entries.first.isCollection) return childUri.path;
|
||||
} on _WebDavNotFoundException {
|
||||
// Falls through to create it below.
|
||||
}
|
||||
|
||||
final response =
|
||||
await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return childUri.path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final fileUri = _childUri(_uriFor(folderId), name);
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(fileUri, depth: '0');
|
||||
} on _WebDavNotFoundException {
|
||||
return null;
|
||||
}
|
||||
if (entries.isEmpty) return null;
|
||||
return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response =
|
||||
await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name);
|
||||
final bytes = await localFile.readAsBytes();
|
||||
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
|
||||
throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
// Many servers return the new ETag directly on the PUT response; fall
|
||||
// back to a follow-up PROPFIND only if it's missing.
|
||||
var etag = response.headers['etag'];
|
||||
if (etag == null) {
|
||||
try {
|
||||
final entries = await _propfind(uri, depth: '0');
|
||||
etag = entries.isEmpty ? null : entries.first.etag;
|
||||
} on _WebDavNotFoundException {
|
||||
etag = null;
|
||||
}
|
||||
}
|
||||
return CloudFileInfo(id: uri.path, versionTag: etag);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response =
|
||||
await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
|
||||
if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final uri = _childUri(_uriFor(folderId), name);
|
||||
final response = await http.put(
|
||||
uri,
|
||||
headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
|
||||
throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return uri.path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final uri = _uriFor(folderId);
|
||||
final selfPath = _normalizedPath(uri.path);
|
||||
|
||||
List<WebDavEntry> entries;
|
||||
try {
|
||||
entries = await _propfind(uri, depth: '1');
|
||||
} on _WebDavNotFoundException {
|
||||
return [];
|
||||
}
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries) {
|
||||
if (_normalizedPath(entry.path) == selfPath) continue;
|
||||
final parsed = parseLockFileName(_nameFromPath(entry.path));
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
52
lib/services/cloud_oauth_config.dart
Normal file
52
lib/services/cloud_oauth_config.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/// Fill these in once the corresponding OAuth apps/registrations exist —
|
||||
/// see README.md "Manual setup required" for exact steps per provider.
|
||||
class CloudOAuthConfig {
|
||||
// --- Google Drive ---
|
||||
//
|
||||
// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
|
||||
// authenticates using a *Web application* type OAuth client's ID, not
|
||||
// the Android client's own ID. The separate Android OAuth client
|
||||
// (registered with the package name + debug/release SHA-1) is still
|
||||
// required, but only to let Credential Manager verify this specific
|
||||
// signed app — its client ID itself is never referenced here.
|
||||
// - iOS uses its own iOS-type OAuth client ID directly.
|
||||
|
||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||
/// Android.
|
||||
static const String? googleAndroidServerClientId = null; // TODO: fill in
|
||||
|
||||
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||
/// directly in ios/Runner/Info.plist.
|
||||
static const String? googleIosClientId = null; // TODO: fill in
|
||||
|
||||
// --- Dropbox ---
|
||||
//
|
||||
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
|
||||
|
||||
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
|
||||
static const String? dropboxAppKey = null; // TODO: fill in
|
||||
|
||||
/// Custom URL scheme redirect registered in the Dropbox app console.
|
||||
/// The scheme "mofueltaxback-dropbox" is already wired up in
|
||||
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
|
||||
/// a different scheme, use exactly:
|
||||
/// "mofueltaxback-dropbox://oauth2redirect"
|
||||
static const String? dropboxRedirectUri = null; // TODO: fill in
|
||||
|
||||
// --- OneDrive (Microsoft Graph) ---
|
||||
//
|
||||
// From an app registration in Azure Portal → Entra ID → App
|
||||
// registrations, with Graph delegated permissions Files.ReadWrite.All +
|
||||
// offline_access, redirect URI registered as a "Mobile and desktop
|
||||
// application" platform.
|
||||
|
||||
/// The Application (client) ID from the Azure app registration.
|
||||
static const String? oneDriveClientId = null; // TODO: fill in
|
||||
|
||||
/// Custom URL scheme redirect registered in Azure. The scheme
|
||||
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
|
||||
/// Info.plist, so unless you have a reason to pick a different scheme,
|
||||
/// register and use exactly:
|
||||
/// "mofueltaxback-onedrive://auth"
|
||||
static const String? oneDriveRedirectUri = null; // TODO: fill in
|
||||
}
|
||||
324
lib/services/cloud_sync_service.dart
Normal file
324
lib/services/cloud_sync_service.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'database_service.dart';
|
||||
import 'db_schema.dart';
|
||||
import 'lock_coordinator.dart' as lock;
|
||||
|
||||
class SyncResult {
|
||||
final bool ranSync;
|
||||
final Object? error;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
error = null;
|
||||
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of sync against the shared cloud folder — same
|
||||
/// behavior no matter which [CloudStorageProvider] it's wired to: acquires
|
||||
/// the cross-device lock, pulls + merges the remote database if it
|
||||
/// changed, uploads any pending receipt photos, pushes the local database
|
||||
/// back up, then releases the lock.
|
||||
///
|
||||
/// The merge itself runs as SQL directly against the local database with
|
||||
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
|
||||
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
|
||||
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
|
||||
/// touched (including our own not-yet-pushed edits) are left alone by that
|
||||
/// statement, so no separate "keep local" step is needed — see the README
|
||||
/// for the full reasoning.
|
||||
class CloudSyncService {
|
||||
final CloudStorageProvider provider;
|
||||
final DatabaseService databaseService;
|
||||
|
||||
String? _appFolderId;
|
||||
String? _receiptsFolderId;
|
||||
final Map<String, String> _vinFolderIds = {};
|
||||
final Map<String, String> _monthFolderIds = {};
|
||||
String? _dataFileId;
|
||||
String? _lastKnownRemoteVersionTag;
|
||||
|
||||
CloudSyncService({required this.provider, required this.databaseService});
|
||||
|
||||
bool get isConfigured => _appFolderId != null;
|
||||
|
||||
/// Call once a cloud app folder has been chosen (or restored at launch).
|
||||
void configure(String appFolderId) {
|
||||
_appFolderId = appFolderId;
|
||||
_receiptsFolderId = null;
|
||||
_vinFolderIds.clear();
|
||||
_monthFolderIds.clear();
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteVersionTag = null;
|
||||
}
|
||||
|
||||
void clearConfiguration() {
|
||||
_appFolderId = null;
|
||||
_receiptsFolderId = null;
|
||||
_vinFolderIds.clear();
|
||||
_monthFolderIds.clear();
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteVersionTag = null;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
|
||||
/// folder the user picked in the folder browser) and configures this
|
||||
/// service to use it. Returns the resulting folder ID.
|
||||
Future<String> selectAppFolder(String parentId) async {
|
||||
final session = provider.beginSession();
|
||||
try {
|
||||
final folderId =
|
||||
await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
|
||||
configure(folderId);
|
||||
return folderId;
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a
|
||||
/// receipt photo's local copy is left in place after it's uploaded
|
||||
/// (useful for offline viewing / an on-device backup); when false
|
||||
/// (default), it's deleted once the cloud has it, matching the original
|
||||
/// "local is just a staging area" design.
|
||||
///
|
||||
/// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout:
|
||||
/// how old another device's lock file has to be before this device
|
||||
/// treats it as abandoned (e.g. that device crashed or went offline
|
||||
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
|
||||
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!provider.isSignedIn || appFolderId == null) {
|
||||
return SyncResult.skipped();
|
||||
}
|
||||
|
||||
CloudStorageSession? session;
|
||||
String? lockFileId;
|
||||
|
||||
try {
|
||||
session = provider.beginSession();
|
||||
|
||||
lockFileId = await _acquireLock(
|
||||
session,
|
||||
appFolderId: appFolderId,
|
||||
username: provider.accountLabel!,
|
||||
staleAge: staleLockAge,
|
||||
);
|
||||
|
||||
_receiptsFolderId ??=
|
||||
await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName);
|
||||
|
||||
final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName);
|
||||
_dataFileId = remoteInfo?.id;
|
||||
if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) {
|
||||
await _pullAndMerge(session, remoteInfo.id);
|
||||
}
|
||||
|
||||
final allReceiptsUploaded =
|
||||
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
|
||||
|
||||
// Only push if every pending receipt made it up — otherwise we'd
|
||||
// either upload a row with a local-only file path (meaningless on
|
||||
// another device) or prematurely mark it clean.
|
||||
if (allReceiptsUploaded) {
|
||||
final pushedInfo = await _pushSnapshot(session, appFolderId);
|
||||
_lastKnownRemoteVersionTag = pushedInfo.versionTag;
|
||||
}
|
||||
|
||||
return SyncResult.success();
|
||||
} catch (e) {
|
||||
return SyncResult.failure(e);
|
||||
} finally {
|
||||
if (lockFileId != null && session != null) {
|
||||
try {
|
||||
await session.deleteFile(lockFileId);
|
||||
} catch (_) {
|
||||
// Best-effort: if this fails, the staleness reap on other
|
||||
// devices' next sync attempt will clean it up.
|
||||
}
|
||||
}
|
||||
session?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullAndMerge(CloudStorageSession session, String remoteFileId) async {
|
||||
final bytes = await session.downloadFileBytes(remoteFileId);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath =
|
||||
p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db');
|
||||
final tempFile = File(tempPath);
|
||||
await tempFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
try {
|
||||
final db = databaseService.rawDb;
|
||||
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
|
||||
try {
|
||||
await db.execute(mergeVehiclesSql);
|
||||
await db.execute(mergeFuelEntriesSql);
|
||||
} finally {
|
||||
try {
|
||||
await db.execute('DETACH DATABASE remote_db');
|
||||
} catch (_) {
|
||||
// Don't let a detach failure mask a real merge error above.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads any receipt images still needing it (a local path but no
|
||||
/// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true
|
||||
/// only if every pending image made it up — see [syncNow] for why a
|
||||
/// partial failure here blocks the push step entirely rather than
|
||||
/// pushing something with a leftover local-only path.
|
||||
///
|
||||
/// Deliberately filters on `receipt_drive_file_id IS NULL` (see
|
||||
/// [pendingReceiptUploadWhereClause]), not just "has a local path": once
|
||||
/// [keepLocalCopies] is honored below, an already-uploaded row can still
|
||||
/// have a local path (kept on purpose), and re-matching it here would
|
||||
/// re-upload the same photo as a duplicate cloud file on every sync.
|
||||
Future<bool> _uploadPendingReceipts(
|
||||
CloudStorageSession session, {
|
||||
required bool keepLocalCopies,
|
||||
}) async {
|
||||
final db = databaseService.rawDb;
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
if (rows.isEmpty) return true;
|
||||
|
||||
final vehicleRows = await db.query('vehicles', columns: ['id', 'vin']);
|
||||
final vinByVehicleId = {for (final v in vehicleRows) v['id'] as String: v['vin'] as String};
|
||||
|
||||
var allSucceeded = true;
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
final localPath = row['receipt_image_path'] as String;
|
||||
final localFile = File(localPath);
|
||||
|
||||
if (!await localFile.exists()) {
|
||||
// Nothing left to upload; clear the dangling reference.
|
||||
await db.update('fuel_entries', {'receipt_image_path': null},
|
||||
where: 'id = ?', whereArgs: [id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
final vin = vinByVehicleId[row['vehicle_id']];
|
||||
if (vin == null) {
|
||||
// Vehicle row is missing outright (shouldn't normally happen);
|
||||
// nothing sane to file this under.
|
||||
allSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(row['date'] as int);
|
||||
|
||||
try {
|
||||
final folderId = await _receiptFolderFor(session, vin, date);
|
||||
final uploaded = await session.uploadFile(
|
||||
folderId: folderId,
|
||||
name: '$id${p.extension(localPath)}',
|
||||
localFile: localFile,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
if (!keepLocalCopies) {
|
||||
await databaseService.deleteReceiptImageFile(localPath);
|
||||
}
|
||||
await db.update(
|
||||
'fuel_entries',
|
||||
{
|
||||
'receipt_drive_file_id': uploaded.id,
|
||||
'receipt_image_path': keepLocalCopies ? localPath : null,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
} catch (_) {
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
return allSucceeded;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `Receipts/<vin>/<yyyy.mm>` subfolder for [vin] and
|
||||
/// [date] (the fuel entry's purchase date, not upload time), caching both
|
||||
/// levels for the rest of this [CloudSyncService]'s lifetime (cleared by
|
||||
/// [configure]/[clearConfiguration]) so repeated uploads for the same
|
||||
/// vehicle/month in one sync — or across syncs — don't re-issue the
|
||||
/// lookup.
|
||||
Future<String> _receiptFolderFor(CloudStorageSession session, String vin, DateTime date) async {
|
||||
final vinFolderId = _vinFolderIds[vin] ??
|
||||
await session.findOrCreateFolder(
|
||||
parentId: _receiptsFolderId!,
|
||||
name: sanitizedPathSegment(vin),
|
||||
);
|
||||
_vinFolderIds[vin] = vinFolderId;
|
||||
|
||||
final month = monthFolderName(date);
|
||||
final monthCacheKey = '$vin/$month';
|
||||
final monthFolderId = _monthFolderIds[monthCacheKey] ??
|
||||
await session.findOrCreateFolder(parentId: vinFolderId, name: month);
|
||||
_monthFolderIds[monthCacheKey] = monthFolderId;
|
||||
|
||||
return monthFolderId;
|
||||
}
|
||||
|
||||
/// Uploads the current local database file as the new remote copy, then
|
||||
/// clears the dirty flag on every row now that local matches the cloud.
|
||||
///
|
||||
/// Reads the live database file directly rather than a `VACUUM INTO`
|
||||
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
|
||||
/// WAL) unless explicitly configured otherwise, so the main file is a
|
||||
/// complete, valid database as soon as the last write's Future
|
||||
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
|
||||
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
|
||||
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
|
||||
Future<CloudFileInfo> _pushSnapshot(CloudStorageSession session, String appFolderId) async {
|
||||
final db = databaseService.rawDb;
|
||||
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
|
||||
final info = await session.uploadFile(
|
||||
folderId: appFolderId,
|
||||
name: dataFileName,
|
||||
existingFileId: _dataFileId,
|
||||
localFile: File(databaseService.databasePath),
|
||||
contentType: 'application/x-sqlite3',
|
||||
);
|
||||
_dataFileId = info.id;
|
||||
|
||||
await db.update('vehicles', {'dirty': 0});
|
||||
await db.update('fuel_entries', {'dirty': 0});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
|
||||
|
||||
Future<String> _acquireLock(
|
||||
CloudStorageSession session, {
|
||||
required String appFolderId,
|
||||
required String username,
|
||||
required Duration staleAge,
|
||||
}) {
|
||||
return lock.acquireLock(
|
||||
username: username,
|
||||
createLock: (name) => session.createLockFile(folderId: appFolderId, name: name),
|
||||
listLocks: () => session.listLockFiles(appFolderId),
|
||||
deleteLock: session.deleteFile,
|
||||
staleAge: staleAge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,33 @@ import 'dart:io';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'db_schema.dart';
|
||||
|
||||
const dbFileName = 'fuel_tax_tracker.db';
|
||||
const receiptsFolderName = 'receipts';
|
||||
|
||||
/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in
|
||||
/// the vehicle form doesn't enforce that — so anything outside
|
||||
/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory
|
||||
/// name or cloud folder name, to keep it a safe single path segment (no
|
||||
/// `/`, `..`, etc.).
|
||||
String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
|
||||
|
||||
/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed
|
||||
/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel
|
||||
/// entry's purchase date ([FuelEntry.date]), not when the photo happened to
|
||||
/// be taken or synced.
|
||||
String monthFolderName(DateTime date) =>
|
||||
'${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}';
|
||||
|
||||
/// Name of the *local* on-device staging folder for not-yet-uploaded
|
||||
/// receipt photos — distinct from (though coincidentally the same string
|
||||
/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which
|
||||
/// names the corresponding subfolder inside the shared cloud app folder.
|
||||
const localReceiptsFolderName = 'receipts';
|
||||
|
||||
/// Owns the local SQLite database (vehicles + fuel_entries) and the
|
||||
/// `receipts/` folder of not-yet-uploaded receipt photos, both under
|
||||
|
|
@ -18,9 +38,9 @@ const receiptsFolderName = 'receipts';
|
|||
/// Every row carries `updated_at` (for newest-wins merging), `deleted_at`
|
||||
/// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt]
|
||||
/// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not
|
||||
/// yet pushed to Drive"). `dirty` is intentionally not exposed on the
|
||||
/// yet pushed to the cloud"). `dirty` is intentionally not exposed on the
|
||||
/// domain model classes — it's sync bookkeeping the UI layer never needs to
|
||||
/// know about; only [DriveSyncService] reads/clears it.
|
||||
/// know about; only [CloudSyncService] reads/clears it.
|
||||
class DatabaseService {
|
||||
late Directory _rootDirectory;
|
||||
late Database _db;
|
||||
|
|
@ -28,9 +48,9 @@ class DatabaseService {
|
|||
Directory get rootDirectory => _rootDirectory;
|
||||
|
||||
Directory get receiptsDirectory =>
|
||||
Directory(p.join(_rootDirectory.path, receiptsFolderName));
|
||||
Directory(p.join(_rootDirectory.path, localReceiptsFolderName));
|
||||
|
||||
/// Raw handle for [DriveSyncService], which needs to run ATTACH-based
|
||||
/// Raw handle for [CloudSyncService], which needs to run ATTACH-based
|
||||
/// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD.
|
||||
Database get rawDb => _db;
|
||||
|
||||
|
|
@ -43,7 +63,12 @@ class DatabaseService {
|
|||
await receiptsDirectory.create(recursive: true);
|
||||
|
||||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
||||
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 4,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
@ -52,6 +77,76 @@ class DatabaseService {
|
|||
await db.execute(createFuelEntriesIndexSql);
|
||||
}
|
||||
|
||||
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
|
||||
/// rebuilt fresh on upgrade rather than migrated, since at the time that
|
||||
/// only affected local, not-yet-synced test data. Version 4 (VIN becomes
|
||||
/// editable, with a new hidden `id` taking over as the actual primary
|
||||
/// key/merge key) is the first schema change made after real usage had
|
||||
/// likely accumulated, so this one preserves existing rows instead of
|
||||
/// dropping them: each vehicle gets a freshly generated `id`, and
|
||||
/// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via
|
||||
/// that mapping.
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 4) {
|
||||
await _migrateToVehicleIdSchema(db);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
||||
const uuid = Uuid();
|
||||
|
||||
final oldVehicles = await db.query('vehicles');
|
||||
final vinToId = <String, String>{};
|
||||
|
||||
await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old');
|
||||
await db.execute(createVehiclesTableSql);
|
||||
for (final row in oldVehicles) {
|
||||
final vin = row['vin'] as String;
|
||||
// An already-upgraded-then-reverted-then-upgraded-again edge case
|
||||
// could in theory produce duplicate vin rows pre-migration; keep the
|
||||
// first id assigned per vin so fuel_entries below has a single,
|
||||
// unambiguous target.
|
||||
final id = vinToId.putIfAbsent(vin, uuid.v4);
|
||||
await db.insert('vehicles', {
|
||||
'id': id,
|
||||
'vin': vin,
|
||||
'nickname': row['nickname'],
|
||||
'updated_at': row['updated_at'],
|
||||
'deleted_at': row['deleted_at'],
|
||||
// Force a re-push under the new schema — the shape of what's on
|
||||
// the cloud (if anything's been synced yet) still reflects the old
|
||||
// schema and needs to be overwritten with this one.
|
||||
'dirty': 1,
|
||||
});
|
||||
}
|
||||
await db.execute('DROP TABLE vehicles_old');
|
||||
|
||||
final oldFuelEntries = await db.query('fuel_entries');
|
||||
await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old');
|
||||
await db.execute(createFuelEntriesTableSql);
|
||||
await db.execute(createFuelEntriesIndexSql);
|
||||
for (final row in oldFuelEntries) {
|
||||
final vehicleId = vinToId[row['vehicle_vin'] as String];
|
||||
// No matching vehicle row (shouldn't normally happen) — drop rather
|
||||
// than insert a fuel entry with a dangling reference.
|
||||
if (vehicleId == null) continue;
|
||||
await db.insert('fuel_entries', {
|
||||
'id': row['id'],
|
||||
'vehicle_id': vehicleId,
|
||||
'date': row['date'],
|
||||
'gallons': row['gallons'],
|
||||
'price_per_gallon': row['price_per_gallon'],
|
||||
'total_cost': row['total_cost'],
|
||||
'receipt_image_path': row['receipt_image_path'],
|
||||
'receipt_drive_file_id': row['receipt_drive_file_id'],
|
||||
'updated_at': row['updated_at'],
|
||||
'deleted_at': row['deleted_at'],
|
||||
'dirty': 1,
|
||||
});
|
||||
}
|
||||
await db.execute('DROP TABLE fuel_entries_old');
|
||||
}
|
||||
|
||||
Future<List<Vehicle>> getVehicles() async {
|
||||
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
|
||||
return rows.map(Vehicle.fromMap).toList();
|
||||
|
|
@ -62,6 +157,27 @@ class DatabaseService {
|
|||
return rows.map(FuelEntry.fromMap).toList();
|
||||
}
|
||||
|
||||
/// True if an active (non-deleted) vehicle other than [excludeId] already
|
||||
/// has this VIN. VIN must stay unique even though it's editable, so
|
||||
/// callers adding a new vehicle (no [excludeId]) or changing an existing
|
||||
/// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it
|
||||
/// doesn't collide with itself) should check this first.
|
||||
Future<bool> vinExists(String vin, {String? excludeId}) async {
|
||||
final where = StringBuffer('vin = ? AND deleted_at IS NULL');
|
||||
final whereArgs = <Object?>[vin];
|
||||
if (excludeId != null) {
|
||||
where.write(' AND id != ?');
|
||||
whereArgs.add(excludeId);
|
||||
}
|
||||
final rows = await _db.query(
|
||||
'vehicles',
|
||||
where: where.toString(),
|
||||
whereArgs: whereArgs,
|
||||
limit: 1,
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Inserts or fully overwrites a vehicle row and marks it dirty (pending
|
||||
/// push to Drive).
|
||||
Future<void> saveVehicle(Vehicle vehicle) async {
|
||||
|
|
@ -129,9 +245,24 @@ class DatabaseService {
|
|||
return localPaths;
|
||||
}
|
||||
|
||||
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
|
||||
/// Stores under `receipts/<vin>/<yyyy.mm>/`, mirroring the per-vehicle,
|
||||
/// per-month folder structure used on the cloud side (see
|
||||
/// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local
|
||||
/// staging path already shows which vehicle and month it belongs to.
|
||||
Future<String> storeReceiptImage(
|
||||
File sourceImage,
|
||||
String fuelEntryId,
|
||||
String vin,
|
||||
DateTime date,
|
||||
) async {
|
||||
final ext = p.extension(sourceImage.path);
|
||||
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
|
||||
final entryDir = Directory(p.join(
|
||||
receiptsDirectory.path,
|
||||
sanitizedPathSegment(vin),
|
||||
monthFolderName(date),
|
||||
));
|
||||
await entryDir.create(recursive: true);
|
||||
final destPath = p.join(entryDir.path, '$fuelEntryId$ext');
|
||||
final copied = await sourceImage.copy(destPath);
|
||||
return copied.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
const createVehiclesTableSql = '''
|
||||
CREATE TABLE vehicles (
|
||||
id TEXT PRIMARY KEY,
|
||||
make TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
license_plate TEXT NOT NULL,
|
||||
vin TEXT NOT NULL,
|
||||
nickname TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
|
|
@ -30,15 +28,33 @@ const createFuelEntriesTableSql = '''
|
|||
const createFuelEntriesIndexSql =
|
||||
'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)';
|
||||
|
||||
/// Selects fuel entries whose receipt photo still needs uploading to
|
||||
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
|
||||
/// "has a local path": once a row is uploaded, its local copy may still be
|
||||
/// kept around (see the "keep photos on this phone" setting) — matching on
|
||||
/// the local path alone would re-upload that same photo as a duplicate
|
||||
/// Drive file on every subsequent sync.
|
||||
const pendingReceiptUploadWhereClause = 'dirty = 1 AND receipt_image_path IS NOT NULL '
|
||||
'AND receipt_drive_file_id IS NULL AND deleted_at IS NULL';
|
||||
|
||||
/// Merges attached `remote_db` rows into `main` (the live local database):
|
||||
/// any remote row that's new to us, or newer than our copy, replaces ours.
|
||||
/// Rows this doesn't touch — including our own not-yet-pushed edits — are
|
||||
/// left alone, since the WHERE clause only matches rows remote should win;
|
||||
/// no separate "keep local" statement is needed.
|
||||
///
|
||||
/// Merges on `id` (the hidden, immutable identifier), not `vin` — VIN is
|
||||
/// user-editable, so it can't be relied on as a stable merge key. VIN
|
||||
/// uniqueness among active vehicles is enforced at the app layer instead
|
||||
/// (see `DatabaseService.vinExists`), not by a database constraint here,
|
||||
/// since two devices could in principle each independently add a vehicle
|
||||
/// with the same VIN while offline; that's an accepted rare-conflict edge
|
||||
/// case (see the "field-level conflicts aren't merged" caveat in the
|
||||
/// README) rather than something this merge statement tries to resolve.
|
||||
const mergeVehiclesSql = '''
|
||||
INSERT OR REPLACE INTO main.vehicles
|
||||
(id, make, model, color, license_plate, updated_at, deleted_at, dirty)
|
||||
SELECT r.id, r.make, r.model, r.color, r.license_plate, r.updated_at, r.deleted_at, 0
|
||||
(id, vin, nickname, updated_at, deleted_at, dirty)
|
||||
SELECT r.id, r.vin, r.nickname, r.updated_at, r.deleted_at, 0
|
||||
FROM remote_db.vehicles r
|
||||
LEFT JOIN main.vehicles l ON l.id = r.id
|
||||
WHERE l.id IS NULL OR r.updated_at > l.updated_at
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'drive_oauth_config.dart';
|
||||
|
||||
/// Full Drive access is required (not the narrower `drive.file` scope)
|
||||
/// because users need to browse to and reuse folders that someone else
|
||||
/// created and shared with them, not just folders/files this app itself
|
||||
/// created. See the plan doc for the tradeoffs (this requires Google
|
||||
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
|
||||
/// a full OAuth verification review).
|
||||
const driveScopes = <String>['https://www.googleapis.com/auth/drive'];
|
||||
|
||||
/// Thrown when a Drive API call needs authorization that isn't currently
|
||||
/// available without prompting the user, e.g. during a background sync.
|
||||
class DriveNotAuthorizedException implements Exception {
|
||||
@override
|
||||
String toString() => 'Drive access is not currently authorized.';
|
||||
}
|
||||
|
||||
/// Wraps `google_sign_in` for authenticating with Google and producing an
|
||||
/// authenticated [http.Client] for the Drive API.
|
||||
class DriveAuthService {
|
||||
bool _initialized = false;
|
||||
GoogleSignInAccount? _account;
|
||||
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
String? get currentAccountEmail => _account?.email;
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
await GoogleSignIn.instance.initialize(
|
||||
clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null,
|
||||
serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null,
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Attempts to restore a previous sign-in without any UI. Returns true if
|
||||
/// the user is signed in and Drive access is already authorized.
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
|
||||
_account = account;
|
||||
if (account == null) return false;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient.authorizationForScopes(driveScopes);
|
||||
return authorization != null;
|
||||
}
|
||||
|
||||
/// Interactive sign-in + Drive scope authorization. Must be called from a
|
||||
/// user-initiated action (e.g. a button press).
|
||||
Future<String> signIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes);
|
||||
_account = account;
|
||||
await account.authorizationClient.authorizeScopes(driveScopes);
|
||||
return account.email;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
_account = null;
|
||||
}
|
||||
|
||||
/// Builds an [http.Client] that attaches a fresh Drive authorization
|
||||
/// header to every request. Fetches headers per-request (rather than
|
||||
/// once) so a client that lives across a long sync doesn't use a stale,
|
||||
/// expired token. Never prompts for UI — suitable for background sync —
|
||||
/// so throws [DriveNotAuthorizedException] if authorization isn't already
|
||||
/// in place (the caller should treat that as "Drive is disconnected").
|
||||
http.Client authenticatedHttpClient() {
|
||||
final account = _account;
|
||||
if (account == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
return _DriveHttpClient(account.authorizationClient);
|
||||
}
|
||||
}
|
||||
|
||||
class _DriveHttpClient extends http.BaseClient {
|
||||
final GoogleSignInAuthorizationClient _authClient;
|
||||
final http.Client _inner = http.Client();
|
||||
|
||||
_DriveHttpClient(this._authClient);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final headers =
|
||||
await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false);
|
||||
if (headers == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
request.headers.addAll(headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/// Fill these in once the corresponding OAuth clients exist in Google Cloud
|
||||
/// Console (see README.md "Manual setup required"). Both are needed even
|
||||
/// though only one app runs on each platform:
|
||||
///
|
||||
/// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
|
||||
/// authenticates using a *Web application* type OAuth client's ID, not the
|
||||
/// Android client's own ID. The separate Android OAuth client (registered
|
||||
/// with the package name + debug/release SHA-1) is still required, but
|
||||
/// only to let Credential Manager verify this specific signed app — its
|
||||
/// client ID itself is never referenced here.
|
||||
/// - iOS uses its own iOS-type OAuth client ID directly.
|
||||
class DriveOAuthConfig {
|
||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||
/// Android.
|
||||
static const String? androidServerClientId = null; // TODO: fill in
|
||||
|
||||
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||
/// directly in ios/Runner/Info.plist.
|
||||
static const String? iosClientId = null; // TODO: fill in
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:googleapis/drive/v3.dart' as drive;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_tracker.db';
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
class DriveFolder {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
DriveFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class DriveDataFileInfo {
|
||||
final String id;
|
||||
|
||||
/// Cheap change-detection signal: compare against the last value seen to
|
||||
/// decide whether a pull is actually needed, without downloading content.
|
||||
final String? md5Checksum;
|
||||
|
||||
DriveDataFileInfo({required this.id, required this.md5Checksum});
|
||||
}
|
||||
|
||||
class DriveLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
DriveLockFile({required this.id, required this.username, required this.createdAtUtc});
|
||||
}
|
||||
|
||||
/// Raw Google Drive API operations, built on top of an already-authenticated
|
||||
/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers
|
||||
/// own the client's lifecycle (create it, use a [DriveService] instance for
|
||||
/// the duration of one sync, then close it).
|
||||
class DriveService {
|
||||
final drive.DriveApi _api;
|
||||
|
||||
DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient);
|
||||
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true) the
|
||||
/// top-level folders that other users have shared with the signed-in
|
||||
/// account, regardless of parent.
|
||||
Future<List<DriveFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final query = sharedWithMe
|
||||
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
|
||||
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
|
||||
|
||||
final result = await _api.files.list(
|
||||
q: query,
|
||||
orderBy: 'name',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
return (result.files ?? [])
|
||||
.where((f) => f.id != null && f.name != null)
|
||||
.map((f) => DriveFolder(id: f.id!, name: f.name!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Finds a folder named [appFolderName] under [parentId], or creates one
|
||||
/// if none exists. Multiple devices pointed at the same shared parent
|
||||
/// converge on the same folder this way. If duplicates exist (Drive
|
||||
/// allows same-named folders), the earliest-created one wins.
|
||||
Future<String> findOrCreateAppFolder(String parentId) {
|
||||
return _findOrCreateFolder(name: appFolderName, parentId: parentId);
|
||||
}
|
||||
|
||||
Future<String> findOrCreateReceiptsFolder(String appFolderId) {
|
||||
return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId);
|
||||
}
|
||||
|
||||
Future<String> _findOrCreateFolder({required String name, required String parentId}) async {
|
||||
final existing = await _api.files.list(
|
||||
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
|
||||
orderBy: 'createdTime',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final firstMatch = (existing.files ?? []).firstOrNullWithId();
|
||||
if (firstMatch != null) return firstMatch;
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..mimeType = _folderMimeType
|
||||
..parents = [parentId],
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
/// Looks up the shared data file's ID and md5 checksum without
|
||||
/// downloading its content, so sync can cheaply decide whether a pull is
|
||||
/// actually needed.
|
||||
Future<DriveDataFileInfo?> findDataFile(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name,md5Checksum)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
final files = result.files ?? <drive.File>[];
|
||||
if (files.isEmpty) return null;
|
||||
final first = files.first;
|
||||
if (first.id == null) return null;
|
||||
return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
|
||||
}
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
return _collectBytes(media.stream);
|
||||
}
|
||||
|
||||
/// Creates the data file if [existingFileId] is null, otherwise
|
||||
/// overwrites its content with the bytes of the local sqlite database
|
||||
/// file (see [DriveSyncService]). Returns the (possibly new) file ID and
|
||||
/// its fresh md5 checksum, so the caller can remember it for
|
||||
/// change-detection on the next sync without an extra round-trip.
|
||||
Future<DriveDataFileInfo> uploadDataFile({
|
||||
required String appFolderId,
|
||||
required String? existingFileId,
|
||||
required File localSnapshotFile,
|
||||
}) async {
|
||||
final length = await localSnapshotFile.length();
|
||||
final media = drive.Media(
|
||||
localSnapshotFile.openRead(),
|
||||
length,
|
||||
contentType: 'application/x-sqlite3',
|
||||
);
|
||||
|
||||
if (existingFileId != null) {
|
||||
final updated = await _api.files.update(
|
||||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum);
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = dataFileName
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum);
|
||||
}
|
||||
|
||||
Future<String> uploadReceiptImage({
|
||||
required String receiptsFolderId,
|
||||
required String fileName,
|
||||
required File imageFile,
|
||||
}) async {
|
||||
final length = await imageFile.length();
|
||||
final media = drive.Media(
|
||||
imageFile.openRead(),
|
||||
length,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = fileName
|
||||
..parents = [receiptsFolderId],
|
||||
uploadMedia: media,
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
try {
|
||||
await _api.files.delete(fileId);
|
||||
} on drive.DetailedApiRequestError catch (e) {
|
||||
// Already gone (e.g. deleted by another device) — not an error for
|
||||
// our purposes.
|
||||
if (e.status != 404) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createLockFile({required String appFolderId, required String name}) async {
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<List<DriveLockFile>> listLockFiles(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name contains '.lock'",
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final locks = <DriveLockFile>[];
|
||||
for (final f in result.files ?? <drive.File>[]) {
|
||||
final parsed = _parseLockFileName(f.name);
|
||||
if (f.id != null && parsed != null) {
|
||||
locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
static (String, DateTime)? _parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
|
||||
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstMatchExtension on List<drive.File> {
|
||||
String? firstOrNullWithId() => isEmpty ? null : first.id;
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'database_service.dart';
|
||||
import 'db_schema.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_service.dart';
|
||||
import 'lock_coordinator.dart' as lock;
|
||||
|
||||
class SyncResult {
|
||||
final bool ranSync;
|
||||
final Object? error;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
error = null;
|
||||
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of sync against the shared Drive folder:
|
||||
/// acquires the cross-device lock, pulls + merges the remote database if
|
||||
/// it changed, uploads any pending receipt photos, pushes the local
|
||||
/// database back up, then releases the lock.
|
||||
///
|
||||
/// The merge itself runs as SQL directly against the local database with
|
||||
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
|
||||
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
|
||||
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
|
||||
/// touched (including our own not-yet-pushed edits) are left alone by that
|
||||
/// statement, so no separate "keep local" step is needed — see the README
|
||||
/// for the full reasoning.
|
||||
class DriveSyncService {
|
||||
final DriveAuthService authService;
|
||||
final DatabaseService databaseService;
|
||||
|
||||
String? _appFolderId;
|
||||
String? _receiptsFolderId;
|
||||
String? _dataFileId;
|
||||
String? _lastKnownRemoteMd5;
|
||||
|
||||
DriveSyncService({required this.authService, required this.databaseService});
|
||||
|
||||
bool get isConfigured => _appFolderId != null;
|
||||
|
||||
/// Call once a Drive app folder has been chosen (or restored at launch).
|
||||
void configure(String appFolderId) {
|
||||
_appFolderId = appFolderId;
|
||||
_receiptsFolderId = null;
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteMd5 = null;
|
||||
}
|
||||
|
||||
void clearConfiguration() {
|
||||
_appFolderId = null;
|
||||
_receiptsFolderId = null;
|
||||
_dataFileId = null;
|
||||
_lastKnownRemoteMd5 = null;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
|
||||
/// folder the user picked in the Drive folder browser) and configures
|
||||
/// this service to use it. Returns the resulting folder ID.
|
||||
Future<String> selectAppFolder(String parentId) async {
|
||||
final client = authService.authenticatedHttpClient();
|
||||
try {
|
||||
final drive = DriveService(client);
|
||||
final folderId = await drive.findOrCreateAppFolder(parentId);
|
||||
configure(folderId);
|
||||
return folderId;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<SyncResult> syncNow() async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!authService.isSignedIn || appFolderId == null) {
|
||||
return SyncResult.skipped();
|
||||
}
|
||||
|
||||
http.Client? client;
|
||||
DriveService? drive;
|
||||
String? lockFileId;
|
||||
|
||||
try {
|
||||
client = authService.authenticatedHttpClient();
|
||||
drive = DriveService(client);
|
||||
|
||||
lockFileId = await _acquireLock(
|
||||
drive,
|
||||
appFolderId: appFolderId,
|
||||
username: authService.currentAccountEmail!,
|
||||
);
|
||||
|
||||
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
|
||||
|
||||
final remoteInfo = await drive.findDataFile(appFolderId);
|
||||
_dataFileId = remoteInfo?.id;
|
||||
if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) {
|
||||
await _pullAndMerge(drive, remoteInfo.id);
|
||||
}
|
||||
|
||||
final allReceiptsUploaded = await _uploadPendingReceipts(drive);
|
||||
|
||||
// Only push if every pending receipt made it up — otherwise we'd
|
||||
// either upload a row with a local-only file path (meaningless on
|
||||
// another device) or prematurely mark it clean.
|
||||
if (allReceiptsUploaded) {
|
||||
final pushedInfo = await _pushSnapshot(drive, appFolderId);
|
||||
_lastKnownRemoteMd5 = pushedInfo.md5Checksum;
|
||||
}
|
||||
|
||||
return SyncResult.success();
|
||||
} catch (e) {
|
||||
return SyncResult.failure(e);
|
||||
} finally {
|
||||
if (lockFileId != null && drive != null) {
|
||||
try {
|
||||
await drive.deleteFile(lockFileId);
|
||||
} catch (_) {
|
||||
// Best-effort: if this fails, the 10-minute staleness reap on
|
||||
// other devices' next sync attempt will clean it up.
|
||||
}
|
||||
}
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pullAndMerge(DriveService drive, String remoteFileId) async {
|
||||
final bytes = await drive.downloadFileBytes(remoteFileId);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath =
|
||||
p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db');
|
||||
final tempFile = File(tempPath);
|
||||
await tempFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
try {
|
||||
final db = databaseService.rawDb;
|
||||
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
|
||||
try {
|
||||
await db.execute(mergeVehiclesSql);
|
||||
await db.execute(mergeFuelEntriesSql);
|
||||
} finally {
|
||||
try {
|
||||
await db.execute('DETACH DATABASE remote_db');
|
||||
} catch (_) {
|
||||
// Don't let a detach failure mask a real merge error above.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads any locally-pending receipt images (dirty fuel entries that
|
||||
/// still have a local path, not yet a Drive file ID). Returns true only
|
||||
/// if every pending image made it up — see [syncNow] for why a partial
|
||||
/// failure here blocks the push step entirely rather than pushing
|
||||
/// something with a leftover local path.
|
||||
Future<bool> _uploadPendingReceipts(DriveService drive) async {
|
||||
final db = databaseService.rawDb;
|
||||
final rows = await db.query(
|
||||
'fuel_entries',
|
||||
where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL',
|
||||
);
|
||||
|
||||
var allSucceeded = true;
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
final localPath = row['receipt_image_path'] as String;
|
||||
final localFile = File(localPath);
|
||||
|
||||
if (!await localFile.exists()) {
|
||||
// Nothing left to upload; clear the dangling reference.
|
||||
await db.update('fuel_entries', {'receipt_image_path': null},
|
||||
where: 'id = ?', whereArgs: [id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final driveFileId = await drive.uploadReceiptImage(
|
||||
receiptsFolderId: _receiptsFolderId!,
|
||||
fileName: '$id${p.extension(localPath)}',
|
||||
imageFile: localFile,
|
||||
);
|
||||
await databaseService.deleteReceiptImageFile(localPath);
|
||||
await db.update(
|
||||
'fuel_entries',
|
||||
{'receipt_drive_file_id': driveFileId, 'receipt_image_path': null},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
} catch (_) {
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
return allSucceeded;
|
||||
}
|
||||
|
||||
/// Uploads the current local database file as the new remote copy, then
|
||||
/// clears the dirty flag on every row now that local matches Drive.
|
||||
///
|
||||
/// Reads the live database file directly rather than a `VACUUM INTO`
|
||||
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
|
||||
/// WAL) unless explicitly configured otherwise, so the main file is a
|
||||
/// complete, valid database as soon as the last write's Future
|
||||
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
|
||||
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
|
||||
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
|
||||
Future<DriveDataFileInfo> _pushSnapshot(DriveService drive, String appFolderId) async {
|
||||
final db = databaseService.rawDb;
|
||||
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
|
||||
final info = await drive.uploadDataFile(
|
||||
appFolderId: appFolderId,
|
||||
existingFileId: _dataFileId,
|
||||
localSnapshotFile: File(databaseService.databasePath),
|
||||
);
|
||||
_dataFileId = info.id;
|
||||
|
||||
await db.update('vehicles', {'dirty': 0});
|
||||
await db.update('fuel_entries', {'dirty': 0});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
|
||||
|
||||
Future<String> _acquireLock(
|
||||
DriveService drive, {
|
||||
required String appFolderId,
|
||||
required String username,
|
||||
}) {
|
||||
return lock.acquireLock(
|
||||
username: username,
|
||||
createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name),
|
||||
listLocks: () => drive.listLockFiles(appFolderId),
|
||||
deleteLock: drive.deleteFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'drive_service.dart' show DriveLockFile;
|
||||
import 'cloud/cloud_storage_provider.dart' show CloudLockFile;
|
||||
|
||||
/// Ticket-based mutex using timestamped lock files as the coordination
|
||||
/// point: create a lock named after our own timestamp, then wait until no
|
||||
|
|
@ -7,12 +7,13 @@ import 'drive_service.dart' show DriveLockFile;
|
|||
/// device that crashed/went offline before releasing its own lock.
|
||||
///
|
||||
/// Pure orchestration over injected operations (rather than a concrete
|
||||
/// Drive dependency) so the state machine is unit-testable without a real
|
||||
/// network connection.
|
||||
/// cloud storage dependency) so the state machine is unit-testable without
|
||||
/// a real network connection, and works identically no matter which
|
||||
/// [CloudStorageProvider] it's wired to.
|
||||
Future<String> acquireLock({
|
||||
required String username,
|
||||
required Future<String> Function(String lockName) createLock,
|
||||
required Future<List<DriveLockFile>> Function() listLocks,
|
||||
required Future<List<CloudLockFile>> Function() listLocks,
|
||||
required Future<void> Function(String lockId) deleteLock,
|
||||
DateTime Function()? nowUtc,
|
||||
Future<void> Function(Duration)? delay,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,25 @@
|
|||
/// Two-letter USPS abbreviation to full name, for the 50 states + DC —
|
||||
/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular
|
||||
/// shows up on real receipts as a "Visa" abbreviation, and including it as
|
||||
/// a valid state code would misfire on that.
|
||||
const usStateNames = <String, String>{
|
||||
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
|
||||
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
|
||||
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
|
||||
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
|
||||
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
|
||||
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota',
|
||||
'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska',
|
||||
'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
|
||||
'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina',
|
||||
'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon',
|
||||
'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
|
||||
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
|
||||
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington',
|
||||
'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
|
||||
'DC': 'District of Columbia',
|
||||
};
|
||||
|
||||
/// Best-effort values pulled out of OCR'd receipt text. Any field can be
|
||||
/// null if it couldn't be found, and the confirm screen lets the user fill
|
||||
/// in or correct whatever the parser got wrong.
|
||||
|
|
@ -5,12 +27,25 @@ class ParsedReceipt {
|
|||
final double? gallons;
|
||||
final double? pricePerGallon;
|
||||
final double? totalCost;
|
||||
|
||||
/// The transaction date/time printed on the receipt, if found. Null
|
||||
/// means the confirm screen should fall back to manual entry (it
|
||||
/// defaults to "now" and lets the user pick a different date/time).
|
||||
final DateTime? date;
|
||||
|
||||
/// Two-letter state abbreviation of the station's address, if found
|
||||
/// (e.g. "MO", "TX"). Null means no state could be confidently
|
||||
/// identified — callers should treat that as "unknown", not "Missouri".
|
||||
final String? state;
|
||||
|
||||
final String rawText;
|
||||
|
||||
ParsedReceipt({
|
||||
this.gallons,
|
||||
this.pricePerGallon,
|
||||
this.totalCost,
|
||||
this.date,
|
||||
this.state,
|
||||
required this.rawText,
|
||||
});
|
||||
}
|
||||
|
|
@ -23,36 +58,91 @@ class ParsedReceipt {
|
|||
/// to deriving a missing value from the other two when exactly one is
|
||||
/// missing (total = gallons * price, etc).
|
||||
class ReceiptParser {
|
||||
// These use [ \t]* rather than \s* between a label and its value: \s
|
||||
// matches newlines too, which let a number on one line match a
|
||||
// completely unrelated label several lines further down (e.g. a price
|
||||
// value followed, many lines later, by an incidental "Gallons" heading
|
||||
// for a different column) — a real bug this surfaced against an actual
|
||||
// receipt where "$1.999" ended up matching "...Gallons" two lines below
|
||||
// it. A label and its own value are always on the same line.
|
||||
static final _gallonsPatterns = [
|
||||
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
|
||||
RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false),
|
||||
];
|
||||
|
||||
static final _pricePerGallonPatterns = [
|
||||
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
||||
RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||
RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
||||
caseSensitive: false),
|
||||
];
|
||||
|
||||
static final _totalPatterns = [
|
||||
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
|
||||
caseSensitive: false),
|
||||
RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
// Allows for words between TOTAL and the amount ("Total Sale $120.72",
|
||||
// "Fuel Total: $44.44"), not just a bare colon/dash.
|
||||
RegExp(r'(?<!SUB)\bTOTAL\b[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
||||
];
|
||||
|
||||
/// Matches a bare or $-prefixed 3-decimal number anywhere in the text.
|
||||
/// US fuel receipts overwhelmingly print both gallons pumped and price
|
||||
/// per gallon with exactly 3 decimal places, and only the price gets a
|
||||
/// currency symbol — a much more reliable signal than the label text
|
||||
/// itself, which varies a lot and is often split from its value onto a
|
||||
/// different line by a tabular "Pump / Gallons / Price" header row (in
|
||||
/// which case the label-based patterns above never match at all).
|
||||
///
|
||||
/// Uses `(?!\d)` rather than `\b` after the number: some receipts print
|
||||
/// the unit directly attached with no space ("17.364G"), and a digit
|
||||
/// followed by a letter is *not* a `\b` boundary, so `\b` would miss it.
|
||||
/// `(?<!-)` excludes negative amounts (e.g. a per-gallon discount line
|
||||
/// like "Debit Di/GAL $-0.100"), which are never a real gallons/price
|
||||
/// value and would otherwise get matched as one.
|
||||
static final _threeDecimalNumberPattern = RegExp(r'(?<!-)(\$)?\s*(\d+\.\d{3})(?!\d)');
|
||||
|
||||
// Matches MM/DD/YYYY, MM-DD-YY, and similar — US gas receipts are
|
||||
// consistent about digit-separator-digit-separator-digit ordering even
|
||||
// though the separator (/ or -) and year length (2 or 4 digits) vary.
|
||||
static final _datePattern = RegExp(r'\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b');
|
||||
|
||||
// Optional AM/PM, optional seconds — covers "02:21", "10:11:00 AM", and
|
||||
// "02:11PM" (no space before the meridiem) all at once.
|
||||
static final _timePattern = RegExp(r'\b(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\b');
|
||||
|
||||
// A two-letter code directly followed by a ZIP is by far the strongest
|
||||
// signal an address block gives us (very low false-positive rate); a
|
||||
// code right after a comma is a weaker but still decent fallback for
|
||||
// receipts that print "City, ST" without a visible ZIP alongside it.
|
||||
// Both require the candidate to be checked against [usStateNames] before
|
||||
// being trusted — a bare 2-letter scan alone would misfire constantly
|
||||
// (e.g. "VI" for Visa, "IN" as the word "in", "OR" as the word "or").
|
||||
static final _stateBeforeZipPattern = RegExp(r'\b([A-Z]{2})\s+\d{5}(?:-\d{4})?\b');
|
||||
static final _stateAfterCommaPattern = RegExp(r',\s*([A-Z]{2})\b');
|
||||
|
||||
static ParsedReceipt parse(String text) {
|
||||
final normalized = text.replaceAll(',', '');
|
||||
|
||||
final gallons = _firstMatch(_gallonsPatterns, normalized);
|
||||
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
||||
var totalCost = _firstMatch(_totalPatterns, normalized);
|
||||
var gallons = _firstMatch(_gallonsPatterns, normalized);
|
||||
var pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
||||
final totalCost = _firstMatch(_totalPatterns, normalized);
|
||||
|
||||
if (gallons == null || pricePerGallon == null) {
|
||||
for (final match in _threeDecimalNumberPattern.allMatches(normalized)) {
|
||||
final value = double.tryParse(match.group(2)!);
|
||||
if (value == null) continue;
|
||||
final hasDollarSign = match.group(1) != null;
|
||||
if (hasDollarSign) {
|
||||
pricePerGallon ??= value;
|
||||
} else {
|
||||
gallons ??= value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final derivedTotal = _deriveMissingValue(
|
||||
gallons: gallons,
|
||||
|
|
@ -64,10 +154,76 @@ class ReceiptParser {
|
|||
gallons: derivedTotal.gallons,
|
||||
pricePerGallon: derivedTotal.pricePerGallon,
|
||||
totalCost: derivedTotal.totalCost,
|
||||
date: _parseDate(normalized),
|
||||
// Uses the original text, not the comma-stripped `normalized` copy —
|
||||
// the comma-adjacency fallback pattern needs commas intact.
|
||||
state: _parseState(text),
|
||||
rawText: text,
|
||||
);
|
||||
}
|
||||
|
||||
/// Looks for a US state abbreviation in the station's address block. Only
|
||||
/// trusts a candidate that's both a plausible address position (right
|
||||
/// before a ZIP, or right after a comma) *and* a real state code — see
|
||||
/// [usStateNames] and the patterns above for why both checks matter.
|
||||
static String? _parseState(String text) {
|
||||
final zipMatch = _stateBeforeZipPattern.firstMatch(text);
|
||||
if (zipMatch != null) {
|
||||
final code = zipMatch.group(1)!.toUpperCase();
|
||||
if (usStateNames.containsKey(code)) return code;
|
||||
}
|
||||
|
||||
final commaMatch = _stateAfterCommaPattern.firstMatch(text);
|
||||
if (commaMatch != null) {
|
||||
final code = commaMatch.group(1)!.toUpperCase();
|
||||
if (usStateNames.containsKey(code)) return code;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Finds a date on the receipt and, if a time is printed nearby (same
|
||||
/// line — within a short character window right after the date, since
|
||||
/// receipts almost always print them adjacent, e.g. "DATE 3/26/22
|
||||
/// 18:12" or "Date: ...\nTime: ..."), combines them. Falls back to
|
||||
/// midnight if no time is found, and to null (manual entry) if no date
|
||||
/// is found at all.
|
||||
static DateTime? _parseDate(String text) {
|
||||
final dateMatch = _datePattern.firstMatch(text);
|
||||
if (dateMatch == null) return null;
|
||||
|
||||
final month = int.tryParse(dateMatch.group(1)!);
|
||||
final day = int.tryParse(dateMatch.group(2)!);
|
||||
var year = int.tryParse(dateMatch.group(3)!);
|
||||
if (month == null || day == null || year == null) return null;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
if (year < 100) year += 2000;
|
||||
|
||||
final windowEnd = (dateMatch.end + 40).clamp(0, text.length);
|
||||
final nearbyText = text.substring(dateMatch.end, windowEnd);
|
||||
final timeMatch = _timePattern.firstMatch(nearbyText);
|
||||
|
||||
var hour = 0;
|
||||
var minute = 0;
|
||||
if (timeMatch != null) {
|
||||
hour = int.tryParse(timeMatch.group(1)!) ?? 0;
|
||||
minute = int.tryParse(timeMatch.group(2)!) ?? 0;
|
||||
final meridiem = timeMatch.group(3)?.toUpperCase();
|
||||
if (meridiem == 'PM' && hour != 12) hour += 12;
|
||||
if (meridiem == 'AM' && hour == 12) hour = 0;
|
||||
if (hour > 23 || minute > 59) {
|
||||
hour = 0;
|
||||
minute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return DateTime(year, month, day, hour, minute);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double? _firstMatch(List<RegExp> patterns, String text) {
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(text);
|
||||
|
|
|
|||
31
lib/services/vin_parser.dart
Normal file
31
lib/services/vin_parser.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb
|
||||
/// sticker, dashboard plate, title, etc).
|
||||
///
|
||||
/// Real VINs never contain the letters I, O, or Q (they're excluded from
|
||||
/// the standard specifically so they can't be confused with 1 and 0), so
|
||||
/// requiring that charset both matches genuine VINs and rules out a lot of
|
||||
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
|
||||
/// weight ratings, date codes, etc).
|
||||
class VinParser {
|
||||
static final _labeledVinPattern =
|
||||
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
// \b on both sides matters: since digits and letters are both "word"
|
||||
// characters, this only matches a maximal run of exactly 17 eligible
|
||||
// characters — not a 17-character slice out of an 18+ character run.
|
||||
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
|
||||
|
||||
/// Returns the VIN in uppercase, or null if nothing matching the VIN
|
||||
/// charset/length was found. A labeled "VIN: ..." match is preferred
|
||||
/// over a bare 17-character token, in case a busy sticker has more than
|
||||
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
|
||||
static String? parse(String text) {
|
||||
final labeled = _labeledVinPattern.firstMatch(text);
|
||||
if (labeled != null) return labeled.group(1)!.toUpperCase();
|
||||
|
||||
final bare = _bareVinPattern.firstMatch(text);
|
||||
if (bare != null) return bare.group(1)!.toUpperCase();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
28
lib/widgets/image_source_sheet.dart
Normal file
28
lib/widgets/image_source_sheet.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// Bottom sheet letting the user pick a photo from the camera or their
|
||||
/// existing library. Shared by any screen that needs to snap or choose a
|
||||
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
|
||||
/// the same everywhere.
|
||||
Future<ImageSource?> chooseImageSource(BuildContext context) {
|
||||
return showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Take Photo'),
|
||||
onTap: () => Navigator.of(context).pop(ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Choose from Gallery'),
|
||||
onTap: () => Navigator.of(context).pop(ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
168
pubspec.lock
168
pubspec.lock
|
|
@ -90,7 +90,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
@ -113,6 +113,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
desktop_webview_window:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: desktop_webview_window
|
||||
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -129,6 +137,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -198,11 +214,75 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.1"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_auth_2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2_platform_interface
|
||||
sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -561,7 +641,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
|
|
@ -593,7 +673,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
|
|
@ -805,6 +885,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -837,6 +981,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
window_to_front:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: window_to_front
|
||||
sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -846,7 +1006,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
|
|
|
|||
25
pubspec.yaml
25
pubspec.yaml
|
|
@ -74,6 +74,22 @@ dependencies:
|
|||
# Local SQLite database
|
||||
sqflite: ^2.4.1
|
||||
|
||||
# Browser-based OAuth2 (PKCE) redirect capture for Dropbox/OneDrive —
|
||||
# unlike Google, neither has an official Flutter sign-in package.
|
||||
flutter_web_auth_2: ^4.1.0
|
||||
|
||||
# PKCE code_verifier/code_challenge generation (SHA-256 + base64url)
|
||||
crypto: ^3.0.5
|
||||
|
||||
# WebDAV multistatus (PROPFIND) response parsing — for self-hosted
|
||||
# personal cloud servers (Nextcloud, ownCloud, a bare Apache/nginx WebDAV
|
||||
# server, etc).
|
||||
xml: ^6.6.1
|
||||
|
||||
# OS-backed encrypted storage (Keystore/Keychain) for WebDAV credentials,
|
||||
# so sign-in survives a cold app restart instead of living in memory only.
|
||||
flutter_secure_storage: ^10.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
@ -82,6 +98,15 @@ dev_dependencies:
|
|||
# can be unit tested on the Dart VM, without a real Android/iOS device.
|
||||
sqflite_common_ffi: ^2.3.4+4
|
||||
|
||||
# Platform-interface for faking FlutterSecureStoragePlatform.instance in
|
||||
# webdav_provider_credentials_test.dart, same pattern as faking
|
||||
# PathProviderPlatform.instance elsewhere.
|
||||
flutter_secure_storage_platform_interface: ^2.0.3
|
||||
|
||||
# For faking PathProviderPlatform in cloud_sync_service_test.dart
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
|
|
|
|||
196
test/cloud_sync_service_test.dart
Normal file
196
test/cloud_sync_service_test.dart
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
|
||||
import 'package:fuel_tax_tracker/services/database_service.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Confirms [CloudSyncService]'s orchestration (lock → check remote →
|
||||
/// upload → release) works against *any* [CloudStorageProvider], using a
|
||||
/// fake one — coverage for the provider-agnostic logic itself, independent
|
||||
/// of which real backend (Google Drive, Dropbox, OneDrive) it's wired to.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late DatabaseService databaseService;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('cloud_sync_service_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
databaseService = DatabaseService();
|
||||
await databaseService.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await databaseService.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('syncNow acquires a lock, uploads the data file, and releases the lock', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
final result = await syncService.syncNow();
|
||||
|
||||
expect(result.ranSync, isTrue);
|
||||
expect(session.createdLockNames, hasLength(1));
|
||||
expect(session.createdLockNames.first, startsWith('tester@example.com-'));
|
||||
expect(session.uploadedFileNames, contains(dataFileName));
|
||||
expect(session.deletedFileIds, ['lock-id'], reason: 'the lock must be released afterward');
|
||||
});
|
||||
|
||||
test('syncNow uploads a pending receipt into Receipts/<vin>/<yyyy.mm>', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
final db = databaseService.rawDb;
|
||||
await db.insert('vehicles', {
|
||||
'id': 'vehicle-1',
|
||||
'vin': '1FMPU18L1TLB51349',
|
||||
'nickname': null,
|
||||
'updated_at': 0,
|
||||
'deleted_at': null,
|
||||
'dirty': 0,
|
||||
});
|
||||
final receiptFile = File(p.join(tempDir.path, 'receipt.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
await db.insert('fuel_entries', {
|
||||
'id': 'entry-1',
|
||||
'vehicle_id': 'vehicle-1',
|
||||
// Mid-month/mid-year so DateTime.fromMillisecondsSinceEpoch's local
|
||||
// interpretation can't drift into a different month depending on the
|
||||
// machine's timezone (unlike epoch 0, which can land in Dec 1969).
|
||||
'date': DateTime(2026, 3, 15).millisecondsSinceEpoch,
|
||||
'gallons': 10.0,
|
||||
'price_per_gallon': 3.5,
|
||||
'total_cost': 35.0,
|
||||
'receipt_image_path': receiptFile.path,
|
||||
'receipt_drive_file_id': null,
|
||||
'updated_at': 0,
|
||||
'deleted_at': null,
|
||||
'dirty': 1,
|
||||
});
|
||||
|
||||
final result = await syncService.syncNow(keepLocalReceiptCopies: true);
|
||||
|
||||
expect(result.ranSync, isTrue);
|
||||
expect(session.uploadedFileFolders['entry-1.jpg'],
|
||||
'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03');
|
||||
});
|
||||
|
||||
test('syncNow is a no-op when not configured with a folder yet', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
|
||||
final result = await syncService.syncNow();
|
||||
|
||||
expect(result.ranSync, isFalse);
|
||||
expect(session.createdLockNames, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
|
||||
class _FakeSession implements CloudStorageSession {
|
||||
final List<String> createdLockNames = [];
|
||||
final List<String> deletedFileIds = [];
|
||||
final List<String> uploadedFileNames = [];
|
||||
final Map<String, String> uploadedFileFolders = {};
|
||||
CloudFileInfo? existingDataFile;
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async =>
|
||||
'$parentId/$name';
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
return name == dataFileName ? existingDataFile : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async => <int>[];
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
uploadedFileNames.add(name);
|
||||
uploadedFileFolders[name] = folderId;
|
||||
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async => deletedFileIds.add(fileId);
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
createdLockNames.add(name);
|
||||
return 'lock-id';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async => [];
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
class _FakeProvider implements CloudStorageProvider {
|
||||
final _FakeSession session;
|
||||
_FakeProvider(this.session);
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.googleDrive;
|
||||
|
||||
@override
|
||||
String get displayName => 'Fake Provider';
|
||||
|
||||
@override
|
||||
bool get isSignedIn => true;
|
||||
|
||||
@override
|
||||
String? get accountLabel => 'tester@example.com';
|
||||
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async => true;
|
||||
|
||||
@override
|
||||
Future<String> signIn() async => accountLabel!;
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() => session;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import 'package:path/path.dart' as p;
|
|||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs
|
||||
/// (see [db_schema.dart] / DriveSyncService._pullAndMerge), against real
|
||||
/// (see [db_schema.dart] / CloudSyncService._pullAndMerge), against real
|
||||
/// temporary SQLite files via sqflite_common_ffi — the pure-Dart backend
|
||||
/// that lets sqflite run on the Dart VM for tests, without a real device.
|
||||
void main() {
|
||||
|
|
@ -43,18 +43,19 @@ void main() {
|
|||
|
||||
group('vehicle merge', () {
|
||||
test('pulls in a vehicle that only exists on remote', () async {
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000));
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['id'], 'v1');
|
||||
expect(rows.first['vin'], 'VIN1');
|
||||
expect(rows.first['dirty'], 0);
|
||||
});
|
||||
|
||||
test('leaves a local-only dirty vehicle untouched', () async {
|
||||
await local.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000, dirty: 1));
|
||||
await local.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000, dirty: 1));
|
||||
|
||||
await merge();
|
||||
|
||||
|
|
@ -64,30 +65,60 @@ void main() {
|
|||
});
|
||||
|
||||
test('remote wins when strictly newer than local', () async {
|
||||
await local.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', make: 'OldMake', updatedAt: 1000, dirty: 1));
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'NewMake', updatedAt: 2000));
|
||||
await local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'OldNickname', updatedAt: 1000, dirty: 1));
|
||||
await remote.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'NewNickname', updatedAt: 2000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['make'], 'NewMake');
|
||||
expect(rows.first['nickname'], 'NewNickname');
|
||||
expect(rows.first['dirty'], 0);
|
||||
});
|
||||
|
||||
test('local wins on a tie or when strictly newer', () async {
|
||||
await local.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', make: 'MineNewer', updatedAt: 2000, dirty: 1));
|
||||
await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'TheirsOlder', updatedAt: 1000));
|
||||
await local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'MineNewer', updatedAt: 2000, dirty: 1));
|
||||
await remote.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'TheirsOlder', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['make'], 'MineNewer');
|
||||
expect(rows.first['nickname'], 'MineNewer');
|
||||
expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten');
|
||||
});
|
||||
|
||||
test('a null nickname merges in fine (nickname is optional)', () async {
|
||||
await remote.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', nickname: null, updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['nickname'], isNull);
|
||||
});
|
||||
|
||||
test('the same vehicle edited on two devices merges by id, not vin', () async {
|
||||
// VIN is user-editable, so it can't be the merge key — this is the
|
||||
// scenario that motivated switching the merge key to a hidden id:
|
||||
// the local device renamed the VIN (a legitimate edit) while the
|
||||
// remote copy still has the old VIN and an older updated_at.
|
||||
await local.insert('vehicles',
|
||||
_vehicleRow(id: 'v1', vin: 'VIN1-CORRECTED', nickname: 'Mine', updatedAt: 2000, dirty: 1));
|
||||
await remote.insert(
|
||||
'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1-TYPO', nickname: 'Mine', updatedAt: 1000));
|
||||
|
||||
await merge();
|
||||
|
||||
final rows = await local.query('vehicles');
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first['vin'], 'VIN1-CORRECTED', reason: 'local was newer, so its VIN edit wins');
|
||||
});
|
||||
});
|
||||
|
||||
group('fuel entry merge', () {
|
||||
|
|
@ -134,17 +165,16 @@ Future<Database> _openFreshDb(String path) async {
|
|||
|
||||
Map<String, Object?> _vehicleRow({
|
||||
required String id,
|
||||
String make = 'Ford',
|
||||
required String vin,
|
||||
String? nickname = 'Test Vehicle',
|
||||
int updatedAt = 0,
|
||||
int? deletedAt,
|
||||
int dirty = 0,
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'make': make,
|
||||
'model': 'F-150',
|
||||
'color': 'Red',
|
||||
'license_plate': 'ABC123',
|
||||
'vin': vin,
|
||||
'nickname': nickname,
|
||||
'updated_at': updatedAt,
|
||||
'deleted_at': deletedAt,
|
||||
'dirty': dirty,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/drive_service.dart' show DriveLockFile;
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart' show CloudLockFile;
|
||||
import 'package:fuel_tax_tracker/services/lock_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -16,7 +16,7 @@ void main() {
|
|||
return 'id';
|
||||
},
|
||||
listLocks: () async =>
|
||||
[DriveLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
|
||||
[CloudLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
|
||||
deleteLock: (_) async {},
|
||||
delay: (_) async {},
|
||||
);
|
||||
|
|
@ -35,7 +35,7 @@ void main() {
|
|||
createLock: (_) async => 'my-lock-id',
|
||||
listLocks: () async {
|
||||
listCallCount++;
|
||||
return [DriveLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (_) async {},
|
||||
delay: (d) async => delayCalls.add(d),
|
||||
|
|
@ -55,8 +55,8 @@ void main() {
|
|||
nowUtc: () => now,
|
||||
createLock: (_) async => 'mine',
|
||||
listLocks: () async => [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))),
|
||||
],
|
||||
deleteLock: (_) async {},
|
||||
|
|
@ -81,14 +81,14 @@ void main() {
|
|||
listCallCount++;
|
||||
if (listCallCount == 1) {
|
||||
return [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'other',
|
||||
username: 'other',
|
||||
createdAtUtc: now.subtract(const Duration(seconds: 5))),
|
||||
];
|
||||
}
|
||||
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (id) async => deleteCalls.add(id),
|
||||
delay: (d) async => delayCalls.add(d),
|
||||
|
|
@ -114,14 +114,14 @@ void main() {
|
|||
listCallCount++;
|
||||
if (listCallCount == 1) {
|
||||
return [
|
||||
DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
DriveLockFile(
|
||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
||||
CloudLockFile(
|
||||
id: 'stale',
|
||||
username: 'ghost',
|
||||
createdAtUtc: now.subtract(const Duration(minutes: 15))),
|
||||
];
|
||||
}
|
||||
return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
||||
},
|
||||
deleteLock: (id) async => deleteCalls.add(id),
|
||||
delay: (_) async {},
|
||||
|
|
|
|||
98
test/pending_receipt_upload_test.dart
Normal file
98
test/pending_receipt_upload_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/db_schema.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Regression coverage for a real bug: the "needs upload" query used to
|
||||
/// match on `receipt_image_path IS NOT NULL` alone, which would have kept
|
||||
/// re-uploading the same photo as a duplicate Drive file on every sync
|
||||
/// once a "keep photos on this phone" option let an already-uploaded row
|
||||
/// keep its local path. See [pendingReceiptUploadWhereClause].
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late Database db;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('pending_receipt_test_');
|
||||
db = await databaseFactory.openDatabase(p.join(tempDir.path, 'test.db'));
|
||||
await db.execute(createFuelEntriesTableSql);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> insertEntry(
|
||||
String id, {
|
||||
String? receiptImagePath,
|
||||
String? receiptDriveFileId,
|
||||
int dirty = 1,
|
||||
int? deletedAt,
|
||||
}) {
|
||||
return db.insert('fuel_entries', {
|
||||
'id': id,
|
||||
'vehicle_id': 'v1',
|
||||
'date': 0,
|
||||
'gallons': 10.0,
|
||||
'price_per_gallon': 3.5,
|
||||
'total_cost': 35.0,
|
||||
'receipt_image_path': receiptImagePath,
|
||||
'receipt_drive_file_id': receiptDriveFileId,
|
||||
'updated_at': 0,
|
||||
'deleted_at': deletedAt,
|
||||
'dirty': dirty,
|
||||
});
|
||||
}
|
||||
|
||||
test('matches a row with a local photo not yet uploaded', () async {
|
||||
await insertEntry('needs-upload', receiptImagePath: '/local/a.jpg');
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows.map((r) => r['id']), ['needs-upload']);
|
||||
});
|
||||
|
||||
test('excludes a row already uploaded, even if the local copy was kept', () async {
|
||||
await insertEntry(
|
||||
'kept-after-upload',
|
||||
receiptImagePath: '/local/a.jpg',
|
||||
receiptDriveFileId: 'drive-file-1',
|
||||
);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a row with no local photo at all', () async {
|
||||
await insertEntry('no-receipt');
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a soft-deleted row even if it has a pending local photo', () async {
|
||||
await insertEntry('deleted', receiptImagePath: '/local/a.jpg', deletedAt: 123);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes a clean (already-synced) row', () async {
|
||||
await insertEntry('clean', receiptImagePath: '/local/a.jpg', dirty: 0);
|
||||
|
||||
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
|
||||
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -21,6 +21,37 @@ void main() {
|
|||
expect(result.totalCost, 44.44);
|
||||
});
|
||||
|
||||
test('parses a tabular receipt where labels and values sit on separate lines', () {
|
||||
// Mirrors a real Casey's receipt: "Pump / Gallons / Price" is a
|
||||
// header row, with the actual values on the next line, and the
|
||||
// total is phrased "Total Sale" rather than a bare "Total".
|
||||
const receipt = '''
|
||||
Casey's
|
||||
Store #4021
|
||||
420 W. 6TH STREET
|
||||
KEARNEY, MO 64060
|
||||
Date 08/08/2026
|
||||
Time 02:21
|
||||
VI
|
||||
########8533
|
||||
Pump Gallons Price
|
||||
07 32.115 \$ 3.759
|
||||
Product Amount
|
||||
87E10 \$ 120.72
|
||||
Total Sale \$ 120.72
|
||||
Auth #
|
||||
Visa
|
||||
Seq # 008299
|
||||
98
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 32.115);
|
||||
expect(result.pricePerGallon, 3.759);
|
||||
expect(result.totalCost, 120.72);
|
||||
});
|
||||
|
||||
test('does not confuse SUBTOTAL with TOTAL', () {
|
||||
const receipt = '''
|
||||
SUBTOTAL 10.00
|
||||
|
|
@ -67,6 +98,386 @@ void main() {
|
|||
expect(result.gallons, isNull);
|
||||
expect(result.pricePerGallon, isNull);
|
||||
expect(result.totalCost, isNull);
|
||||
expect(result.date, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ReceiptParser state parsing', () {
|
||||
test('detects a state abbreviation directly before a ZIP code', () {
|
||||
const receipt = '''
|
||||
Casey's
|
||||
420 W. 6TH STREET
|
||||
KEARNEY, MO 64060
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'MO');
|
||||
});
|
||||
|
||||
test('detects a non-Missouri state the same way', () {
|
||||
const receipt = '''
|
||||
Swift Stop #3
|
||||
1809 Hobbs Hwy
|
||||
Seminole TX 79360
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'TX');
|
||||
});
|
||||
|
||||
test('falls back to a state right after a comma when no ZIP is nearby', () {
|
||||
const receipt = '''
|
||||
Buc-ee's
|
||||
6988 Buc-ee's Blvd
|
||||
Leeds, AL
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, 'AL');
|
||||
});
|
||||
|
||||
test('does not mistake "VI" (Visa) for the Virgin Islands', () {
|
||||
const receipt = '''
|
||||
Casey's
|
||||
VI
|
||||
########8533
|
||||
Pump Gallons Price
|
||||
07 32.115 \$ 3.759
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, isNull);
|
||||
});
|
||||
|
||||
test('does not mistake the word "In" for Indiana', () {
|
||||
const receipt = '''
|
||||
Gallons 10.000
|
||||
Price/Gal \$3.399
|
||||
Total Fuel \$33.99
|
||||
All Taxes Included
|
||||
In Fuel Price.
|
||||
''';
|
||||
|
||||
expect(ReceiptParser.parse(receipt).state, isNull);
|
||||
});
|
||||
|
||||
test('returns null when no address-like state pattern is present', () {
|
||||
expect(ReceiptParser.parse('THANK YOU FOR YOUR PURCHASE').state, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ReceiptParser date parsing', () {
|
||||
test('parses "Date: M/D/YYYY" + "Time: H:MM:SS AM/PM" on separate lines', () {
|
||||
const receipt = '''
|
||||
Date: 8/22/2024
|
||||
Time: 10:11:00 AM
|
||||
Gallons 10.000
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2024, 8, 22, 10, 11));
|
||||
});
|
||||
|
||||
test('parses "DATE M/D/YY H:MM" on the same line', () {
|
||||
const receipt = '''
|
||||
DATE 3/26/22 18:12
|
||||
PUMP# 06
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2022, 3, 26, 18, 12));
|
||||
});
|
||||
|
||||
test('parses a 2-digit year and a no-space AM/PM time', () {
|
||||
const receipt = '''
|
||||
Date 01/23/20
|
||||
Time 02:11PM
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.date, DateTime(2020, 1, 23, 14, 11));
|
||||
});
|
||||
|
||||
test('12 AM and 12 PM convert correctly (midnight/noon edge case)', () {
|
||||
expect(ReceiptParser.parse('Date 1/1/24 12:00 AM').date, DateTime(2024, 1, 1, 0, 0));
|
||||
expect(ReceiptParser.parse('Date 1/1/24 12:30 PM').date, DateTime(2024, 1, 1, 12, 30));
|
||||
});
|
||||
|
||||
test('falls back to midnight when a date is found but no time is nearby', () {
|
||||
// Comfortably more than the 40-character "nearby" search window
|
||||
// between the date and the unrelated text before a time appears
|
||||
// much later, regardless of how this string literal is indented.
|
||||
final filler = 'x' * 100;
|
||||
final receipt = 'Date 12/14/16\n$filler\nTime 5:11 PM';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
// Too far from the date to count as "nearby", so this should be
|
||||
// date-only at midnight — still better than no date at all, and the
|
||||
// confirm screen lets the user fix the time manually either way.
|
||||
expect(result.date, isNotNull);
|
||||
expect(result.date!.year, 2016);
|
||||
expect(result.date!.month, 12);
|
||||
expect(result.date!.day, 14);
|
||||
expect(result.date!.hour, 0);
|
||||
expect(result.date!.minute, 0);
|
||||
});
|
||||
|
||||
test('returns null when no date-like pattern is present', () {
|
||||
final result = ReceiptParser.parse('GALLONS 10.000\nPPG 3.500');
|
||||
|
||||
expect(result.date, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// Transcribed from a batch of real photographed receipts across different
|
||||
// gas station chains, to catch label/layout variations the handful of
|
||||
// hand-written cases above don't happen to cover.
|
||||
group('ReceiptParser against real receipt layouts', () {
|
||||
test('Break Time — labels adjacent to values on the same line', () {
|
||||
const receipt = '''
|
||||
Date: 8/22/2024
|
||||
Time: 10:11:00 AM
|
||||
OXY87
|
||||
Pump Number 22
|
||||
Gallons 10.000
|
||||
Price/Gal \$3.399
|
||||
Total Fuel \$33.99
|
||||
Total Sale \$33.99
|
||||
Visa \$33.99
|
||||
All Taxes Included
|
||||
In Fuel Price.
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 10.000);
|
||||
expect(result.pricePerGallon, 3.399);
|
||||
expect(result.totalCost, 33.99);
|
||||
});
|
||||
|
||||
test('Break Time — tabular Pump/Gallons/Price header row', () {
|
||||
const receipt = '''
|
||||
Member# 111795637651
|
||||
Invoice# 11733
|
||||
Date: 03/23/22
|
||||
Time: 10:05
|
||||
Auth# 020413
|
||||
VI Acct #
|
||||
************0344
|
||||
Pump Gallons Price
|
||||
14 5.116 \$ 5.499
|
||||
Product Amount
|
||||
Regular \$ 28.13
|
||||
Total Sale \$ 28.13
|
||||
SALE- Contactless
|
||||
Approved
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 5.116);
|
||||
expect(result.pricePerGallon, 5.499);
|
||||
expect(result.totalCost, 28.13);
|
||||
});
|
||||
|
||||
test('Buc-ee\'s — abbreviated "PRICE/G" label', () {
|
||||
const receipt = '''
|
||||
BUC-EE'S
|
||||
6988 Buc-ee's Blvd
|
||||
Leeds AL
|
||||
PUMP No. 63
|
||||
GALLONS 9.999
|
||||
PRICE/G \$2.499
|
||||
TOTAL FUEL \$24.99
|
||||
Regular
|
||||
TOTAL SALE \$24.99
|
||||
Visa \$24.99
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 9.999);
|
||||
expect(result.pricePerGallon, 2.499);
|
||||
expect(result.totalCost, 24.99);
|
||||
});
|
||||
|
||||
test('Costco — tabular header row, "Total Sale" phrasing', () {
|
||||
const receipt = '''
|
||||
Costco #01448
|
||||
1524 Beasie RD
|
||||
Murfreesboro TN
|
||||
Date: 01/03/24
|
||||
Time: 19:31
|
||||
Pump Gallons Price
|
||||
11 12.598 \$ 2.399
|
||||
Product Amount
|
||||
Regular \$ 30.22
|
||||
Total Sale \$ 30.22
|
||||
SALE- Contactless
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 12.598);
|
||||
expect(result.pricePerGallon, 2.399);
|
||||
expect(result.totalCost, 30.22);
|
||||
});
|
||||
|
||||
test('Murphy USA — "QTY(GAL)" label instead of "GALLONS"', () {
|
||||
const receipt = '''
|
||||
PUMP: 8
|
||||
PROD: UNLEAD
|
||||
PRICE/GAL: \$1.799
|
||||
NET/GAL: \$1.799
|
||||
QTY(GAL): 13.164
|
||||
FUEL TOTAL: \$23.68
|
||||
NET TOTAL: \$23.68
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 13.164);
|
||||
expect(result.pricePerGallon, 1.799);
|
||||
expect(result.totalCost, 23.68);
|
||||
});
|
||||
|
||||
test('Phillips 66 — "PRICE/G" label and "FUEL SALE" as the total', () {
|
||||
const receipt = '''
|
||||
WELCOME
|
||||
LAWNSIDE PHILLIPS 66
|
||||
355 Warrington Ave
|
||||
Lawnside NJ 08045
|
||||
DATE 3/26/22 18:12
|
||||
TRAN# 060757
|
||||
PUMP# 06
|
||||
SERVICE LEVEL: FULL
|
||||
PRODUCT: REGULAR
|
||||
GALLONS 3.642
|
||||
PRICE/G \$4.119
|
||||
FUEL SALE \$15.00
|
||||
CREDIT \$15.00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 3.642);
|
||||
expect(result.pricePerGallon, 4.119);
|
||||
expect(result.totalCost, 15.00);
|
||||
});
|
||||
|
||||
test('Phillips 66 — second "FUEL SALE" example', () {
|
||||
const receipt = '''
|
||||
WELCOME
|
||||
SWIFT STOP #3
|
||||
1809 HOBBS HWY
|
||||
SEMINOLE TX 79360
|
||||
DATE 5/9/23 11:16
|
||||
TRAN#9103742
|
||||
PUMP# 10
|
||||
SERVICE LEVEL: SELF
|
||||
PRODUCT: DIESEL 2
|
||||
GALLONS: 29.854
|
||||
PRICE/G: \$3.499
|
||||
FUEL SALE \$181.66
|
||||
CREDIT \$181.66
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 29.854);
|
||||
expect(result.pricePerGallon, 3.499);
|
||||
expect(result.totalCost, 181.66);
|
||||
});
|
||||
|
||||
test('Phillips 66 / Murphy-style — "QTY(GAL)" plus "FUEL TOTAL"', () {
|
||||
const receipt = '''
|
||||
Verified by PIN
|
||||
PIN USED
|
||||
PUMP: 5
|
||||
PROD: UNLEAD
|
||||
PRICE/GAL: \$3.959
|
||||
NET/GAL: \$3.959
|
||||
QTY(GAL): 11.068
|
||||
FUEL TOTAL: \$43.82
|
||||
NET TOTAL: \$43.82
|
||||
GET REWARDED!
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 11.068);
|
||||
expect(result.pricePerGallon, 3.959);
|
||||
expect(result.totalCost, 43.82);
|
||||
});
|
||||
|
||||
test('QuikTrip — tabular header row, total not printed on receipt (derived)', () {
|
||||
const receipt = '''
|
||||
QUIKTRIP #01012
|
||||
383 George Liles PWK
|
||||
Concord, NC
|
||||
Invoice # 0000000
|
||||
Date 01/23/20
|
||||
Time 02:11PM
|
||||
Auth # 03581I
|
||||
Acct #
|
||||
************1821
|
||||
Pump Gallons Price
|
||||
14 10.496 \$2.359
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 10.496);
|
||||
expect(result.pricePerGallon, 2.359);
|
||||
// Not printed within the visible receipt text — derived from
|
||||
// gallons * price rather than found via a label.
|
||||
expect(result.totalCost, closeTo(10.496 * 2.359, 0.01));
|
||||
});
|
||||
|
||||
test('Sinclair — unit letter attached with no space, and a negative '
|
||||
'per-gallon discount line that must not be mistaken for gallons', () {
|
||||
const receipt = '''
|
||||
PUMP# 3
|
||||
UNLEADED CR 17.364G
|
||||
PRICE/GAL \$2.649
|
||||
DISCOUNTS BEFORE
|
||||
FUELING
|
||||
Debit Di/GAL \$-0.100
|
||||
FUEL TOTAL \$46.00
|
||||
DEBIT \$46.00
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 17.364);
|
||||
expect(result.pricePerGallon, 2.649);
|
||||
expect(result.totalCost, 46.00);
|
||||
});
|
||||
|
||||
test('Sinclair — PPG and GALLONS/FUEL TOTAL both split across header/data rows', () {
|
||||
const receipt = '''
|
||||
SINCLAIR
|
||||
5905 NW 72ND ST
|
||||
KANSAS CITY MO
|
||||
Date 12/14/16
|
||||
Time 5:11 PM
|
||||
Auth 068113
|
||||
VISA
|
||||
Pump Product PPG
|
||||
05 UNLD \$1.999
|
||||
Gallons Fuel Total
|
||||
9.505 \$19.00
|
||||
Thank you
|
||||
''';
|
||||
|
||||
final result = ReceiptParser.parse(receipt);
|
||||
|
||||
expect(result.gallons, 9.505);
|
||||
expect(result.pricePerGallon, 1.999);
|
||||
// "Fuel Total" is split from its value by a newline in this layout,
|
||||
// so it isn't label-matched — derived from gallons * price instead,
|
||||
// landing within a cent of the printed $19.00.
|
||||
expect(result.totalCost, closeTo(19.00, 0.01));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
89
test/receipt_storage_test.dart
Normal file
89
test/receipt_storage_test.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/database_service.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Receipts are filed under `receipts/<vin>/<yyyy.mm>/` locally (mirroring
|
||||
/// the `Receipts/<vin>/<yyyy.mm>` layout the cloud side uses — see
|
||||
/// `cloud_sync_service_test.dart`), so browsing either one shows which
|
||||
/// vehicle and month a photo belongs to.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late DatabaseService database;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('receipt_storage_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
database = DatabaseService();
|
||||
await database.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('storeReceiptImage files the photo under receipts/<vin>/<yyyy.mm>/', () async {
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final storedPath = await database.storeReceiptImage(
|
||||
source,
|
||||
'entry-1',
|
||||
'1FMPU18L1TLB51349',
|
||||
DateTime(2026, 8, 13),
|
||||
);
|
||||
|
||||
expect(
|
||||
p.dirname(storedPath),
|
||||
p.join(database.receiptsDirectory.path, '1FMPU18L1TLB51349', '2026.08'),
|
||||
);
|
||||
expect(p.basename(storedPath), 'entry-1.jpg');
|
||||
expect(await File(storedPath).exists(), isTrue);
|
||||
});
|
||||
|
||||
test('storeReceiptImage sanitizes a VIN with characters unsafe in a path', () async {
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final storedPath = await database.storeReceiptImage(
|
||||
source,
|
||||
'entry-2',
|
||||
'../etc/passwd',
|
||||
DateTime(2026, 1, 5),
|
||||
);
|
||||
|
||||
expect(
|
||||
p.dirname(storedPath),
|
||||
p.join(
|
||||
database.receiptsDirectory.path,
|
||||
sanitizedPathSegment('../etc/passwd'),
|
||||
'2026.01',
|
||||
),
|
||||
);
|
||||
expect(p.isWithin(database.receiptsDirectory.path, storedPath), isTrue);
|
||||
});
|
||||
|
||||
test('monthFolderName pads single-digit months', () {
|
||||
expect(monthFolderName(DateTime(2026, 1, 5)), '2026.01');
|
||||
expect(monthFolderName(DateTime(2026, 12, 5)), '2026.12');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
137
test/vehicle_id_migration_test.dart
Normal file
137
test/vehicle_id_migration_test.dart
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/database_service.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Confirms the version 3 → 4 migration (VIN becomes editable; a hidden
|
||||
/// `id` takes over as vehicles' primary key/merge key) preserves existing
|
||||
/// local data rather than dropping it, unlike the earlier VIN-as-primary-key
|
||||
/// migration which was a from-scratch rebuild. Builds a real on-disk
|
||||
/// version-3 database by hand (the old schema, pre-dating this change), then
|
||||
/// runs [DatabaseService.init] against it and checks what comes out.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('vehicle_id_migration_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('migrates a version-3 database, preserving vehicles and fuel entries', () async {
|
||||
final dbDir = Directory(p.join(tempDir.path, 'FuelTaxTracker'));
|
||||
await dbDir.create(recursive: true);
|
||||
final dbPath = p.join(dbDir.path, dbFileName);
|
||||
|
||||
final oldDb = await databaseFactory.openDatabase(
|
||||
dbPath,
|
||||
options: OpenDatabaseOptions(
|
||||
version: 3,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE vehicles (
|
||||
vin TEXT PRIMARY KEY,
|
||||
nickname TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE fuel_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
vehicle_vin TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
gallons REAL NOT NULL,
|
||||
price_per_gallon REAL NOT NULL,
|
||||
total_cost REAL NOT NULL,
|
||||
receipt_image_path TEXT,
|
||||
receipt_drive_file_id TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
''');
|
||||
},
|
||||
),
|
||||
);
|
||||
await oldDb.insert('vehicles', {
|
||||
'vin': 'VIN1',
|
||||
'nickname': "Mom's Car",
|
||||
'updated_at': 1000,
|
||||
'deleted_at': null,
|
||||
'dirty': 0,
|
||||
});
|
||||
await oldDb.insert('vehicles', {
|
||||
'vin': 'VIN2',
|
||||
'nickname': null,
|
||||
'updated_at': 1500,
|
||||
'deleted_at': 2000, // a soft-deleted vehicle, should still migrate
|
||||
'dirty': 0,
|
||||
});
|
||||
await oldDb.insert('fuel_entries', {
|
||||
'id': 'f1',
|
||||
'vehicle_vin': 'VIN1',
|
||||
'date': 1200,
|
||||
'gallons': 12.5,
|
||||
'price_per_gallon': 3.2,
|
||||
'total_cost': 40.0,
|
||||
'receipt_image_path': '/local/receipt.jpg',
|
||||
'receipt_drive_file_id': null,
|
||||
'updated_at': 1200,
|
||||
'deleted_at': null,
|
||||
'dirty': 0,
|
||||
});
|
||||
await oldDb.close();
|
||||
|
||||
final databaseService = DatabaseService();
|
||||
await databaseService.init();
|
||||
addTearDown(() => databaseService.rawDb.close());
|
||||
|
||||
final vehicles = await databaseService.getVehicles();
|
||||
expect(vehicles, hasLength(1), reason: 'the soft-deleted vehicle should not show as active');
|
||||
expect(vehicles.first.vin, 'VIN1');
|
||||
expect(vehicles.first.nickname, "Mom's Car");
|
||||
expect(vehicles.first.id, isNotEmpty);
|
||||
|
||||
final allVehicleRows = await databaseService.rawDb.query('vehicles');
|
||||
expect(allVehicleRows, hasLength(2), reason: 'the soft-deleted vehicle should still exist');
|
||||
final ids = {for (final row in allVehicleRows) row['vin']: row['id'] as String};
|
||||
expect(ids['VIN1'], isNotEmpty);
|
||||
expect(ids['VIN2'], isNotEmpty);
|
||||
expect(ids['VIN1'], isNot(ids['VIN2']), reason: 'each vehicle gets its own generated id');
|
||||
for (final row in allVehicleRows) {
|
||||
expect(row['dirty'], 1, reason: 'migrated rows must be re-pushed under the new schema');
|
||||
}
|
||||
|
||||
final fuelEntries = await databaseService.getFuelEntries();
|
||||
expect(fuelEntries, hasLength(1));
|
||||
expect(fuelEntries.first.vehicleId, ids['VIN1'],
|
||||
reason: "the fuel entry's foreign key should now point at VIN1's new id");
|
||||
expect(fuelEntries.first.gallons, 12.5);
|
||||
expect(fuelEntries.first.receiptImagePath, '/local/receipt.jpg');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
47
test/vin_parser_test.dart
Normal file
47
test/vin_parser_test.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/vin_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('VinParser', () {
|
||||
test('parses a bare 17-character VIN', () {
|
||||
const text = '''
|
||||
VEHICLE IDENTIFICATION NUMBER
|
||||
1HGCM82633A004352
|
||||
MFD BY HONDA MFG CO
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('prefers a "VIN:" labeled match over another candidate elsewhere', () {
|
||||
const text = '''
|
||||
PART NO 4F2CZ58899BA12345
|
||||
VIN: 1HGCM82633A004352
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('normalizes to uppercase', () {
|
||||
expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('does not match a run containing I, O, or Q (never valid in a real VIN)', () {
|
||||
// Same length (17) but contains an "O" partway through, so no
|
||||
// 17-character eligible-charset run exists anywhere in it.
|
||||
expect(VinParser.parse('1HGCM82633AOO4352'), isNull);
|
||||
});
|
||||
|
||||
test('does not match a 16-character (too short) run', () {
|
||||
expect(VinParser.parse('1HGCM82633A00435'), isNull);
|
||||
});
|
||||
|
||||
test('does not match an 18-character (too long) run', () {
|
||||
expect(VinParser.parse('1HGCM82633A0043521'), isNull);
|
||||
});
|
||||
|
||||
test('returns null when nothing matches', () {
|
||||
expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
115
test/webdav_provider_credentials_test.dart
Normal file
115
test/webdav_provider_credentials_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
|
||||
|
||||
const _multistatusBody = '''<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>''';
|
||||
|
||||
/// Confirms sign-in survives a cold app restart: [WebDavProvider] persists
|
||||
/// verified credentials to secure storage on [signInWithCredentials], and a
|
||||
/// *fresh* instance (an in-memory field reset, standing in for a relaunched
|
||||
/// app) can restore the session from them via [attemptSilentSignIn] — the
|
||||
/// gap flagged in README's "Known limitations" and fixed here.
|
||||
void main() {
|
||||
late HttpServer server;
|
||||
late String serverUrl;
|
||||
const username = 'alice';
|
||||
const password = 'hunter2';
|
||||
|
||||
setUp(() async {
|
||||
FlutterSecureStoragePlatform.instance = _FakeSecureStoragePlatform();
|
||||
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
serverUrl = 'http://127.0.0.1:${server.port}/';
|
||||
unawaited(server.forEach((request) async {
|
||||
final auth = request.headers.value('authorization');
|
||||
final expected = 'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
if (request.method == 'PROPFIND' && auth == expected) {
|
||||
request.response.statusCode = 207;
|
||||
request.response.headers.contentType = ContentType('application', 'xml');
|
||||
request.response.write(_multistatusBody);
|
||||
} else if (request.method == 'PROPFIND') {
|
||||
request.response.statusCode = 401;
|
||||
} else {
|
||||
request.response.statusCode = 404;
|
||||
}
|
||||
await request.response.close();
|
||||
}));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
});
|
||||
|
||||
test('signInWithCredentials persists credentials that a fresh instance can restore', () async {
|
||||
final first = WebDavProvider();
|
||||
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
|
||||
expect(first.isSignedIn, isTrue);
|
||||
|
||||
// A brand new instance has no in-memory session — simulates the app
|
||||
// process having been killed and relaunched.
|
||||
final afterRestart = WebDavProvider();
|
||||
expect(afterRestart.isSignedIn, isFalse);
|
||||
|
||||
final restored = await afterRestart.attemptSilentSignIn();
|
||||
|
||||
expect(restored, isTrue);
|
||||
expect(afterRestart.isSignedIn, isTrue);
|
||||
expect(afterRestart.accountLabel, first.accountLabel);
|
||||
});
|
||||
|
||||
test('attemptSilentSignIn returns false, not throws, when nothing was ever stored', () async {
|
||||
final provider = WebDavProvider();
|
||||
|
||||
expect(await provider.attemptSilentSignIn(), isFalse);
|
||||
expect(provider.isSignedIn, isFalse);
|
||||
});
|
||||
|
||||
test('signOut clears stored credentials so a later restart no longer restores', () async {
|
||||
final first = WebDavProvider();
|
||||
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
|
||||
await first.signOut();
|
||||
|
||||
final afterRestart = WebDavProvider();
|
||||
expect(await afterRestart.attemptSilentSignIn(), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSecureStoragePlatform extends FlutterSecureStoragePlatform {
|
||||
final Map<String, String> _store = {};
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value, required Map<String, String> options}) async {
|
||||
_store[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key, required Map<String, String> options}) async => _store[key];
|
||||
|
||||
@override
|
||||
Future<bool> containsKey({required String key, required Map<String, String> options}) async =>
|
||||
_store.containsKey(key);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key, required Map<String, String> options}) async {
|
||||
_store.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> readAll({required Map<String, String> options}) async => Map.of(_store);
|
||||
|
||||
@override
|
||||
Future<void> deleteAll({required Map<String, String> options}) async => _store.clear();
|
||||
}
|
||||
122
test/webdav_provider_test.dart
Normal file
122
test/webdav_provider_test.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final baseUrl = Uri.parse('https://cloud.example.com/');
|
||||
|
||||
group('parseWebDavMultistatus', () {
|
||||
// Real WebDAV servers disagree on namespace prefix for the same "DAV:"
|
||||
// namespace — Nextcloud defaults to `d:`, many Apache mod_dav setups
|
||||
// use `D:`, and some use no prefix at all with a default xmlns. All
|
||||
// three must parse identically since the parser matches by local name.
|
||||
test('parses a lowercase d: prefixed response (Nextcloud-style)', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
<d:getetag>"abc123"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/receipts/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
<d:getetag>"def456"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/fuel_tax_tracker.db</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:getetag>"ghi789"</d:getetag>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(3));
|
||||
expect(entries[0].path, '/remote.php/dav/files/user/MO-Fuel-Tax-Back/');
|
||||
expect(entries[0].isCollection, isTrue);
|
||||
expect(entries[0].etag, '"abc123"');
|
||||
expect(entries[1].isCollection, isTrue);
|
||||
expect(entries[2].path, endsWith('fuel_tax_tracker.db'));
|
||||
expect(entries[2].isCollection, isFalse);
|
||||
expect(entries[2].etag, '"ghi789"');
|
||||
});
|
||||
|
||||
test('parses an uppercase D: prefixed response identically', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/dav/MO-Fuel-Tax-Back/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getetag>"xyz111"</D:getetag>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries.first.isCollection, isTrue);
|
||||
expect(entries.first.etag, '"xyz111"');
|
||||
});
|
||||
|
||||
test('parses an unprefixed default-namespace response identically', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<href>/dav/MO-Fuel-Tax-Back/lock-file.lock</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<resourcetype/>
|
||||
<getetag>"lock999"</getetag>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
</multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries.first.isCollection, isFalse);
|
||||
expect(entries.first.path, '/dav/MO-Fuel-Tax-Back/lock-file.lock');
|
||||
});
|
||||
|
||||
test('a file with no getetag prop yields a null etag', () {
|
||||
final xml = '''<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/dav/no-etag.txt</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>''';
|
||||
|
||||
final entries = parseWebDavMultistatus(xml, baseUrl);
|
||||
|
||||
expect(entries.single.etag, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue