Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
483fbeafb6 Remote sync of webdav 2026-08-13 17:05:10 -05:00
f01186df79 Added sqlite 2026-08-08 16:55:40 -05:00
73a7c93e64 Added Google Drive storage location 2026-08-08 14:59:18 -05:00
41 changed files with 6183 additions and 429 deletions

376
README.md
View file

@ -3,37 +3,225 @@
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 to a data file in a folder you choose. photo. Vehicles and fuel entries live in a local SQLite database that's kept
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 (make, model, color, license plate). - Manage a list of vehicles, identified by **VIN** (required, and unique
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google across active vehicles — but editable, e.g. to fix a typo from a misread
ML Kit — no internet connection or API key required. scan; scan it from a photo of the door-jamb sticker or dashboard plate
- Review/edit the parsed gallons, price per gallon, and total cost before instead of typing all 17 characters). An optional **nickname** ("Mom's
saving (OCR on printed receipts is usually good but not perfect). Car", "Red Ford F-150") is what's shown as the primary label everywhere;
without one, the VIN is shown instead.
- Capture a fuel receipt photo per vehicle — from the camera or an existing
photo in your library — and OCR it on-device with Google ML Kit — no
internet connection or API key required for the OCR itself.
- If a receipt's address shows a state other than Missouri, a warning
explains that the fuel tax refund only covers Missouri purchases and lets
you choose whether to log the entry anyway.
- Review/edit the parsed gallons, price per gallon, total cost, *and*
date/time before saving (OCR on printed receipts is usually good but not
perfect; the date/time picker is always available as a manual fallback
when a date can't be found on the receipt).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
photos, and delete. photos, and delete.
- Settings screen to choose where receipt photos and the data file are - Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV
stored (defaults to the app's own documents folder; existing data is server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
copied over when you change it). endpoint) and pick any folder you have access to — including one someone
else created and shared with you — as the shared storage location. The
app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder there, so
anyone pointed at the same shared parent converges on the same data
automatically, regardless of which of the four providers each person is
using. WebDAV is the only one of the four that needs no developer-console
setup at all — just a server URL, username, and password.
- Works fully offline: writes always land in the local SQLite database
first; a background sync pushes changes to 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 JSON (de)serialization models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
`deletedAt` (a soft-delete tombstone, so deletions sync too). 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 state + CRUD, backed by StorageService, exposed via Provider app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path updatedAt on mutations and triggers background sync.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text and the receipts/ folder, always at
ApplicationDocumentsDirectory/FuelTaxTracker.
db_schema.dart CREATE TABLE statements and the merge SQL — shared between
the app and its tests so they can never drift apart.
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) confirm fuel entry, receipt viewer, settings, cloud folder browser).
``` ```
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/` ## Manual setup required (cloud storage)
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
@ -51,43 +239,145 @@ 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. to produce a working APK. None of the three providers' sign-in/sync paths
can be exercised end-to-end until you've done that provider's manual OAuth
setup above.
## Permissions ## 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.
- **Storage**: no explicit storage permission is declared. The default save - **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the
location is inside the app's own sandbox (no permission needed). If you main Android manifest (needed for release builds; debug builds get
point Settings at a location outside the sandbox, the OS-native folder `INTERNET` for free).
picker (via `file_picker`) is what grants access — see the caveat below. - **OAuth redirects**: no manifest changes are needed for `google_sign_in`
itself when not using `google-services.json` — see the Google setup
section above instead. Dropbox and OneDrive's browser-based OAuth redirect
is already wired up via a `flutter_web_auth_2` callback activity
(Android) / extra `CFBundleURLTypes` entries (iOS) for the
`mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you
only need to register the matching redirect URI in each provider's own
console (see setup above), not touch these files, unless you deliberately
choose different scheme names.
## How sync works
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
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions - **Field-level conflicts aren't merged.** Two people editing the *same*
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP record at the same time: whole record, newer `updated_at` wins — not a
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't field-by-field merge.
actually wired up for library modules in this Flutter/AGP combination yet, - **Lock acquisition has no hard timeout** beyond the configurable
so the plugin's own Kotlin sources never get compiled and the build fails staleness reap (Settings → Advanced). Fine at the scale this is built for
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is (a handful of people); could in theory spin under many simultaneous
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time contenders.
you bump dependencies — check the package's CHANGELOG for when this is - **VIN uniqueness is checked, but not race-proof across devices.** Editing
properly resolved upstream. or adding a vehicle checks for an existing active vehicle with the same
- **iOS folder picking and app restarts**: `file_picker`'s directory picker VIN before saving, but two offline devices could still each independently
on iOS uses `UIDocumentPickerViewController`, which hands back a create (or rename into) the same VIN before either has seen the other's
security-scoped URL. This app does not currently persist a security-scoped change — same category of accepted limitation as "field-level conflicts
bookmark for that URL, so if you pick a folder outside the app's own aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after `vin`, specifically so this doesn't corrupt the merge itself if it
an app restart is not guaranteed. Picking the default in-sandbox location, happens — you'd just end up with two vehicle rows sharing a VIN until
or a folder on Android, does not have this limitation. If cross-restart someone notices and fixes it by hand.
external storage on iOS matters for your use case, this needs a proper - **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`). (`serverClientId`), not just the Android client's SHA-1 registration —
see the manual setup section. This is a quirk of the Credential
Manager-based implementation and easy to miss if you're used to older
`google_sign_in` versions.
- **Dropbox and OneDrive's sign-in isn't restored across a cold app
restart.** Their OAuth refresh tokens are kept in memory only for now
(Google's own SDK handles its own persistent session separately, and
WebDAV persists its credentials to OS-encrypted storage — see below — so
this only affects these two hand-built OAuth providers) —
`attemptSilentSignIn()` always returns false for them, so you'll need to
reconnect once per app launch until refresh-token persistence is added.
- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore
on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the
session after a cold restart — it re-verifies them with the same PROPFIND
check `signInWithCredentials` uses rather than trusting the stored values
blindly, since the server or password could have changed since. A wrong
or revoked password just silently fails the restore (same as no
connection ever having been made); there's no proactive UI nudge to
reconnect beyond the sync error that shows up on the next sync attempt.
- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks
the access token's expiry before every call and refreshes proactively,
but a token revoked or invalidated out-of-band (e.g. from that provider's
own "manage app access" page) surfaces as a failed sync (visible in
Settings as "Last sync failed") rather than prompting a fresh sign-in
automatically.
- **Dropbox has no distinct "Shared with me" tab** in the folder browser
(the interface's `sharedWithMe` parameter is a no-op for
`DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into
the account's normal folder tree, so they already show up under "My
Files" in the common case. Revisit if an unmounted/pending share needs to
be browsable directly.
- **WebDAV has no "Shared with me" concept at all** (it's not part of the
base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false,
same as Dropbox, and it's up to the server/user to point the app at
whatever path a share is mounted under. WebDAV credentials are also sent
as HTTP Basic Auth on every request, so an **HTTPS server URL is
effectively required** — the app doesn't block a plain `http://` URL, but
it would send the password in the clear.
- **Receipt parsing is best-effort regex matching** on the OCR'd text - **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 or camera capture path itself (that - No automated tests exercise the OCR, camera/gallery picker, or real Drive
requires a real device/emulator with a camera); `receipt_parser_test.dart` API calls (those need a real device/emulator and live credentials);
covers the parsing logic against fixed OCR text. `receipt_parser_test.dart`, `lock_coordinator_test.dart`,
`db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter
two using `sqflite_common_ffi` to run real SQL against temp SQLite files
on the Dart VM) cover the pure logic pieces against fixed inputs —
`receipt_parser_test.dart` in particular is transcribed from 13 real
photographed receipts across different gas station chains.
- No migration path exists from the earlier JSON-file storage format —
not needed since no real data had accumulated under it yet, but flag it
if that's no longer true for you.

View file

@ -1,6 +1,11 @@
<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}"
@ -27,6 +32,22 @@
<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

View file

@ -8,6 +8,8 @@
<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>
@ -68,5 +70,41 @@
<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>

View file

@ -41,6 +41,32 @@ 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();
}, },
); );

View file

@ -1,11 +1,21 @@
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,
@ -14,26 +24,75 @@ 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,
}); });
Map<String, dynamic> toJson() => { /// True once the receipt photo has been uploaded to Drive, regardless of
/// whether a local copy is also being kept (see the "keep photos on this
/// phone" setting). Viewing it locally is preferred when a local copy
/// exists; otherwise it requires downloading from Drive on demand.
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
/// True while a receipt photo 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,
'vehicleId': vehicleId, 'vehicle_id': vehicleId,
'date': date.toIso8601String(), 'date': date.millisecondsSinceEpoch,
'gallons': gallons, 'gallons': gallons,
'pricePerGallon': pricePerGallon, 'price_per_gallon': pricePerGallon,
'totalCost': totalCost, 'total_cost': totalCost,
'receiptImagePath': receiptImagePath, 'receipt_image_path': receiptImagePath,
'receipt_drive_file_id': receiptDriveFileId,
'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry( factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
id: json['id'] as String, id: map['id'] as String,
vehicleId: json['vehicleId'] as String, vehicleId: map['vehicle_id'] as String,
date: DateTime.parse(json['date'] as String), date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
gallons: (json['gallons'] as num).toDouble(), gallons: (map['gallons'] as num).toDouble(),
pricePerGallon: (json['pricePerGallon'] as num).toDouble(), pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(), totalCost: (map['total_cost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?, receiptImagePath: map['receipt_image_path'] 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,
); );
} }

View file

@ -1,48 +1,79 @@
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;
final String model; /// The vehicle's identification number. Required and must be unique
final String color; /// among active (non-deleted) vehicles, but — unlike [id] — the user can
final String licensePlate; /// edit it later (e.g. to fix a typo from a misread VIN scan).
final String vin;
/// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional —
/// when absent, screens fall back to other identifying info instead.
final String? nickname;
final DateTime updatedAt;
/// Soft-delete tombstone: null means active. Deleting sets this instead
/// 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.make, required this.vin,
required this.model, required this.updatedAt,
required this.color, this.nickname,
required this.licensePlate, this.deletedAt,
}); });
String get displayName => '$color $make $model ($licensePlate)'; /// What screens should show as the vehicle's primary label: the nickname
/// if one was given, otherwise the VIN itself.
String get displayLabel {
final trimmedNickname = nickname?.trim();
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
return trimmedNickname;
}
return vin;
}
/// Note: [id] is intentionally not overridable here — it's the hidden
/// identifier, not an editable field.
Vehicle copyWith({ Vehicle copyWith({
String? make, String? vin,
String? model, String? nickname,
String? color, bool clearNickname = false,
String? licensePlate, DateTime? updatedAt,
DateTime? deletedAt,
}) { }) {
return Vehicle( return Vehicle(
id: id, id: id,
make: make ?? this.make, vin: vin ?? this.vin,
model: model ?? this.model, nickname: clearNickname ? null : (nickname ?? this.nickname),
color: color ?? this.color, updatedAt: updatedAt ?? this.updatedAt,
licensePlate: licensePlate ?? this.licensePlate, deletedAt: deletedAt ?? this.deletedAt,
); );
} }
Map<String, dynamic> toJson() => { Map<String, Object?> toMap() => {
'id': id, 'id': id,
'make': make, 'vin': vin,
'model': model, 'nickname': nickname,
'color': color, 'updated_at': updatedAt.millisecondsSinceEpoch,
'licensePlate': licensePlate, 'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle( factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
id: json['id'] as String, id: map['id'] as String,
make: json['make'] as String, vin: map['vin'] as String,
model: json['model'] as String, nickname: map['nickname'] as String?,
color: json['color'] as String, updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
licensePlate: json['licensePlate'] as String, deletedAt: map['deleted_at'] != null
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
: null,
); );
} }

View file

@ -1,8 +1,14 @@
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.
@ -17,52 +23,107 @@ 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 _makeController; late final TextEditingController _nicknameController;
late final TextEditingController _modelController; late final TextEditingController _vinController;
late final TextEditingController _colorController;
late final TextEditingController _plateController; bool _saving = false;
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();
_makeController = TextEditingController(text: widget.vehicle?.make ?? ''); _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_modelController = TextEditingController(text: widget.vehicle?.model ?? ''); _vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
} }
@override @override
void dispose() { void dispose() {
_makeController.dispose(); _nicknameController.dispose();
_modelController.dispose(); _vinController.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;
final appState = context.read<AppState>(); setState(() {
if (_isEditing) { _saving = true;
await appState.updateVehicle(widget.vehicle!.copyWith( _error = null;
make: _makeController.text.trim(), });
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
));
} else {
await appState.addVehicle(
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
);
}
if (mounted) Navigator.of(context).pop(); final nickname = _nicknameController.text.trim();
final appState = context.read<AppState>();
try {
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
clearNickname: nickname.isEmpty,
));
} else {
await appState.addVehicle(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
);
}
if (mounted) Navigator.of(context).pop();
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
} }
Future<void> _confirmDelete() async { Future<void> _confirmDelete() async {
@ -72,7 +133,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!.displayName}. This cannot be undone.', '${widget.vehicle!.displayLabel}. 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')),
@ -114,36 +175,48 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
TextFormField( TextFormField(
controller: _makeController, controller: _nicknameController,
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'), decoration: const InputDecoration(
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: _modelController, controller: _vinController,
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'), decoration: InputDecoration(
textCapitalization: TextCapitalization.words, labelText: 'VIN *',
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, hintText: 'Vehicle Identification Number',
), helperText: 'Required — tap the camera to scan it from a photo',
const SizedBox(height: 12), suffixIcon: _scanningVin
TextFormField( ? const Padding(
controller: _colorController, padding: EdgeInsets.all(12),
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'), child: SizedBox(
textCapitalization: TextCapitalization.words, height: 20,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, width: 20,
), child: CircularProgressIndicator(strokeWidth: 2),
const SizedBox(height: 12), ),
TextFormField( )
controller: _plateController, : IconButton(
decoration: const InputDecoration(labelText: 'License Plate'), icon: const Icon(Icons.camera_alt_outlined),
tooltip: 'Scan VIN from a photo',
onPressed: _scanVin,
),
),
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: _save, onPressed: _saving ? null : _save,
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
), ),
], ],
), ),

View file

@ -0,0 +1,219 @@
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),
);
},
);
}
}

View file

@ -33,12 +33,16 @@ 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;
DateTime _date = DateTime.now(); late DateTime _date;
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) ?? '',
); );
@ -69,11 +73,20 @@ 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: DateTime.now().subtract(const Duration(days: 365 * 5)), firstDate: firstDate,
lastDate: DateTime.now(), lastDate: lastDate,
); );
if (pickedDate == null || !mounted) return; if (pickedDate == null || !mounted) return;
@ -136,7 +149,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
GestureDetector( GestureDetector(
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
), ),
), ),
child: ClipRRect( child: ClipRRect(

View file

@ -59,18 +59,20 @@ 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: CircleAvatar( leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'), title: Text(vehicle.displayLabel),
), // Only repeat the VIN here when the title is already showing the
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'), // nickname instead — otherwise the title (falling back to the VIN
subtitle: Text( // when there's no nickname) would show it twice.
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged', subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
), 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)),

View file

@ -1,12 +1,73 @@
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';
/// Full-screen, pinch-zoomable view of a saved receipt photo. import '../services/app_state.dart';
class ReceiptImageScreen extends StatelessWidget {
final String imagePath;
const ReceiptImageScreen({super.key, required this.imagePath}); /// Full-screen, pinch-zoomable view of a receipt photo. Pass
/// [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) {
@ -17,11 +78,26 @@ class ReceiptImageScreen extends StatelessWidget {
foregroundColor: Colors.white, foregroundColor: Colors.white,
title: const Text('Receipt'), title: const Text('Receipt'),
), ),
body: Center( body: Center(child: _buildBody()),
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();
}
} }

View file

@ -1,8 +1,10 @@
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});
@ -12,31 +14,75 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _changing = false; bool _busy = false;
String? _error; String? _error;
Future<void> _chooseFolder() async { Future<void> _connect(CloudProviderId id) async {
setState(() => _error = null); setState(() {
_busy = true;
final selectedPath = await FilePicker.platform.getDirectoryPath( _error = null;
dialogTitle: 'Choose a folder for receipts and data', });
);
if (selectedPath == null || !mounted) return;
setState(() => _changing = true);
try { try {
await context.read<AppState>().changeSaveDirectory(selectedPath); await context.read<AppState>().connectProvider(id);
} catch (e) { } catch (e) {
setState(() => _error = 'Could not switch to that folder: $e'); setState(() => _error = 'Could not sign in: $e');
} finally { } finally {
if (mounted) setState(() => _changing = false); if (mounted) setState(() => _busy = 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')),
@ -49,40 +95,151 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Save Location', style: Theme.of(context).textTheme.titleMedium), Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( if (!appState.isCloudConnected) ...[
'Receipt photos and the data file are stored here:', Text(
style: Theme.of(context).textTheme.bodyMedium, 'Connect a cloud storage account to share vehicles and fuel '
), 'receipts with other people, and to keep a backup off this device.',
const SizedBox(height: 4), style: Theme.of(context).textTheme.bodyMedium,
SelectableText( ),
saveDir, const SizedBox(height: 12),
style: Theme.of(context).textTheme.bodySmall?.copyWith( if (_error != null) ...[
fontFamily: 'monospace', Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('${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(
const SizedBox(height: 16), onPressed: (_busy || appState.cloudFolderPath == null)
if (_error != null) ...[ ? null
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), : _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8), 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),
),
),
], ],
FilledButton.icon(
onPressed: _changing ? null : _chooseFolder,
icon: _changing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: const Text('Choose Folder'),
),
], ],
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
],
),
),
const SizedBox(height: 16),
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -103,3 +260,93 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
} }
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -9,24 +9,42 @@ 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(source: ImageSource.camera, imageQuality: 85); photo = await picker.pickImage(
source: source,
imageQuality: keepMaxQuality ? null : receiptImageQuality,
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
);
} catch (e) { } 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 camera: $e')), SnackBar(content: Text('Could not open $sourceLabel: $e')),
); );
} }
return; return;
@ -54,8 +72,16 @@ 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(
@ -68,6 +94,30 @@ class VehicleDetailScreen extends StatelessWidget {
} }
} }
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
/// warn if the receipt's address is somewhere else, and let the user
/// decide whether to log it anyway (e.g. it might still be worth
/// tracking for other reasons even if it won't qualify for a refund).
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Non-Missouri Purchase'),
content: Text(
'This receipt looks like it\'s from a gas station in $stateName. '
'The Missouri fuel tax refund only applies to fuel purchased in '
'Missouri.\n\nDo you still want to add this entry?',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
],
),
);
return proceed ?? false;
}
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async { Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@ -88,6 +138,38 @@ 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>();
@ -104,7 +186,7 @@ class VehicleDetailScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(vehicle.displayName), title: Text(vehicle.displayLabel),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
@ -144,24 +226,7 @@ class VehicleDetailScreen extends StatelessWidget {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ListTile( child: ListTile(
leading: entry.receiptImagePath != null leading: _buildReceiptLeading(context, entry),
? 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)}',

View file

@ -1,63 +1,343 @@
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 'storage_service.dart'; import 'cloud/cloud_storage_provider.dart';
import 'cloud/dropbox_provider.dart';
import 'cloud/google_drive_provider.dart';
import 'cloud/onedrive_provider.dart';
import 'cloud/webdav_provider.dart';
import 'cloud_sync_service.dart';
import 'database_service.dart';
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 [StorageService] for persistence. Screens read from /// entries), backed by [DatabaseService] (local SQLite) for persistence and
/// this via Provider and call its mutating methods, which take care of /// a [CloudSyncService] for pushing/pulling the shared copy on whichever
/// writing through to disk and notifying listeners. /// [CloudStorageProvider] the user has connected. Screens read from this
/// via Provider and call its mutating methods, which write through to the
/// local database immediately and kick off a best-effort background sync.
///
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
/// database, refreshed after every mutation and after every sync (since
/// 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 StorageService storage = StorageService(); final DatabaseService database = DatabaseService();
/// 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 {
await storage.init(); isLoading = true;
final data = await storage.loadData(); initError = null;
vehicles = data.vehicles; notifyListeners();
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;
notifyListeners();
return;
}
isLoading = false; isLoading = false;
notifyListeners(); 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();
} }
Future<void> addVehicle({ /// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
required String make, /// (currently just WebDAV) that needs a server URL/username/password
required String model, /// instead of an OAuth browser flow. The caller (Settings) is responsible
required String color, /// for collecting those from the user first.
required String licensePlate, Future<void> connectProviderWithCredentials(
CloudProviderId id, {
required String serverUrl,
required String username,
required String password,
}) async { }) async {
vehicles.add(Vehicle( 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({
required String vin,
String? nickname,
}) async {
if (await database.vinExists(vin)) {
throw DuplicateVinException(vin);
}
final vehicle = Vehicle(
id: _uuid.v4(), id: _uuid.v4(),
make: make, vin: vin,
model: model, nickname: nickname,
color: color, updatedAt: DateTime.now().toUtc(),
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 {
final index = vehicles.indexWhere((v) => v.id == updated.id); if (await database.vinExists(updated.vin, excludeId: updated.id)) {
if (index != -1) { throw DuplicateVinException(updated.vin);
vehicles[index] = updated;
await _persist();
} }
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
await _persist();
} }
Future<void> deleteVehicle(String vehicleId) async { Future<void> deleteVehicle(String id) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { final now = DateTime.now().toUtc();
await storage.deleteReceiptImage(entry.receiptImagePath); final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
for (final path in orphanedLocalPaths) {
await database.deleteReceiptImageFile(path);
} }
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); await database.softDeleteVehicle(id, now);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist(); await _persist();
} }
@ -72,7 +352,8 @@ class AppState extends ChangeNotifier {
final id = _uuid.v4(); final id = _uuid.v4();
String? storedImagePath; String? storedImagePath;
if (receiptImage != null) { if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id); final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
} }
final entry = FuelEntry( final entry = FuelEntry(
@ -83,16 +364,17 @@ class AppState extends ChangeNotifier {
pricePerGallon: pricePerGallon, pricePerGallon: pricePerGallon,
totalCost: totalCost, totalCost: totalCost,
receiptImagePath: storedImagePath, receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(),
); );
fuelEntries.add(entry); await database.saveFuelEntry(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 storage.deleteReceiptImage(entry.receiptImagePath); await database.deleteReceiptImageFile(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId); await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
await _persist(); await _persist();
} }
@ -117,13 +399,13 @@ class AppState extends ChangeNotifier {
} }
} }
Future<void> changeSaveDirectory(String newPath) async {
await storage.setSaveDirectory(newPath);
notifyListeners();
}
Future<void> _persist() async { Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries); await _refreshFromDatabase();
notifyListeners(); notifyListeners();
unawaited(syncNow());
} }
} }
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View file

@ -0,0 +1,153 @@
import 'dart:io';
/// Shared naming convention across all providers: whichever cloud storage
/// backend is active, the app looks for (or creates) a folder with this
/// exact name wherever the user points it, so two devices pointed at the
/// same shared parent location converge on the same data regardless of
/// which provider they're using.
const appFolderName = 'MO-Fuel-Tax-Back';
const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_tracker.db';
enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav }
class CloudFolder {
final String id;
final String name;
CloudFolder({required this.id, required this.name});
}
class CloudFileInfo {
final String id;
/// Opaque change-detection signal — Drive's md5Checksum, Dropbox's
/// content_hash, OneDrive's cTag all satisfy "did this change since I
/// last looked", which is the only thing callers need from it.
final String? versionTag;
CloudFileInfo({required this.id, required this.versionTag});
}
class CloudLockFile {
final String id;
final String username;
final DateTime createdAtUtc;
CloudLockFile({required this.id, required this.username, required this.createdAtUtc});
}
/// Thrown when an operation needs authorization that isn't currently
/// available without prompting the user, e.g. during a background sync
/// with an expired/revoked token.
class CloudNotAuthorizedException implements Exception {
final String providerName;
CloudNotAuthorizedException(this.providerName);
@override
String toString() => '$providerName access is not currently authorized.';
}
/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive).
/// Owns account-level identity/auth; per-sync operations go through a
/// [CloudStorageSession] obtained via [beginSession].
abstract class CloudStorageProvider {
CloudProviderId get id;
String get displayName;
bool get isSignedIn;
String? get accountLabel;
/// Attempts to restore a previous sign-in without any UI. Returns true
/// if signed in and authorized.
Future<bool> attemptSilentSignIn();
/// Interactive sign-in. Must be called from a user-initiated action
/// (e.g. a button press). Returns a label to display (email/username).
Future<String> signIn();
Future<void> signOut();
/// Starts one session's worth of operations (roughly: one sync round,
/// or one folder-browsing screen visit). The caller owns its lifecycle —
/// call [CloudStorageSession.close] when done with it.
CloudStorageSession beginSession();
}
/// Raw operations against one cloud storage backend, scoped to a single
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
/// opaque per-provider — for most providers a real ID, but for a
/// path-addressed API (Dropbox) a session may internally treat the path
/// itself as the "id". Callers never need to know which.
abstract class CloudStorageSession {
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
/// provider supports it) top-level folders shared with the signed-in
/// account regardless of parent. Providers that don't have a meaningful
/// separate "shared with me" concept may just ignore [sharedWithMe] and
/// always list under [parentId].
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
/// True if this provider has a distinct "Shared with me" browsing mode
/// worth showing as a separate tab in the folder picker UI.
bool get supportsSharedWithMe;
/// Finds a folder named [name] directly under [parentId], or creates one
/// if none exists. If duplicates exist, the earliest-created one wins.
Future<String> findOrCreateFolder({required String parentId, required String name});
/// Looks up a file's ID + versionTag by name within [folderId] without
/// downloading its content, or null if no such file exists yet.
Future<CloudFileInfo?> findFile({required String folderId, required String name});
Future<List<int>> downloadFileBytes(String fileId);
/// Creates the file if [existingFileId] is null, otherwise overwrites
/// its content. Returns the (possibly new) file ID and fresh versionTag.
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
});
Future<void> deleteFile(String fileId);
Future<String> createLockFile({required String folderId, required String name});
Future<List<CloudLockFile>> listLockFiles(String folderId);
/// Releases any resources (e.g. closes an underlying HTTP client).
void close();
}
/// Implemented by providers that need the user to type in connection
/// details (server URL, username, password) instead of completing an
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
/// central authorization server to redirect to. The Settings screen checks
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
/// "Connect" opens a small credentials form instead of calling
/// [CloudStorageProvider.signIn] directly.
abstract class ManualCredentialCloudStorageProvider {
Future<String> signInWithCredentials({
required String serverUrl,
required String username,
required String password,
});
}
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
/// every provider's [CloudStorageSession.listLockFiles] implementation
/// rather than duplicated, since the lock file naming convention itself
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
(String, DateTime)? parseLockFileName(String? name) {
if (name == null || !name.endsWith('.lock')) return null;
final withoutExt = name.substring(0, name.length - '.lock'.length);
final lastDash = withoutExt.lastIndexOf('-');
if (lastDash == -1) return null;
final username = withoutExt.substring(0, lastDash);
final epochStr = withoutExt.substring(lastDash + 1);
final epoch = int.tryParse(epochStr);
if (epoch == null) return null;
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
}

View file

@ -0,0 +1,364 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
import 'oauth_pkce.dart';
/// Dropbox implementation of [CloudStorageProvider].
///
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
/// folder's own Dropbox path (e.g. "/MO-Fuel-Tax-Back") as its "id" for
/// purposes of the generic [CloudStorageSession] interface — an
/// implementation detail entirely inside this file, invisible to
/// [CloudSyncService].
class DropboxProvider implements CloudStorageProvider {
String? _accessToken;
String? _refreshToken;
DateTime? _accessTokenExpiry;
String? _accountLabel;
@override
CloudProviderId get id => CloudProviderId.dropbox;
@override
String get displayName => 'Dropbox';
@override
bool get isSignedIn => _refreshToken != null;
@override
String? get accountLabel => _accountLabel;
@override
Future<bool> attemptSilentSignIn() async {
// The refresh token isn't persisted across app launches in this first
// pass (kept in memory only) — see README's Known limitations. Silent
// restore always fails; the user reconnects once per cold start until
// that's added.
return false;
}
@override
Future<String> signIn() async {
final appKey = CloudOAuthConfig.dropboxAppKey;
final redirectUri = CloudOAuthConfig.dropboxRedirectUri;
if (appKey == null || redirectUri == null) {
throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).');
}
final pkce = PkcePair.generate();
final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', {
'client_id': appKey,
'response_type': 'code',
'code_challenge': pkce.codeChallenge,
'code_challenge_method': 'S256',
'redirect_uri': redirectUri,
'token_access_type': 'offline',
});
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
final resultUrl = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: callbackUrlScheme,
);
final code = Uri.parse(resultUrl).queryParameters['code'];
if (code == null) {
throw StateError('Dropbox sign-in did not return an authorization code.');
}
await _exchangeCodeForTokens(
code: code,
codeVerifier: pkce.codeVerifier,
appKey: appKey,
redirectUri: redirectUri,
);
_accountLabel = await _fetchAccountEmail();
return _accountLabel!;
}
Future<void> _exchangeCodeForTokens({
required String code,
required String codeVerifier,
required String appKey,
required String redirectUri,
}) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/oauth2/token'),
body: {
'code': code,
'grant_type': 'authorization_code',
'client_id': appKey,
'code_verifier': codeVerifier,
'redirect_uri': redirectUri,
},
);
if (response.statusCode != 200) {
throw StateError('Dropbox token exchange failed: ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String?;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
}
Future<String> _fetchAccountEmail() async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/users/get_current_account'),
headers: {'Authorization': 'Bearer $_accessToken'},
);
if (response.statusCode != 200) return 'Dropbox account';
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['email'] as String? ?? 'Dropbox account';
}
/// Refreshes the access token if it's missing or close to expiring.
/// Never prompts for UI — suitable for background sync — so throws
/// [CloudNotAuthorizedException] if there's no refresh token to use.
Future<String> _freshAccessToken() async {
final stillValid = _accessToken != null &&
_accessTokenExpiry != null &&
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
if (stillValid) return _accessToken!;
final refreshToken = _refreshToken;
final appKey = CloudOAuthConfig.dropboxAppKey;
if (refreshToken == null || appKey == null) {
throw CloudNotAuthorizedException(displayName);
}
final response = await http.post(
Uri.https('api.dropboxapi.com', '/oauth2/token'),
body: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': appKey,
},
);
if (response.statusCode != 200) {
throw CloudNotAuthorizedException(displayName);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
return _accessToken!;
}
@override
Future<void> signOut() async {
_accessToken = null;
_refreshToken = null;
_accessTokenExpiry = null;
_accountLabel = null;
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return DropboxSession(this);
}
}
class DropboxSession implements CloudStorageSession {
final DropboxProvider _provider;
DropboxSession(this._provider);
@override
bool get supportsSharedWithMe => false;
Future<Map<String, String>> _authHeader() async =>
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
/// Dropbox's root path is `""`, not `"/"` — our generic interface uses
/// the literal string `'root'` for "the top of the tree" (matching
/// Google Drive's convention), so translate that here.
String _normalizePath(String id) => id == 'root' ? '' : id;
String _childPath(String parentId, String name) {
final parent = _normalizePath(parentId);
return '$parent/$name';
}
Future<Map<String, dynamic>> _post(String path, Map<String, dynamic> body) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', path),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// True if a Dropbox API error response's `.tag` chain indicates "the
/// path doesn't exist" — Dropbox reports this as a normal 409 response
/// with a structured error body, not a 404, so it needs its own check
/// rather than a status-code check.
bool _isPathNotFoundError(http.Response response) {
if (response.statusCode != 409) return false;
try {
final body = jsonDecode(response.body) as Map<String, dynamic>;
return jsonEncode(body['error']).contains('not_found');
} catch (_) {
return false;
}
}
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final path = _normalizePath(parentId ?? 'root');
final json = await _post('/2/files/list_folder', {'path': path});
final entries = (json['entries'] as List<dynamic>? ?? []);
return entries
.cast<Map<String, dynamic>>()
.where((e) => e['.tag'] == 'folder')
.map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childPath = _childPath(parentId, name);
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': childPath}),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
if (json['.tag'] == 'folder') return childPath;
} else if (!_isPathNotFoundError(response)) {
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
}
await _post('/2/files/create_folder_v2', {'path': childPath});
return childPath;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final filePath = _childPath(folderId, name);
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': filePath}),
);
if (_isPathNotFoundError(response)) return null;
if (response.statusCode != 200) {
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
if (json['.tag'] != 'file') return null;
return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/download'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': fileId}),
},
);
if (response.statusCode != 200) {
throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final targetPath = existingFileId ?? _childPath(folderId, name);
final bytes = await localFile.readAsBytes();
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/upload'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}),
'Content-Type': 'application/octet-stream',
},
body: bytes,
);
if (response.statusCode != 200) {
throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(
id: json['path_display'] as String? ?? targetPath,
versionTag: json['content_hash'] as String?,
);
}
@override
Future<void> deleteFile(String fileId) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/delete_v2'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': fileId}),
);
if (response.statusCode != 200 && !_isPathNotFoundError(response)) {
throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final path = _childPath(folderId, name);
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/upload'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}),
'Content-Type': 'application/octet-stream',
},
body: const <int>[],
);
if (response.statusCode != 200) {
throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}');
}
return path;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)});
final entries = (json['entries'] as List<dynamic>? ?? []);
final locks = <CloudLockFile>[];
for (final entry in entries.cast<Map<String, dynamic>>()) {
if (entry['.tag'] != 'file') continue;
final parsed = parseLockFileName(entry['name'] as String?);
if (parsed != null) {
locks.add(CloudLockFile(
id: entry['path_display'] as String,
username: parsed.$1,
createdAtUtc: parsed.$2,
));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis/drive/v3.dart' as drive;
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
/// Full Drive access is required (not the narrower `drive.file` scope)
/// because users need to browse to and reuse folders that someone else
/// created and shared with them, not just folders/files this app itself
/// created. See the plan doc for the tradeoffs (this requires Google
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
/// a full OAuth verification review).
const _driveScopes = <String>['https://www.googleapis.com/auth/drive'];
const _folderMimeType = 'application/vnd.google-apps.folder';
/// Google Drive implementation of [CloudStorageProvider], via
/// `google_sign_in` for auth and the `googleapis` `DriveApi` client for
/// everything else.
class GoogleDriveProvider implements CloudStorageProvider {
bool _initialized = false;
GoogleSignInAccount? _account;
@override
CloudProviderId get id => CloudProviderId.googleDrive;
@override
String get displayName => 'Google Drive';
@override
bool get isSignedIn => _account != null;
@override
String? get accountLabel => _account?.email;
Future<void> _ensureInitialized() async {
if (_initialized) return;
await GoogleSignIn.instance.initialize(
clientId: Platform.isIOS ? CloudOAuthConfig.googleIosClientId : null,
serverClientId: Platform.isAndroid ? CloudOAuthConfig.googleAndroidServerClientId : null,
);
_initialized = true;
}
@override
Future<bool> attemptSilentSignIn() async {
await _ensureInitialized();
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
_account = account;
if (account == null) return false;
final authorization =
await account.authorizationClient.authorizationForScopes(_driveScopes);
return authorization != null;
}
@override
Future<String> signIn() async {
await _ensureInitialized();
final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes);
_account = account;
await account.authorizationClient.authorizeScopes(_driveScopes);
return account.email;
}
@override
Future<void> signOut() async {
await GoogleSignIn.instance.signOut();
_account = null;
}
@override
CloudStorageSession beginSession() {
final account = _account;
if (account == null) {
throw CloudNotAuthorizedException(displayName);
}
final client = _GoogleAuthHttpClient(account.authorizationClient);
return GoogleDriveSession(client);
}
}
class _GoogleAuthHttpClient extends http.BaseClient {
final GoogleSignInAuthorizationClient _authClient;
final http.Client _inner = http.Client();
_GoogleAuthHttpClient(this._authClient);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final headers =
await _authClient.authorizationHeaders(_driveScopes, promptIfNecessary: false);
if (headers == null) {
throw CloudNotAuthorizedException('Google Drive');
}
request.headers.addAll(headers);
return _inner.send(request);
}
@override
void close() {
_inner.close();
super.close();
}
}
class GoogleDriveSession implements CloudStorageSession {
final http.Client _client;
final drive.DriveApi _api;
GoogleDriveSession(this._client) : _api = drive.DriveApi(_client);
@override
bool get supportsSharedWithMe => true;
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final query = sharedWithMe
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
final result = await _api.files.list(
q: query,
orderBy: 'name',
$fields: 'files(id,name)',
spaces: 'drive',
);
return (result.files ?? [])
.where((f) => f.id != null && f.name != null)
.map((f) => CloudFolder(id: f.id!, name: f.name!))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final existing = await _api.files.list(
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
orderBy: 'createdTime',
$fields: 'files(id,name)',
spaces: 'drive',
);
final files = existing.files ?? <drive.File>[];
if (files.isNotEmpty && files.first.id != null) return files.first.id!;
final created = await _api.files.create(
drive.File()
..name = name
..mimeType = _folderMimeType
..parents = [parentId],
);
return created.id!;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final result = await _api.files.list(
q: "'$folderId' in parents and trashed=false and name='$name'",
orderBy: 'modifiedTime desc',
$fields: 'files(id,name,md5Checksum)',
spaces: 'drive',
);
final files = result.files ?? <drive.File>[];
if (files.isEmpty || files.first.id == null) return null;
return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final media = await _api.files.get(
fileId,
downloadOptions: drive.DownloadOptions.fullMedia,
) as drive.Media;
return _collectBytes(media.stream);
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final length = await localFile.length();
final media = drive.Media(localFile.openRead(), length, contentType: contentType);
if (existingFileId != null) {
final updated = await _api.files.update(
drive.File(),
existingFileId,
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return CloudFileInfo(id: updated.id ?? existingFileId, versionTag: updated.md5Checksum);
}
final created = await _api.files.create(
drive.File()
..name = name
..parents = [folderId],
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return CloudFileInfo(id: created.id!, versionTag: created.md5Checksum);
}
@override
Future<void> deleteFile(String fileId) async {
try {
await _api.files.delete(fileId);
} on drive.DetailedApiRequestError catch (e) {
// Already gone (e.g. deleted by another device) — not an error for
// our purposes.
if (e.status != 404) rethrow;
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final created = await _api.files.create(
drive.File()
..name = name
..parents = [folderId],
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
);
return created.id!;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final result = await _api.files.list(
q: "'$folderId' in parents and trashed=false and name contains '.lock'",
$fields: 'files(id,name)',
spaces: 'drive',
);
final locks = <CloudLockFile>[];
for (final f in result.files ?? <drive.File>[]) {
final parsed = parseLockFileName(f.name);
if (f.id != null && parsed != null) {
locks.add(CloudLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
}
}
return locks;
}
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
final bytes = <int>[];
await for (final chunk in stream) {
bytes.addAll(chunk);
}
return bytes;
}
@override
void close() => _client.close();
}

View file

@ -0,0 +1,31 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's
/// OAuth2 Authorization Code flow — proves to the token endpoint that the
/// app completing the exchange is the same one that started the browser
/// redirect, without needing an embedded client secret (appropriate for a
/// public/mobile client, since a secret can't actually be kept secret in
/// a distributed app binary).
class PkcePair {
final String codeVerifier;
final String codeChallenge;
PkcePair._(this.codeVerifier, this.codeChallenge);
factory PkcePair.generate() {
final verifier = _randomUrlSafeString(64);
final challenge =
base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', '');
return PkcePair._(verifier, challenge);
}
static String _randomUrlSafeString(int length) {
// RFC 7636's unreserved character set for a code_verifier.
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join();
}
}

View file

@ -0,0 +1,350 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
import 'oauth_pkce.dart';
const _graphScopes = 'offline_access Files.ReadWrite.All';
/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph.
/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the
/// interface directly with no path-based workaround.
class OneDriveProvider implements CloudStorageProvider {
String? _accessToken;
String? _refreshToken;
DateTime? _accessTokenExpiry;
String? _accountLabel;
@override
CloudProviderId get id => CloudProviderId.oneDrive;
@override
String get displayName => 'OneDrive';
@override
bool get isSignedIn => _refreshToken != null;
@override
String? get accountLabel => _accountLabel;
@override
Future<bool> attemptSilentSignIn() async {
// As with Dropbox, the refresh token is kept in memory only in this
// first pass — see README's Known limitations.
return false;
}
@override
Future<String> signIn() async {
final clientId = CloudOAuthConfig.oneDriveClientId;
final redirectUri = CloudOAuthConfig.oneDriveRedirectUri;
if (clientId == null || redirectUri == null) {
throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).');
}
final pkce = PkcePair.generate();
final authUrl = Uri.https(
'login.microsoftonline.com',
'/common/oauth2/v2.0/authorize',
{
'client_id': clientId,
'response_type': 'code',
'redirect_uri': redirectUri,
'response_mode': 'query',
'scope': _graphScopes,
'code_challenge': pkce.codeChallenge,
'code_challenge_method': 'S256',
},
);
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
final resultUrl = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: callbackUrlScheme,
);
final code = Uri.parse(resultUrl).queryParameters['code'];
if (code == null) {
throw StateError('OneDrive sign-in did not return an authorization code.');
}
await _exchangeCodeForTokens(
code: code,
codeVerifier: pkce.codeVerifier,
clientId: clientId,
redirectUri: redirectUri,
);
_accountLabel = await _fetchAccountEmail();
return _accountLabel!;
}
Future<void> _exchangeCodeForTokens({
required String code,
required String codeVerifier,
required String clientId,
required String redirectUri,
}) async {
final response = await http.post(
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
body: {
'client_id': clientId,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'code_verifier': codeVerifier,
'scope': _graphScopes,
},
);
if (response.statusCode != 200) {
throw StateError('OneDrive token exchange failed: ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String?;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
}
Future<String> _fetchAccountEmail() async {
final response = await http.get(
Uri.https('graph.microsoft.com', '/v1.0/me'),
headers: {'Authorization': 'Bearer $_accessToken'},
);
if (response.statusCode != 200) return 'OneDrive account';
final json = jsonDecode(response.body) as Map<String, dynamic>;
return (json['mail'] as String?) ??
(json['userPrincipalName'] as String?) ??
'OneDrive account';
}
Future<String> _freshAccessToken() async {
final stillValid = _accessToken != null &&
_accessTokenExpiry != null &&
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
if (stillValid) return _accessToken!;
final refreshToken = _refreshToken;
final clientId = CloudOAuthConfig.oneDriveClientId;
if (refreshToken == null || clientId == null) {
throw CloudNotAuthorizedException(displayName);
}
final response = await http.post(
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
body: {
'client_id': clientId,
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'scope': _graphScopes,
},
);
if (response.statusCode != 200) {
throw CloudNotAuthorizedException(displayName);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String? ?? _refreshToken;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
return _accessToken!;
}
@override
Future<void> signOut() async {
_accessToken = null;
_refreshToken = null;
_accessTokenExpiry = null;
_accountLabel = null;
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return OneDriveSession(this);
}
}
class OneDriveSession implements CloudStorageSession {
final OneDriveProvider _provider;
OneDriveSession(this._provider);
@override
bool get supportsSharedWithMe => true;
Future<Map<String, String>> _authHeader() async =>
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path');
/// The interface's `'root'` sentinel (matching Google's convention) maps
/// to Graph's own `/me/drive/root` special item.
String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id';
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final uri = sharedWithMe
? _graph('/me/drive/sharedWithMe')
: _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children');
final response = await http.get(uri, headers: await _authHeader());
if (response.statusCode != 200) {
throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final folders = <CloudFolder>[];
for (final entry in entries) {
if (entry['folder'] == null) continue;
// "Shared with me" items carry the shared item's own id under
// `remoteItem`, not the top-level entry id.
final remoteItem = entry['remoteItem'] as Map<String, dynamic>?;
final id = (remoteItem?['id'] ?? entry['id']) as String?;
final name = entry['name'] as String?;
if (id != null && name != null) {
folders.add(CloudFolder(id: id, name: name));
}
}
return folders;
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children');
final listResponse = await http.get(childrenUri, headers: await _authHeader());
if (listResponse.statusCode != 200) {
throw StateError(
'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}');
}
final listJson = jsonDecode(listResponse.body) as Map<String, dynamic>;
final entries = (listJson['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
for (final entry in entries) {
if (entry['folder'] != null && entry['name'] == name) {
return entry['id'] as String;
}
}
final createResponse = await http.post(
childrenUri,
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({
'name': name,
'folder': <String, dynamic>{},
'@microsoft.graph.conflictBehavior': 'fail',
}),
);
if (createResponse.statusCode != 201) {
throw StateError(
'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}');
}
final created = jsonDecode(createResponse.body) as Map<String, dynamic>;
return created['id'] as String;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
final response = await http.get(uri, headers: await _authHeader());
if (response.statusCode == 404) return null;
if (response.statusCode != 200) {
throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response =
await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader());
if (response.statusCode != 200) {
throw StateError('OneDrive download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final bytes = await localFile.readAsBytes();
// Graph's simple upload endpoint (content < 4MB, which every receipt
// photo and the sqlite data file comfortably are) — no upload session
// needed.
final uri = existingFileId != null
? _graph('/me/drive/items/$existingFileId/content')
: _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
final response = await http.put(
uri,
headers: {...await _authHeader(), 'Content-Type': contentType},
body: bytes,
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
}
@override
Future<void> deleteFile(String fileId) async {
final response =
await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader());
if (response.statusCode != 204 && response.statusCode != 404) {
throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
final response = await http.put(
uri,
headers: {...await _authHeader(), 'Content-Type': 'text/plain'},
body: const <int>[],
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['id'] as String;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final response = await http.get(
_graph('/me/drive/${_itemSegment(folderId)}/children'),
headers: await _authHeader(),
);
if (response.statusCode != 200) {
throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final locks = <CloudLockFile>[];
for (final entry in entries) {
final parsed = parseLockFileName(entry['name'] as String?);
if (parsed != null) {
locks.add(CloudLockFile(
id: entry['id'] as String,
username: parsed.$1,
createdAtUtc: parsed.$2,
));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,390 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'cloud_storage_provider.dart';
const _secureStorageKeyServerUrl = 'webdav_server_url';
const _secureStorageKeyUsername = 'webdav_username';
const _secureStorageKeyPassword = 'webdav_password';
const _propfindRequestBody = '''<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getetag/>
</D:prop>
</D:propfind>''';
/// One `<D:response>` entry from a WebDAV PROPFIND multistatus response.
/// Not private, and [parseWebDavMultistatus] is a free function, purely so
/// the XML parsing can be unit-tested directly against sample responses
/// from different server implementations — real WebDAV servers vary in
/// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all),
/// which is exactly the kind of real-world format variance this app has
/// been burned by before with format-specific assumptions.
class WebDavEntry {
final String path;
final bool isCollection;
final String? etag;
WebDavEntry({required this.path, required this.isCollection, required this.etag});
}
/// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with
/// each entry's href resolved to an absolute path against [baseUrl].
/// Matches elements by local name only (ignoring namespace prefix), since
/// that's the part that varies across server implementations.
List<WebDavEntry> parseWebDavMultistatus(String xmlBody, Uri baseUrl) {
final document = XmlDocument.parse(xmlBody);
return _byLocalName(document, 'response').map((responseEl) {
final href = _byLocalName(responseEl, 'href').first.innerText;
final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty;
final etagEls = _byLocalName(responseEl, 'getetag').toList();
return WebDavEntry(
path: baseUrl.resolve(href).path,
isCollection: isCollection,
etag: etagEls.isEmpty ? null : etagEls.first.innerText,
);
}).toList();
}
Iterable<XmlElement> _byLocalName(XmlNode node, String localName) =>
node.descendants.whereType<XmlElement>().where((e) => e.name.local == localName);
class _RawResponse {
final int statusCode;
final String body;
final Map<String, String> headers;
_RawResponse({required this.statusCode, required this.body, required this.headers});
}
/// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that
/// `package:http`'s GET/PUT/DELETE convenience functions don't support.
Future<_RawResponse> _send(String method, Uri uri, {Map<String, String>? headers, Object? body}) async {
final client = http.Client();
try {
final request = http.Request(method, uri);
if (headers != null) request.headers.addAll(headers);
if (body is String) request.body = body;
if (body is List<int>) request.bodyBytes = body;
final streamed = await client.send(request);
final responseBody = await streamed.stream.bytesToString();
return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers);
} finally {
client.close();
}
}
String _basicAuthHeader(String username, String password) =>
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path;
String _nameFromPath(String path) {
final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty);
return segments.isEmpty ? '' : Uri.decodeComponent(segments.last);
}
/// WebDAV implementation of [CloudStorageProvider], for self-hosted
/// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any
/// generic WebDAV server) rather than a named commercial provider. Unlike
/// the OAuth-based providers, there's no browser sign-in flow and no
/// developer-console app to register ahead of time — the user supplies a
/// server URL, username, and password (an app-specific password is
/// recommended on servers that support one, e.g. Nextcloud's Security
/// settings) directly via [signInWithCredentials].
class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider {
final FlutterSecureStorage _secureStorage;
Uri? _baseUrl;
String? _username;
String? _password;
WebDavProvider({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
@override
CloudProviderId get id => CloudProviderId.webdav;
@override
String get displayName => 'WebDAV';
@override
bool get isSignedIn => _baseUrl != null;
@override
String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null;
/// Restores a session from credentials saved on a previous
/// [signInWithCredentials] call (OS-encrypted storage — Keystore on
/// Android, Keychain on iOS), re-verifying them with the same PROPFIND
/// check rather than trusting them blindly, since the server config or
/// password could have changed since. Any failure here — wrong/expired
/// credentials, no network, nothing stored yet — just means "not signed
/// in"; this never throws; a real error from a user-initiated attempt
/// belongs to [signInWithCredentials], not this silent path.
@override
Future<bool> attemptSilentSignIn() async {
try {
final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl);
final username = await _secureStorage.read(key: _secureStorageKeyUsername);
final password = await _secureStorage.read(key: _secureStorageKeyPassword);
if (serverUrl == null || username == null || password == null) return false;
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
return true;
} catch (_) {
return false;
}
}
@override
Future<String> signIn() {
throw UnsupportedError(
'WebDAV needs a server URL and credentials — use signInWithCredentials instead.');
}
@override
Future<String> signInWithCredentials({
required String serverUrl,
required String username,
required String password,
}) async {
final label =
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString());
await _secureStorage.write(key: _secureStorageKeyUsername, value: username);
await _secureStorage.write(key: _secureStorageKeyPassword, value: password);
return label;
}
/// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes
/// the URL, does a side-effect-free PROPFIND on the root to confirm the
/// URL and credentials actually work, and — only once that's confirmed —
/// sets this instance's session fields.
Future<String> _verifiedSignIn({
required String serverUrl,
required String username,
required String password,
}) async {
var normalized = serverUrl.trim();
if (!normalized.contains('://')) normalized = 'https://$normalized';
if (!normalized.endsWith('/')) normalized += '/';
final baseUrl = Uri.parse(normalized);
final response = await _send('PROPFIND', baseUrl, headers: {
'Authorization': _basicAuthHeader(username, password),
'Depth': '0',
'Content-Type': 'application/xml; charset=utf-8',
}, body: _propfindRequestBody);
if (response.statusCode == 401) {
throw StateError('WebDAV sign-in failed: invalid username or password.');
}
if (response.statusCode != 207 && response.statusCode != 200) {
throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}');
}
_baseUrl = baseUrl;
_username = username;
_password = password;
return accountLabel!;
}
@override
Future<void> signOut() async {
_baseUrl = null;
_username = null;
_password = null;
await _secureStorage.delete(key: _secureStorageKeyServerUrl);
await _secureStorage.delete(key: _secureStorageKeyUsername);
await _secureStorage.delete(key: _secureStorageKeyPassword);
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return WebDavSession(this);
}
String get _authHeader => _basicAuthHeader(_username!, _password!);
}
class _WebDavNotFoundException implements Exception {}
class WebDavSession implements CloudStorageSession {
final WebDavProvider _provider;
WebDavSession(this._provider);
@override
bool get supportsSharedWithMe => false;
Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id);
Uri _childUri(Uri parent, String name) {
final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/');
return parentUri.resolve(Uri.encodeComponent(name));
}
Future<List<WebDavEntry>> _propfind(Uri uri, {required String depth}) async {
final response = await _send('PROPFIND', uri, headers: {
'Authorization': _provider._authHeader,
'Depth': depth,
'Content-Type': 'application/xml; charset=utf-8',
}, body: _propfindRequestBody);
if (response.statusCode == 404) throw _WebDavNotFoundException();
if (response.statusCode != 207) {
throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}');
}
return parseWebDavMultistatus(response.body, _provider._baseUrl!);
}
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final uri = _uriFor(parentId ?? 'root');
final selfPath = _normalizedPath(uri.path);
List<WebDavEntry> entries;
try {
entries = await _propfind(uri, depth: '1');
} on _WebDavNotFoundException {
return [];
}
return entries
.where((e) => e.isCollection && _normalizedPath(e.path) != selfPath)
.map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path)))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childUri = _childUri(_uriFor(parentId), name);
try {
final entries = await _propfind(childUri, depth: '0');
if (entries.isNotEmpty && entries.first.isCollection) return childUri.path;
} on _WebDavNotFoundException {
// Falls through to create it below.
}
final response =
await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}');
}
return childUri.path;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final fileUri = _childUri(_uriFor(folderId), name);
List<WebDavEntry> entries;
try {
entries = await _propfind(fileUri, depth: '0');
} on _WebDavNotFoundException {
return null;
}
if (entries.isEmpty) return null;
return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response =
await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200) {
throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name);
final bytes = await localFile.readAsBytes();
final response = await http.put(
uri,
headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType},
body: bytes,
);
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}');
}
// Many servers return the new ETag directly on the PUT response; fall
// back to a follow-up PROPFIND only if it's missing.
var etag = response.headers['etag'];
if (etag == null) {
try {
final entries = await _propfind(uri, depth: '0');
etag = entries.isEmpty ? null : entries.first.etag;
} on _WebDavNotFoundException {
etag = null;
}
}
return CloudFileInfo(id: uri.path, versionTag: etag);
}
@override
Future<void> deleteFile(String fileId) async {
final response =
await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) {
throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final uri = _childUri(_uriFor(folderId), name);
final response = await http.put(
uri,
headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'},
body: const <int>[],
);
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}');
}
return uri.path;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final uri = _uriFor(folderId);
final selfPath = _normalizedPath(uri.path);
List<WebDavEntry> entries;
try {
entries = await _propfind(uri, depth: '1');
} on _WebDavNotFoundException {
return [];
}
final locks = <CloudLockFile>[];
for (final entry in entries) {
if (_normalizedPath(entry.path) == selfPath) continue;
final parsed = parseLockFileName(_nameFromPath(entry.path));
if (parsed != null) {
locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,52 @@
/// Fill these in once the corresponding OAuth apps/registrations exist —
/// see README.md "Manual setup required" for exact steps per provider.
class CloudOAuthConfig {
// --- Google Drive ---
//
// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
// authenticates using a *Web application* type OAuth client's ID, not
// the Android client's own ID. The separate Android OAuth client
// (registered with the package name + debug/release SHA-1) is still
// required, but only to let Credential Manager verify this specific
// signed app — its client ID itself is never referenced here.
// - iOS uses its own iOS-type OAuth client ID directly.
/// The Web application OAuth client ID. Required for sign-in to work on
/// Android.
static const String? googleAndroidServerClientId = null; // TODO: fill in
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
/// directly in ios/Runner/Info.plist.
static const String? googleIosClientId = null; // TODO: fill in
// --- Dropbox ---
//
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
static const String? dropboxAppKey = null; // TODO: fill in
/// Custom URL scheme redirect registered in the Dropbox app console.
/// The scheme "mofueltaxback-dropbox" is already wired up in
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
/// a different scheme, use exactly:
/// "mofueltaxback-dropbox://oauth2redirect"
static const String? dropboxRedirectUri = null; // TODO: fill in
// --- OneDrive (Microsoft Graph) ---
//
// From an app registration in Azure Portal → Entra ID → App
// registrations, with Graph delegated permissions Files.ReadWrite.All +
// offline_access, redirect URI registered as a "Mobile and desktop
// application" platform.
/// The Application (client) ID from the Azure app registration.
static const String? oneDriveClientId = null; // TODO: fill in
/// Custom URL scheme redirect registered in Azure. The scheme
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
/// Info.plist, so unless you have a reason to pick a different scheme,
/// register and use exactly:
/// "mofueltaxback-onedrive://auth"
static const String? oneDriveRedirectUri = null; // TODO: fill in
}

View file

@ -0,0 +1,324 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'cloud/cloud_storage_provider.dart';
import 'database_service.dart';
import 'db_schema.dart';
import 'lock_coordinator.dart' as lock;
class SyncResult {
final bool ranSync;
final Object? error;
SyncResult.skipped()
: ranSync = false,
error = null;
SyncResult.success()
: ranSync = true,
error = null;
SyncResult.failure(this.error) : ranSync = false;
}
/// Orchestrates one round of sync against the shared cloud folder — same
/// behavior no matter which [CloudStorageProvider] it's wired to: acquires
/// the cross-device lock, pulls + merges the remote database if it
/// changed, uploads any pending receipt photos, pushes the local database
/// back up, then releases the lock.
///
/// The merge itself runs as SQL directly against the local database with
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
/// touched (including our own not-yet-pushed edits) are left alone by that
/// statement, so no separate "keep local" step is needed — see the README
/// for the full reasoning.
class CloudSyncService {
final CloudStorageProvider provider;
final DatabaseService databaseService;
String? _appFolderId;
String? _receiptsFolderId;
final Map<String, String> _vinFolderIds = {};
final Map<String, String> _monthFolderIds = {};
String? _dataFileId;
String? _lastKnownRemoteVersionTag;
CloudSyncService({required this.provider, required this.databaseService});
bool get isConfigured => _appFolderId != null;
/// Call once a cloud app folder has been chosen (or restored at launch).
void configure(String appFolderId) {
_appFolderId = appFolderId;
_receiptsFolderId = null;
_vinFolderIds.clear();
_monthFolderIds.clear();
_dataFileId = null;
_lastKnownRemoteVersionTag = null;
}
void clearConfiguration() {
_appFolderId = null;
_receiptsFolderId = null;
_vinFolderIds.clear();
_monthFolderIds.clear();
_dataFileId = null;
_lastKnownRemoteVersionTag = null;
}
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
/// folder the user picked in the folder browser) and configures this
/// service to use it. Returns the resulting folder ID.
Future<String> selectAppFolder(String parentId) async {
final session = provider.beginSession();
try {
final folderId =
await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
configure(folderId);
return folderId;
} finally {
session.close();
}
}
/// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a
/// receipt photo's local copy is left in place after it's uploaded
/// (useful for offline viewing / an on-device backup); when false
/// (default), it's deleted once the cloud has it, matching the original
/// "local is just a staging area" design.
///
/// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout:
/// how old another device's lock file has to be before this device
/// treats it as abandoned (e.g. that device crashed or went offline
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
Future<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
}) async {
final appFolderId = _appFolderId;
if (!provider.isSignedIn || appFolderId == null) {
return SyncResult.skipped();
}
CloudStorageSession? session;
String? lockFileId;
try {
session = provider.beginSession();
lockFileId = await _acquireLock(
session,
appFolderId: appFolderId,
username: provider.accountLabel!,
staleAge: staleLockAge,
);
_receiptsFolderId ??=
await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName);
final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName);
_dataFileId = remoteInfo?.id;
if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) {
await _pullAndMerge(session, remoteInfo.id);
}
final allReceiptsUploaded =
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
// Only push if every pending receipt made it up — otherwise we'd
// either upload a row with a local-only file path (meaningless on
// another device) or prematurely mark it clean.
if (allReceiptsUploaded) {
final pushedInfo = await _pushSnapshot(session, appFolderId);
_lastKnownRemoteVersionTag = pushedInfo.versionTag;
}
return SyncResult.success();
} catch (e) {
return SyncResult.failure(e);
} finally {
if (lockFileId != null && session != null) {
try {
await session.deleteFile(lockFileId);
} catch (_) {
// Best-effort: if this fails, the staleness reap on other
// devices' next sync attempt will clean it up.
}
}
session?.close();
}
}
Future<void> _pullAndMerge(CloudStorageSession session, String remoteFileId) async {
final bytes = await session.downloadFileBytes(remoteFileId);
final tempDir = await getTemporaryDirectory();
final tempPath =
p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db');
final tempFile = File(tempPath);
await tempFile.writeAsBytes(bytes, flush: true);
try {
final db = databaseService.rawDb;
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
try {
await db.execute(mergeVehiclesSql);
await db.execute(mergeFuelEntriesSql);
} finally {
try {
await db.execute('DETACH DATABASE remote_db');
} catch (_) {
// Don't let a detach failure mask a real merge error above.
}
}
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}
/// Uploads any receipt images still needing it (a local path but no
/// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true
/// only if every pending image made it up — see [syncNow] for why a
/// partial failure here blocks the push step entirely rather than
/// pushing something with a leftover local-only path.
///
/// Deliberately filters on `receipt_drive_file_id IS NULL` (see
/// [pendingReceiptUploadWhereClause]), not just "has a local path": once
/// [keepLocalCopies] is honored below, an already-uploaded row can still
/// have a local path (kept on purpose), and re-matching it here would
/// re-upload the same photo as a duplicate cloud file on every sync.
Future<bool> _uploadPendingReceipts(
CloudStorageSession session, {
required bool keepLocalCopies,
}) async {
final db = databaseService.rawDb;
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
if (rows.isEmpty) return true;
final vehicleRows = await db.query('vehicles', columns: ['id', 'vin']);
final vinByVehicleId = {for (final v in vehicleRows) v['id'] as String: v['vin'] as String};
var allSucceeded = true;
for (final row in rows) {
final id = row['id'] as String;
final localPath = row['receipt_image_path'] as String;
final localFile = File(localPath);
if (!await localFile.exists()) {
// Nothing left to upload; clear the dangling reference.
await db.update('fuel_entries', {'receipt_image_path': null},
where: 'id = ?', whereArgs: [id]);
continue;
}
final vin = vinByVehicleId[row['vehicle_id']];
if (vin == null) {
// Vehicle row is missing outright (shouldn't normally happen);
// nothing sane to file this under.
allSucceeded = false;
continue;
}
final date = DateTime.fromMillisecondsSinceEpoch(row['date'] as int);
try {
final folderId = await _receiptFolderFor(session, vin, date);
final uploaded = await session.uploadFile(
folderId: folderId,
name: '$id${p.extension(localPath)}',
localFile: localFile,
contentType: 'image/jpeg',
);
if (!keepLocalCopies) {
await databaseService.deleteReceiptImageFile(localPath);
}
await db.update(
'fuel_entries',
{
'receipt_drive_file_id': uploaded.id,
'receipt_image_path': keepLocalCopies ? localPath : null,
},
where: 'id = ?',
whereArgs: [id],
);
} catch (_) {
allSucceeded = false;
}
}
return allSucceeded;
}
/// Finds-or-creates the `Receipts/<vin>/<yyyy.mm>` subfolder for [vin] and
/// [date] (the fuel entry's purchase date, not upload time), caching both
/// levels for the rest of this [CloudSyncService]'s lifetime (cleared by
/// [configure]/[clearConfiguration]) so repeated uploads for the same
/// vehicle/month in one sync — or across syncs — don't re-issue the
/// lookup.
Future<String> _receiptFolderFor(CloudStorageSession session, String vin, DateTime date) async {
final vinFolderId = _vinFolderIds[vin] ??
await session.findOrCreateFolder(
parentId: _receiptsFolderId!,
name: sanitizedPathSegment(vin),
);
_vinFolderIds[vin] = vinFolderId;
final month = monthFolderName(date);
final monthCacheKey = '$vin/$month';
final monthFolderId = _monthFolderIds[monthCacheKey] ??
await session.findOrCreateFolder(parentId: vinFolderId, name: month);
_monthFolderIds[monthCacheKey] = monthFolderId;
return monthFolderId;
}
/// Uploads the current local database file as the new remote copy, then
/// clears the dirty flag on every row now that local matches the cloud.
///
/// Reads the live database file directly rather than a `VACUUM INTO`
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
/// WAL) unless explicitly configured otherwise, so the main file is a
/// complete, valid database as soon as the last write's Future
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
Future<CloudFileInfo> _pushSnapshot(CloudStorageSession session, String appFolderId) async {
final db = databaseService.rawDb;
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
final info = await session.uploadFile(
folderId: appFolderId,
name: dataFileName,
existingFileId: _dataFileId,
localFile: File(databaseService.databasePath),
contentType: 'application/x-sqlite3',
);
_dataFileId = info.id;
await db.update('vehicles', {'dirty': 0});
await db.update('fuel_entries', {'dirty': 0});
return info;
}
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
Future<String> _acquireLock(
CloudStorageSession session, {
required String appFolderId,
required String username,
required Duration staleAge,
}) {
return lock.acquireLock(
username: username,
createLock: (name) => session.createLockFile(folderId: appFolderId, name: name),
listLocks: () => session.listLockFiles(appFolderId),
deleteLock: session.deleteFile,
staleAge: staleAge,
);
}
}

View file

@ -0,0 +1,277 @@
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();
}
}
}

View file

@ -0,0 +1,77 @@
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
''';

View file

@ -0,0 +1,49 @@
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;
}

View file

@ -1,3 +1,25 @@
/// Two-letter USPS abbreviation to full name, for the 50 states + DC —
/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular
/// shows up on real receipts as a "Visa" abbreviation, and including it as
/// a valid state code would misfire on that.
const usStateNames = <String, String>{
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota',
'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska',
'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina',
'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon',
'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington',
'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
'DC': 'District of Columbia',
};
/// Best-effort values pulled out of OCR'd receipt text. Any field can be /// 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.
@ -5,12 +27,25 @@ 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,
}); });
} }
@ -23,36 +58,91 @@ 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?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false), RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false),
]; ];
static final _pricePerGallonPatterns = [ static final _pricePerGallonPatterns = [
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false), caseSensitive: false),
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false), caseSensitive: false),
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false), caseSensitive: false),
]; ];
static final _totalPatterns = [ static final _totalPatterns = [
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
RegExp(r'SALE\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'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), // Allows for words between TOTAL and the amount ("Total Sale $120.72",
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', // "Fuel Total: $44.44"), not just a bare colon/dash.
caseSensitive: false), RegExp(r'(?<!SUB)\bTOTAL\b[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
]; ];
/// Matches a bare or $-prefixed 3-decimal number anywhere in the text.
/// US fuel receipts overwhelmingly print both gallons pumped and price
/// per gallon with exactly 3 decimal places, and only the price gets a
/// currency symbol — a much more reliable signal than the label text
/// itself, which varies a lot and is often split from its value onto a
/// different line by a tabular "Pump / Gallons / Price" header row (in
/// which case the label-based patterns above never match at all).
///
/// Uses `(?!\d)` rather than `\b` after the number: some receipts print
/// the unit directly attached with no space ("17.364G"), and a digit
/// followed by a letter is *not* a `\b` boundary, so `\b` would miss it.
/// `(?<!-)` excludes negative amounts (e.g. a per-gallon discount line
/// like "Debit Di/GAL $-0.100"), which are never a real gallons/price
/// value and would otherwise get matched as one.
static final _threeDecimalNumberPattern = RegExp(r'(?<!-)(\$)?\s*(\d+\.\d{3})(?!\d)');
// Matches MM/DD/YYYY, MM-DD-YY, and similar — US gas receipts are
// consistent about digit-separator-digit-separator-digit ordering even
// though the separator (/ or -) and year length (2 or 4 digits) vary.
static final _datePattern = RegExp(r'\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b');
// Optional AM/PM, optional seconds — covers "02:21", "10:11:00 AM", and
// "02:11PM" (no space before the meridiem) all at once.
static final _timePattern = RegExp(r'\b(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\b');
// A two-letter code directly followed by a ZIP is by far the strongest
// signal an address block gives us (very low false-positive rate); a
// code right after a comma is a weaker but still decent fallback for
// receipts that print "City, ST" without a visible ZIP alongside it.
// Both require the candidate to be checked against [usStateNames] before
// being trusted — a bare 2-letter scan alone would misfire constantly
// (e.g. "VI" for Visa, "IN" as the word "in", "OR" as the word "or").
static final _stateBeforeZipPattern = RegExp(r'\b([A-Z]{2})\s+\d{5}(?:-\d{4})?\b');
static final _stateAfterCommaPattern = RegExp(r',\s*([A-Z]{2})\b');
static ParsedReceipt parse(String text) { static ParsedReceipt parse(String text) {
final normalized = text.replaceAll(',', ''); final normalized = text.replaceAll(',', '');
final gallons = _firstMatch(_gallonsPatterns, normalized); var gallons = _firstMatch(_gallonsPatterns, normalized);
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized); var pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
var totalCost = _firstMatch(_totalPatterns, normalized); final totalCost = _firstMatch(_totalPatterns, normalized);
if (gallons == null || pricePerGallon == null) {
for (final match in _threeDecimalNumberPattern.allMatches(normalized)) {
final value = double.tryParse(match.group(2)!);
if (value == null) continue;
final hasDollarSign = match.group(1) != null;
if (hasDollarSign) {
pricePerGallon ??= value;
} else {
gallons ??= value;
}
}
}
final derivedTotal = _deriveMissingValue( final derivedTotal = _deriveMissingValue(
gallons: gallons, gallons: gallons,
@ -64,10 +154,76 @@ 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);

View file

@ -1,128 +0,0 @@
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);
}
}

View file

@ -0,0 +1,31 @@
/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb
/// sticker, dashboard plate, title, etc).
///
/// Real VINs never contain the letters I, O, or Q (they're excluded from
/// the standard specifically so they can't be confused with 1 and 0), so
/// requiring that charset both matches genuine VINs and rules out a lot of
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
/// weight ratings, date codes, etc).
class VinParser {
static final _labeledVinPattern =
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
// \b on both sides matters: since digits and letters are both "word"
// characters, this only matches a maximal run of exactly 17 eligible
// characters — not a 17-character slice out of an 18+ character run.
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
/// Returns the VIN in uppercase, or null if nothing matching the VIN
/// charset/length was found. A labeled "VIN: ..." match is preferred
/// over a bare 17-character token, in case a busy sticker has more than
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
static String? parse(String text) {
final labeled = _labeledVinPattern.firstMatch(text);
if (labeled != null) return labeled.group(1)!.toUpperCase();
final bare = _bareVinPattern.firstMatch(text);
if (bare != null) return bare.group(1)!.toUpperCase();
return null;
}
}

View file

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
/// Bottom sheet letting the user pick a photo from the camera or their
/// existing library. Shared by any screen that needs to snap or choose a
/// photo (receipt capture, VIN scanning) so the choice looks and behaves
/// the same everywhere.
Future<ImageSource?> chooseImageSource(BuildContext context) {
return showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Take Photo'),
onTap: () => Navigator.of(context).pop(ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Choose from Gallery'),
onTap: () => Navigator.of(context).pop(ImageSource.gallery),
),
],
),
),
);
}

View file

@ -1,6 +1,14 @@
# 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:
@ -57,6 +65,22 @@ 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:
@ -66,7 +90,7 @@ packages:
source: hosted source: hosted
version: "0.3.5+4" version: "0.3.5+4"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@ -89,6 +113,14 @@ 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:
@ -105,6 +137,14 @@ 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:
@ -113,14 +153,6 @@ 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:
@ -182,16 +214,96 @@ 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:
@ -208,6 +320,54 @@ 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:
@ -217,7 +377,7 @@ packages:
source: hosted source: hosted
version: "2.0.2" version: "2.0.2"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -400,6 +560,14 @@ 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:
@ -408,6 +576,14 @@ 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:
@ -465,7 +641,7 @@ packages:
source: hosted source: hosted
version: "2.2.2" version: "2.2.2"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
@ -497,7 +673,7 @@ packages:
source: hosted source: hosted
version: "3.1.6" version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: plugin_platform_interface name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
@ -597,6 +773,62 @@ 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:
@ -621,6 +853,14 @@ 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:
@ -645,6 +885,70 @@ 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:
@ -681,10 +985,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.0" version: "6.4.0"
window_to_front:
dependency: transitive
description:
name: window_to_front
sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241"
url: "https://pub.dev"
source: hosted
version: "0.0.4"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -694,7 +1006,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: transitive dependency: "direct main"
description: description:
name: xml name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"

View file

@ -44,21 +44,10 @@ 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
# Directory picker for configurable save location. # App sandbox paths (local offline staging area)
# 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 like chosen save directory # Persist small settings (Drive connection state, last synced time)
shared_preferences: ^2.3.4 shared_preferences: ^2.3.4
# Unique IDs for vehicles/fuel entries # Unique IDs for vehicles/fuel entries
@ -70,10 +59,54 @@ 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

View file

@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Confirms [CloudSyncService]'s orchestration (lock → check remote →
/// upload → release) works against *any* [CloudStorageProvider], using a
/// fake one — coverage for the provider-agnostic logic itself, independent
/// of which real backend (Google Drive, Dropbox, OneDrive) it's wired to.
void main() {
late Directory tempDir;
late DatabaseService databaseService;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('cloud_sync_service_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
databaseService = DatabaseService();
await databaseService.init();
});
tearDown(() async {
await databaseService.rawDb.close();
await tempDir.delete(recursive: true);
});
test('syncNow acquires a lock, uploads the data file, and releases the lock', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final result = await syncService.syncNow();
expect(result.ranSync, isTrue);
expect(session.createdLockNames, hasLength(1));
expect(session.createdLockNames.first, startsWith('tester@example.com-'));
expect(session.uploadedFileNames, contains(dataFileName));
expect(session.deletedFileIds, ['lock-id'], reason: 'the lock must be released afterward');
});
test('syncNow uploads a pending receipt into Receipts/<vin>/<yyyy.mm>', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final db = databaseService.rawDb;
await db.insert('vehicles', {
'id': 'vehicle-1',
'vin': '1FMPU18L1TLB51349',
'nickname': null,
'updated_at': 0,
'deleted_at': null,
'dirty': 0,
});
final receiptFile = File(p.join(tempDir.path, 'receipt.jpg'))..writeAsBytesSync([1, 2, 3]);
await db.insert('fuel_entries', {
'id': 'entry-1',
'vehicle_id': 'vehicle-1',
// Mid-month/mid-year so DateTime.fromMillisecondsSinceEpoch's local
// interpretation can't drift into a different month depending on the
// machine's timezone (unlike epoch 0, which can land in Dec 1969).
'date': DateTime(2026, 3, 15).millisecondsSinceEpoch,
'gallons': 10.0,
'price_per_gallon': 3.5,
'total_cost': 35.0,
'receipt_image_path': receiptFile.path,
'receipt_drive_file_id': null,
'updated_at': 0,
'deleted_at': null,
'dirty': 1,
});
final result = await syncService.syncNow(keepLocalReceiptCopies: true);
expect(result.ranSync, isTrue);
expect(session.uploadedFileFolders['entry-1.jpg'],
'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03');
});
test('syncNow is a no-op when not configured with a folder yet', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
final result = await syncService.syncNow();
expect(result.ranSync, isFalse);
expect(session.createdLockNames, isEmpty);
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}
class _FakeSession implements CloudStorageSession {
final List<String> createdLockNames = [];
final List<String> deletedFileIds = [];
final List<String> uploadedFileNames = [];
final Map<String, String> uploadedFileFolders = {};
CloudFileInfo? existingDataFile;
@override
bool get supportsSharedWithMe => false;
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async =>
'$parentId/$name';
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
return name == dataFileName ? existingDataFile : null;
}
@override
Future<List<int>> downloadFileBytes(String fileId) async => <int>[];
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
uploadedFileNames.add(name);
uploadedFileFolders[name] = folderId;
return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1');
}
@override
Future<void> deleteFile(String fileId) async => deletedFileIds.add(fileId);
@override
Future<String> createLockFile({required String folderId, required String name}) async {
createdLockNames.add(name);
return 'lock-id';
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async => [];
@override
void close() {}
}
class _FakeProvider implements CloudStorageProvider {
final _FakeSession session;
_FakeProvider(this.session);
@override
CloudProviderId get id => CloudProviderId.googleDrive;
@override
String get displayName => 'Fake Provider';
@override
bool get isSignedIn => true;
@override
String? get accountLabel => 'tester@example.com';
@override
Future<bool> attemptSilentSignIn() async => true;
@override
Future<String> signIn() async => accountLabel!;
@override
Future<void> signOut() async {}
@override
CloudStorageSession beginSession() => session;
}

202
test/db_merge_test.dart Normal file
View file

@ -0,0 +1,202 @@
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,
};

View file

@ -0,0 +1,133 @@
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']);
});
});
}

View file

@ -0,0 +1,98 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/db_schema.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Regression coverage for a real bug: the "needs upload" query used to
/// match on `receipt_image_path IS NOT NULL` alone, which would have kept
/// re-uploading the same photo as a duplicate Drive file on every sync
/// once a "keep photos on this phone" option let an already-uploaded row
/// keep its local path. See [pendingReceiptUploadWhereClause].
void main() {
late Directory tempDir;
late Database db;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('pending_receipt_test_');
db = await databaseFactory.openDatabase(p.join(tempDir.path, 'test.db'));
await db.execute(createFuelEntriesTableSql);
});
tearDown(() async {
await db.close();
await tempDir.delete(recursive: true);
});
Future<void> insertEntry(
String id, {
String? receiptImagePath,
String? receiptDriveFileId,
int dirty = 1,
int? deletedAt,
}) {
return db.insert('fuel_entries', {
'id': id,
'vehicle_id': 'v1',
'date': 0,
'gallons': 10.0,
'price_per_gallon': 3.5,
'total_cost': 35.0,
'receipt_image_path': receiptImagePath,
'receipt_drive_file_id': receiptDriveFileId,
'updated_at': 0,
'deleted_at': deletedAt,
'dirty': dirty,
});
}
test('matches a row with a local photo not yet uploaded', () async {
await insertEntry('needs-upload', receiptImagePath: '/local/a.jpg');
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
expect(rows.map((r) => r['id']), ['needs-upload']);
});
test('excludes a row already uploaded, even if the local copy was kept', () async {
await insertEntry(
'kept-after-upload',
receiptImagePath: '/local/a.jpg',
receiptDriveFileId: 'drive-file-1',
);
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
expect(rows, isEmpty);
});
test('excludes a row with no local photo at all', () async {
await insertEntry('no-receipt');
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
expect(rows, isEmpty);
});
test('excludes a soft-deleted row even if it has a pending local photo', () async {
await insertEntry('deleted', receiptImagePath: '/local/a.jpg', deletedAt: 123);
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
expect(rows, isEmpty);
});
test('excludes a clean (already-synced) row', () async {
await insertEntry('clean', receiptImagePath: '/local/a.jpg', dirty: 0);
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
expect(rows, isEmpty);
});
}

View file

@ -21,6 +21,37 @@ 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
@ -67,6 +98,386 @@ 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));
}); });
}); });
} }

View file

@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Receipts are filed under `receipts/<vin>/<yyyy.mm>/` locally (mirroring
/// the `Receipts/<vin>/<yyyy.mm>` layout the cloud side uses — see
/// `cloud_sync_service_test.dart`), so browsing either one shows which
/// vehicle and month a photo belongs to.
void main() {
late Directory tempDir;
late DatabaseService database;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('receipt_storage_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
database = DatabaseService();
await database.init();
});
tearDown(() async {
await database.rawDb.close();
await tempDir.delete(recursive: true);
});
test('storeReceiptImage files the photo under receipts/<vin>/<yyyy.mm>/', () async {
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
final storedPath = await database.storeReceiptImage(
source,
'entry-1',
'1FMPU18L1TLB51349',
DateTime(2026, 8, 13),
);
expect(
p.dirname(storedPath),
p.join(database.receiptsDirectory.path, '1FMPU18L1TLB51349', '2026.08'),
);
expect(p.basename(storedPath), 'entry-1.jpg');
expect(await File(storedPath).exists(), isTrue);
});
test('storeReceiptImage sanitizes a VIN with characters unsafe in a path', () async {
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
final storedPath = await database.storeReceiptImage(
source,
'entry-2',
'../etc/passwd',
DateTime(2026, 1, 5),
);
expect(
p.dirname(storedPath),
p.join(
database.receiptsDirectory.path,
sanitizedPathSegment('../etc/passwd'),
'2026.01',
),
);
expect(p.isWithin(database.receiptsDirectory.path, storedPath), isTrue);
});
test('monthFolderName pads single-digit months', () {
expect(monthFolderName(DateTime(2026, 1, 5)), '2026.01');
expect(monthFolderName(DateTime(2026, 12, 5)), '2026.12');
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}

View file

@ -0,0 +1,137 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/database_service.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Confirms the version 3 → 4 migration (VIN becomes editable; a hidden
/// `id` takes over as vehicles' primary key/merge key) preserves existing
/// local data rather than dropping it, unlike the earlier VIN-as-primary-key
/// migration which was a from-scratch rebuild. Builds a real on-disk
/// version-3 database by hand (the old schema, pre-dating this change), then
/// runs [DatabaseService.init] against it and checks what comes out.
void main() {
late Directory tempDir;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('vehicle_id_migration_test_');
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
test('migrates a version-3 database, preserving vehicles and fuel entries', () async {
final dbDir = Directory(p.join(tempDir.path, 'FuelTaxTracker'));
await dbDir.create(recursive: true);
final dbPath = p.join(dbDir.path, dbFileName);
final oldDb = await databaseFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: 3,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE vehicles (
vin TEXT PRIMARY KEY,
nickname TEXT,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
dirty INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
CREATE TABLE fuel_entries (
id TEXT PRIMARY KEY,
vehicle_vin TEXT NOT NULL,
date INTEGER NOT NULL,
gallons REAL NOT NULL,
price_per_gallon REAL NOT NULL,
total_cost REAL NOT NULL,
receipt_image_path TEXT,
receipt_drive_file_id TEXT,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
dirty INTEGER NOT NULL DEFAULT 1
)
''');
},
),
);
await oldDb.insert('vehicles', {
'vin': 'VIN1',
'nickname': "Mom's Car",
'updated_at': 1000,
'deleted_at': null,
'dirty': 0,
});
await oldDb.insert('vehicles', {
'vin': 'VIN2',
'nickname': null,
'updated_at': 1500,
'deleted_at': 2000, // a soft-deleted vehicle, should still migrate
'dirty': 0,
});
await oldDb.insert('fuel_entries', {
'id': 'f1',
'vehicle_vin': 'VIN1',
'date': 1200,
'gallons': 12.5,
'price_per_gallon': 3.2,
'total_cost': 40.0,
'receipt_image_path': '/local/receipt.jpg',
'receipt_drive_file_id': null,
'updated_at': 1200,
'deleted_at': null,
'dirty': 0,
});
await oldDb.close();
final databaseService = DatabaseService();
await databaseService.init();
addTearDown(() => databaseService.rawDb.close());
final vehicles = await databaseService.getVehicles();
expect(vehicles, hasLength(1), reason: 'the soft-deleted vehicle should not show as active');
expect(vehicles.first.vin, 'VIN1');
expect(vehicles.first.nickname, "Mom's Car");
expect(vehicles.first.id, isNotEmpty);
final allVehicleRows = await databaseService.rawDb.query('vehicles');
expect(allVehicleRows, hasLength(2), reason: 'the soft-deleted vehicle should still exist');
final ids = {for (final row in allVehicleRows) row['vin']: row['id'] as String};
expect(ids['VIN1'], isNotEmpty);
expect(ids['VIN2'], isNotEmpty);
expect(ids['VIN1'], isNot(ids['VIN2']), reason: 'each vehicle gets its own generated id');
for (final row in allVehicleRows) {
expect(row['dirty'], 1, reason: 'migrated rows must be re-pushed under the new schema');
}
final fuelEntries = await databaseService.getFuelEntries();
expect(fuelEntries, hasLength(1));
expect(fuelEntries.first.vehicleId, ids['VIN1'],
reason: "the fuel entry's foreign key should now point at VIN1's new id");
expect(fuelEntries.first.gallons, 12.5);
expect(fuelEntries.first.receiptImagePath, '/local/receipt.jpg');
});
}
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
final String tempPath;
_FakePathProviderPlatform(this.tempPath);
@override
Future<String?> getApplicationDocumentsPath() async => tempPath;
@override
Future<String?> getTemporaryPath() async => tempPath;
}

47
test/vin_parser_test.dart Normal file
View file

@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/vin_parser.dart';
void main() {
group('VinParser', () {
test('parses a bare 17-character VIN', () {
const text = '''
VEHICLE IDENTIFICATION NUMBER
1HGCM82633A004352
MFD BY HONDA MFG CO
''';
expect(VinParser.parse(text), '1HGCM82633A004352');
});
test('prefers a "VIN:" labeled match over another candidate elsewhere', () {
const text = '''
PART NO 4F2CZ58899BA12345
VIN: 1HGCM82633A004352
''';
expect(VinParser.parse(text), '1HGCM82633A004352');
});
test('normalizes to uppercase', () {
expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352');
});
test('does not match a run containing I, O, or Q (never valid in a real VIN)', () {
// Same length (17) but contains an "O" partway through, so no
// 17-character eligible-charset run exists anywhere in it.
expect(VinParser.parse('1HGCM82633AOO4352'), isNull);
});
test('does not match a 16-character (too short) run', () {
expect(VinParser.parse('1HGCM82633A00435'), isNull);
});
test('does not match an 18-character (too long) run', () {
expect(VinParser.parse('1HGCM82633A0043521'), isNull);
});
test('returns null when nothing matches', () {
expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull);
});
});
}

View file

@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
const _multistatusBody = '''<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/</D:href>
<D:propstat>
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>''';
/// Confirms sign-in survives a cold app restart: [WebDavProvider] persists
/// verified credentials to secure storage on [signInWithCredentials], and a
/// *fresh* instance (an in-memory field reset, standing in for a relaunched
/// app) can restore the session from them via [attemptSilentSignIn] — the
/// gap flagged in README's "Known limitations" and fixed here.
void main() {
late HttpServer server;
late String serverUrl;
const username = 'alice';
const password = 'hunter2';
setUp(() async {
FlutterSecureStoragePlatform.instance = _FakeSecureStoragePlatform();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
serverUrl = 'http://127.0.0.1:${server.port}/';
unawaited(server.forEach((request) async {
final auth = request.headers.value('authorization');
final expected = 'Basic ${base64Encode(utf8.encode('$username:$password'))}';
if (request.method == 'PROPFIND' && auth == expected) {
request.response.statusCode = 207;
request.response.headers.contentType = ContentType('application', 'xml');
request.response.write(_multistatusBody);
} else if (request.method == 'PROPFIND') {
request.response.statusCode = 401;
} else {
request.response.statusCode = 404;
}
await request.response.close();
}));
});
tearDown(() async {
await server.close(force: true);
});
test('signInWithCredentials persists credentials that a fresh instance can restore', () async {
final first = WebDavProvider();
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
expect(first.isSignedIn, isTrue);
// A brand new instance has no in-memory session — simulates the app
// process having been killed and relaunched.
final afterRestart = WebDavProvider();
expect(afterRestart.isSignedIn, isFalse);
final restored = await afterRestart.attemptSilentSignIn();
expect(restored, isTrue);
expect(afterRestart.isSignedIn, isTrue);
expect(afterRestart.accountLabel, first.accountLabel);
});
test('attemptSilentSignIn returns false, not throws, when nothing was ever stored', () async {
final provider = WebDavProvider();
expect(await provider.attemptSilentSignIn(), isFalse);
expect(provider.isSignedIn, isFalse);
});
test('signOut clears stored credentials so a later restart no longer restores', () async {
final first = WebDavProvider();
await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password);
await first.signOut();
final afterRestart = WebDavProvider();
expect(await afterRestart.attemptSilentSignIn(), isFalse);
});
}
class _FakeSecureStoragePlatform extends FlutterSecureStoragePlatform {
final Map<String, String> _store = {};
@override
Future<void> write({required String key, required String value, required Map<String, String> options}) async {
_store[key] = value;
}
@override
Future<String?> read({required String key, required Map<String, String> options}) async => _store[key];
@override
Future<bool> containsKey({required String key, required Map<String, String> options}) async =>
_store.containsKey(key);
@override
Future<void> delete({required String key, required Map<String, String> options}) async {
_store.remove(key);
}
@override
Future<Map<String, String>> readAll({required Map<String, String> options}) async => Map.of(_store);
@override
Future<void> deleteAll({required Map<String, String> options}) async => _store.clear();
}

View file

@ -0,0 +1,122 @@
import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final baseUrl = Uri.parse('https://cloud.example.com/');
group('parseWebDavMultistatus', () {
// Real WebDAV servers disagree on namespace prefix for the same "DAV:"
// namespace — Nextcloud defaults to `d:`, many Apache mod_dav setups
// use `D:`, and some use no prefix at all with a default xmlns. All
// three must parse identically since the parser matches by local name.
test('parses a lowercase d: prefixed response (Nextcloud-style)', () {
final xml = '''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/user/MO-Fuel-Tax-Back/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/></d:resourcetype>
<d:getetag>&quot;abc123&quot;</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>&quot;def456&quot;</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>&quot;ghi789&quot;</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);
});
});
}