Compare commits
No commits in common. "483fbeafb6b81de8c256b6480c3dda93ec71770c" and "e630f2a3eccc5a9d8e037e8f2edb35f11fd06240" have entirely different histories.
483fbeafb6
...
e630f2a3ec
41 changed files with 425 additions and 6179 deletions
376
README.md
376
README.md
|
|
@ -3,225 +3,37 @@
|
||||||
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
|
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,
|
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
|
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
|
photo to a data file in a folder you choose.
|
||||||
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
|
## Features
|
||||||
|
|
||||||
- Manage a list of vehicles, identified by **VIN** (required, and unique
|
- Manage a list of vehicles (make, model, color, license plate).
|
||||||
across active vehicles — but editable, e.g. to fix a typo from a misread
|
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google
|
||||||
scan; scan it from a photo of the door-jamb sticker or dashboard plate
|
ML Kit — no internet connection or API key required.
|
||||||
instead of typing all 17 characters). An optional **nickname** ("Mom's
|
- Review/edit the parsed gallons, price per gallon, and total cost before
|
||||||
Car", "Red Ford F-150") is what's shown as the primary label everywhere;
|
saving (OCR on printed receipts is usually good but not perfect).
|
||||||
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
|
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
|
||||||
photos, and delete.
|
photos, and delete.
|
||||||
- Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV
|
- Settings screen to choose where receipt photos and the data file are
|
||||||
server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
|
stored (defaults to the app's own documents folder; existing data is
|
||||||
endpoint) and pick any folder you have access to — including one someone
|
copied over when you change it).
|
||||||
else created and shared with you — as the shared storage location. The
|
|
||||||
app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder there, so
|
|
||||||
anyone pointed at the same shared parent converges on the same data
|
|
||||||
automatically, regardless of which of the four providers each person is
|
|
||||||
using. WebDAV is the only one of the four that needs no developer-console
|
|
||||||
setup at all — just a server URL, username, and password.
|
|
||||||
- Works fully offline: writes always land in the local SQLite database
|
|
||||||
first; a background sync pushes changes to the cloud and pulls others'
|
|
||||||
changes down once online.
|
|
||||||
- Settings lets you choose whether a receipt photo's local copy is deleted
|
|
||||||
once it's safely uploaded to the cloud (the default — keeps the phone's
|
|
||||||
storage footprint small) or kept on the phone for offline viewing.
|
|
||||||
- Settings → Advanced lets you tune the sync lock's staleness timeout (how
|
|
||||||
long before another device's abandoned lock is cleared so sync isn't
|
|
||||||
stuck waiting forever), from 1–60 minutes.
|
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
|
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization
|
||||||
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
|
|
||||||
`deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's
|
|
||||||
`id` is a hidden, generated, immutable primary key — never shown in the
|
|
||||||
UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
|
|
||||||
required, unique-among-active-vehicles, but user-editable field, and
|
|
||||||
`nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
|
|
||||||
`receiptDriveFileId`, or both at once if "keep photos on this phone" is
|
|
||||||
on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
|
|
||||||
distinction.
|
|
||||||
services/
|
services/
|
||||||
app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps
|
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider
|
||||||
updatedAt on mutations and triggers background sync.
|
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path
|
||||||
database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
|
ocr_service.dart Thin wrapper around google_mlkit_text_recognition
|
||||||
and the receipts/ folder, always at
|
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text
|
||||||
ApplicationDocumentsDirectory/FuelTaxTracker.
|
|
||||||
db_schema.dart CREATE TABLE statements and the merge SQL — shared between
|
|
||||||
the app and its tests so they can never drift apart.
|
|
||||||
cloud/
|
|
||||||
cloud_storage_provider.dart The CloudStorageProvider/CloudStorageSession
|
|
||||||
interface every backend implements (auth, folder
|
|
||||||
browsing, file upload/download, lock file ops) —
|
|
||||||
this is what cloud_sync_service.dart and the rest
|
|
||||||
of the app talk to; they never know which of the
|
|
||||||
three providers below is actually active.
|
|
||||||
google_drive_provider.dart Google sign-in + the googleapis DriveApi client.
|
|
||||||
dropbox_provider.dart Hand-rolled OAuth2 PKCE + Dropbox's path-addressed
|
|
||||||
REST API (files/list_folder, files/upload, etc).
|
|
||||||
onedrive_provider.dart Hand-rolled OAuth2 PKCE + Microsoft Graph
|
|
||||||
(/me/drive/...), ID-addressed like Drive.
|
|
||||||
webdav_provider.dart Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE)
|
|
||||||
over HTTP Basic Auth — no OAuth, no developer
|
|
||||||
console, just a server URL + username + password.
|
|
||||||
For self-hosted servers (Nextcloud, ownCloud, a
|
|
||||||
Synology NAS, etc). Path-addressed like Dropbox;
|
|
||||||
uses the resource's ETag as its versionTag.
|
|
||||||
oauth_pkce.dart PKCE code_verifier/code_challenge generation, shared
|
|
||||||
by the Dropbox and OneDrive providers (Google's own
|
|
||||||
SDK handles its OAuth flow itself, and WebDAV uses
|
|
||||||
plain Basic Auth, so neither needs this).
|
|
||||||
cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever
|
|
||||||
provider(s) you want to use — see setup below.
|
|
||||||
cloud_sync_service.dart Orchestrates one sync round against whichever
|
|
||||||
CloudStorageProvider is active: acquire the
|
|
||||||
cross-device lock, ATTACH + merge the remote database
|
|
||||||
into the local one, upload pending receipt photos,
|
|
||||||
push the local database back up, release the lock.
|
|
||||||
lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable
|
|
||||||
logic (independently unit-tested without a real backend).
|
|
||||||
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
|
|
||||||
receipt_parser.dart Regex-based extraction of gallons/price/total/date from OCR
|
|
||||||
text — falls back to manual entry (date picker, or the
|
|
||||||
gallons*price derivation) for whatever isn't found.
|
|
||||||
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
|
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
|
||||||
confirm fuel entry, receipt viewer, settings, cloud folder browser).
|
confirm fuel entry, receipt viewer, settings)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual setup required (cloud storage)
|
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
|
||||||
|
subfolder of photos, both inside whatever directory Settings points at.
|
||||||
You only need to complete setup for whichever provider(s) you actually want
|
|
||||||
to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same
|
|
||||||
shape: I can't provision cloud resources on your behalf, so each needs an
|
|
||||||
app/OAuth client you create yourself in that provider's own developer
|
|
||||||
console, with the resulting IDs pasted into
|
|
||||||
`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect"
|
|
||||||
button for one of these three once its config fields are filled in with
|
|
||||||
real values. **WebDAV needs none of this** — see its section below.
|
|
||||||
|
|
||||||
### Google Drive (Google Cloud Console)
|
|
||||||
|
|
||||||
In [Google Cloud Console](https://console.cloud.google.com/):
|
|
||||||
|
|
||||||
1. Create/select a project, enable the **Google Drive API** (APIs & Services
|
|
||||||
→ Library).
|
|
||||||
2. **OAuth consent screen**: set it to **External**, keep it in **Testing**
|
|
||||||
status, and add your Google account plus everyone else's you're sharing
|
|
||||||
with as **test users**. This avoids Google's formal verification review,
|
|
||||||
which the broad `drive` scope would otherwise require — fine for a known,
|
|
||||||
small group, not a public release.
|
|
||||||
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
|
|
||||||
(shows as "restricted/sensitive" — expected, fine in Testing mode).
|
|
||||||
4. **Credentials → Create Credentials → OAuth client ID**, three times:
|
|
||||||
- **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the
|
|
||||||
SHA-1 of your debug keystore (`keytool -list -v -keystore
|
|
||||||
~/.android/debug.keystore`, password `android`), and later your release
|
|
||||||
keystore's SHA-1 too. This client ID itself is never referenced in
|
|
||||||
code — it exists purely so Android's Credential Manager trusts this
|
|
||||||
specific signed app.
|
|
||||||
- **Web application**: no redirect URIs needed. Copy its **Client ID** —
|
|
||||||
this is what Android sign-in actually authenticates against (a
|
|
||||||
Credential Manager quirk: it needs a *web* client ID, passed as
|
|
||||||
`serverClientId`, even for a mobile app).
|
|
||||||
- **iOS**: bundle ID matching the Xcode project. Copy its **Client ID**
|
|
||||||
and note the reversed form (`com.googleusercontent.apps.<...>`).
|
|
||||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `googleAndroidServerClientId` ← the **Web application** client's ID.
|
|
||||||
- `googleIosClientId` ← the **iOS** client's ID.
|
|
||||||
6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` →
|
|
||||||
`CFBundleURLSchemes` with your iOS client's reversed ID.
|
|
||||||
|
|
||||||
### Dropbox (Dropbox App Console)
|
|
||||||
|
|
||||||
Dropbox has no official Flutter sign-in SDK, so this uses a hand-built
|
|
||||||
OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a
|
|
||||||
public/mobile app) via `flutter_web_auth_2`, which opens the system browser
|
|
||||||
and catches the redirect through a custom URL scheme already registered in
|
|
||||||
`AndroidManifest.xml` / `Info.plist`.
|
|
||||||
|
|
||||||
1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps).
|
|
||||||
2. Access type: **Full Dropbox** (not "App folder") — needed because
|
|
||||||
browsing to and reusing a folder someone *else* created and shared
|
|
||||||
requires seeing the whole account, the same reasoning as Google's full
|
|
||||||
`drive` scope above.
|
|
||||||
3. Under **OAuth 2** → **Redirect URIs**, add
|
|
||||||
`mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already
|
|
||||||
wired up in the Android manifest and iOS Info.plist; use a different one
|
|
||||||
only if you also update those two files to match).
|
|
||||||
4. Copy the app's **App key** from the Settings tab.
|
|
||||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `dropboxAppKey` ← the App key.
|
|
||||||
- `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`.
|
|
||||||
6. While the app is in **Development** status, only your own Dropbox
|
|
||||||
account can sign in; add teammates under the app's **Permissions** /
|
|
||||||
member-access settings, or apply for **Production** status, once you're
|
|
||||||
ready to share it with others.
|
|
||||||
|
|
||||||
### OneDrive (Azure Portal / Microsoft Graph)
|
|
||||||
|
|
||||||
Same PKCE approach as Dropbox, against the Microsoft identity platform and
|
|
||||||
Microsoft Graph.
|
|
||||||
|
|
||||||
1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** →
|
|
||||||
**App registrations** → **New registration**.
|
|
||||||
2. Supported account types: **Personal Microsoft accounts only** (or "any
|
|
||||||
organizational directory and personal Microsoft accounts" if you also
|
|
||||||
want work/school accounts to be able to sign in — those may additionally
|
|
||||||
need their tenant admin's consent for the scopes below).
|
|
||||||
3. Under **Authentication** → **Add a platform** → **Mobile and desktop
|
|
||||||
applications**, add the redirect URI `mofueltaxback-onedrive://auth`
|
|
||||||
(this exact scheme is already wired up in the Android manifest and iOS
|
|
||||||
Info.plist; use a different one only if you also update those two files
|
|
||||||
to match).
|
|
||||||
4. Under **API permissions**, add Microsoft Graph **delegated** permissions
|
|
||||||
`Files.ReadWrite.All` and `offline_access` (the latter is required to get
|
|
||||||
a refresh token back from the token endpoint).
|
|
||||||
5. Copy the **Application (client) ID** from the Overview page.
|
|
||||||
6. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `oneDriveClientId` ← the Application (client) ID.
|
|
||||||
- `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`.
|
|
||||||
|
|
||||||
### WebDAV (self-hosted — no developer console needed)
|
|
||||||
|
|
||||||
This is the option for a personal cloud server you already run — Nextcloud,
|
|
||||||
ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare
|
|
||||||
Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere;
|
|
||||||
tapping "Connect WebDAV" in Settings just opens a form asking for:
|
|
||||||
|
|
||||||
- **Server URL** — the full WebDAV endpoint, e.g.
|
|
||||||
`https://cloud.example.com/remote.php/dav/files/yourusername/` for
|
|
||||||
Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you.
|
|
||||||
`https://` is assumed if you omit the scheme.
|
|
||||||
- **Username** and **Password** — for servers that support app-specific
|
|
||||||
passwords (Nextcloud: Settings → Security → "Create new app password"),
|
|
||||||
use one of those instead of your real account password, so this app can
|
|
||||||
be revoked independently later.
|
|
||||||
|
|
||||||
The app verifies the URL and credentials with a harmless `PROPFIND` request
|
|
||||||
before treating you as signed in, so a typo or wrong password fails
|
|
||||||
immediately with a clear error rather than surfacing later during sync.
|
|
||||||
Nothing needs to be filled in `cloud_oauth_config.dart` or the
|
|
||||||
Android/iOS manifest files for this provider.
|
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
|
|
@ -239,145 +51,43 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
|
||||||
|
|
||||||
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
|
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
|
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
|
||||||
to produce a working APK. None of the three providers' sign-in/sync paths
|
to produce a working APK.
|
||||||
can be exercised end-to-end until you've done that provider's manual OAuth
|
|
||||||
setup above.
|
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
||||||
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
||||||
set up for receipt capture.
|
set up for receipt capture.
|
||||||
- **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the
|
- **Storage**: no explicit storage permission is declared. The default save
|
||||||
main Android manifest (needed for release builds; debug builds get
|
location is inside the app's own sandbox (no permission needed). If you
|
||||||
`INTERNET` for free).
|
point Settings at a location outside the sandbox, the OS-native folder
|
||||||
- **OAuth redirects**: no manifest changes are needed for `google_sign_in`
|
picker (via `file_picker`) is what grants access — see the caveat below.
|
||||||
itself when not using `google-services.json` — see the Google setup
|
|
||||||
section above instead. Dropbox and OneDrive's browser-based OAuth redirect
|
|
||||||
is already wired up via a `flutter_web_auth_2` callback activity
|
|
||||||
(Android) / extra `CFBundleURLTypes` entries (iOS) for the
|
|
||||||
`mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you
|
|
||||||
only need to register the matching redirect URI in each provider's own
|
|
||||||
console (see setup above), not touch these files, unless you deliberately
|
|
||||||
choose different scheme names.
|
|
||||||
|
|
||||||
## How sync works
|
|
||||||
|
|
||||||
Every local change (add/edit a vehicle, log a fuel entry) writes to the
|
|
||||||
local SQLite database immediately, then triggers a best-effort background
|
|
||||||
sync — also triggered whenever connectivity comes back or the app starts.
|
|
||||||
Every row carries `updated_at` (for merge resolution), `deleted_at` (a
|
|
||||||
soft-delete tombstone — see below), and a local-only `dirty` flag (pending
|
|
||||||
push to Drive, never itself treated as meaningful sync data). Sync:
|
|
||||||
|
|
||||||
1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared cloud
|
|
||||||
folder, then waits until no *other* lock file older than its own remains
|
|
||||||
(polling every second, deleting any it finds older than the configured
|
|
||||||
staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as
|
|
||||||
orphaned/stale) — a ticket-based mutex using the cloud folder itself as
|
|
||||||
the coordination point, so two devices never overwrite each other's
|
|
||||||
edits to the shared database mid-write. This logic (`lock_coordinator.dart`)
|
|
||||||
is identical regardless of which of the four providers is active.
|
|
||||||
2. Checks the remote database file's change-detection tag (Drive's
|
|
||||||
`md5Checksum`, Dropbox's `content_hash`, OneDrive's `cTag`, or a WebDAV
|
|
||||||
resource's `ETag` — an opaque `versionTag` as far as the sync engine is
|
|
||||||
concerned) against the last one seen (a metadata-only call, no content
|
|
||||||
download) to decide if a pull is even needed. If it changed: downloads
|
|
||||||
it to a temp file, `ATTACH`es it to
|
|
||||||
the local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE
|
|
||||||
local.<key> IS NULL OR remote.updated_at > local.updated_at` per table
|
|
||||||
(`id` for both tables — vehicles' hidden generated id, not the
|
|
||||||
user-editable VIN). Rows that statement doesn't match — including this
|
|
||||||
device's own not-yet-pushed edits — are left untouched, so no separate
|
|
||||||
"keep local" step is needed.
|
|
||||||
3. Uploads any receipt photos still needing it (a local path but no cloud
|
|
||||||
file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud
|
|
||||||
file ID. The local copy is then deleted or kept depending on the
|
|
||||||
Settings "keep photos on this phone" toggle. If any upload fails, the
|
|
||||||
push step below is skipped entirely this cycle — a row is never pushed
|
|
||||||
while it still holds a local-only, not-yet-uploaded path.
|
|
||||||
4. Reads the local database file directly (sqflite's default journal mode
|
|
||||||
isn't WAL, so the file is complete and consistent as soon as the last
|
|
||||||
write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as
|
|
||||||
cheap insurance) and uploads it as the new remote copy, then clears the
|
|
||||||
`dirty` flag on every row now that local matches what's on the cloud.
|
|
||||||
5. Releases the lock.
|
|
||||||
|
|
||||||
**Deletions propagate correctly**, unlike a naive "union records" merge:
|
|
||||||
deleting sets `deleted_at` instead of removing the row, so a deletion is
|
|
||||||
just another change with its own `updated_at`, and rides the same
|
|
||||||
newest-wins rule as any edit — no separate deletion-handling logic needed.
|
|
||||||
|
|
||||||
## Known caveats / things to revisit
|
## Known caveats / things to revisit
|
||||||
|
|
||||||
- **Field-level conflicts aren't merged.** Two people editing the *same*
|
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions
|
||||||
record at the same time: whole record, newer `updated_at` wins — not a
|
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP
|
||||||
field-by-field merge.
|
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't
|
||||||
- **Lock acquisition has no hard timeout** beyond the configurable
|
actually wired up for library modules in this Flutter/AGP combination yet,
|
||||||
staleness reap (Settings → Advanced). Fine at the scale this is built for
|
so the plugin's own Kotlin sources never get compiled and the build fails
|
||||||
(a handful of people); could in theory spin under many simultaneous
|
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is
|
||||||
contenders.
|
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time
|
||||||
- **VIN uniqueness is checked, but not race-proof across devices.** Editing
|
you bump dependencies — check the package's CHANGELOG for when this is
|
||||||
or adding a vehicle checks for an existing active vehicle with the same
|
properly resolved upstream.
|
||||||
VIN before saving, but two offline devices could still each independently
|
- **iOS folder picking and app restarts**: `file_picker`'s directory picker
|
||||||
create (or rename into) the same VIN before either has seen the other's
|
on iOS uses `UIDocumentPickerViewController`, which hands back a
|
||||||
change — same category of accepted limitation as "field-level conflicts
|
security-scoped URL. This app does not currently persist a security-scoped
|
||||||
aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not
|
bookmark for that URL, so if you pick a folder outside the app's own
|
||||||
`vin`, specifically so this doesn't corrupt the merge itself if it
|
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after
|
||||||
happens — you'd just end up with two vehicle rows sharing a VIN until
|
an app restart is not guaranteed. Picking the default in-sandbox location,
|
||||||
someone notices and fixes it by hand.
|
or a folder on Android, does not have this limitation. If cross-restart
|
||||||
- **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
|
external storage on iOS matters for your use case, this needs a proper
|
||||||
(`serverClientId`), not just the Android client's SHA-1 registration —
|
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
|
||||||
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
|
- **Receipt parsing is best-effort regex matching** on the OCR'd text
|
||||||
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
|
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
|
||||||
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
|
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
|
||||||
parse partially or not at all — the confirm screen always lets you fill in
|
parse partially or not at all — the confirm screen always lets you fill in
|
||||||
or correct whatever wasn't found.
|
or correct whatever wasn't found.
|
||||||
- No automated tests exercise the OCR, camera/gallery picker, or real Drive
|
- No automated tests exercise the OCR or camera capture path itself (that
|
||||||
API calls (those need a real device/emulator and live credentials);
|
requires a real device/emulator with a camera); `receipt_parser_test.dart`
|
||||||
`receipt_parser_test.dart`, `lock_coordinator_test.dart`,
|
covers the parsing logic against fixed OCR text.
|
||||||
`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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
|
|
||||||
android/app/src/debug/AndroidManifest.xml, but release builds need
|
|
||||||
it declared here explicitly. -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
|
||||||
<application
|
<application
|
||||||
android:label="fuel_tax_tracker"
|
android:label="fuel_tax_tracker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -32,22 +27,6 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
<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>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Fuel Tax Tracker</string>
|
<string>Fuel Tax Tracker</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
|
@ -70,41 +68,5 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Google Sign-In: required for the OAuth redirect to route back into
|
|
||||||
the app, regardless of whether the client ID itself is set here or
|
|
||||||
passed in Dart (see lib/services/drive_oauth_config.dart). Replace
|
|
||||||
with your iOS OAuth client's REVERSED_CLIENT_ID from Google Cloud
|
|
||||||
Console (reverse of the client ID, e.g.
|
|
||||||
com.googleusercontent.apps.XXXXXXXXXXXX-yyyyyyyyyyyyyyyyyyyyyyyyyyyy). -->
|
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleTypeRole</key>
|
|
||||||
<string>Editor</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -41,32 +41,6 @@ class AppRoot extends StatelessWidget {
|
||||||
body: Center(child: CircularProgressIndicator()),
|
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();
|
return const HomeScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,11 @@
|
||||||
class FuelEntry {
|
class FuelEntry {
|
||||||
final String id;
|
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 String vehicleId;
|
||||||
|
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
final double gallons;
|
final double gallons;
|
||||||
final double pricePerGallon;
|
final double pricePerGallon;
|
||||||
final double totalCost;
|
final double totalCost;
|
||||||
final String? receiptImagePath;
|
final String? receiptImagePath;
|
||||||
final String? receiptDriveFileId;
|
|
||||||
final DateTime updatedAt;
|
|
||||||
|
|
||||||
/// Soft-delete tombstone: null means active. See [Vehicle.deletedAt] for
|
|
||||||
/// why this is a flag rather than an actual row deletion.
|
|
||||||
final DateTime? deletedAt;
|
|
||||||
|
|
||||||
FuelEntry({
|
FuelEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -24,75 +14,26 @@ class FuelEntry {
|
||||||
required this.gallons,
|
required this.gallons,
|
||||||
required this.pricePerGallon,
|
required this.pricePerGallon,
|
||||||
required this.totalCost,
|
required this.totalCost,
|
||||||
required this.updatedAt,
|
|
||||||
this.receiptImagePath,
|
this.receiptImagePath,
|
||||||
this.receiptDriveFileId,
|
|
||||||
this.deletedAt,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True once the receipt photo has been uploaded to Drive, regardless of
|
Map<String, dynamic> toJson() => {
|
||||||
/// 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 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,
|
|
||||||
String? receiptDriveFileId,
|
|
||||||
DateTime? updatedAt,
|
|
||||||
DateTime? deletedAt,
|
|
||||||
bool clearReceiptImagePath = false,
|
|
||||||
}) {
|
|
||||||
return FuelEntry(
|
|
||||||
id: id,
|
|
||||||
vehicleId: vehicleId,
|
|
||||||
date: date,
|
|
||||||
gallons: gallons,
|
|
||||||
pricePerGallon: pricePerGallon,
|
|
||||||
totalCost: totalCost,
|
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
|
||||||
receiptImagePath:
|
|
||||||
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
|
|
||||||
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
|
|
||||||
deletedAt: deletedAt ?? this.deletedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> toMap() => {
|
|
||||||
'id': id,
|
'id': id,
|
||||||
'vehicle_id': vehicleId,
|
'vehicleId': vehicleId,
|
||||||
'date': date.millisecondsSinceEpoch,
|
'date': date.toIso8601String(),
|
||||||
'gallons': gallons,
|
'gallons': gallons,
|
||||||
'price_per_gallon': pricePerGallon,
|
'pricePerGallon': pricePerGallon,
|
||||||
'total_cost': totalCost,
|
'totalCost': totalCost,
|
||||||
'receipt_image_path': receiptImagePath,
|
'receiptImagePath': receiptImagePath,
|
||||||
'receipt_drive_file_id': receiptDriveFileId,
|
|
||||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
|
||||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
|
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
|
||||||
id: map['id'] as String,
|
id: json['id'] as String,
|
||||||
vehicleId: map['vehicle_id'] as String,
|
vehicleId: json['vehicleId'] as String,
|
||||||
date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
|
date: DateTime.parse(json['date'] as String),
|
||||||
gallons: (map['gallons'] as num).toDouble(),
|
gallons: (json['gallons'] as num).toDouble(),
|
||||||
pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
|
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
|
||||||
totalCost: (map['total_cost'] as num).toDouble(),
|
totalCost: (json['totalCost'] as num).toDouble(),
|
||||||
receiptImagePath: map['receipt_image_path'] as String?,
|
receiptImagePath: json['receiptImagePath'] as String?,
|
||||||
receiptDriveFileId: map['receipt_drive_file_id'] 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)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,48 @@
|
||||||
class Vehicle {
|
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 id;
|
||||||
|
final String make;
|
||||||
/// The vehicle's identification number. Required and must be unique
|
final String model;
|
||||||
/// among active (non-deleted) vehicles, but — unlike [id] — the user can
|
final String color;
|
||||||
/// edit it later (e.g. to fix a typo from a misread VIN scan).
|
final String licensePlate;
|
||||||
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
|
|
||||||
/// of removing the row, so the deletion itself can be merged/synced like
|
|
||||||
/// any other change (newest `updatedAt` wins) instead of silently
|
|
||||||
/// disappearing and later being resurrected by a device that hasn't seen
|
|
||||||
/// the deletion yet.
|
|
||||||
final DateTime? deletedAt;
|
|
||||||
|
|
||||||
Vehicle({
|
Vehicle({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.vin,
|
required this.make,
|
||||||
required this.updatedAt,
|
required this.model,
|
||||||
this.nickname,
|
required this.color,
|
||||||
this.deletedAt,
|
required this.licensePlate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// What screens should show as the vehicle's primary label: the nickname
|
String get displayName => '$color $make $model ($licensePlate)';
|
||||||
/// 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({
|
Vehicle copyWith({
|
||||||
String? vin,
|
String? make,
|
||||||
String? nickname,
|
String? model,
|
||||||
bool clearNickname = false,
|
String? color,
|
||||||
DateTime? updatedAt,
|
String? licensePlate,
|
||||||
DateTime? deletedAt,
|
|
||||||
}) {
|
}) {
|
||||||
return Vehicle(
|
return Vehicle(
|
||||||
id: id,
|
id: id,
|
||||||
vin: vin ?? this.vin,
|
make: make ?? this.make,
|
||||||
nickname: clearNickname ? null : (nickname ?? this.nickname),
|
model: model ?? this.model,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
color: color ?? this.color,
|
||||||
deletedAt: deletedAt ?? this.deletedAt,
|
licensePlate: licensePlate ?? this.licensePlate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> toMap() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'vin': vin,
|
'make': make,
|
||||||
'nickname': nickname,
|
'model': model,
|
||||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
'color': color,
|
||||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
'licensePlate': licensePlate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
|
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||||
id: map['id'] as String,
|
id: json['id'] as String,
|
||||||
vin: map['vin'] as String,
|
make: json['make'] as String,
|
||||||
nickname: map['nickname'] as String?,
|
model: json['model'] as String,
|
||||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
color: json['color'] as String,
|
||||||
deletedAt: map['deleted_at'] != null
|
licensePlate: json['licensePlate'] as String,
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.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
|
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||||
/// omit it to create a new one.
|
/// omit it to create a new one.
|
||||||
|
|
@ -23,107 +17,52 @@ class AddEditVehicleScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
late final TextEditingController _nicknameController;
|
late final TextEditingController _makeController;
|
||||||
late final TextEditingController _vinController;
|
late final TextEditingController _modelController;
|
||||||
|
late final TextEditingController _colorController;
|
||||||
bool _saving = false;
|
late final TextEditingController _plateController;
|
||||||
bool _scanningVin = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
bool get _isEditing => widget.vehicle != null;
|
bool get _isEditing => widget.vehicle != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
|
||||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
|
||||||
|
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
|
||||||
|
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nicknameController.dispose();
|
_makeController.dispose();
|
||||||
_vinController.dispose();
|
_modelController.dispose();
|
||||||
|
_colorController.dispose();
|
||||||
|
_plateController.dispose();
|
||||||
super.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 {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_saving = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final nickname = _nicknameController.text.trim();
|
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
|
|
||||||
try {
|
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||||
vin: _vinController.text.trim(),
|
make: _makeController.text.trim(),
|
||||||
nickname: nickname.isEmpty ? null : nickname,
|
model: _modelController.text.trim(),
|
||||||
clearNickname: nickname.isEmpty,
|
color: _colorController.text.trim(),
|
||||||
|
licensePlate: _plateController.text.trim(),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
await appState.addVehicle(
|
await appState.addVehicle(
|
||||||
vin: _vinController.text.trim(),
|
make: _makeController.text.trim(),
|
||||||
nickname: nickname.isEmpty ? null : nickname,
|
model: _modelController.text.trim(),
|
||||||
|
color: _colorController.text.trim(),
|
||||||
|
licensePlate: _plateController.text.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} on DuplicateVinException catch (e) {
|
|
||||||
setState(() => _error = e.toString());
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _saving = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete() async {
|
Future<void> _confirmDelete() async {
|
||||||
|
|
@ -133,7 +72,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
title: const Text('Delete vehicle?'),
|
title: const Text('Delete vehicle?'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'This will also delete all fuel entries and receipt photos logged for '
|
'This will also delete all fuel entries and receipt photos logged for '
|
||||||
'${widget.vehicle!.displayLabel}. This cannot be undone.',
|
'${widget.vehicle!.displayName}. This cannot be undone.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
||||||
|
|
@ -175,48 +114,36 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nicknameController,
|
controller: _makeController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
|
||||||
labelText: 'Nickname (optional)',
|
|
||||||
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
|
|
||||||
),
|
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _vinController,
|
controller: _modelController,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
|
||||||
labelText: 'VIN *',
|
textCapitalization: TextCapitalization.words,
|
||||||
hintText: 'Vehicle Identification Number',
|
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
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'),
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
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),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _save,
|
||||||
child: _saving
|
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
|
||||||
import '../services/cloud/cloud_storage_provider.dart';
|
|
||||||
|
|
||||||
enum _BrowseRoot { myFiles, sharedWithMe }
|
|
||||||
|
|
||||||
/// 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<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
|
|
||||||
late final CloudStorageSession _session;
|
|
||||||
|
|
||||||
_BrowseRoot _root = _BrowseRoot.myFiles;
|
|
||||||
final List<CloudFolder> _pathStack = [];
|
|
||||||
|
|
||||||
List<CloudFolder>? _folders;
|
|
||||||
bool _loading = true;
|
|
||||||
String? _error;
|
|
||||||
bool _confirming = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_session = context.read<AppState>().activeProvider!.beginSession();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_session.close();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
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.myFiles) return 'root';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String get _breadcrumbPath =>
|
|
||||||
([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / ');
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
List<CloudFolder> folders;
|
|
||||||
if (_pathStack.isNotEmpty) {
|
|
||||||
folders = await _session.listFolders(parentId: _pathStack.last.id);
|
|
||||||
} else if (_root == _BrowseRoot.myFiles) {
|
|
||||||
folders = await _session.listFolders(parentId: 'root');
|
|
||||||
} else {
|
|
||||||
folders = await _session.listFolders(sharedWithMe: true);
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_folders = folders;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_error = 'Could not load folders: $e';
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _switchRoot(_BrowseRoot root) {
|
|
||||||
if (root == _root) return;
|
|
||||||
setState(() {
|
|
||||||
_root = root;
|
|
||||||
_pathStack.clear();
|
|
||||||
});
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openFolder(CloudFolder folder) {
|
|
||||||
setState(() => _pathStack.add(folder));
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _goToBreadcrumb(int index) {
|
|
||||||
// index == -1 means the root label itself.
|
|
||||||
setState(() {
|
|
||||||
if (index < 0) {
|
|
||||||
_pathStack.clear();
|
|
||||||
} else {
|
|
||||||
_pathStack.removeRange(index + 1, _pathStack.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _useThisFolder() async {
|
|
||||||
final parentId = _currentFolderId;
|
|
||||||
if (parentId == null) return;
|
|
||||||
|
|
||||||
setState(() => _confirming = true);
|
|
||||||
try {
|
|
||||||
await context.read<AppState>().chooseCloudFolder(
|
|
||||||
parentId: parentId,
|
|
||||||
breadcrumbPath: _breadcrumbPath,
|
|
||||||
);
|
|
||||||
if (mounted) Navigator.of(context).pop();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Could not use this folder: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _confirming = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final providerName = context.read<AppState>().activeProvider!.displayName;
|
|
||||||
final canUseCurrentFolder = _currentFolderId != null;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text('Choose $providerName Folder')),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
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(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => _goToBreadcrumb(-1),
|
|
||||||
child: Text(_rootLabel),
|
|
||||||
),
|
|
||||||
for (var i = 0; i < _pathStack.length; i++) ...[
|
|
||||||
const Icon(Icons.chevron_right, size: 18),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => _goToBreadcrumb(i),
|
|
||||||
child: Text(_pathStack[i].name),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(child: _buildBody()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
bottomNavigationBar: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder,
|
|
||||||
icon: _confirming
|
|
||||||
? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.check),
|
|
||||||
label: Text('Use "$_breadcrumbPath"'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (_loading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
if (_error != null) {
|
|
||||||
return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)));
|
|
||||||
}
|
|
||||||
final folders = _folders ?? [];
|
|
||||||
if (folders.isEmpty) {
|
|
||||||
return const Center(child: Text('No folders here.'));
|
|
||||||
}
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: folders.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final folder = folders[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.folder_outlined),
|
|
||||||
title: Text(folder.name),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => _openFolder(folder),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -33,16 +33,12 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
late final TextEditingController _gallonsController;
|
late final TextEditingController _gallonsController;
|
||||||
late final TextEditingController _priceController;
|
late final TextEditingController _priceController;
|
||||||
late final TextEditingController _totalController;
|
late final TextEditingController _totalController;
|
||||||
late DateTime _date;
|
DateTime _date = DateTime.now();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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(
|
_gallonsController = TextEditingController(
|
||||||
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
||||||
);
|
);
|
||||||
|
|
@ -73,20 +69,11 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate() async {
|
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(
|
final pickedDate = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _date,
|
initialDate: _date,
|
||||||
firstDate: firstDate,
|
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
|
||||||
lastDate: lastDate,
|
lastDate: DateTime.now(),
|
||||||
);
|
);
|
||||||
if (pickedDate == null || !mounted) return;
|
if (pickedDate == null || !mounted) return;
|
||||||
|
|
||||||
|
|
@ -149,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|
|
||||||
|
|
@ -59,20 +59,18 @@ class _VehicleCard extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
|
|
||||||
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
|
leading: CircleAvatar(
|
||||||
title: Text(vehicle.displayLabel),
|
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
|
||||||
// Only repeat the VIN here when the title is already showing the
|
),
|
||||||
// nickname instead — otherwise the title (falling back to the VIN
|
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
|
||||||
// when there's no nickname) would show it twice.
|
subtitle: Text(
|
||||||
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
|
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
|
||||||
isThreeLine: hasNickname,
|
),
|
||||||
|
isThreeLine: true,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,12 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
/// Full-screen, pinch-zoomable view of a saved receipt photo.
|
||||||
|
class ReceiptImageScreen extends StatelessWidget {
|
||||||
|
final String imagePath;
|
||||||
|
|
||||||
/// Full-screen, pinch-zoomable view of a receipt photo. Pass
|
const ReceiptImageScreen({super.key, required this.imagePath});
|
||||||
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId})
|
|
||||||
: assert(localImagePath != null || driveFileId != null,
|
|
||||||
'Must provide either a local path or a Drive file ID');
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
|
||||||
Uint8List? _downloadedBytes;
|
|
||||||
bool _loading = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
if (widget.driveFileId != null) {
|
|
||||||
_download();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _download() async {
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
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 bytes = await session.downloadFileBytes(widget.driveFileId!);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_downloadedBytes = Uint8List.fromList(bytes);
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_error = 'Could not download receipt: $e';
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
session.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -78,26 +17,11 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
title: const Text('Receipt'),
|
title: const Text('Receipt'),
|
||||||
),
|
),
|
||||||
body: Center(child: _buildBody()),
|
body: Center(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.file(File(imagePath)),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (widget.localImagePath != null) {
|
|
||||||
return InteractiveViewer(child: Image.file(File(widget.localImagePath!)));
|
|
||||||
}
|
|
||||||
if (_loading) {
|
|
||||||
return const CircularProgressIndicator(color: Colors.white);
|
|
||||||
}
|
|
||||||
if (_error != null) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text(_error!, style: const TextStyle(color: Colors.white)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_downloadedBytes != null) {
|
|
||||||
return InteractiveViewer(child: Image.memory(_downloadedBytes!));
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/cloud/cloud_storage_provider.dart';
|
|
||||||
import 'cloud_folder_browser_screen.dart';
|
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
@ -14,75 +12,31 @@ class SettingsScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
bool _busy = false;
|
bool _changing = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
Future<void> _connect(CloudProviderId id) async {
|
Future<void> _chooseFolder() async {
|
||||||
setState(() {
|
setState(() => _error = null);
|
||||||
_busy = true;
|
|
||||||
_error = null;
|
final selectedPath = await FilePicker.platform.getDirectoryPath(
|
||||||
});
|
dialogTitle: 'Choose a folder for receipts and data',
|
||||||
|
);
|
||||||
|
if (selectedPath == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _changing = true);
|
||||||
try {
|
try {
|
||||||
await context.read<AppState>().connectProvider(id);
|
await context.read<AppState>().changeSaveDirectory(selectedPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _error = 'Could not sign in: $e');
|
setState(() => _error = 'Could not switch to that folder: $e');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted) setState(() => _changing = false);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>().disconnectCloud();
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _busy = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _chooseFolder() {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _syncNow() async {
|
|
||||||
setState(() => _busy = true);
|
|
||||||
try {
|
|
||||||
await context.read<AppState>().syncNow();
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _busy = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
final saveDir = appState.storage.saveDirectory.path;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
|
@ -95,148 +49,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
|
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (!appState.isCloudConnected) ...[
|
|
||||||
Text(
|
Text(
|
||||||
'Connect a cloud storage account to share vehicles and fuel '
|
'Receipt photos and the data file are stored here:',
|
||||||
'receipts with other people, and to keep a backup off this device.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
saveDir,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final provider in appState.availableProviders)
|
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _busy
|
onPressed: _changing ? null : _chooseFolder,
|
||||||
? null
|
icon: _changing
|
||||||
: () => provider is ManualCredentialCloudStorageProvider
|
|
||||||
? _connectManual(provider.id, provider.displayName)
|
|
||||||
: _connect(provider.id),
|
|
||||||
icon: const Icon(Icons.login),
|
|
||||||
label: Text('Connect ${provider.displayName}'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
appState.cloudFolderPath == null
|
|
||||||
? 'No folder selected yet.'
|
|
||||||
: 'Folder: ${appState.cloudFolderPath}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _busy ? null : _chooseFolder,
|
|
||||||
icon: const Icon(Icons.folder_open),
|
|
||||||
label: Text(appState.cloudFolderPath == null
|
|
||||||
? 'Choose Folder'
|
|
||||||
: 'Change Folder'),
|
|
||||||
),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: (_busy || appState.cloudFolderPath == null)
|
|
||||||
? null
|
|
||||||
: _syncNow,
|
|
||||||
icon: appState.isSyncing
|
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 16,
|
height: 16,
|
||||||
width: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
: const Icon(Icons.sync),
|
)
|
||||||
label: const Text('Sync Now'),
|
: const Icon(Icons.folder_open),
|
||||||
),
|
label: const Text('Choose Folder'),
|
||||||
TextButton.icon(
|
|
||||||
onPressed: _busy ? null : _disconnect,
|
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
label: const Text('Disconnect'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (appState.lastSyncedAt != null)
|
|
||||||
Text(
|
|
||||||
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
if (appState.lastSyncError != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4),
|
|
||||||
child: Text(
|
|
||||||
'Last sync failed: ${appState.lastSyncError}',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -260,93 +103,3 @@ 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,42 +9,24 @@ import '../models/fuel_entry.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/ocr_service.dart';
|
import '../services/ocr_service.dart';
|
||||||
import '../services/receipt_parser.dart';
|
import '../services/receipt_parser.dart';
|
||||||
import '../widgets/image_source_sheet.dart';
|
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
import 'confirm_fuel_entry_screen.dart';
|
import 'confirm_fuel_entry_screen.dart';
|
||||||
import 'receipt_image_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 {
|
class VehicleDetailScreen extends StatelessWidget {
|
||||||
final String vehicleId;
|
final String vehicleId;
|
||||||
|
|
||||||
const VehicleDetailScreen({super.key, required this.vehicleId});
|
const VehicleDetailScreen({super.key, required this.vehicleId});
|
||||||
|
|
||||||
Future<void> _captureReceipt(BuildContext context) async {
|
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();
|
final picker = ImagePicker();
|
||||||
XFile? photo;
|
XFile? photo;
|
||||||
try {
|
try {
|
||||||
photo = await picker.pickImage(
|
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
||||||
source: source,
|
|
||||||
imageQuality: keepMaxQuality ? null : receiptImageQuality,
|
|
||||||
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
|
|
||||||
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Could not open $sourceLabel: $e')),
|
SnackBar(content: Text('Could not open camera: $e')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -72,16 +54,8 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
|
|
||||||
final parsed = ReceiptParser.parse(recognizedText);
|
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) {
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ConfirmFuelEntryScreen(
|
builder: (_) => ConfirmFuelEntryScreen(
|
||||||
|
|
@ -94,30 +68,6 @@ 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 {
|
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -138,38 +88,6 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
|
|
||||||
if (entry.receiptImagePath != null) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Image.file(
|
|
||||||
File(entry.receiptImagePath!),
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.isReceiptUploadedToDrive) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const CircleAvatar(child: Icon(Icons.receipt_long));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
|
@ -186,7 +104,7 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(vehicle.displayLabel),
|
title: Text(vehicle.displayName),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
|
@ -226,7 +144,24 @@ class VehicleDetailScreen extends StatelessWidget {
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: _buildReceiptLeading(context, entry),
|
leading: entry.receiptImagePath != null
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.file(
|
||||||
|
File(entry.receiptImagePath!),
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const CircleAvatar(child: Icon(Icons.receipt_long)),
|
||||||
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
|
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
|
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
|
||||||
|
|
|
||||||
|
|
@ -1,343 +1,63 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/fuel_entry.dart';
|
import '../models/fuel_entry.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import 'cloud/cloud_storage_provider.dart';
|
import 'storage_service.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';
|
|
||||||
|
|
||||||
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
|
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||||
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
|
/// entries), backed by [StorageService] for persistence. Screens read from
|
||||||
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
|
/// this via Provider and call its mutating methods, which take care of
|
||||||
/// [CloudStorageProvider] the user has connected. Screens read from this
|
/// writing through to disk and notifying listeners.
|
||||||
/// 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
|
|
||||||
/// sync's merge happens as SQL directly against the database, not by
|
|
||||||
/// handing back updated Dart objects).
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
final DatabaseService database = DatabaseService();
|
final StorageService storage = StorageService();
|
||||||
|
|
||||||
/// 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();
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
List<Vehicle> vehicles = [];
|
List<Vehicle> vehicles = [];
|
||||||
List<FuelEntry> fuelEntries = [];
|
List<FuelEntry> fuelEntries = [];
|
||||||
bool isLoading = true;
|
bool isLoading = true;
|
||||||
|
|
||||||
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 {
|
Future<void> init() async {
|
||||||
isLoading = true;
|
await storage.init();
|
||||||
initError = null;
|
final data = await storage.loadData();
|
||||||
notifyListeners();
|
vehicles = data.vehicles;
|
||||||
|
fuelEntries = data.entries;
|
||||||
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;
|
isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
|
||||||
if (results.any((r) => r != ConnectivityResult.none)) {
|
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
unawaited(_restoreCloudConnection());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_connectivitySubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshFromDatabase() async {
|
|
||||||
vehicles = await database.getVehicles();
|
|
||||||
fuelEntries = await database.getFuelEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
activeProvider = provider;
|
|
||||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
|
||||||
|
|
||||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
|
||||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
|
||||||
if (folderId != null) {
|
|
||||||
cloudSync!.configure(folderId);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
if (folderId != null) {
|
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> connectProvider(CloudProviderId id) async {
|
|
||||||
final provider = _providerById(id);
|
|
||||||
if (provider == null) return;
|
|
||||||
|
|
||||||
await provider.signIn();
|
|
||||||
activeProvider = provider;
|
|
||||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 sync = cloudSync;
|
|
||||||
if (sync == null) return;
|
|
||||||
|
|
||||||
final folderId = await sync.selectAppFolder(parentId);
|
|
||||||
cloudFolderPath = breadcrumbPath;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_prefsKeyCloudFolderId, folderId);
|
|
||||||
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> syncNow() async {
|
|
||||||
final sync = cloudSync;
|
|
||||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
|
||||||
|
|
||||||
isSyncing = true;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
final result = await sync.syncNow(
|
|
||||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
|
||||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.ranSync) {
|
|
||||||
await _refreshFromDatabase();
|
|
||||||
lastSyncedAt = DateTime.now();
|
|
||||||
lastSyncError = null;
|
|
||||||
} else if (result.error != null) {
|
|
||||||
lastSyncError = result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncing = false;
|
|
||||||
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({
|
Future<void> addVehicle({
|
||||||
required String vin,
|
required String make,
|
||||||
String? nickname,
|
required String model,
|
||||||
|
required String color,
|
||||||
|
required String licensePlate,
|
||||||
}) async {
|
}) async {
|
||||||
if (await database.vinExists(vin)) {
|
vehicles.add(Vehicle(
|
||||||
throw DuplicateVinException(vin);
|
|
||||||
}
|
|
||||||
final vehicle = Vehicle(
|
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
vin: vin,
|
make: make,
|
||||||
nickname: nickname,
|
model: model,
|
||||||
updatedAt: DateTime.now().toUtc(),
|
color: color,
|
||||||
);
|
licensePlate: licensePlate,
|
||||||
await database.saveVehicle(vehicle);
|
));
|
||||||
await _persist();
|
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 {
|
Future<void> updateVehicle(Vehicle updated) async {
|
||||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
||||||
throw DuplicateVinException(updated.vin);
|
if (index != -1) {
|
||||||
}
|
vehicles[index] = updated;
|
||||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteVehicle(String id) async {
|
|
||||||
final now = DateTime.now().toUtc();
|
|
||||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
|
||||||
for (final path in orphanedLocalPaths) {
|
|
||||||
await database.deleteReceiptImageFile(path);
|
|
||||||
}
|
}
|
||||||
await database.softDeleteVehicle(id, now);
|
|
||||||
|
Future<void> deleteVehicle(String vehicleId) async {
|
||||||
|
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
|
||||||
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||||
|
}
|
||||||
|
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
|
||||||
|
vehicles.removeWhere((v) => v.id == vehicleId);
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,8 +72,7 @@ class AppState extends ChangeNotifier {
|
||||||
final id = _uuid.v4();
|
final id = _uuid.v4();
|
||||||
String? storedImagePath;
|
String? storedImagePath;
|
||||||
if (receiptImage != null) {
|
if (receiptImage != null) {
|
||||||
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
|
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
|
||||||
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final entry = FuelEntry(
|
final entry = FuelEntry(
|
||||||
|
|
@ -364,17 +83,16 @@ class AppState extends ChangeNotifier {
|
||||||
pricePerGallon: pricePerGallon,
|
pricePerGallon: pricePerGallon,
|
||||||
totalCost: totalCost,
|
totalCost: totalCost,
|
||||||
receiptImagePath: storedImagePath,
|
receiptImagePath: storedImagePath,
|
||||||
updatedAt: DateTime.now().toUtc(),
|
|
||||||
);
|
);
|
||||||
await database.saveFuelEntry(entry);
|
fuelEntries.add(entry);
|
||||||
await _persist();
|
await _persist();
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteFuelEntry(String entryId) async {
|
Future<void> deleteFuelEntry(String entryId) async {
|
||||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||||
await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
|
fuelEntries.removeWhere((e) => e.id == entryId);
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,13 +117,13 @@ class AppState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persist() async {
|
Future<void> changeSaveDirectory(String newPath) async {
|
||||||
await _refreshFromDatabase();
|
await storage.setSaveDirectory(newPath);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension _FirstOrNull<T> on Iterable<T> {
|
Future<void> _persist() async {
|
||||||
T? get firstOrNull => isEmpty ? null : first;
|
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
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() {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
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() {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
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() {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
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';
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
/// `ApplicationDocumentsDirectory/FuelTaxTracker`.
|
|
||||||
///
|
|
||||||
/// 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 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 [CloudSyncService] reads/clears it.
|
|
||||||
class DatabaseService {
|
|
||||||
late Directory _rootDirectory;
|
|
||||||
late Database _db;
|
|
||||||
|
|
||||||
Directory get rootDirectory => _rootDirectory;
|
|
||||||
|
|
||||||
Directory get receiptsDirectory =>
|
|
||||||
Directory(p.join(_rootDirectory.path, localReceiptsFolderName));
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
String get databasePath => _db.path;
|
|
||||||
|
|
||||||
Future<void> init() async {
|
|
||||||
final docsDir = await getApplicationDocumentsDirectory();
|
|
||||||
_rootDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
|
|
||||||
await _rootDirectory.create(recursive: true);
|
|
||||||
await receiptsDirectory.create(recursive: true);
|
|
||||||
|
|
||||||
final dbPath = p.join(_rootDirectory.path, dbFileName);
|
|
||||||
_db = await openDatabase(
|
|
||||||
dbPath,
|
|
||||||
version: 4,
|
|
||||||
onCreate: _onCreate,
|
|
||||||
onUpgrade: _onUpgrade,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
|
||||||
await db.execute(createVehiclesTableSql);
|
|
||||||
await db.execute(createFuelEntriesTableSql);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<FuelEntry>> getFuelEntries() async {
|
|
||||||
final rows = await _db.query('fuel_entries', where: 'deleted_at IS NULL');
|
|
||||||
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 {
|
|
||||||
final map = Map<String, Object?>.from(vehicle.toMap())..['dirty'] = 1;
|
|
||||||
await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveFuelEntry(FuelEntry entry) async {
|
|
||||||
final map = Map<String, Object?>.from(entry.toMap())..['dirty'] = 1;
|
|
||||||
await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> softDeleteVehicle(String id, DateTime deletedAt) async {
|
|
||||||
await _db.update(
|
|
||||||
'vehicles',
|
|
||||||
{
|
|
||||||
'deleted_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'updated_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'dirty': 1,
|
|
||||||
},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> softDeleteFuelEntry(String id, DateTime deletedAt) async {
|
|
||||||
await _db.update(
|
|
||||||
'fuel_entries',
|
|
||||||
{
|
|
||||||
'deleted_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'updated_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'dirty': 1,
|
|
||||||
},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Soft-deletes every active fuel entry for [vehicleId] (cascade for a
|
|
||||||
/// vehicle deletion) and returns their local receipt image paths, if
|
|
||||||
/// any, so the caller can clean those files up too.
|
|
||||||
Future<List<String>> softDeleteFuelEntriesForVehicle(
|
|
||||||
String vehicleId,
|
|
||||||
DateTime deletedAt,
|
|
||||||
) async {
|
|
||||||
final rows = await _db.query(
|
|
||||||
'fuel_entries',
|
|
||||||
where: 'vehicle_id = ? AND deleted_at IS NULL',
|
|
||||||
whereArgs: [vehicleId],
|
|
||||||
);
|
|
||||||
final localPaths =
|
|
||||||
rows.map((r) => r['receipt_image_path'] as String?).whereType<String>().toList();
|
|
||||||
|
|
||||||
await _db.update(
|
|
||||||
'fuel_entries',
|
|
||||||
{
|
|
||||||
'deleted_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'updated_at': deletedAt.millisecondsSinceEpoch,
|
|
||||||
'dirty': 1,
|
|
||||||
},
|
|
||||||
where: 'vehicle_id = ? AND deleted_at IS NULL',
|
|
||||||
whereArgs: [vehicleId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return localPaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> deleteReceiptImageFile(String? path) async {
|
|
||||||
if (path == null) return;
|
|
||||||
final file = File(path);
|
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
const createVehiclesTableSql = '''
|
|
||||||
CREATE TABLE vehicles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
vin TEXT NOT NULL,
|
|
||||||
nickname TEXT,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
dirty INTEGER NOT NULL DEFAULT 1
|
|
||||||
)
|
|
||||||
''';
|
|
||||||
|
|
||||||
const createFuelEntriesTableSql = '''
|
|
||||||
CREATE TABLE fuel_entries (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
vehicle_id 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
|
|
||||||
)
|
|
||||||
''';
|
|
||||||
|
|
||||||
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, 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
|
|
||||||
''';
|
|
||||||
|
|
||||||
/// Same rule as [mergeVehiclesSql]. `receipt_image_path` is always forced
|
|
||||||
/// to NULL on the merged-in copy — it's a local filesystem path, meaningless
|
|
||||||
/// (and never valid) on another device, and rows are only ever pushed to
|
|
||||||
/// Drive once their pending receipt has already been uploaded there, so a
|
|
||||||
/// remote row should never legitimately have one anyway.
|
|
||||||
const mergeFuelEntriesSql = '''
|
|
||||||
INSERT OR REPLACE INTO main.fuel_entries
|
|
||||||
(id, vehicle_id, date, gallons, price_per_gallon, total_cost,
|
|
||||||
receipt_image_path, receipt_drive_file_id, updated_at, deleted_at, dirty)
|
|
||||||
SELECT r.id, r.vehicle_id, r.date, r.gallons, r.price_per_gallon, r.total_cost,
|
|
||||||
NULL, r.receipt_drive_file_id, r.updated_at, r.deleted_at, 0
|
|
||||||
FROM remote_db.fuel_entries r
|
|
||||||
LEFT JOIN main.fuel_entries l ON l.id = r.id
|
|
||||||
WHERE l.id IS NULL OR r.updated_at > l.updated_at
|
|
||||||
''';
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
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
|
|
||||||
/// *other* lock older than ours remains — reaping ("deleting") any it
|
|
||||||
/// finds older than [staleAge] along the way, as a backstop against a
|
|
||||||
/// device that crashed/went offline before releasing its own lock.
|
|
||||||
///
|
|
||||||
/// Pure orchestration over injected operations (rather than a concrete
|
|
||||||
/// 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<CloudLockFile>> Function() listLocks,
|
|
||||||
required Future<void> Function(String lockId) deleteLock,
|
|
||||||
DateTime Function()? nowUtc,
|
|
||||||
Future<void> Function(Duration)? delay,
|
|
||||||
Duration staleAge = const Duration(minutes: 10),
|
|
||||||
Duration pollInterval = const Duration(seconds: 1),
|
|
||||||
}) async {
|
|
||||||
final now = nowUtc ?? () => DateTime.now().toUtc();
|
|
||||||
final wait = delay ?? Future.delayed;
|
|
||||||
|
|
||||||
final epochMillis = now().millisecondsSinceEpoch;
|
|
||||||
final lockName = '$username-$epochMillis.lock';
|
|
||||||
final lockId = await createLock(lockName);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
final locks = await listLocks();
|
|
||||||
final others = locks.where((l) => l.id != lockId);
|
|
||||||
final olderOthers =
|
|
||||||
others.where((l) => l.createdAtUtc.millisecondsSinceEpoch < epochMillis).toList();
|
|
||||||
|
|
||||||
if (olderOthers.isEmpty) break;
|
|
||||||
|
|
||||||
final current = now();
|
|
||||||
for (final stale in olderOthers) {
|
|
||||||
if (current.difference(stale.createdAtUtc) > staleAge) {
|
|
||||||
await deleteLock(stale.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(pollInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lockId;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,3 @@
|
||||||
/// 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
|
/// 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
|
/// null if it couldn't be found, and the confirm screen lets the user fill
|
||||||
/// in or correct whatever the parser got wrong.
|
/// in or correct whatever the parser got wrong.
|
||||||
|
|
@ -27,25 +5,12 @@ class ParsedReceipt {
|
||||||
final double? gallons;
|
final double? gallons;
|
||||||
final double? pricePerGallon;
|
final double? pricePerGallon;
|
||||||
final double? totalCost;
|
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;
|
final String rawText;
|
||||||
|
|
||||||
ParsedReceipt({
|
ParsedReceipt({
|
||||||
this.gallons,
|
this.gallons,
|
||||||
this.pricePerGallon,
|
this.pricePerGallon,
|
||||||
this.totalCost,
|
this.totalCost,
|
||||||
this.date,
|
|
||||||
this.state,
|
|
||||||
required this.rawText,
|
required this.rawText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -58,91 +23,36 @@ class ParsedReceipt {
|
||||||
/// to deriving a missing value from the other two when exactly one is
|
/// to deriving a missing value from the other two when exactly one is
|
||||||
/// missing (total = gallons * price, etc).
|
/// missing (total = gallons * price, etc).
|
||||||
class ReceiptParser {
|
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 = [
|
static final _gallonsPatterns = [
|
||||||
RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||||
RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
|
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
|
||||||
RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false),
|
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
|
||||||
];
|
];
|
||||||
|
|
||||||
static final _pricePerGallonPatterns = [
|
static final _pricePerGallonPatterns = [
|
||||||
RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||||
caseSensitive: false),
|
caseSensitive: false),
|
||||||
RegExp(r'\bPPG\b[ \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[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||||
caseSensitive: false),
|
caseSensitive: false),
|
||||||
RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
|
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
|
||||||
caseSensitive: false),
|
caseSensitive: false),
|
||||||
];
|
];
|
||||||
|
|
||||||
static final _totalPatterns = [
|
static final _totalPatterns = [
|
||||||
RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||||
RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||||
// Allows for words between TOTAL and the amount ("Total Sale $120.72",
|
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
|
||||||
// "Fuel Total: $44.44"), not just a bare colon/dash.
|
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
|
||||||
RegExp(r'(?<!SUB)\bTOTAL\b[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
|
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) {
|
static ParsedReceipt parse(String text) {
|
||||||
final normalized = text.replaceAll(',', '');
|
final normalized = text.replaceAll(',', '');
|
||||||
|
|
||||||
var gallons = _firstMatch(_gallonsPatterns, normalized);
|
final gallons = _firstMatch(_gallonsPatterns, normalized);
|
||||||
var pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
|
||||||
final totalCost = _firstMatch(_totalPatterns, normalized);
|
var 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(
|
final derivedTotal = _deriveMissingValue(
|
||||||
gallons: gallons,
|
gallons: gallons,
|
||||||
|
|
@ -154,76 +64,10 @@ class ReceiptParser {
|
||||||
gallons: derivedTotal.gallons,
|
gallons: derivedTotal.gallons,
|
||||||
pricePerGallon: derivedTotal.pricePerGallon,
|
pricePerGallon: derivedTotal.pricePerGallon,
|
||||||
totalCost: derivedTotal.totalCost,
|
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,
|
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) {
|
static double? _firstMatch(List<RegExp> patterns, String text) {
|
||||||
for (final pattern in patterns) {
|
for (final pattern in patterns) {
|
||||||
final match = pattern.firstMatch(text);
|
final match = pattern.firstMatch(text);
|
||||||
|
|
|
||||||
128
lib/services/storage_service.dart
Normal file
128
lib/services/storage_service.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/fuel_entry.dart';
|
||||||
|
import '../models/vehicle.dart';
|
||||||
|
|
||||||
|
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
|
||||||
|
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
|
||||||
|
/// The parent directory containing both is user-configurable (see
|
||||||
|
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
|
||||||
|
class StorageService {
|
||||||
|
static const _prefsKey = 'save_directory_path';
|
||||||
|
static const _dataFileName = 'fuel_tax_data.json';
|
||||||
|
static const _receiptsFolderName = 'receipts';
|
||||||
|
|
||||||
|
late Directory _saveDirectory;
|
||||||
|
|
||||||
|
Directory get saveDirectory => _saveDirectory;
|
||||||
|
|
||||||
|
Directory get receiptsDirectory =>
|
||||||
|
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
|
||||||
|
|
||||||
|
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
|
||||||
|
|
||||||
|
/// Must be called once before any other method. Loads the previously
|
||||||
|
/// chosen save directory, falling back to the app's own documents
|
||||||
|
/// directory the first time the app runs.
|
||||||
|
Future<void> init() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final storedPath = prefs.getString(_prefsKey);
|
||||||
|
|
||||||
|
if (storedPath != null && await Directory(storedPath).exists()) {
|
||||||
|
_saveDirectory = Directory(storedPath);
|
||||||
|
} else {
|
||||||
|
final docsDir = await getApplicationDocumentsDirectory();
|
||||||
|
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveDirectory.create(recursive: true);
|
||||||
|
await receiptsDirectory.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
|
||||||
|
if (!await _dataFile.exists()) {
|
||||||
|
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = await _dataFile.readAsString();
|
||||||
|
if (raw.trim().isEmpty) {
|
||||||
|
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
|
||||||
|
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final entries = (json['fuelEntries'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => FuelEntry.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return (vehicles: vehicles, entries: entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveData({
|
||||||
|
required List<Vehicle> vehicles,
|
||||||
|
required List<FuelEntry> entries,
|
||||||
|
}) async {
|
||||||
|
final json = {
|
||||||
|
'vehicles': vehicles.map((v) => v.toJson()).toList(),
|
||||||
|
'fuelEntries': entries.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies a captured receipt image into the receipts folder and returns
|
||||||
|
/// the path it was stored at.
|
||||||
|
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
|
||||||
|
final ext = p.extension(sourceImage.path);
|
||||||
|
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
|
||||||
|
final copied = await sourceImage.copy(destPath);
|
||||||
|
return copied.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteReceiptImage(String? path) async {
|
||||||
|
if (path == null) return;
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes where the data file and receipt images live, moving any
|
||||||
|
/// existing data/images from the old location into the new one.
|
||||||
|
Future<void> setSaveDirectory(String newPath) async {
|
||||||
|
final newDir = Directory(newPath);
|
||||||
|
await newDir.create(recursive: true);
|
||||||
|
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
|
||||||
|
await newReceiptsDir.create(recursive: true);
|
||||||
|
|
||||||
|
final oldDataFile = _dataFile;
|
||||||
|
final oldReceiptsDir = receiptsDirectory;
|
||||||
|
|
||||||
|
if (await oldDataFile.exists() &&
|
||||||
|
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
|
||||||
|
await oldDataFile.copy(p.join(newPath, _dataFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await oldReceiptsDir.exists()) {
|
||||||
|
await for (final entity in oldReceiptsDir.list()) {
|
||||||
|
if (entity is File) {
|
||||||
|
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
|
||||||
|
if (!p.equals(entity.path, destPath)) {
|
||||||
|
await entity.copy(destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveDirectory = newDir;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_prefsKey, newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
342
pubspec.lock
342
pubspec.lock
|
|
@ -1,14 +1,6 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
_discoveryapis_commons:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: _discoveryapis_commons
|
|
||||||
sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.7"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -65,22 +57,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
connectivity_plus:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: connectivity_plus
|
|
||||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.1.5"
|
|
||||||
connectivity_plus_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: connectivity_plus_platform_interface
|
|
||||||
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.0"
|
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -90,7 +66,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+4"
|
version: "0.3.5+4"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
@ -113,14 +89,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.14"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -137,14 +105,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -153,6 +113,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.3.10"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -214,96 +182,16 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.35"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
google_identity_services_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_identity_services_web
|
|
||||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.3+1"
|
|
||||||
google_mlkit_commons:
|
google_mlkit_commons:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -320,54 +208,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.1"
|
version: "0.15.1"
|
||||||
google_sign_in:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_sign_in
|
|
||||||
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.2.0"
|
|
||||||
google_sign_in_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_android
|
|
||||||
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.2.16"
|
|
||||||
google_sign_in_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_ios
|
|
||||||
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.3.0"
|
|
||||||
google_sign_in_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_platform_interface
|
|
||||||
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.0"
|
|
||||||
google_sign_in_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_web
|
|
||||||
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.3"
|
|
||||||
googleapis:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: googleapis
|
|
||||||
sha256: "5c9e0f25be1dec13d8d2158263141104c51b5ba83487537c17a2330581e505ee"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "14.0.0"
|
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -377,7 +217,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|
@ -560,14 +400,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.19.2"
|
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -576,14 +408,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
nm:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: nm
|
|
||||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.5.0"
|
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -641,7 +465,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.2.2"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: "direct dev"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
|
@ -673,7 +497,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.6"
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: "direct dev"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: plugin_platform_interface
|
name: plugin_platform_interface
|
||||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
|
@ -773,62 +597,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sqflite:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: sqflite
|
|
||||||
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.3"
|
|
||||||
sqflite_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqflite_android
|
|
||||||
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.3"
|
|
||||||
sqflite_common:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqflite_common
|
|
||||||
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.11"
|
|
||||||
sqflite_common_ffi:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: sqflite_common_ffi
|
|
||||||
sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.2"
|
|
||||||
sqflite_darwin:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqflite_darwin
|
|
||||||
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.3+1"
|
|
||||||
sqflite_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqflite_platform_interface
|
|
||||||
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.1"
|
|
||||||
sqlite3:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqlite3
|
|
||||||
sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.5.1"
|
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -853,14 +621,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
synchronized:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: synchronized
|
|
||||||
sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.4.1+1"
|
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -885,70 +645,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -985,18 +681,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.0"
|
version: "5.15.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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1006,7 +694,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
|
|
||||||
59
pubspec.yaml
59
pubspec.yaml
|
|
@ -44,10 +44,21 @@ dependencies:
|
||||||
# On-device OCR text recognition
|
# On-device OCR text recognition
|
||||||
google_mlkit_text_recognition: ^0.15.0
|
google_mlkit_text_recognition: ^0.15.0
|
||||||
|
|
||||||
# App sandbox paths (local offline staging area)
|
# Directory picker for configurable save location.
|
||||||
|
# Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin
|
||||||
|
# Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers
|
||||||
|
# it, but that isn't actually wired up yet for library modules in this
|
||||||
|
# Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11
|
||||||
|
# still compiles against compileSdk 36 (matches flutter.compileSdkVersion,
|
||||||
|
# satisfying flutter_plugin_android_lifecycle's requirement) but applies
|
||||||
|
# its own Kotlin plugin unconditionally, which works. (10.3.11 itself is
|
||||||
|
# retracted on pub.dev, hence pinning to 10.3.10.)
|
||||||
|
file_picker: 10.3.10
|
||||||
|
|
||||||
|
# App sandbox paths (default save location, temp files)
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# Persist small settings (Drive connection state, last synced time)
|
# Persist small settings like chosen save directory
|
||||||
shared_preferences: ^2.3.4
|
shared_preferences: ^2.3.4
|
||||||
|
|
||||||
# Unique IDs for vehicles/fuel entries
|
# Unique IDs for vehicles/fuel entries
|
||||||
|
|
@ -59,54 +70,10 @@ dependencies:
|
||||||
# Path joining/manipulation helpers
|
# Path joining/manipulation helpers
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|
||||||
# Google OAuth sign-in for Drive access
|
|
||||||
google_sign_in: ^7.1.1
|
|
||||||
|
|
||||||
# Google Drive API client
|
|
||||||
googleapis: ^14.0.0
|
|
||||||
|
|
||||||
# HTTP client interface (for the authenticated client we hand to DriveApi)
|
|
||||||
http: ^1.2.2
|
|
||||||
|
|
||||||
# Detect connectivity changes to trigger background sync
|
|
||||||
connectivity_plus: ^6.1.0
|
|
||||||
|
|
||||||
# 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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Pure-Dart/FFI SQLite backend so database logic (the ATTACH-based merge)
|
|
||||||
# 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
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# 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
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
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';
|
|
||||||
|
|
||||||
/// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs
|
|
||||||
/// (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() {
|
|
||||||
late Directory tempDir;
|
|
||||||
late Database local;
|
|
||||||
late Database remote;
|
|
||||||
|
|
||||||
setUpAll(() {
|
|
||||||
sqfliteFfiInit();
|
|
||||||
databaseFactory = databaseFactoryFfi;
|
|
||||||
});
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
tempDir = await Directory.systemTemp.createTemp('db_merge_test_');
|
|
||||||
local = await _openFreshDb(p.join(tempDir.path, 'local.db'));
|
|
||||||
remote = await _openFreshDb(p.join(tempDir.path, 'remote.db'));
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await local.close();
|
|
||||||
await remote.close();
|
|
||||||
await tempDir.delete(recursive: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> merge() async {
|
|
||||||
await local.execute("ATTACH DATABASE '${remote.path}' AS remote_db");
|
|
||||||
try {
|
|
||||||
await local.execute(mergeVehiclesSql);
|
|
||||||
await local.execute(mergeFuelEntriesSql);
|
|
||||||
} finally {
|
|
||||||
await local.execute('DETACH DATABASE remote_db');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
group('vehicle merge', () {
|
|
||||||
test('pulls in a vehicle that only exists on remote', () async {
|
|
||||||
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', vin: 'VIN1', updatedAt: 1000, dirty: 1));
|
|
||||||
|
|
||||||
await merge();
|
|
||||||
|
|
||||||
final rows = await local.query('vehicles');
|
|
||||||
expect(rows, hasLength(1));
|
|
||||||
expect(rows.first['dirty'], 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('remote wins when strictly newer than local', () async {
|
|
||||||
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['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', 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['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', () {
|
|
||||||
test('adopts a newer tombstone from remote (deletion propagates)', () async {
|
|
||||||
await local.insert('fuel_entries',
|
|
||||||
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000, dirty: 0));
|
|
||||||
await remote.insert(
|
|
||||||
'fuel_entries',
|
|
||||||
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 2000, deletedAt: 2000),
|
|
||||||
);
|
|
||||||
|
|
||||||
await merge();
|
|
||||||
|
|
||||||
final rows = await local.query('fuel_entries');
|
|
||||||
expect(rows, hasLength(1));
|
|
||||||
expect(rows.first['deleted_at'], 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('never adopts a receipt_image_path value from remote', () async {
|
|
||||||
// Defensive case: even if a remote row somehow had a local-looking
|
|
||||||
// path in that column, the merge must not copy it onto this device.
|
|
||||||
await remote.insert(
|
|
||||||
'fuel_entries',
|
|
||||||
_fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000)
|
|
||||||
..['receipt_image_path'] = '/some/other/devices/path.jpg',
|
|
||||||
);
|
|
||||||
|
|
||||||
await merge();
|
|
||||||
|
|
||||||
final rows = await local.query('fuel_entries');
|
|
||||||
expect(rows, hasLength(1));
|
|
||||||
expect(rows.first['receipt_image_path'], isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Database> _openFreshDb(String path) async {
|
|
||||||
final db = await databaseFactory.openDatabase(path);
|
|
||||||
await db.execute(createVehiclesTableSql);
|
|
||||||
await db.execute(createFuelEntriesTableSql);
|
|
||||||
await db.execute(createFuelEntriesIndexSql);
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> _vehicleRow({
|
|
||||||
required String id,
|
|
||||||
required String vin,
|
|
||||||
String? nickname = 'Test Vehicle',
|
|
||||||
int updatedAt = 0,
|
|
||||||
int? deletedAt,
|
|
||||||
int dirty = 0,
|
|
||||||
}) =>
|
|
||||||
{
|
|
||||||
'id': id,
|
|
||||||
'vin': vin,
|
|
||||||
'nickname': nickname,
|
|
||||||
'updated_at': updatedAt,
|
|
||||||
'deleted_at': deletedAt,
|
|
||||||
'dirty': dirty,
|
|
||||||
};
|
|
||||||
|
|
||||||
Map<String, Object?> _fuelEntryRow({
|
|
||||||
required String id,
|
|
||||||
required String vehicleId,
|
|
||||||
int updatedAt = 0,
|
|
||||||
int? deletedAt,
|
|
||||||
int dirty = 0,
|
|
||||||
}) =>
|
|
||||||
{
|
|
||||||
'id': id,
|
|
||||||
'vehicle_id': vehicleId,
|
|
||||||
'date': updatedAt,
|
|
||||||
'gallons': 10.0,
|
|
||||||
'price_per_gallon': 3.5,
|
|
||||||
'total_cost': 35.0,
|
|
||||||
'receipt_image_path': null,
|
|
||||||
'receipt_drive_file_id': null,
|
|
||||||
'updated_at': updatedAt,
|
|
||||||
'deleted_at': deletedAt,
|
|
||||||
'dirty': dirty,
|
|
||||||
};
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart' show CloudLockFile;
|
|
||||||
import 'package:fuel_tax_tracker/services/lock_coordinator.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('acquireLock', () {
|
|
||||||
test('creates a lock file named {username}-{epochMillis}.lock', () async {
|
|
||||||
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
|
|
||||||
String? capturedName;
|
|
||||||
|
|
||||||
await acquireLock(
|
|
||||||
username: 'me@example.com',
|
|
||||||
nowUtc: () => now,
|
|
||||||
createLock: (name) async {
|
|
||||||
capturedName = name;
|
|
||||||
return 'id';
|
|
||||||
},
|
|
||||||
listLocks: () async =>
|
|
||||||
[CloudLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)],
|
|
||||||
deleteLock: (_) async {},
|
|
||||||
delay: (_) async {},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(capturedName, 'me@example.com-${now.millisecondsSinceEpoch}.lock');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('proceeds immediately when no other lock is older', () async {
|
|
||||||
var listCallCount = 0;
|
|
||||||
final delayCalls = <Duration>[];
|
|
||||||
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
|
|
||||||
|
|
||||||
final lockId = await acquireLock(
|
|
||||||
username: 'me',
|
|
||||||
nowUtc: () => now,
|
|
||||||
createLock: (_) async => 'my-lock-id',
|
|
||||||
listLocks: () async {
|
|
||||||
listCallCount++;
|
|
||||||
return [CloudLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)];
|
|
||||||
},
|
|
||||||
deleteLock: (_) async {},
|
|
||||||
delay: (d) async => delayCalls.add(d),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lockId, 'my-lock-id');
|
|
||||||
expect(listCallCount, 1);
|
|
||||||
expect(delayCalls, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a newer lock does not block acquisition', () async {
|
|
||||||
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
|
|
||||||
final delayCalls = <Duration>[];
|
|
||||||
|
|
||||||
final lockId = await acquireLock(
|
|
||||||
username: 'me',
|
|
||||||
nowUtc: () => now,
|
|
||||||
createLock: (_) async => 'mine',
|
|
||||||
listLocks: () async => [
|
|
||||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
|
||||||
CloudLockFile(
|
|
||||||
id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))),
|
|
||||||
],
|
|
||||||
deleteLock: (_) async {},
|
|
||||||
delay: (d) async => delayCalls.add(d),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lockId, 'mine');
|
|
||||||
expect(delayCalls, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('waits for an older, non-stale lock, then proceeds once it is gone', () async {
|
|
||||||
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
|
|
||||||
var listCallCount = 0;
|
|
||||||
final delayCalls = <Duration>[];
|
|
||||||
final deleteCalls = <String>[];
|
|
||||||
|
|
||||||
final lockId = await acquireLock(
|
|
||||||
username: 'me',
|
|
||||||
nowUtc: () => now,
|
|
||||||
createLock: (_) async => 'mine',
|
|
||||||
listLocks: () async {
|
|
||||||
listCallCount++;
|
|
||||||
if (listCallCount == 1) {
|
|
||||||
return [
|
|
||||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
|
||||||
CloudLockFile(
|
|
||||||
id: 'other',
|
|
||||||
username: 'other',
|
|
||||||
createdAtUtc: now.subtract(const Duration(seconds: 5))),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
|
||||||
},
|
|
||||||
deleteLock: (id) async => deleteCalls.add(id),
|
|
||||||
delay: (d) async => delayCalls.add(d),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lockId, 'mine');
|
|
||||||
expect(listCallCount, 2);
|
|
||||||
expect(delayCalls.length, 1);
|
|
||||||
expect(deleteCalls, isEmpty, reason: 'a non-stale older lock should not be deleted');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deletes an older lock once it exceeds the staleness threshold', () async {
|
|
||||||
final now = DateTime.utc(2024, 1, 1, 12, 0, 0);
|
|
||||||
var listCallCount = 0;
|
|
||||||
final deleteCalls = <String>[];
|
|
||||||
|
|
||||||
await acquireLock(
|
|
||||||
username: 'me',
|
|
||||||
nowUtc: () => now,
|
|
||||||
staleAge: const Duration(minutes: 10),
|
|
||||||
createLock: (_) async => 'mine',
|
|
||||||
listLocks: () async {
|
|
||||||
listCallCount++;
|
|
||||||
if (listCallCount == 1) {
|
|
||||||
return [
|
|
||||||
CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now),
|
|
||||||
CloudLockFile(
|
|
||||||
id: 'stale',
|
|
||||||
username: 'ghost',
|
|
||||||
createdAtUtc: now.subtract(const Duration(minutes: 15))),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)];
|
|
||||||
},
|
|
||||||
deleteLock: (id) async => deleteCalls.add(id),
|
|
||||||
delay: (_) async {},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(deleteCalls, ['stale']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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,37 +21,6 @@ void main() {
|
||||||
expect(result.totalCost, 44.44);
|
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', () {
|
test('does not confuse SUBTOTAL with TOTAL', () {
|
||||||
const receipt = '''
|
const receipt = '''
|
||||||
SUBTOTAL 10.00
|
SUBTOTAL 10.00
|
||||||
|
|
@ -98,386 +67,6 @@ void main() {
|
||||||
expect(result.gallons, isNull);
|
expect(result.gallons, isNull);
|
||||||
expect(result.pricePerGallon, isNull);
|
expect(result.pricePerGallon, isNull);
|
||||||
expect(result.totalCost, 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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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