diff --git a/.gitignore b/.gitignore index 3820a95..930bae3 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Release signing credentials — never commit. The keystore itself lives +# outside the repo entirely (~/.android/), but the passwords in here are +# sensitive regardless. +/android/key.properties +*.jks +*.keystore diff --git a/HOMEPAGE.html b/HOMEPAGE.html new file mode 100644 index 0000000..fc52a76 --- /dev/null +++ b/HOMEPAGE.html @@ -0,0 +1,432 @@ + + +
+
+ +
+
+
+ Show Me The Fuel Refund app icon +
+
+ Missouri · Fuel Tax Refund Tracker + Show Me The Fuel Refund +
+
+
+ +
+

Every fill-up is money Missouri owes you back.

+

+ Snap a photo of the receipt. The app reads the gallons, the price, and the + date — and keeps a running, ready-to-file tally toward your Missouri + Motor Fuel Tax Refund. +

+
+ + + +
+
+

What's on the receipt

+ 4 items +
+ +
+ 01 +
+

Receipts, read automatically

+

Photograph a pump receipt and the app fills in the date, gallons, and + price for you. Every field stays editable, so a misread is a two-second fix.

+
+
+
+ 02 +
+

One tally per vehicle

+

Add each vehicle by VIN and its fuel purchases sort themselves out — + no spreadsheets, no shoebox of paper.

+
+
+
+ 03 +
+

A report built for filing

+

Pick a date range and get a print- or share-ready PDF, itemized and + totaled, with a direct link to the state's official claim form.

+
+
+
+ 04 +
+

Backed up your way

+

Connect Google Drive, Dropbox, OneDrive, or your own WebDAV server. + Your data goes to storage you own — never to us.

+
+
+
+ + + +
+
+

Two refunds, one app

+ current rates +
+ +
+
+ Highway use + Ordinary vehicle fill-ups + + + Up to 12.5¢/gal + Form 4923-H + +
+
+ Non-highway use + Off-road, farm, and other qualifying use + + + Up to 29.5¢/gal + Form 4923 + +
+
+ Most people qualify for one or the other — some for both. This is + informational, not tax advice; rates are set by Missouri law and subject + to change. Confirm current eligibility and rates with the Missouri + Department of Revenue before filing. +
+
+
+ + + +
+
+

Local-first

+

Every record lives on your device by default. Nothing reaches us, ever.

+
+
+

No tracking

+

No analytics, no crash reporting, no ad-tech beyond the ads themselves.

+
+
+

Your cloud, your rules

+

Optional backup goes to an account only you control — and only you can reach.

+
+
+ + + +
+
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..e782391 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,92 @@ +# Privacy Policy — Show Me The Fuel Refund + +**Effective date:** August 17, 2026 + +This policy explains what "Show Me The Fuel Refund" (the "app") does and does not +do with your data. If anything here is unclear, contact +**ohbrer+ShowMeTheFuelRefund@gmail.com**. + +## Overview + +The app helps you collect and organize Missouri fuel purchase receipts so you can +claim Missouri's Motor Fuel Tax Refund. It has no server or backend of its own — +every vehicle, fuel receipt, and photo you log is stored locally on your device, +and optionally backed up to a cloud storage account **you** choose and control. +We (the developer) never receive, see, or have access to a copy of your data. + +## Data Stored Locally + +The app stores the following on your device only, unless you connect a cloud +storage account (see below): + +- Vehicle information you enter (VIN, nickname) +- Fuel purchase records (date, gallons, price, total cost) +- Receipt photos you capture or select + +This data is never transmitted to us. Uninstalling the app, or never connecting a +cloud backup, means this data exists only on that device. + +## Optional Cloud Backup + +If you connect a cloud storage account in Settings, a copy of the data above is +also stored there — in storage **you** control: + +- **Google Drive** — via Google Sign-In and the Drive API +- **Dropbox** — via the Dropbox API +- **Microsoft OneDrive** — via the Microsoft Graph API +- **WebDAV** — your own self-hosted server (Nextcloud, ownCloud, or similar) + +For OAuth-based providers (Google Drive, Dropbox, OneDrive), the app requests +only the permissions needed to create, read, and write files in a folder it +manages there. We do not read, log, or have access to anything stored in these +accounts — the connection is directly between your device and the provider you +chose. For WebDAV, your server credentials are stored using your device's +OS-level encrypted storage (Android Keystore) and are used only to connect +directly to the server you specify. + +## Advertising (Google AdMob) + +Unless you've purchased "Remove Ads for a Year," the app shows a single +interstitial ad, at most once per app session, via Google AdMob. AdMob may +collect data such as an advertising identifier and coarse, IP-based location to +serve and measure ads. This collection is Google's, governed by +[Google's own privacy policy](https://policies.google.com/privacy) and +[AdMob's data disclosure](https://support.google.com/admob/answer/6128543) — it +is not something this app adds on top of, and we have no access to it. + +## In-App Purchases + +"Remove Ads for a Year" is processed entirely through Google Play Billing. We do +not receive or store payment information — that's handled by Google Play +directly. + +## What We Don't Do + +- No analytics SDKs +- No crash-reporting SDKs +- No tracking of your usage or behavior +- No server of ours that receives, stores, or processes your data +- No selling or sharing of data with anyone, for any reason + +## Data Deletion + +You can delete your data at any time from **Settings > Data > Advanced** +(purge all data, or a specific date range), and disconnect any cloud storage +account from **Settings > Data**. Deleting the app removes everything stored +locally; anything already backed up to your own cloud storage account remains +there under your control until you delete it yourself. + +## Children's Privacy + +This app is not directed at children under 13 and does not knowingly collect +data from them. + +## Changes to This Policy + +If this policy changes, the effective date above will be updated. Continued use +of the app after a change constitutes acceptance of the updated policy. + +## Contact + +Questions, problem receipts/VINs, or error reports: +**ohbrer+ShowMeTheFuelRefund@gmail.com** diff --git a/README.md b/README.md index f160e3b..0c409cd 100644 --- a/README.md +++ b/README.md @@ -3,37 +3,227 @@ A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt -photo to a data file in a folder you choose. +photo. Vehicles and fuel entries live in a 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 -- Manage a list of vehicles (make, model, color, license plate). -- Capture a fuel receipt photo per vehicle and OCR it on-device with Google - ML Kit — no internet connection or API key required. -- Review/edit the parsed gallons, price per gallon, and total cost before - saving (OCR on printed receipts is usually good but not perfect). +- Manage a list of vehicles, identified by **VIN** (required, and unique + across active vehicles — but editable, e.g. to fix a typo from a misread + scan; scan it from a photo of the door-jamb sticker or dashboard plate + instead of typing all 17 characters). An optional **nickname** ("Mom's + Car", "Red Ford F-150") is what's shown as the primary label everywhere; + without one, the VIN is shown instead. +- Capture a fuel receipt photo per vehicle — from the camera or an existing + photo in your library — and OCR it on-device with Google ML Kit — no + internet connection or API key required for the OCR itself. +- If a receipt's address shows a state other than Missouri, a warning + explains that the fuel tax refund only covers Missouri purchases and lets + you choose whether to log the entry anyway. +- Review/edit the parsed gallons, price per gallon, total cost, *and* + date/time before saving (OCR on printed receipts is usually good but not + perfect; the date/time picker is always available as a manual fallback + when a date can't be found on the receipt). - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete. -- Settings screen to choose where receipt photos and the data file are - stored (defaults to the app's own documents folder; existing data is - copied over when you change it). +- Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV + server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV + endpoint) and pick any folder you have access to — including one someone + else created and shared with you — as the shared storage location. The + app looks for (or creates) a `Show Me The Fuel Refund` subfolder there + (using it directly, without nesting, if the folder you picked is already + named that), 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 ``` 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/ - app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider - storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path - ocr_service.dart Thin wrapper around google_mlkit_text_recognition - receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text + app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps + updatedAt on mutations and triggers background sync. + database_service.dart Owns the local SQLite database (vehicles + fuel_entries) + and the receipts/ folder, always at + ApplicationDocumentsDirectory/FuelTaxTracker. + db_schema.dart CREATE TABLE statements and the merge SQL — shared between + the app and its tests so they can never drift apart. + 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, - 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/` -subfolder of photos, both inside whatever directory Settings points at. +## Manual setup required (cloud storage) + +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.ohbrer.show_me_the_fuel_refund` + 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 @@ -51,43 +241,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 `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 - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already set up for receipt capture. -- **Storage**: no explicit storage permission is declared. The default save - location is inside the app's own sandbox (no permission needed). If you - point Settings at a location outside the sandbox, the OS-native folder - picker (via `file_picker`) is what grants access — see the caveat below. +- **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the + main Android manifest (needed for release builds; debug builds get + `INTERNET` for free). +- **OAuth redirects**: no manifest changes are needed for `google_sign_in` + itself when not using `google-services.json` — see the Google setup + section above instead. Dropbox and OneDrive's browser-based OAuth redirect + is already wired up via a `flutter_web_auth_2` callback activity + (Android) / extra `CFBundleURLTypes` entries (iOS) for the + `mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you + only need to register the matching redirect URI in each provider's own + console (see setup above), not touch these files, unless you deliberately + choose different scheme names. + +## How sync works + +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. 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 -- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions - 11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP - 9+ (assuming AGP's built-in Kotlin support handles it), but that isn't - actually wired up for library modules in this Flutter/AGP combination yet, - so the plugin's own Kotlin sources never get compiled and the build fails - with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is - retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time - you bump dependencies — check the package's CHANGELOG for when this is - properly resolved upstream. -- **iOS folder picking and app restarts**: `file_picker`'s directory picker - on iOS uses `UIDocumentPickerViewController`, which hands back a - security-scoped URL. This app does not currently persist a security-scoped - bookmark for that URL, so if you pick a folder outside the app's own - sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after - an app restart is not guaranteed. Picking the default in-sandbox location, - or a folder on Android, does not have this limitation. If cross-restart - external storage on iOS matters for your use case, this needs a proper - bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`). +- **Field-level conflicts aren't merged.** Two people editing the *same* + record at the same time: whole record, newer `updated_at` wins — not a + field-by-field merge. +- **Lock acquisition has no hard timeout** beyond the configurable + staleness reap (Settings → Advanced). Fine at the scale this is built for + (a handful of people); could in theory spin under many simultaneous + contenders. +- **VIN uniqueness is checked, but not race-proof across devices.** Editing + or adding a vehicle checks for an existing active vehicle with the same + VIN before saving, but two offline devices could still each independently + create (or rename into) the same VIN before either has seen the other's + change — same category of accepted limitation as "field-level conflicts + aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not + `vin`, specifically so this doesn't corrupt the merge itself if it + happens — you'd just end up with two vehicle rows sharing a VIN until + someone notices and fixes it by hand. +- **`google_sign_in` v7's Android path requires a *Web* OAuth client ID** + (`serverClientId`), not just the Android client's SHA-1 registration — + see the manual setup section. This is a quirk of the Credential + Manager-based implementation and easy to miss if you're used to older + `google_sign_in` versions. +- **Dropbox and OneDrive's sign-in isn't restored across a cold app + restart.** Their OAuth refresh tokens are kept in memory only for now + (Google's own SDK handles its own persistent session separately, and + WebDAV persists its credentials to OS-encrypted storage — see below — so + this only affects these two hand-built OAuth providers) — + `attemptSilentSignIn()` always returns false for them, so you'll need to + reconnect once per app launch until refresh-token persistence is added. +- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore + on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the + session after a cold restart — it re-verifies them with the same PROPFIND + check `signInWithCredentials` uses rather than trusting the stored values + blindly, since the server or password could have changed since. A wrong + or revoked password just silently fails the restore (same as no + connection ever having been made); there's no proactive UI nudge to + reconnect beyond the sync error that shows up on the next sync attempt. +- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks + the access token's expiry before every call and refreshes proactively, + but a token revoked or invalidated out-of-band (e.g. from that provider's + own "manage app access" page) surfaces as a failed sync (visible in + Settings as "Last sync failed") rather than prompting a fresh sign-in + automatically. +- **Dropbox has no distinct "Shared with me" tab** in the folder browser + (the interface's `sharedWithMe` parameter is a no-op for + `DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into + the account's normal folder tree, so they already show up under "My + Files" in the common case. Revisit if an unmounted/pending share needs to + be browsable directly. +- **WebDAV has no "Shared with me" concept at all** (it's not part of the + base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false, + same as Dropbox, and it's up to the server/user to point the app at + whatever path a share is mounted under. WebDAV credentials are also sent + as HTTP Basic Auth on every request, so an **HTTPS server URL is + effectively required** — the app doesn't block a plain `http://` URL, but + it would send the password in the clear. - **Receipt parsing is best-effort regex matching** on the OCR'd text (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may parse partially or not at all — the confirm screen always lets you fill in or correct whatever wasn't found. -- No automated tests exercise the OCR or camera capture path itself (that - requires a real device/emulator with a camera); `receipt_parser_test.dart` - covers the parsing logic against fixed OCR text. +- No automated tests exercise the OCR, camera/gallery picker, or real Drive + API calls (those need a real device/emulator and live credentials); + `receipt_parser_test.dart`, `lock_coordinator_test.dart`, + `db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter + two using `sqflite_common_ffi` to run real SQL against temp SQLite files + on the Dart VM) cover the pure logic pieces against fixed inputs — + `receipt_parser_test.dart` in particular is transcribed from 13 real + photographed receipts across different gas station chains. +- No migration path exists from the earlier JSON-file storage format — + not needed since no real data had accumulated under it yet, but flag it + if that's no longer true for you. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 267a318..373db13 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,24 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing credentials — see key.properties (gitignored; the +// keystore file itself lives outside the repo, at the path it points to). +// Missing entirely just means a debug-signed release build, same as +// before, so this doesn't break local dev if key.properties isn't set up. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { - namespace = "com.courtneyarnold.fuel_tax_tracker" + namespace = "com.ohbrer.show_me_the_fuel_refund" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,8 +28,7 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.courtneyarnold.fuel_tax_tracker" + applicationId = "com.ohbrer.show_me_the_fuel_refund" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -25,11 +37,31 @@ android { versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Real release signing once key.properties exists (see above); + // falls back to the debug key otherwise, so `flutter run + // --release` still works for local testing without it. + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..43ee6d9 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,35 @@ +# google_mlkit_text_recognition's plugin code references all of ML Kit's +# regional script recognizers (Chinese/Devanagari/Japanese/Korean) +# generically, even though this app only depends on (and only ever uses) +# the default Latin recognizer. R8 can't resolve the others since their +# artifacts genuinely aren't on the classpath — safe to silence, they're +# never called at runtime here. +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions + +# The -dontwarn rules above only silence build-time warnings — they don't +# stop R8 from stripping/renaming classes the Latin recognizer actually +# does use at runtime via reflection. Without an explicit -keep, release +# builds installed and launched fine but every OCR attempt threw +# "Attempt to invoke virtual method 'java.lang.Class +# java.lang.Object.getClass()' on a null object reference" from inside the +# ML Kit plugin, silently swallowed by receipt_capture.dart's catch block +# and surfaced to the user as "Couldn't automatically read this receipt." +-keep class com.google.mlkit.** { *; } +-keep class com.google.android.gms.internal.mlkit_vision_text_common.** { *; } + +# AndroidX WorkManager (pulled in transitively by one of the Google SDKs, +# not used directly by this app) initializes its Room-backed WorkDatabase +# reflectively at process startup via androidx.startup.InitializationProvider +# — R8 was stripping/renaming those generated Room implementation classes, +# crashing every release build before Flutter even started ("Failed to +# create an instance of androidx.work.impl.WorkDatabase"). Keeping the +# whole (small, self-contained) impl package is the standard fix. +-keep class androidx.work.impl.** { *; } +-keep class androidx.room.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ab01282..e65e775 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,13 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/courtneyarnold/fuel_tax_tracker/MainActivity.kt b/android/app/src/main/kotlin/com/ohbrer/show_me_the_fuel_refund/MainActivity.kt similarity index 67% rename from android/app/src/main/kotlin/com/courtneyarnold/fuel_tax_tracker/MainActivity.kt rename to android/app/src/main/kotlin/com/ohbrer/show_me_the_fuel_refund/MainActivity.kt index 5532cca..92a5f3e 100644 --- a/android/app/src/main/kotlin/com/courtneyarnold/fuel_tax_tracker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/ohbrer/show_me_the_fuel_refund/MainActivity.kt @@ -1,4 +1,4 @@ -package com.courtneyarnold.fuel_tax_tracker +package com.ohbrer.show_me_the_fuel_refund import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..a6ab1bf Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..cd370fb Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..b3b4db8 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..b261793 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..a6ab1bf Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png new file mode 100644 index 0000000..cd370fb Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..b3b4db8 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png new file mode 100644 index 0000000..b261793 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 0000000..649db16 Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..77c3f18 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png new file mode 100644 index 0000000..a5169e6 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..c406a4e Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png new file mode 100644 index 0000000..e9835d6 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..7aad901 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png new file mode 100644 index 0000000..63ef10c Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 0000000..649db16 Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..8e24958 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..3cc4948 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..77c3f18 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..a5169e6 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..c406a4e Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..e9835d6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..7aad901 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..63ef10c Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..8e24958 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..3cc4948 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..65ca23d 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..25fe56c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..41a03e6 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..358883b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..97050a2 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..34cb63f --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..dbc9ea9 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..0d1fa8f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges "*" Vehicle : caches + AppState "1" --> "*" FuelEntry : caches + AppState ..> DuplicateVinException : throws + DatabaseService --> Vehicle + DatabaseService --> FuelEntry + AppState --|> ChangeNotifier +``` + +`ad_free_entitlement` has **no** Dart model. `DatabaseService.getAdFreeUntil` / `setAdFreeUntil` read and write that singleton row; `AppState.adFreeUntil` is the in-memory cache. + +## Cloud storage and sync + +```mermaid +classDiagram + class CloudProviderId { + <> + googleDrive + dropbox + oneDrive + webdav + } + + class CloudStorageProvider { + <> + +CloudProviderId id + +String displayName + +bool isSignedIn + +String? accountLabel + +attemptSilentSignIn() bool + +signIn() String + +signOut() + +beginSession() CloudStorageSession + } + + class CloudStorageSession { + <> + +bool supportsSharedWithMe + +listFolders() List~CloudFolder~ + +findOrCreateFolder() String + +moveFolder() String + +findFile() CloudFileInfo? + +downloadFileBytes() List~int~ + +uploadFile() CloudFileInfo + +deleteFile() + +createLockFile() String + +listLockFiles() List~CloudLockFile~ + +close() + } + + class ManualCredentialCloudStorageProvider { + <> + +signInWithCredentials() String + } + + class GoogleDriveProvider + class DropboxProvider + class OneDriveProvider + class WebDavProvider + + class GoogleDriveSession + class DropboxSession + class OneDriveSession + class WebDavSession + + class CloudFolder { + +String id + +String name + } + + class CloudFileInfo { + +String id + +String? versionTag + } + + class CloudLockFile { + +String id + +String username + +DateTime createdAtUtc + } + + class CloudNotAuthorizedException { + +String providerName + } + + class CloudSyncService { + +CloudStorageProvider provider + +DatabaseService databaseService + +bool isConfigured + +configure(appFolderId) + +clearConfiguration() + +selectAppFolder() String + +syncNow() SyncResult + } + + class SyncResult { + +bool ranSync + +Object? error + +bool adGateBlocked + +skipped() SyncResult + +success() SyncResult + +failure(error) SyncResult + +adGateBlocked() SyncResult + } + + CloudStorageProvider <|.. GoogleDriveProvider + CloudStorageProvider <|.. DropboxProvider + CloudStorageProvider <|.. OneDriveProvider + CloudStorageProvider <|.. WebDavProvider + ManualCredentialCloudStorageProvider <|.. WebDavProvider + + CloudStorageSession <|.. GoogleDriveSession + CloudStorageSession <|.. DropboxSession + CloudStorageSession <|.. OneDriveSession + CloudStorageSession <|.. WebDavSession + + CloudStorageProvider --> CloudStorageSession : beginSession() + CloudStorageProvider --> CloudProviderId + CloudStorageSession --> CloudFolder + CloudStorageSession --> CloudFileInfo + CloudStorageSession --> CloudLockFile + + CloudSyncService --> CloudStorageProvider + CloudSyncService --> DatabaseService + CloudSyncService --> SyncResult + AppState o-- CloudStorageProvider : activeProvider + AppState o-- CloudSyncService : cloudSync + AppState "1" *-- "*" CloudStorageProvider : availableProviders +``` + +Lock acquisition is a free function, `acquireLock` in `lock_coordinator.dart`, injected with create/list/delete callbacks so it can be unit-tested without a network. `CloudSyncService` is the only production caller. + +OAuth helpers (`PkcePair`, `CloudOAuthConfig`) support Dropbox and OneDrive sign-in. Google uses its own SDK; WebDAV uses HTTP Basic Auth. + +## Reports, OCR, ads, purchases + +```mermaid +classDiagram + class FuelReport { + +DateTime startDate + +DateTime endDate + +List~VehicleReportRow~ rows + +double totalGallons + +double totalCost + +int fillCount + +List~ReportEntry~ allEntries + +List~MonthlyTotal~ monthlyTotals + } + + class VehicleReportRow { + +Vehicle vehicle + +List~FuelEntry~ entries + +double totalGallons + +double totalCost + +int entryCount + } + + class ReportEntry { + +FuelEntry entry + +Vehicle vehicle + } + + class MonthlyTotal { + +DateTime month + +int fillCount + +double totalGallons + +double totalCost + +double avgPricePerGallon + } + + class ParsedReceipt { + +double? gallons + +double? pricePerGallon + +double? totalCost + +DateTime? date + +String? state + +String rawText + } + + class ReceiptParser { + +parse(text) ParsedReceipt$ + } + + class OcrService { + +recognizeText(imageFile) String + +dispose() + } + + class VinParser { + +parse(text) String?$ + } + + class AdService { + +initialize() + +showGateAd() AdGateResult + } + + class AdGateResult { + <> + alreadyOpen + justShown + blocked + } + + class PurchaseService { + +String adFreeYearProductId$ + +String? priceLabel + +initialize() + +buyAdFreeYear() + +dispose() + } + + class ReceiptImageLoadResult { + +Map bytesByEntryId + +List~FuelEntry~ failedEntries + } + + FuelReport "1" *-- "*" VehicleReportRow + VehicleReportRow --> Vehicle + VehicleReportRow --> FuelEntry + FuelReport --> ReportEntry + ReportEntry --> FuelEntry + ReportEntry --> Vehicle + FuelReport --> MonthlyTotal + ReceiptParser --> ParsedReceipt + OcrService ..> ReceiptParser : raw text in + AdService --> AdGateResult + AppState *-- AdService + AppState *-- PurchaseService +``` + +`buildFuelReport(...)` (in `fuel_report.dart`) constructs a `FuelReport` from the in-memory vehicle/entry lists. `buildFuelReportPdf(...)` renders it; `loadReceiptImageBytes(...)` resolves local or cloud receipt photos into a `ReceiptImageLoadResult`. `estimatedFuelRefund(gallons)` is a pure function (Missouri highway rate, $0.125/gal). + +## UI structure + +Widgets consume `AppState` via `Provider` / `context.watch`. They do not talk to SQLite or cloud APIs directly. + +```mermaid +classDiagram + class FuelTaxTrackerApp { + <> + } + class AppRoot { + <> + } + class UserAgreementScreen { + <> + } + class MainShell { + <> + } + class ReceiptsScreen { + <> + } + class HomeScreen { + <> + } + class ReportScreen { + <> + } + class SettingsScreen { + <> + } + class DataSettingsScreen { + <> + } + class UiSettingsScreen { + <> + } + class FaqScreen { + <> + } + class AddEditVehicleScreen { + <> + } + class VehicleDetailScreen { + <> + } + class ConfirmFuelEntryScreen { + <> + } + class EditFuelEntryScreen { + <> + } + class ReceiptDetailScreen { + <> + } + class ReceiptImageScreen { + <> + } + class CloudFolderBrowserScreen { + <> + } + + FuelTaxTrackerApp --> AppRoot + FuelTaxTrackerApp --> AppState : ChangeNotifierProvider + AppRoot --> UserAgreementScreen : if not accepted + AppRoot --> MainShell : after agreement + MainShell --> ReceiptsScreen + MainShell --> HomeScreen + MainShell --> ReportScreen + MainShell --> SettingsScreen + SettingsScreen --> DataSettingsScreen + SettingsScreen --> UiSettingsScreen + SettingsScreen --> FaqScreen + DataSettingsScreen --> CloudFolderBrowserScreen + HomeScreen --> AddEditVehicleScreen + HomeScreen --> VehicleDetailScreen + HomeScreen --> FaqScreen + VehicleDetailScreen --> ConfirmFuelEntryScreen + VehicleDetailScreen --> EditFuelEntryScreen + VehicleDetailScreen --> ReceiptImageScreen + ReceiptsScreen --> ConfirmFuelEntryScreen + ReceiptsScreen --> ReceiptDetailScreen + ReceiptsScreen --> EditFuelEntryScreen + ReceiptDetailScreen --> ReceiptImageScreen + ReportScreen --> FuelReport : buildFuelReport() +``` + +Shared widgets (not expanded above): `HeroBanner`, `ReceiptThumbnail`, `ReceiptCapture`, `OnboardingTourOverlay`, `BackupReminder`, `AdFreeUpsellDialog`, `ImageSourceSheet`. Theme tokens live on `AppTheme`. + +## How the layers connect + +``` +UI screens/widgets + │ Provider + ▼ + AppState ──────────► PurchaseService + │ AdService + ├── DatabaseService ── SQLite (vehicles, fuel_entries, ad_free_entitlement) + │ └── local receipts/ folder + └── CloudSyncService ── CloudStorageProvider + ├── GoogleDriveProvider + ├── DropboxProvider + ├── OneDriveProvider + └── WebDavProvider +``` diff --git a/docs/erd.md b/docs/erd.md new file mode 100644 index 0000000..f384bbb --- /dev/null +++ b/docs/erd.md @@ -0,0 +1,73 @@ +# SQLite entity-relationship diagram + +Local database: `ApplicationDocumentsDirectory/FuelTaxTracker/fuel_tax_tracker.db` +Schema version: **5** (defined in `lib/services/database_service.dart` / `lib/services/db_schema.dart`) + +```mermaid +erDiagram + VEHICLES ||--o{ FUEL_ENTRIES : "owns (vehicle_id)" + + VEHICLES { + TEXT id PK "UUID, hidden, immutable" + TEXT vin "required; unique among active rows, app-enforced" + TEXT nickname "nullable display label" + INTEGER updated_at "UTC millis; newest-wins merge key" + INTEGER deleted_at "nullable soft-delete tombstone" + INTEGER dirty "local-only; 1 = not yet pushed" + } + + FUEL_ENTRIES { + TEXT id PK "UUID" + TEXT vehicle_id FK "references vehicles.id — no SQLite FK" + INTEGER date "purchase datetime, local millis" + REAL gallons + REAL price_per_gallon + REAL total_cost + TEXT receipt_image_path "nullable on-device path" + TEXT receipt_drive_file_id "nullable cloud file id" + INTEGER updated_at "UTC millis; newest-wins merge key" + INTEGER deleted_at "nullable soft-delete tombstone" + INTEGER dirty "local-only; 1 = not yet pushed" + } + + AD_FREE_ENTITLEMENT { + INTEGER id PK "always 1 (CHECK id = 1)" + INTEGER ad_free_until "nullable UTC millis" + INTEGER updated_at "UTC millis; newest-wins merge key" + } +``` + +## Relationships + +| From | To | Cardinality | Enforced by | +| --- | --- | --- | --- | +| `vehicles` | `fuel_entries` | 1 : 0..n | Application (`FuelEntry.vehicleId` → `Vehicle.id`). There is **no** `FOREIGN KEY` clause. | +| `ad_free_entitlement` | — | singleton | `PRIMARY KEY CHECK (id = 1)` | + +VIN is **not** the foreign key. Fuel entries reference the hidden vehicle `id` so the user can edit a VIN without breaking receipts or sync history. + +## Indexes and constraints + +- `vehicles.id` — `PRIMARY KEY` +- `fuel_entries.id` — `PRIMARY KEY` +- `idx_fuel_entries_vehicle_id` on `fuel_entries(vehicle_id)` +- `ad_free_entitlement.id` — `PRIMARY KEY CHECK (id = 1)` +- VIN uniqueness among **active** (`deleted_at IS NULL`) vehicles is enforced in `DatabaseService.vinExists` / `AppState.addVehicle` / `AppState.updateVehicle`, not by a unique index. Two offline devices can independently add the same VIN; that rare conflict is accepted rather than merged field-by-field. + +## Sync and lifecycle columns + +Every synced table carries `updated_at`. Cloud merge is last-write-wins on that timestamp (`INSERT OR REPLACE` of a remote row that is new or newer). See `mergeVehiclesSql`, `mergeFuelEntriesSql`, and `mergeAdFreeEntitlementSql` in `db_schema.dart`. + +| Column | Scope | Meaning | +| --- | --- | --- | +| `updated_at` | all three tables | Newest value wins when merging a remote copy. | +| `deleted_at` | `vehicles`, `fuel_entries` | Soft-delete tombstone. Rows are never hard-deleted, so a deletion can propagate to other devices instead of being resurrected by a stale remote copy. | +| `dirty` | `vehicles`, `fuel_entries` only | Local bookkeeping: `1` = not yet pushed. Cleared to `0` on merge-in. **Not** mapped onto the Dart models. | +| `receipt_image_path` | `fuel_entries` | Device-local filesystem path. Forced to `NULL` when merging a remote row — a path from another phone is never valid here. | +| `receipt_drive_file_id` | `fuel_entries` | Opaque id of the uploaded receipt on the active cloud provider. Presence means “already uploaded”; a pending upload is `path IS NOT NULL AND drive_file_id IS NULL`. | + +`ad_free_entitlement` has no `dirty` or `deleted_at`. It is a single row that exists so a consumable Play Store purchase (which cannot be restored after consume) can survive reinstall via the same cloud merge as vehicles and fuel entries. + +## What is *not* in SQLite + +Preferences live in `SharedPreferences`, not this database: theme, onboarding/agreement flags, cloud provider + folder ids, keep-photos-locally, stale-lock timeout, estimated-refund toggle. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2242947..d43d66f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -441,7 +441,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -498,7 +498,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..7f5dca6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..d8bf219 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..359eb01 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..c5c5458 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..a7ed542 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..bfd6188 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..0f2c794 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..359eb01 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..f580722 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..2e4e5d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..7ac1a97 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..96ec2df Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..788a4e7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..1786d0a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..2e4e5d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..62aa25d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..74a032d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..6ff7619 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..360b2c5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..78ca733 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..9ac5e67 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..8bb185b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "darkbackground.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..8e24958 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png new file mode 100644 index 0000000..649db16 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 0bedcf2..f3387d4 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,23 +1,56 @@ { "images" : [ { - "idiom" : "universal", "filename" : "LaunchImage.png", + "idiom" : "universal", "scale" : "1x" }, { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark.png", "idiom" : "universal", + "scale" : "1x" + }, + { "filename" : "LaunchImage@2x.png", + "idiom" : "universal", "scale" : "2x" }, { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@2x.png", "idiom" : "universal", + "scale" : "2x" + }, + { "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@3x.png", + "idiom" : "universal", "scale" : "3x" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } } diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9da19ea..b261793 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 9da19ea..a5169e6 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 9da19ea..e9835d6 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png new file mode 100644 index 0000000..b261793 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png new file mode 100644 index 0000000..a5169e6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png new file mode 100644 index 0000000..e9835d6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..8d2b7d5 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -16,13 +16,19 @@ - - + + - - + + + + + + + + @@ -32,6 +38,7 @@ - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 0c8f743..62bcaf4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,72 +1,118 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - NSCameraUsageDescription - Fuel Tax Tracker uses the camera to take photos of fuel receipts. - CFBundleDisplayName - Fuel Tax Tracker - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - fuel_tax_tracker - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - UIApplicationSupportsMultipleScenes - - UISceneConfigurations + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + NSCameraUsageDescription + Fuel Tax Tracker uses the camera to take photos of fuel receipts. + NSPhotoLibraryUsageDescription + Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt. + CFBundleDisplayName + Receipt Tracker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + fuel_tax_tracker + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID + + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-dropbox + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-onedrive + + + + UIStatusBarHidden + + + GADApplicationIdentifier + ca-app-pub-3940256099942544~1458002511 - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - diff --git a/lib/app_navigator.dart b/lib/app_navigator.dart new file mode 100644 index 0000000..ebba6fb --- /dev/null +++ b/lib/app_navigator.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// A stable handle on the app's navigator, for the few places that need to +/// put something on screen from outside a widget's own build method: +/// +/// - [AppState.onAdWatched]'s "remove ads for a year" upsell, which fires +/// from deep inside a background sync rather than from a widget. +/// - `showAdPlaceholder`, called by [AdService], which owns no +/// [BuildContext] of its own (real ads are native overlays that never +/// needed one). +/// +/// Lives here rather than in `main.dart` so services and widgets can reach +/// it without importing the app's entry point. +final navigatorKey = GlobalKey(); diff --git a/lib/main.dart b/lib/main.dart index 5733b13..c648e5c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'screens/home_screen.dart'; +import 'app_navigator.dart'; +import 'screens/main_shell.dart'; +import 'screens/user_agreement_screen.dart'; import 'services/app_state.dart'; +import 'theme/app_theme.dart'; +import 'widgets/ad_free_upsell_dialog.dart'; void main() { runApp(const FuelTaxTrackerApp()); @@ -15,15 +19,21 @@ class FuelTaxTrackerApp extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => AppState()..init(), - child: MaterialApp( - title: 'Fuel Tax Tracker', - theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), - darkTheme: ThemeData( - colorSchemeSeed: Colors.indigo, - brightness: Brightness.dark, - useMaterial3: true, - ), - home: const AppRoot(), + child: Consumer( + builder: (context, appState, _) { + appState.onAdWatched ??= () { + final dialogContext = navigatorKey.currentContext; + if (dialogContext != null) showAdFreeUpsellDialog(dialogContext); + }; + return MaterialApp( + navigatorKey: navigatorKey, + title: 'Receipt Tracker', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: appState.themeMode, + home: const AppRoot(), + ); + }, ), ); } @@ -41,7 +51,36 @@ class AppRoot extends StatelessWidget { body: Center(child: CircularProgressIndicator()), ); } - return const HomeScreen(); + 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'), + ), + ], + ), + ), + ), + ); + } + if (!appState.hasAcceptedUserAgreement) { + return const UserAgreementScreen(); + } + return const MainShell(); }, ); } diff --git a/lib/models/fuel_entry.dart b/lib/models/fuel_entry.dart index 1dcf87f..730c886 100644 --- a/lib/models/fuel_entry.dart +++ b/lib/models/fuel_entry.dart @@ -1,11 +1,21 @@ class FuelEntry { final String id; + + /// References [Vehicle.id] (the hidden, immutable identifier) — not the + /// VIN, which is user-editable and so unsuitable as a foreign key. final String vehicleId; + final DateTime date; final double gallons; final double pricePerGallon; final double totalCost; 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({ required this.id, @@ -14,26 +24,75 @@ class FuelEntry { required this.gallons, required this.pricePerGallon, required this.totalCost, + required this.updatedAt, this.receiptImagePath, + this.receiptDriveFileId, + this.deletedAt, }); - Map 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 toMap() => { 'id': id, - 'vehicleId': vehicleId, - 'date': date.toIso8601String(), + 'vehicle_id': vehicleId, + 'date': date.millisecondsSinceEpoch, 'gallons': gallons, - 'pricePerGallon': pricePerGallon, - 'totalCost': totalCost, - 'receiptImagePath': receiptImagePath, + 'price_per_gallon': pricePerGallon, + 'total_cost': totalCost, + 'receipt_image_path': receiptImagePath, + 'receipt_drive_file_id': receiptDriveFileId, + 'updated_at': updatedAt.millisecondsSinceEpoch, + 'deleted_at': deletedAt?.millisecondsSinceEpoch, }; - factory FuelEntry.fromJson(Map json) => FuelEntry( - id: json['id'] as String, - vehicleId: json['vehicleId'] as String, - date: DateTime.parse(json['date'] as String), - gallons: (json['gallons'] as num).toDouble(), - pricePerGallon: (json['pricePerGallon'] as num).toDouble(), - totalCost: (json['totalCost'] as num).toDouble(), - receiptImagePath: json['receiptImagePath'] as String?, + factory FuelEntry.fromMap(Map map) => FuelEntry( + id: map['id'] as String, + vehicleId: map['vehicle_id'] as String, + date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int), + gallons: (map['gallons'] as num).toDouble(), + pricePerGallon: (map['price_per_gallon'] as num).toDouble(), + totalCost: (map['total_cost'] as num).toDouble(), + 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, ); } diff --git a/lib/models/vehicle.dart b/lib/models/vehicle.dart index 42ea698..ca3b57a 100644 --- a/lib/models/vehicle.dart +++ b/lib/models/vehicle.dart @@ -1,48 +1,79 @@ class Vehicle { + /// Hidden, immutable, generated primary key — never shown in the UI and + /// never editable. This is what fuel entries actually reference and what + /// sync merges on, so that [vin] itself is free to be edited without + /// breaking those references or losing sync history. final String id; - final String make; - final String model; - final String color; - final String licensePlate; + + /// The vehicle's identification number. Required and must be unique + /// among active (non-deleted) vehicles, but — unlike [id] — the user can + /// edit it later (e.g. to fix a typo from a misread VIN scan). + final String vin; + + /// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional — + /// when absent, screens fall back to other identifying info instead. + final String? nickname; + + final DateTime updatedAt; + + /// Soft-delete tombstone: null means active. Deleting sets this instead + /// 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({ required this.id, - required this.make, - required this.model, - required this.color, - required this.licensePlate, + required this.vin, + required this.updatedAt, + this.nickname, + this.deletedAt, }); - String get displayName => '$color $make $model ($licensePlate)'; + /// What screens should show as the vehicle's primary label: the nickname + /// if one was given, otherwise the VIN itself. + String get displayLabel { + final trimmedNickname = nickname?.trim(); + if (trimmedNickname != null && trimmedNickname.isNotEmpty) { + return trimmedNickname; + } + return vin; + } + /// Note: [id] is intentionally not overridable here — it's the hidden + /// identifier, not an editable field. Vehicle copyWith({ - String? make, - String? model, - String? color, - String? licensePlate, + String? vin, + String? nickname, + bool clearNickname = false, + DateTime? updatedAt, + DateTime? deletedAt, }) { return Vehicle( id: id, - make: make ?? this.make, - model: model ?? this.model, - color: color ?? this.color, - licensePlate: licensePlate ?? this.licensePlate, + vin: vin ?? this.vin, + nickname: clearNickname ? null : (nickname ?? this.nickname), + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, ); } - Map toJson() => { + Map toMap() => { 'id': id, - 'make': make, - 'model': model, - 'color': color, - 'licensePlate': licensePlate, + 'vin': vin, + 'nickname': nickname, + 'updated_at': updatedAt.millisecondsSinceEpoch, + 'deleted_at': deletedAt?.millisecondsSinceEpoch, }; - factory Vehicle.fromJson(Map json) => Vehicle( - id: json['id'] as String, - make: json['make'] as String, - model: json['model'] as String, - color: json['color'] as String, - licensePlate: json['licensePlate'] as String, + factory Vehicle.fromMap(Map map) => Vehicle( + id: map['id'] as String, + vin: map['vin'] as String, + nickname: map['nickname'] as String?, + updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true), + deletedAt: map['deleted_at'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true) + : null, ); } diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart index 4390db9..5b08f8d 100644 --- a/lib/screens/add_edit_vehicle_screen.dart +++ b/lib/screens/add_edit_vehicle_screen.dart @@ -1,15 +1,30 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; +import '../services/ocr_service.dart'; +import '../services/vin_parser.dart'; +import '../widgets/image_source_sheet.dart'; +import 'vehicle_detail_screen.dart'; /// 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. [promptToCreateForReceipt] is set when this +/// screen was reached because the user tried to log a receipt with no +/// vehicle to attach it to yet — it shows an explanatory dialog on arrival +/// so that's clear, rather than silently landing on the add-vehicle form. class AddEditVehicleScreen extends StatefulWidget { final Vehicle? vehicle; + final bool promptToCreateForReceipt; - const AddEditVehicleScreen({super.key, this.vehicle}); + const AddEditVehicleScreen({ + super.key, + this.vehicle, + this.promptToCreateForReceipt = false, + }); @override State createState() => _AddEditVehicleScreenState(); @@ -17,52 +32,249 @@ class AddEditVehicleScreen extends StatefulWidget { class _AddEditVehicleScreenState extends State { final _formKey = GlobalKey(); - late final TextEditingController _makeController; - late final TextEditingController _modelController; - late final TextEditingController _colorController; - late final TextEditingController _plateController; + late final TextEditingController _nicknameController; + late final TextEditingController _vinController; + + bool _saving = false; + bool _scanningVin = false; + bool _checkingDuplicateVin = false; + String? _error; bool get _isEditing => widget.vehicle != null; @override void initState() { super.initState(); - _makeController = TextEditingController(text: widget.vehicle?.make ?? ''); - _modelController = TextEditingController(text: widget.vehicle?.model ?? ''); - _colorController = TextEditingController(text: widget.vehicle?.color ?? ''); - _plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? ''); + _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? ''); + _vinController = TextEditingController(text: widget.vehicle?.vin ?? ''); + + if (widget.promptToCreateForReceipt) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + content: const Text('Create a vehicle to attach receipt'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + }); + } } @override void dispose() { - _makeController.dispose(); - _modelController.dispose(); - _colorController.dispose(); - _plateController.dispose(); + _nicknameController.dispose(); + _vinController.dispose(); super.dispose(); } + void _showVinLocationHelp() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Where to find the VIN'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "It's usually on a sticker in the driver's side door jamb, or on a " + 'small plate at the base of the windshield.', + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset('assets/vin_help/door_jamb.jpg'), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset('assets/vin_help/windshield.jpg'), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')), + ], + ), + ); + } + + Future _scanVin() async { + final source = await chooseImageSource( + context, + heading: 'VIN From', + onInfoTap: _showVinLocationHelp, + ); + 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; + await _checkForExistingVin(); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text("Couldn't read a VIN from that photo. Please enter it manually."), + duration: const Duration(seconds: 8), + action: recognizedText.trim().isEmpty + ? null + // Lets you see exactly what the on-device OCR read, rather + // than only knowing "no VIN was found in it" — the + // difference between "OCR misread a character" and "OCR + // read it fine but the VIN pattern itself needs a fix" + // isn't visible any other way. + : SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)), + ), + ); + } + } + + void _showRecognizedText(String text) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Recognized Text'), + content: SingleChildScrollView(child: SelectableText(text)), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')), + ], + ), + ); + } + + /// Field-level counterpart to [_checkForExistingVin]'s dialog: catches + /// "Required" as before, plus flags the field itself as invalid when the + /// VIN belongs to another active vehicle — excluding this vehicle's own + /// id while editing, same as [AppState.updateVehicle]'s check, so editing + /// a vehicle without touching its VIN doesn't flag itself. + String? _validateVin(String? value) { + final vin = value?.trim() ?? ''; + if (vin.isEmpty) return 'Required'; + + final existing = context.read().vehicleByVin(vin); + if (existing != null && existing.id != widget.vehicle?.id) { + return 'A vehicle with VIN "$vin" already exists.'; + } + return null; + } + + /// Only relevant when adding a new vehicle — editing one already starts + /// with its own VIN pre-filled, which would trivially "match itself". + /// Guarded against overlapping calls so rapid edits (or the scan-fill + /// path firing right after `onChanged`) can't stack multiple dialogs. + Future _checkForExistingVin() async { + if (_isEditing || _checkingDuplicateVin) return; + + final vin = _vinController.text.trim(); + if (vin.isEmpty) return; + final existing = context.read().vehicleByVin(vin); + if (existing == null) return; + + _checkingDuplicateVin = true; + final openExisting = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Vehicle already exists'), + content: Text('Vehicle with the VIN $vin already exists, would you like to open that vehicle?'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')), + ], + ), + ); + _checkingDuplicateVin = false; + if (!mounted) return; + + if (openExisting != true) { + // Declining to open the existing vehicle leaves them here with a + // still-duplicate VIN — surface that on the field itself rather than + // only catching it later at Save. + _formKey.currentState?.validate(); + return; + } + + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: existing.id))); + } + Future _save() async { if (!_formKey.currentState!.validate()) return; - final appState = context.read(); - if (_isEditing) { - await appState.updateVehicle(widget.vehicle!.copyWith( - make: _makeController.text.trim(), - model: _modelController.text.trim(), - color: _colorController.text.trim(), - licensePlate: _plateController.text.trim(), - )); - } else { - await appState.addVehicle( - make: _makeController.text.trim(), - model: _modelController.text.trim(), - color: _colorController.text.trim(), - licensePlate: _plateController.text.trim(), - ); - } + setState(() { + _saving = true; + _error = null; + }); - if (mounted) Navigator.of(context).pop(); + final vin = _vinController.text.trim(); + final nickname = _nicknameController.text.trim(); + final appState = context.read(); + + try { + if (_isEditing) { + await appState.updateVehicle(widget.vehicle!.copyWith( + vin: vin, + nickname: nickname.isEmpty ? null : nickname, + clearNickname: nickname.isEmpty, + )); + } else { + await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname); + } + + if (mounted) { + // Reached from "log a receipt with no vehicle yet" — once the + // vehicle exists, continue straight into the receipt capture flow + // the user was actually trying to do, rather than just landing + // back on an (again) empty Receipts tab. The caller does the + // continuing; this just hands back which vehicle to do it for. + final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt; + Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null); + } + } on DuplicateVinException catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } } Future _confirmDelete() async { @@ -72,7 +284,7 @@ class _AddEditVehicleScreenState extends State { title: const Text('Delete vehicle?'), content: Text( 'This will also delete all fuel entries and receipt photos logged for ' - '${widget.vehicle!.displayName}. This cannot be undone.', + '${widget.vehicle!.displayLabel}. This cannot be undone.', ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), @@ -114,36 +326,49 @@ class _AddEditVehicleScreenState extends State { padding: const EdgeInsets.all(16), children: [ TextFormField( - controller: _makeController, - decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'), + controller: _nicknameController, + decoration: const InputDecoration( + labelText: 'Nickname (optional)', + hintText: 'e.g. Mom\'s Car, Red Ford F-150', + ), textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), const SizedBox(height: 12), TextFormField( - controller: _modelController, - decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'), - textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _colorController, - decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'), - textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _plateController, - decoration: const InputDecoration(labelText: 'License Plate'), + controller: _vinController, + decoration: InputDecoration( + labelText: 'VIN *', + hintText: 'Vehicle Identification Number', + helperText: 'Required — tap the camera to scan it from a photo', + suffixIcon: _scanningVin + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.camera_alt_outlined), + tooltip: 'Scan VIN from a photo', + onPressed: _scanVin, + ), + ), textCapitalization: TextCapitalization.characters, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, + validator: _validateVin, + onChanged: _isEditing ? null : (_) => _checkForExistingVin(), ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], const SizedBox(height: 24), FilledButton( - onPressed: _save, - child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), ), ], ), diff --git a/lib/screens/cloud_folder_browser_screen.dart b/lib/screens/cloud_folder_browser_screen.dart new file mode 100644 index 0000000..3756208 --- /dev/null +++ b/lib/screens/cloud_folder_browser_screen.dart @@ -0,0 +1,229 @@ +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 `Show Me The Fuel Refund` 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. Picking a +/// folder that's already named that directly (e.g. a shared folder set up +/// by someone else) uses it as-is instead of nesting another one inside — +/// see `CloudSyncService.selectAppFolder`. +class CloudFolderBrowserScreen extends StatefulWidget { + const CloudFolderBrowserScreen({super.key}); + + @override + State createState() => _CloudFolderBrowserScreenState(); +} + +class _CloudFolderBrowserScreenState extends State { + late final CloudStorageSession _session; + + _BrowseRoot _root = _BrowseRoot.myFiles; + final List _pathStack = []; + + List? _folders; + bool _loading = true; + String? _error; + bool _confirming = false; + + @override + void initState() { + super.initState(); + _session = context.read().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; + } + + /// The currently browsed-into folder's own name, so [_useThisFolder] can + /// tell whether the user picked a folder already named + /// `Show Me The Fuel Refund` — null at a virtual root ("My Files"), + /// which is never itself named that. + String? get _currentFolderName => _pathStack.isNotEmpty ? _pathStack.last.name : null; + + String get _breadcrumbPath => + ([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / '); + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + List 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 _useThisFolder() async { + final parentId = _currentFolderId; + if (parentId == null) return; + + setState(() => _confirming = true); + try { + await context.read().chooseCloudFolder( + parentId: parentId, + breadcrumbPath: _breadcrumbPath, + currentFolderName: _currentFolderName, + ); + 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().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), + ); + }, + ); + } +} diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart index 554dc56..b298be5 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:provider/provider.dart'; import '../services/app_state.dart'; import '../services/receipt_parser.dart'; +import '../widgets/backup_reminder.dart'; import 'receipt_image_screen.dart'; /// Shown right after a receipt photo is captured and OCR'd. Pre-fills @@ -33,12 +35,16 @@ class _ConfirmFuelEntryScreenState extends State { late final TextEditingController _gallonsController; late final TextEditingController _priceController; late final TextEditingController _totalController; - DateTime _date = DateTime.now(); + late DateTime _date; bool _saving = false; @override void initState() { super.initState(); + // Prefer the date/time printed on the receipt; fall back to now, same + // as before, when it couldn't be parsed — the date picker below is + // always available either way for manual entry/correction. + _date = widget.parsed.date ?? DateTime.now(); _gallonsController = TextEditingController( text: widget.parsed.gallons?.toStringAsFixed(3) ?? '', ); @@ -48,6 +54,8 @@ class _ConfirmFuelEntryScreenState extends State { _totalController = TextEditingController( text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '', ); + // So an ad is ready by the time _save() finishes without delaying it. + context.read().preloadFuelSaveAd(); } @override @@ -69,11 +77,20 @@ class _ConfirmFuelEntryScreenState extends State { } Future _pickDate() async { + // initialDate must fall within [firstDate, lastDate] or the picker + // throws — widen the bounds to cover _date in case a misread date from + // the receipt landed outside the normal 5-years-back-to-today window. + final today = DateTime.now(); + final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5))) + ? _date + : today.subtract(const Duration(days: 365 * 5)); + final lastDate = _date.isAfter(today) ? _date : today; + final pickedDate = await showDatePicker( context: context, initialDate: _date, - firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)), - lastDate: DateTime.now(), + firstDate: firstDate, + lastDate: lastDate, ); if (pickedDate == null || !mounted) return; @@ -99,7 +116,8 @@ class _ConfirmFuelEntryScreenState extends State { setState(() => _saving = true); try { - await context.read().addFuelEntry( + final appState = context.read(); + await appState.addFuelEntry( vehicleId: widget.vehicleId, date: _date, gallons: double.parse(_gallonsController.text), @@ -107,7 +125,31 @@ class _ConfirmFuelEntryScreenState extends State { totalCost: double.parse(_totalController.text), receiptImage: widget.imageFile, ); - if (mounted) Navigator.of(context).pop(); + if (!mounted) return; + + // The entry is already committed by this point, so the ad isn't + // gating the save — it just fills the moment between saving and + // landing back on the list. Awaited rather than fired off, so the + // two don't race and the ad can't end up drawn over the Receipts + // tab after this screen has gone. + final adShown = await appState.maybeShowFuelSaveAd(); + if (!mounted) return; + + // Ask while this screen (and its context) is still fully alive, + // before popping — simpler than trying to show a dialog against a + // context whose widget is mid-removal. Held back when an ad just + // ran: one full-screen surface per save, and the reminder comes + // back around on the next save anyway. + var wantsBackupSetup = false; + if (!adShown && !appState.hasCloudBackupConfigured) { + wantsBackupSetup = await showBackupReminderDialog(context); + if (!mounted) return; + } + + Navigator.of(context).pop(); + if (wantsBackupSetup) { + unawaited(openCloudBackupSetup(context)); + } } finally { if (mounted) setState(() => _saving = false); } @@ -136,7 +178,7 @@ class _ConfirmFuelEntryScreenState extends State { GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), + builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path), ), ), child: ClipRRect( diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart new file mode 100644 index 0000000..fcc5c13 --- /dev/null +++ b/lib/screens/data_settings_screen.dart @@ -0,0 +1,571 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import '../services/cloud/cloud_storage_provider.dart'; +import '../services/onboarding_keys.dart'; +import 'cloud_folder_browser_screen.dart'; + +/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage +/// connection, the two photo-handling switches, and an Advanced section +/// (stale sync lock timeout, plus the destructive purge actions — grouped +/// there rather than given their own top-level "Danger Zone" so they sit +/// behind the same disclosure as other rarely-touched settings). +class DataSettingsScreen extends StatefulWidget { + const DataSettingsScreen({super.key}); + + @override + State createState() => _DataSettingsScreenState(); +} + +class _DataSettingsScreenState extends State { + static final _dateFormat = DateFormat.yMMMd(); + + bool _busy = false; + String? _error; + + DateTime? _purgeRangeStart; + DateTime? _purgeRangeEnd; + String? _purgeRangeError; + + Future _connect(CloudProviderId id) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await context.read().connectProvider(id); + } catch (e) { + setState(() => _error = 'Could not sign in: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _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().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 _disconnect() async { + setState(() => _busy = true); + try { + await context.read().disconnectCloud(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _chooseFolder() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()), + ); + } + + Future _syncNow() async { + setState(() => _busy = true); + try { + await context.read().syncNow(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// The resting (non-syncing) Sync Now icon — badged with the same + /// play-triangle [CloudBackupActionButton] uses, for anyone who isn't on + /// an active ad-free purchase, since tapping this can trigger the + /// rewarded ad gate (see [AppState.syncNow]/`AdService.showGateAd`). Not + /// a live prediction of whether *this* tap specifically will show one — + /// same general "this leads to an ad-supported feature" disclosure the + /// icon-badge uses elsewhere, not an attempt to account for the open + /// gate window or the free-first-sync case. + Widget _syncNowIcon(BuildContext context, AppState appState) { + const icon = Icon(Icons.sync); + if (appState.adsCurrentlyDisabled) return icon; + final colors = Theme.of(context).colorScheme; + return Badge( + backgroundColor: colors.tertiary, + label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary), + child: icon, + ); + } + + /// Shared warning-dialog shell for both purge actions — [message] is the + /// caller's job to make specific and unambiguous, since this is the only + /// thing standing between the user and an unrecoverable delete. + Future _confirmPurge({required String title, required String message}) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext).colorScheme.error, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + return confirmed ?? false; + } + + /// Extra context appended to a purge warning when a cloud account is + /// connected — a purely local delete would otherwise just get silently + /// re-imported from the remote copy on the very next sync, which isn't + /// obvious from the app's normal behavior. + String _cloudSyncCaveat(AppState appState) { + if (!appState.isCloudConnected) return ''; + return '\n\nThis device is connected to ${appState.activeProvider!.displayName}. The ' + 'deletion will be pushed there on the next sync, same as any other delete.'; + } + + Future _purgeAllData() async { + final appState = context.read(); + final confirmed = await _confirmPurge( + title: 'Purge all data?', + message: 'This permanently deletes every vehicle, fuel entry, and receipt photo on ' + "this device — starting completely fresh. This can't be undone." + '${_cloudSyncCaveat(appState)}', + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + try { + await appState.purgeAllData(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All data purged.')), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _pickPurgeRangeDate({required bool isStart}) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: (isStart ? _purgeRangeStart : _purgeRangeEnd) ?? now, + firstDate: DateTime(2000), + lastDate: now, + ); + if (picked == null) return; + setState(() { + _purgeRangeError = null; + if (isStart) { + _purgeRangeStart = picked; + } else { + _purgeRangeEnd = picked; + } + }); + } + + Future _purgeDataInRange() async { + final start = _purgeRangeStart; + final end = _purgeRangeEnd; + if (start == null || end == null) return; + if (end.isBefore(start)) { + setState(() => _purgeRangeError = 'End date must be on or after the start date.'); + return; + } + + final appState = context.read(); + final confirmed = await _confirmPurge( + title: 'Purge data in range?', + message: 'This permanently deletes every fuel entry and receipt photo dated from ' + '${_dateFormat.format(start)} to ${_dateFormat.format(end)}, across all ' + "vehicles. Vehicles themselves aren't affected. This can't be undone." + '${_cloudSyncCaveat(appState)}', + ); + if (!confirmed || !mounted) return; + + setState(() => _busy = true); + try { + await appState.purgeFuelEntriesInRange(start, end); + if (mounted) { + setState(() { + _purgeRangeStart = null; + _purgeRangeEnd = null; + _purgeRangeError = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fuel entries in range purged.')), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Data')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + key: OnboardingKeys.cloudStorageCard, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (!appState.isCloudConnected) ...[ + Text( + 'Connect a cloud storage account to share vehicles and fuel ' + 'receipts with other people, and to keep a backup off this device.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + if (_error != null) ...[ + 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( + onPressed: (_busy || appState.cloudFolderPath == null) + ? null + : _syncNow, + icon: appState.isSyncing + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : _syncNowIcon(context, appState), + label: const Text('Sync Now'), + ), + TextButton.icon( + onPressed: _busy ? null : _disconnect, + icon: const Icon(Icons.logout), + label: const Text('Disconnect'), + ), + ], + ), + const SizedBox(height: 8), + if (appState.lastSyncedAt != null) + Text( + 'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (appState.lastSyncError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Last sync failed: ${appState.lastSyncError}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: SwitchListTile( + title: const Text('Keep photos on this phone after syncing'), + subtitle: const Text( + 'Otherwise, a receipt photo is removed from this device once ' + "it's safely uploaded to the cloud.", + ), + value: appState.keepReceiptPhotosLocally, + onChanged: (value) => context.read().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().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().setStaleLockMinutes(value.round()), + ), + Text('${appState.staleLockMinutes} minute(s)'), + const Divider(height: 32), + Text( + 'Danger Zone', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 12), + Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + 'Deletes every vehicle, fuel entry, and receipt photo on this device ' + 'and starts completely fresh.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _busy ? null : _purgeAllData, + icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error), + label: Text( + 'Purge All Data', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Theme.of(context).colorScheme.error), + ), + ), + ), + const Divider(height: 32), + Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + 'Deletes fuel entries and receipt photos dated within a range, across ' + 'all vehicles. Vehicles themselves are kept.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true), + child: Text( + _purgeRangeStart == null + ? 'Start date' + : _dateFormat.format(_purgeRangeStart!), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false), + child: Text( + _purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!), + ), + ), + ), + ], + ), + if (_purgeRangeError != null) ...[ + const SizedBox(height: 8), + Text( + _purgeRangeError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null) + ? null + : _purgeDataInRange, + icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error), + label: Text( + 'Purge Range', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +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(); + 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'), + ), + ], + ); + } +} diff --git a/lib/screens/edit_fuel_entry_screen.dart b/lib/screens/edit_fuel_entry_screen.dart new file mode 100644 index 0000000..53d5af7 --- /dev/null +++ b/lib/screens/edit_fuel_entry_screen.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/fuel_entry.dart'; +import '../services/app_state.dart'; +import '../widgets/receipt_thumbnail.dart'; + +/// Lets the user correct the *data* logged about a receipt (date, gallons, +/// price/gal, total cost, and which vehicle it's attached to) — not the +/// receipt photo itself, which is shown read-only up top (tap it to view +/// full-screen, same as everywhere else it's shown) and can't be replaced +/// from here. +class EditFuelEntryScreen extends StatefulWidget { + final FuelEntry entry; + + const EditFuelEntryScreen({super.key, required this.entry}); + + @override + State createState() => _EditFuelEntryScreenState(); +} + +class _EditFuelEntryScreenState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _gallonsController; + late final TextEditingController _priceController; + late final TextEditingController _totalController; + late DateTime _date; + late String _vehicleId; + bool _saving = false; + + @override + void initState() { + super.initState(); + _date = widget.entry.date; + _vehicleId = widget.entry.vehicleId; + _gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3)); + _priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3)); + _totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2)); + } + + @override + void dispose() { + _gallonsController.dispose(); + _priceController.dispose(); + _totalController.dispose(); + super.dispose(); + } + + void _calculateTotal() { + final gallons = double.tryParse(_gallonsController.text); + final price = double.tryParse(_priceController.text); + if (gallons != null && price != null) { + setState(() { + _totalController.text = (gallons * price).toStringAsFixed(2); + }); + } + } + + Future _pickDate() async { + // initialDate must fall within [firstDate, lastDate] or the picker + // throws — widen the bounds to cover _date in case it's already + // outside the normal 5-years-back-to-today window. + final today = DateTime.now(); + final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5))) + ? _date + : today.subtract(const Duration(days: 365 * 5)); + final lastDate = _date.isAfter(today) ? _date : today; + + final pickedDate = await showDatePicker( + context: context, + initialDate: _date, + firstDate: firstDate, + lastDate: lastDate, + ); + if (pickedDate == null || !mounted) return; + + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_date), + ); + if (pickedTime == null) return; + + setState(() { + _date = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + }); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _saving = true); + try { + await context.read().updateFuelEntry( + entryId: widget.entry.id, + vehicleId: _vehicleId, + date: _date, + gallons: double.parse(_gallonsController.text), + pricePerGallon: double.parse(_priceController.text), + totalCost: double.parse(_totalController.text), + ); + if (mounted) Navigator.of(context).pop(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + String? _requiredDecimal(String? value) { + if (value == null || value.trim().isEmpty) return 'Required'; + if (double.tryParse(value) == null) return 'Enter a valid number'; + return null; + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat.yMMMd().add_jm(); + final vehicles = context.watch().vehicles; + + return Scaffold( + appBar: AppBar(title: const Text('Edit Fuel Entry')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: ReceiptThumbnail(entry: widget.entry, size: 180), + ), + const SizedBox(height: 8), + Text( + 'Tap the photo to view it full-screen. The photo itself can\'t ' + "be changed here — only the data below can.", + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _vehicleId, + decoration: const InputDecoration(labelText: 'Vehicle'), + items: [ + for (final vehicle in vehicles) + DropdownMenuItem(value: vehicle.id, child: Text(vehicle.displayLabel)), + ], + onChanged: (value) { + if (value != null) setState(() => _vehicleId = value); + }, + ), + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Date & time'), + subtitle: Text(dateFormat.format(_date)), + trailing: const Icon(Icons.edit_calendar_outlined), + onTap: _pickDate, + ), + const SizedBox(height: 8), + TextFormField( + controller: _gallonsController, + decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: _requiredDecimal, + ), + const SizedBox(height: 12), + TextFormField( + controller: _priceController, + decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: _requiredDecimal, + ), + const SizedBox(height: 12), + TextFormField( + controller: _totalController, + decoration: InputDecoration( + labelText: 'Total cost', + prefixText: '\$', + suffixIcon: IconButton( + icon: const Icon(Icons.calculate_outlined), + tooltip: 'Calculate from gallons × price', + onPressed: _calculateTotal, + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: _requiredDecimal, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save Changes'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/faq_screen.dart b/lib/screens/faq_screen.dart new file mode 100644 index 0000000..f2ef532 --- /dev/null +++ b/lib/screens/faq_screen.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; + +class _FaqEntry { + final String question; + final String answer; + const _FaqEntry(this.question, this.answer); +} + +class _FaqSection { + final String title; + final List<_FaqEntry> entries; + const _FaqSection(this.title, this.entries); +} + +const _faqSections = <_FaqSection>[ + _FaqSection('Missouri Motor Fuel Tax Refund', [ + _FaqEntry( + 'What is the Missouri Motor Fuel Tax Refund?', + 'Missouri actually offers two separate fuel tax refunds — it\'s worth evaluating ' + 'both to see how you could benefit:\n\n' + '• Highway use — up to 12.5¢ per gallon.\n' + '• Non-highway use — up to 29.5¢ per gallon.\n\n' + "This app helps you collect and organize the receipts you'll need to claim " + "either one. This isn't tax advice — for guidance on which refund(s) fit your " + 'situation, consult a tax professional. Rates and eligibility are set by ' + 'Missouri law, which can change at any time, so always confirm current details ' + 'with the Missouri Department of Revenue before filing.', + ), + _FaqEntry( + 'Is this app affiliated with the Missouri Department of Revenue?', + "No. This is an independent tool for organizing your own receipts — it isn't run by, " + "endorsed by, or connected to the State of Missouri. It doesn't file anything on " + 'your behalf.', + ), + _FaqEntry( + 'How do I generate a report for my refund claim?', + "On the Reports tab, pick a date range to see your totals, then use Share or Print. " + "There's also a link to Missouri's official Motor Fuel Refund Claim form once " + "you're ready to file.", + ), + ]), + _FaqSection('Usage', [ + _FaqEntry( + 'How do I log a fuel purchase?', + "On the Receipts tab, tap the + button, snap a photo of the receipt, and the app " + 'reads the date, gallons, and price automatically. Review (and correct, if ' + 'needed) the values before saving — every field stays editable both before and ' + 'after saving.', + ), + _FaqEntry( + 'What if the app misreads my receipt?', + 'Automatic reading is usually accurate but not perfect — correct any field yourself ' + 'before saving, or edit it afterward from the entry\'s detail page.', + ), + _FaqEntry( + "Found a receipt, VIN, or error the app couldn't handle?", + 'Send it to ohbrer+ShowMeTheFuelRefund@gmail.com — example receipts or VINs that ' + "didn't scan correctly, screenshots of error screens, or anything else that " + "didn't work as expected all help improve the app.", + ), + _FaqEntry( + 'Do I need to add a vehicle before logging a receipt?', + 'Yes — every fuel entry is linked to a vehicle. Add one on the Vehicles tab first ' + "(you'll need its VIN).", + ), + ]), + _FaqSection('Data Safety', [ + _FaqEntry( + 'Is my data backed up?', + 'Not by default — everything you log lives only on this device until you connect a ' + 'cloud storage account (Settings > Data). Once connected, a copy is kept there ' + 'too, in storage you control.', + ), + _FaqEntry( + 'What cloud storage options are supported?', + 'Google Drive, Dropbox, OneDrive, or your own WebDAV server (Nextcloud, ownCloud, or ' + 'any self-hosted WebDAV server).', + ), + _FaqEntry( + "What happens if I lose my phone, or reinstall the app?", + "If you never connected cloud storage, that data is gone — it only ever lived on " + "that device. If you had connected one, reconnect the same account on the new " + "device (or after reinstalling) and your vehicles, receipts, and photos sync back " + 'down automatically.', + ), + _FaqEntry( + 'How do I delete my data?', + 'Settings > Data > Advanced has options to purge everything, or just fuel entries in ' + "a specific date range. Both are permanent and can't be undone.", + ), + ]), + _FaqSection('Advertisements', [ + _FaqEntry( + 'Why do I sometimes see an ad?', + 'Syncing your data to the cloud requires watching a short ad, to help support the ' + "app — at most once every 5 minutes, so it won't show again if you sync " + 'repeatedly in a short span.', + ), + _FaqEntry( + 'How do I remove ads?', + '"Remove Ads for a Year" in Settings is a one-time purchase that disables ads for a ' + "year from when you buy it. It doesn't auto-renew or charge you again " + "automatically — once the year's up, ads come back until you buy again.", + ), + ]), +]; + +/// A simple, static list of common questions grouped into sections — +/// reached from [SettingsScreen] (and every tab's AppBar via +/// [FaqActionButton]), the "back up your data" reminder, and the +/// first-launch user agreement screen. +class FaqScreen extends StatelessWidget { + /// If given, that question's entry starts expanded (and the rest + /// collapsed) instead of everything starting collapsed — used when a + /// link elsewhere in the app points at one specific answer, e.g. the + /// backup reminder dialog jumping straight to the backup question rather + /// than making the user hunt for it. + final String? initiallyExpandedQuestion; + + const FaqScreen({super.key, this.initiallyExpandedQuestion}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar(title: const Text('FAQ')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final section in _faqSections) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 8), + child: Text( + section.title, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ), + for (final entry in section.entries) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Card( + child: ExpansionTile( + title: Text(entry.question, style: textTheme.titleSmall), + initiallyExpanded: entry.question == initiallyExpandedQuestion, + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.answer, style: textTheme.bodyMedium), + ], + ), + ), + ), + const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +/// A "?" AppBar action that jumps straight to [FaqScreen] — added to every +/// tab's AppBar (Receipts, Vehicles, Reports, Settings; see [MainShell]) +/// so help is reachable the same way no matter which screen the user's on. +class FaqActionButton extends StatelessWidget { + const FaqActionButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.help_outline), + tooltip: 'FAQ', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FaqScreen()), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index cc5e1fb..5641314 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,10 +3,14 @@ import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; +import '../widgets/cloud_backup_action_button.dart'; import 'add_edit_vehicle_screen.dart'; -import 'settings_screen.dart'; +import 'faq_screen.dart'; import 'vehicle_detail_screen.dart'; +/// The "Vehicles" tab body — Settings and Reports are reached via the +/// bottom nav now (see [MainShell]), not from icons here. class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -17,15 +21,19 @@ class HomeScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('My Vehicles'), + title: const Text('Vehicles'), + actionsPadding: const EdgeInsets.only(right: 20), actions: [ IconButton( - icon: const Icon(Icons.settings_outlined), - tooltip: 'Settings', + key: OnboardingKeys.vehiclesAddButton, + icon: const Icon(Icons.add), + tooltip: 'Add Vehicle', onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), + MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()), ), ), + const CloudBackupActionButton(), + const FaqActionButton(), ], ), body: vehicles.isEmpty @@ -40,13 +48,6 @@ class HomeScreen extends StatelessWidget { return _VehicleCard(vehicle: vehicle, totalGallons: gallons); }, ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()), - ), - icon: const Icon(Icons.add), - label: const Text('Add Vehicle'), - ), ); } } @@ -59,22 +60,49 @@ class _VehicleCard extends StatelessWidget { @override Widget build(BuildContext context) { + final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false; + final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged'; + return Card( clipBehavior: Clip.antiAlias, - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: CircleAvatar( - child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'), - ), - title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'), - subtitle: Text( - 'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged', - ), - isThreeLine: true, - trailing: const Icon(Icons.chevron_right), + child: InkWell( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)), ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + // Row cross-axis defaults to center, so the chevron sits + // vertically centered against the title+subtitle block even when + // the nickname line makes that block taller — ListTile's + // isThreeLine forces leading/trailing to the top instead, per + // Material spec, which is what pinned it to the top before. + child: Row( + children: [ + const CircleAvatar(child: Icon(Icons.directions_car_outlined)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium), + // Only repeat the VIN here when the title is already + // showing the nickname instead — otherwise the title + // (falling back to the VIN when there's no nickname) + // would show it twice. + Text( + hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), ), ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart new file mode 100644 index 0000000..01a7c18 --- /dev/null +++ b/lib/screens/main_shell.dart @@ -0,0 +1,368 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; +import '../widgets/onboarding_tour_overlay.dart'; +import 'data_settings_screen.dart'; +import 'home_screen.dart'; +import 'receipts_screen.dart'; +import 'report_screen.dart'; +import 'settings_screen.dart'; + +/// App root once loaded: a persistent bottom nav bar over the four main +/// sections — Receipts (all vehicles), Vehicles (the vehicle list), +/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this +/// shell only owns the [PageView]/[NavigationBar] and which tab is +/// selected. +/// +/// A right-to-left swipe on the body advances to the next tab (and +/// left-to-right goes back) with the same sliding animation tapping a +/// [NavigationDestination] uses — both go through [_goToTab], which is the +/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin] +/// on the three stateful tab screens (Receipts/Report/Settings) is what +/// keeps each tab's state — scroll position, toggles, in-progress form +/// fields — alive when [PageView] builds/tears down pages outside its +/// cache extent, the same guarantee the previous [IndexedStack]-based +/// version gave "for free". +/// +/// This is also where the first-launch guided tour is orchestrated (see +/// [_startOnboardingTourIfNeeded] and friends below) — it's the natural +/// home for that since it's the only widget that owns both the +/// [PageController] (to switch tabs) and a [Navigator] ancestor (to push +/// the one route the tour needs, Settings > Data) that everything else in +/// the tour can share. +class MainShell extends StatefulWidget { + const MainShell({super.key}); + + @override + State createState() => _MainShellState(); +} + +/// One step of the first-launch tour: which element(s) to highlight +/// together, what to say about them, and how to get the app into the +/// right state to show it (switch tabs, push a route, ...) before it's +/// shown. Most steps highlight a single element, but [targetKeys] can hold +/// more than one — e.g. the first step highlights both the "+" button and +/// the Receipts tab it lives on, together. +class _OnboardingStep { + final String title; + final String description; + final List targetKeys; + + /// Gets the app into the right state to show this step (switch tabs, + /// push a route, ...). Returns the route it just pushed, if any — so + /// [_MainShellState._waitForTargets] can wait for that specific route's + /// transition to settle before spotlighting something on it — or null + /// for a step (like every tab switch) that didn't push one. + final ModalRoute? Function() activate; + + const _OnboardingStep({ + required this.title, + required this.description, + required this.targetKeys, + required this.activate, + }); +} + +class _MainShellState extends State { + int _index = 0; + final _pageController = PageController(); + + OverlayEntry? _tourEntry; + int _tourStepIndex = 0; + bool _tourPushedDataScreen = false; + + static const _tabs = [ + ReceiptsScreen(), + HomeScreen(), + ReportScreen(), + SettingsScreen(), + ]; + + late final List<_OnboardingStep> _tourSteps = [ + _OnboardingStep( + title: 'What This App Does', + description: 'This app helps you collect and organize your Missouri fuel purchase ' + "receipts so you can claim Missouri's Motor Fuel Tax Refund — up to 12.5¢/gal " + 'for highway use, up to 29.5¢/gal non-highway.\n\n' + 'Disclaimer: this refund is available for as long as Missouri lawmakers continue to ' + 'offer it, and state law could change or end the program at any time. Always confirm ' + 'current eligibility and rates with the Missouri Department of Revenue before filing.', + // No specific element to highlight for this intro step — just a + // centered card over a dimmed screen, wherever the user happens to + // be when the tour starts (normally the Receipts tab, since that's + // MainShell's default). + targetKeys: const [], + activate: () => null, + ), + _OnboardingStep( + title: 'Add Receipts Here', + description: "This is the Receipts tab — tap the + button here to log a fuel receipt. " + 'Snap a photo of it and the app reads the date, gallons, and price for you ' + 'automatically.', + targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination], + activate: () { + _jumpToTab(0); + return null; + }, + ), + _OnboardingStep( + title: 'Add Vehicles Here', + description: 'This is the Vehicles tab — tap the + button here to add a vehicle. Every ' + 'receipt gets linked to one of your vehicles, so add one here first before logging ' + 'a receipt for it.', + targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination], + activate: () { + _jumpToTab(1); + return null; + }, + ), + _OnboardingStep( + title: 'Choose a Report Date Range', + description: "This is the Reports tab. Pick a start and end date here to set the " + "timeframe your report covers, then use Share or Print once it's ready. When you're " + "ready to file, tap the link for Missouri's official Motor Fuel Refund Claim form.", + // Ordered to match their actual top-to-bottom position on the page + // (date range, then the share/print row, then the refund-form link + // below it) — _waitForTargets scrolls each into view in this same + // order, so the last one (closest to the bottom) determines the + // final scroll position without undoing visibility of the one right + // above it. reportsNavDestination isn't inside that scrollable at + // all, so where it falls in the list doesn't matter. + targetKeys: [ + OnboardingKeys.reportDateRange, + OnboardingKeys.reportsNavDestination, + OnboardingKeys.reportShareAndPrintRow, + OnboardingKeys.refundFormLink, + ], + activate: () { + _jumpToTab(2); + return null; + }, + ), + _OnboardingStep( + title: 'Find Data Settings', + description: 'This is the Settings tab — tap Data here to manage where your vehicles, ' + 'receipts, and photos get backed up.', + targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry], + activate: () { + _jumpToTab(3); + return null; + }, + ), + _OnboardingStep( + title: 'Back Up to the Cloud', + description: 'Connect a cloud storage account here to back up every vehicle, receipt, ' + "and photo you log — so nothing is lost if this device is lost, damaged, or " + 'replaced.', + targetKeys: [OnboardingKeys.cloudStorageCard], + activate: () { + _tourPushedDataScreen = true; + final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen()); + // Not awaited: Navigator.push's returned future only completes on + // pop, not on the pushed route finishing its build — the polling + // wait in _showTourStep (for the target key's RenderObject to + // exist, then for `route` itself — returned below — to finish its + // transition) is what actually waits for this screen to be ready. + unawaited(Navigator.of(context).push(route)); + return route; + }, + ), + ]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _startOnboardingTourIfNeeded()); + } + + @override + void dispose() { + _tourEntry?.remove(); + _pageController.dispose(); + super.dispose(); + } + + void _goToTab(int index) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + } + + /// Instant (non-animated) tab switch used by the tour — the tour's own + /// step transitions already animate via the overlay fading between + /// steps, so an additionally-animated page slide underneath would just + /// make each step feel slower without adding anything. + void _jumpToTab(int index) { + _pageController.jumpToPage(index); + setState(() => _index = index); + } + + void _startOnboardingTourIfNeeded() { + final appState = context.read(); + if (appState.hasSeenOnboardingTour) return; + _tourStepIndex = 0; + _showTourStep(); + } + + /// Polls until every key in [keys] has a laid-out [RenderBox] — needed + /// because the step's [_OnboardingStep.activate] (a tab switch, or a + /// route push) only *starts* getting the target(s) on screen; it + /// doesn't block until that frame has actually built. Bounded so a step + /// whose target never appears (e.g. the Reports step's Share/Print row, + /// which only renders once a report actually exists) doesn't hang the + /// tour forever — it just falls back to a spotlight-less, centered card + /// for that step (or, if only some of [keys] resolve in time, + /// [OnboardingTourOverlay] highlights whichever ones did). + /// + /// Once resolved, each target is also scrolled into view (a no-op for + /// anything not inside a scrollable, like an AppBar action or a + /// bottom-nav destination) — otherwise an element further down a long + /// page (like the Reports tab's refund-form link) could still be + /// off-screen despite having a real, laid-out RenderBox, and would get + /// "highlighted" somewhere the user can't actually see. + Future _waitForTargets(List keys, ModalRoute? pushedRoute) async { + for (var attempt = 0; attempt < 30; attempt++) { + if (!mounted) return; + final allResolved = keys.every((key) { + final renderObject = key.currentContext?.findRenderObject(); + return renderObject is RenderBox && renderObject.hasSize; + }); + if (allResolved) break; + await Future.delayed(const Duration(milliseconds: 16)); + } + if (!mounted) return; + + for (final key in keys) { + key.currentContext?.findRenderObject()?.showOnScreen(duration: Duration.zero); + } + // A couple of extra frames for that (instant, but still frame-driven) + // scroll to actually apply before the overlay captures each target's + // on-screen position. + for (var i = 0; i < 5; i++) { + if (!mounted) return; + await Future.delayed(const Duration(milliseconds: 16)); + } + if (!mounted) return; + // The "Back Up to the Cloud" step's activate() pushes a route and + // returns it as [pushedRoute] — wait for that push transition to fully + // settle before capturing its target's position, or the spotlight ends + // up shifted by however far the slide-in hadn't yet finished. A no-op + // for every other step, which only switches tabs and so has no route + // to pass here. + await waitForRouteTransition(pushedRoute?.animation); + if (!mounted) return; + // The route's own AnimationController reports AnimationStatus.completed + // at this point, but empirically the render tree's transforms (from + // FadeForwardsPageTransitionsBuilder's SlideTransition, the actual + // Android default as of Flutter 3.44) still reflect a mid-transition + // position for a few more frames after that — confirmed by walking the + // RenderObject ancestor chain and finding an active + // RenderFractionalTranslation still present, with a position matching + // the transition's *starting* offset rather than its resting + // Offset.zero. These extra frames give it time to actually settle + // before the spotlight measures anything. SchedulerBinding.endOfFrame + // (rather than a bare Future.delayed) so this genuinely waits on real + // frames — in a widget test, tester.pumpAndSettle() only keeps pumping + // while something has an actual frame scheduled, which a bare delay + // timer doesn't count as once the route's own transition has already + // finished. + for (var i = 0; i < 10; i++) { + if (!mounted) return; + await SchedulerBinding.instance.endOfFrame; + } + } + + Future _showTourStep() async { + final step = _tourSteps[_tourStepIndex]; + final pushedRoute = step.activate(); + await _waitForTargets(step.targetKeys, pushedRoute); + if (!mounted) return; + + _tourEntry?.remove(); + _tourEntry = OverlayEntry( + builder: (_) => OnboardingTourOverlay( + targetKeys: step.targetKeys, + title: step.title, + description: step.description, + stepNumber: _tourStepIndex + 1, + totalSteps: _tourSteps.length, + onNext: _tourNext, + onSkip: _finishTour, + ), + ); + Overlay.of(context, rootOverlay: true).insert(_tourEntry!); + } + + void _tourNext() { + if (_tourStepIndex >= _tourSteps.length - 1) { + _finishTour(); + return; + } + _tourStepIndex++; + _showTourStep(); + } + + void _finishTour() { + _tourEntry?.remove(); + _tourEntry = null; + if (_tourPushedDataScreen) { + final navigator = Navigator.of(context); + if (navigator.canPop()) navigator.pop(); + _tourPushedDataScreen = false; + } + // Whether the tour finished naturally (on the Settings tab, since the + // last step lives there) or was skipped mid-way through (on whichever + // tab that step happened to be on), always land back on Receipts — + // that's the tab a first-time user should actually start using. + _jumpToTab(0); + unawaited(context.read().markOnboardingTourSeen()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: PageView( + controller: _pageController, + onPageChanged: (index) => setState(() => _index = index), + children: _tabs, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: _goToTab, + destinations: [ + NavigationDestination( + key: OnboardingKeys.receiptsNavDestination, + icon: const Icon(Icons.receipt_long_outlined), + selectedIcon: const Icon(Icons.receipt_long), + label: 'Receipts', + ), + NavigationDestination( + key: OnboardingKeys.vehiclesNavDestination, + icon: const Icon(Icons.directions_car_outlined), + selectedIcon: const Icon(Icons.directions_car), + label: 'Vehicles', + ), + NavigationDestination( + key: OnboardingKeys.reportsNavDestination, + icon: const Icon(Icons.summarize_outlined), + selectedIcon: const Icon(Icons.summarize), + label: 'Reports', + ), + NavigationDestination( + key: OnboardingKeys.settingsNavDestination, + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} diff --git a/lib/screens/receipt_detail_screen.dart b/lib/screens/receipt_detail_screen.dart new file mode 100644 index 0000000..929c91e --- /dev/null +++ b/lib/screens/receipt_detail_screen.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/fuel_entry.dart'; +import '../services/app_state.dart'; +import '../widgets/receipt_thumbnail.dart'; +import 'edit_fuel_entry_screen.dart'; + +/// The single place to view a receipt's photo and its logged data +/// together, with edit/delete for the entry — reached by tapping a receipt +/// row on either the Receipts tab or a vehicle's detail page. Looks up +/// [entryId] fresh from [AppState] on every build (rather than taking a +/// [FuelEntry] directly) so it reflects an edit made via +/// [EditFuelEntryScreen] as soon as that screen is popped back to this one. +class ReceiptDetailScreen extends StatelessWidget { + final String entryId; + + const ReceiptDetailScreen({super.key, required this.entryId}); + + Future _deleteEntry(BuildContext context, FuelEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete fuel entry?'), + content: const Text('This will remove the entry and its receipt photo.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await context.read().deleteFuelEntry(entry.id); + if (context.mounted) Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final entry = appState.fuelEntryById(entryId); + + if (entry == null) { + return const Scaffold(body: Center(child: Text('Receipt not found'))); + } + + final vehicle = appState.vehicleById(entry.vehicleId); + final dateFormat = DateFormat.yMMMd().add_jm(); + final currencyFormat = NumberFormat.simpleCurrency(); + + return Scaffold( + appBar: AppBar( + title: const Text('Receipt'), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit fuel entry', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => EditFuelEntryScreen(entry: entry)), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete fuel entry', + onPressed: () => _deleteEntry(context, entry), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Center(child: ReceiptThumbnail(entry: entry, size: 240)), + const SizedBox(height: 8), + Text( + 'Tap the photo to view it full-screen.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + if (vehicle != null) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.directions_car_outlined), + title: Text(vehicle.displayLabel), + subtitle: Text('VIN: ${vehicle.vin}'), + ), + const Divider(height: 24), + ], + _DetailRow(label: 'Date & time', value: dateFormat.format(entry.date)), + _DetailRow(label: 'Gallons', value: '${entry.gallons.toStringAsFixed(3)} gal'), + _DetailRow(label: 'Price per gallon', value: currencyFormat.format(entry.pricePerGallon)), + _DetailRow(label: 'Total cost', value: currencyFormat.format(entry.totalCost)), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} diff --git a/lib/screens/receipt_image_screen.dart b/lib/screens/receipt_image_screen.dart index 8a1408a..1913605 100644 --- a/lib/screens/receipt_image_screen.dart +++ b/lib/screens/receipt_image_screen.dart @@ -1,12 +1,73 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; -/// Full-screen, pinch-zoomable view of a saved receipt photo. -class ReceiptImageScreen extends StatelessWidget { - final String imagePath; +import '../services/app_state.dart'; - 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 createState() => _ReceiptImageScreenState(); +} + +class _ReceiptImageScreenState extends State { + Uint8List? _downloadedBytes; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + if (widget.driveFileId != null) { + _download(); + } + } + + Future _download() async { + setState(() { + _loading = true; + _error = null; + }); + + final provider = context.read().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 Widget build(BuildContext context) { @@ -17,11 +78,26 @@ class ReceiptImageScreen extends StatelessWidget { foregroundColor: Colors.white, title: const Text('Receipt'), ), - body: Center( - child: InteractiveViewer( - child: Image.file(File(imagePath)), - ), - ), + body: Center(child: _buildBody()), ); } + + 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(); + } } diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart new file mode 100644 index 0000000..887ef8e --- /dev/null +++ b/lib/screens/receipts_screen.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../models/fuel_entry.dart'; +import '../models/vehicle.dart'; +import '../services/app_state.dart'; +import '../services/estimated_refund.dart'; +import '../services/onboarding_keys.dart'; +import '../theme/app_theme.dart'; +import '../widgets/cloud_backup_action_button.dart'; +import '../widgets/hero_banner.dart'; +import '../widgets/receipt_capture.dart'; +import 'add_edit_vehicle_screen.dart'; +import 'faq_screen.dart'; +import 'receipt_detail_screen.dart'; + +/// The three summary periods selectable below the hero totals — the +/// totals and the "Recent Activity" list below are both filtered to +/// whichever one is currently selected. +enum _DateRange { + currentMonth('Current Month'), + last3Months('Last 3 Months'), + yearToDate('Year to Date'); + + final String label; + const _DateRange(this.label); + + /// Inclusive start of this range, relative to [now]. There's no + /// explicit end — an entry just needs to not be *before* this to count, + /// since receipts are never dated in the future in practice. + DateTime startDate(DateTime now) { + switch (this) { + case _DateRange.currentMonth: + return DateTime(now.year, now.month, 1); + case _DateRange.last3Months: + // Current month plus the two before it, from the 1st of the + // earliest — DateTime normalizes a zero/negative month across a + // year boundary correctly (e.g. month 0 becomes December prior year). + return DateTime(now.year, now.month - 2, 1); + case _DateRange.yearToDate: + return DateTime(now.year, 1, 1); + } + } +} + +/// The "Receipts" tab: every fuel entry across every vehicle, newest +/// first, styled after the theme mockups' receipt-focused layout — a +/// hero total, a date-range filter, then a flat, divided list of receipts +/// (date + amount + which vehicle) for that range, each tapping straight +/// through to that receipt's photo rather than to the vehicle it belongs to. +class ReceiptsScreen extends StatefulWidget { + const ReceiptsScreen({super.key}); + + @override + State createState() => _ReceiptsScreenState(); +} + +class _ReceiptsScreenState extends State with AutomaticKeepAliveClientMixin { + _DateRange _range = _DateRange.yearToDate; + + // Keeps this tab's state (the selected date range) alive when MainShell's + // PageView builds/tears down tabs outside its cache extent as the user + // swipes between them. + @override + bool get wantKeepAlive => true; + + Future _addReceipt(BuildContext context) async { + final vehicles = context.read().vehicles; + if (vehicles.isEmpty) { + final newVehicleId = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true), + ), + ); + // Creating the vehicle was only ever a means to an end here — continue + // straight into the receipt capture the user actually came here for, + // rather than dropping them back on a Receipts tab that's still empty. + if (newVehicleId != null && context.mounted) { + await captureReceiptForVehicle(context, newVehicleId); + } + return; + } + if (vehicles.length == 1) { + await captureReceiptForVehicle(context, vehicles.first.id); + return; + } + + final chosen = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Which vehicle is this receipt for?'), + ), + for (final vehicle in vehicles) + ListTile( + leading: const Icon(Icons.directions_car_outlined), + title: Text(vehicle.displayLabel), + onTap: () => Navigator.of(context).pop(vehicle), + ), + ], + ), + ), + ); + if (chosen != null && context.mounted) { + await captureReceiptForVehicle(context, chosen.id); + } + } + + void _openReceipt(BuildContext context, FuelEntry entry) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final appState = context.watch(); + final rangeStart = _range.startDate(DateTime.now()); + final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList() + ..sort((a, b) => b.date.compareTo(a.date)); + final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost); + final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons); + final dateFormat = DateFormat.yMMMd(); + final currencyFormat = NumberFormat.simpleCurrency(); + final gallonsFormat = NumberFormat('#,##0.000'); + // The hero stat is a big headline number, not a precise figure — round + // to whole gallons there rather than showing the same 3-decimal + // precision the per-entry rows use. + final heroGallonsFormat = NumberFormat('#,##0'); + + return Scaffold( + appBar: AppBar( + title: const Text('Receipts'), + actionsPadding: const EdgeInsets.only(right: 20), + actions: [ + IconButton( + key: OnboardingKeys.receiptsAddButton, + icon: const Icon(Icons.add), + tooltip: 'Log Fuel Receipt', + onPressed: () => _addReceipt(context), + ), + const CloudBackupActionButton(), + const FaqActionButton(), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + children: [ + _HeroTotal( + totalCost: totalCost, + totalGallons: totalGallons, + currencyFormat: currencyFormat, + gallonsFormat: heroGallonsFormat, + selectedRange: _range, + onRangeSelected: (range) => setState(() => _range = range), + estimatedRefundValue: appState.showEstimatedFuelRefund + ? currencyFormat.format(estimatedFuelRefund(totalGallons)) + : null, + ), + Padding( + padding: const EdgeInsets.fromLTRB(2, 14, 2, 8), + child: Text( + 'Recent Activity', + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + if (entries.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('No fuel entries in this range.'), + ) + else + for (var i = 0; i < entries.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _ReceiptRow( + entry: entries[i], + vehicle: appState.vehicleById(entries[i].vehicleId), + dateFormat: dateFormat, + currencyFormat: currencyFormat, + gallonsFormat: gallonsFormat, + showEstimatedRefund: appState.showEstimatedFuelRefund, + onTap: () => _openReceipt(context, entries[i]), + ), + ], + ], + ), + ); + } +} + +/// Sits inside the hero banner (on the gradient, not the plain scaffold +/// background), so selected/unselected use the hero's own white/translucent +/// text colors rather than the surface-based colorScheme ones. +class _DateRangeSelector extends StatelessWidget { + final _DateRange selected; + final ValueChanged<_DateRange> onSelected; + + const _DateRangeSelector({required this.selected, required this.onSelected}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (final range in _DateRange.values) + GestureDetector( + onTap: () => onSelected(range), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + range.label, + style: TextStyle( + color: range == selected + ? AppTheme.heroText + : AppTheme.heroTextSecondary(context), + fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600, + fontSize: 12.5, + ), + ), + ), + ), + ], + ); + } +} + +class _HeroTotal extends StatelessWidget { + final double totalCost; + final double totalGallons; + final NumberFormat currencyFormat; + final NumberFormat gallonsFormat; + final _DateRange selectedRange; + final ValueChanged<_DateRange> onRangeSelected; + final String? estimatedRefundValue; + + const _HeroTotal({ + required this.totalCost, + required this.totalGallons, + required this.currencyFormat, + required this.gallonsFormat, + required this.selectedRange, + required this.onRangeSelected, + this.estimatedRefundValue, + }); + + @override + Widget build(BuildContext context) { + return HeroBanner( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeroStatsRow( + gallonsValue: gallonsFormat.format(totalGallons), + costValue: currencyFormat.format(totalCost), + estimatedRefundValue: estimatedRefundValue, + ), + const SizedBox(height: 7), + _DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected), + ], + ), + ); + } +} + +class _ReceiptRow extends StatelessWidget { + final FuelEntry entry; + final Vehicle? vehicle; + final DateFormat dateFormat; + final NumberFormat currencyFormat; + final NumberFormat gallonsFormat; + final bool showEstimatedRefund; + final VoidCallback onTap; + + const _ReceiptRow({ + required this.entry, + required this.vehicle, + required this.dateFormat, + required this.currencyFormat, + required this.gallonsFormat, + required this.showEstimatedRefund, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateFormat.format(entry.date), + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + ), + const SizedBox(height: 3), + Text( + showEstimatedRefund + ? '${gallonsFormat.format(entry.gallons)} gal · Est. ' + '${currencyFormat.format(estimatedFuelRefund(entry.gallons))}' + : '${gallonsFormat.format(entry.gallons)} gal', + style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currencyFormat.format(entry.totalCost), + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + vehicle?.displayLabel ?? 'Unknown vehicle', + style: TextStyle( + color: colorScheme.primary, + fontSize: 10.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart new file mode 100644 index 0000000..49d2af6 --- /dev/null +++ b/lib/screens/report_screen.dart @@ -0,0 +1,481 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:printing/printing.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../services/app_state.dart'; +import '../services/estimated_refund.dart'; +import '../services/fuel_report.dart'; +import '../services/fuel_report_images.dart'; +import '../services/fuel_report_pdf.dart'; +import '../services/onboarding_keys.dart'; +import '../widgets/cloud_backup_action_button.dart'; +import 'faq_screen.dart'; + +/// Missouri's Motor Fuel Refund Claim form isn't something this app can +/// file for the user — it's a state form, submitted to the state — so the +/// Reports tab just links out to where to get it rather than trying to +/// reproduce or embed it. +final _refundFormUrl = Uri.parse( + 'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99', +); + +class ReportScreen extends StatefulWidget { + const ReportScreen({super.key}); + + @override + State createState() => _ReportScreenState(); +} + +class _ReportScreenState extends State with AutomaticKeepAliveClientMixin { + static final _dateFormat = DateFormat.yMMMd(); + + DateTime? _startDate; + DateTime? _endDate; + bool _includeReceiptImages = false; + bool _includeLineItems = false; + bool _busy = false; + + /// Which vehicles (by id) to include in the report — checkboxes next to + /// each vehicle below the date range. [_seenVehicleIds] tracks every + /// vehicle a checkbox has ever been shown for, so a vehicle newly + /// appearing in range (first time it's seen) defaults to selected without + /// clobbering a selection the user already made for one seen before (e.g. + /// after picking a different range and back). + final Set _selectedVehicleIds = {}; + final Set _seenVehicleIds = {}; + + // Keeps this tab's state (chosen date range, include-image/line-item + // toggles) alive when MainShell's PageView builds/tears down tabs + // outside its cache extent as the user swipes between them. + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + final (start, end) = defaultReportDateRange(DateTime.now()); + _startDate = start; + _endDate = end; + context.read().preloadReportAd(); + } + + Future _pickDate({required bool isStart}) async { + final now = DateTime.now(); + // The default range's end can fall in the future (it covers the + // fuel-tax-refund period currently underway, per defaultReportDateRange) + // — showDatePicker asserts initialDate <= lastDate, so lastDate has to + // stretch to cover it too, not just "today". + final lastDate = _endDate != null && _endDate!.isAfter(now) ? _endDate! : now; + final picked = await showDatePicker( + context: context, + initialDate: (isStart ? _startDate : _endDate) ?? now, + firstDate: DateTime(2000), + lastDate: lastDate, + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + } + + Future _buildPdfBytes(FuelReport report) async { + var receiptImages = const {}; + if (_includeReceiptImages) { + final loaded = await loadReceiptImageBytes( + report: report, + activeProvider: context.read().activeProvider, + ); + receiptImages = loaded.bytesByEntryId; + if (loaded.failedEntries.isNotEmpty && mounted) { + final count = loaded.failedEntries.length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, " + 'not signed in to the provider that has it, or the remote file is gone.', + ), + duration: const Duration(seconds: 6), + ), + ); + } + } + return buildFuelReportPdf( + report, + includeLineItems: _includeLineItems, + includeReceiptImages: _includeReceiptImages, + receiptImages: receiptImages, + ); + } + + Future _share(FuelReport report) async { + setState(() => _busy = true); + try { + final bytes = await _buildPdfBytes(report); + await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report)); + if (mounted) unawaited(context.read().maybeShowReportAd()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _print(FuelReport report) async { + setState(() => _busy = true); + try { + final bytes = await _buildPdfBytes(report); + await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report)); + if (mounted) unawaited(context.read().maybeShowReportAd()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _preview(FuelReport report) async { + setState(() => _busy = true); + Uint8List bytes; + try { + bytes = await _buildPdfBytes(report); + } finally { + if (mounted) setState(() => _busy = false); + } + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _ReportPreviewScreen(bytes: bytes, fileName: fuelReportFileName(report)), + ), + ); + } + + Future _openRefundFormLink() async { + final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication); + if (!launched && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Couldn't open the link.")), + ); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + final appState = context.watch(); + + String? rangeError; + FuelReport? rawReport; + if (_startDate != null && _endDate != null) { + if (_endDate!.isBefore(_startDate!)) { + rangeError = 'End date must be on or after the start date.'; + } else { + rawReport = buildFuelReport( + vehicles: appState.vehicles, + fuelEntries: appState.fuelEntries, + startDate: _startDate!, + endDate: _endDate!, + ); + for (final row in rawReport.rows) { + if (_seenVehicleIds.add(row.vehicle.id)) { + _selectedVehicleIds.add(row.vehicle.id); + } + } + } + } + + final report = rawReport == null + ? null + : FuelReport( + startDate: rawReport.startDate, + endDate: rawReport.endDate, + rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(), + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Fuel Report'), + actions: const [CloudBackupActionButton(), FaqActionButton()], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + key: OnboardingKeys.reportDateRange, + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: true), + child: Text(_startDate == null ? 'Start date' : _dateFormat.format(_startDate!)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: () => _pickDate(isStart: false), + child: Text(_endDate == null ? 'End date' : _dateFormat.format(_endDate!)), + ), + ), + ], + ), + if (rangeError != null) ...[ + const SizedBox(height: 8), + Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Include Receipt Images'), + subtitle: const Text( + 'Every receipt photo in the date range, grouped by vehicle and ' + 'sorted by date — packed as many to a page as will fit.', + ), + value: _includeReceiptImages, + onChanged: (value) => setState(() => _includeReceiptImages = value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Include Receipt Line Items'), + subtitle: const Text( + 'Every receipt as its own line (date, gallons, price/gal, ' + 'cost), grouped by vehicle and sorted by date.', + ), + value: _includeLineItems, + onChanged: (value) => setState(() => _includeLineItems = value), + ), + if (_includeReceiptImages && _includeLineItems) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Each line item and its receipt photo will be tagged with a ' + 'matching number.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + if (rawReport == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('Choose a start and end date to generate a report.'), + ) + else if (rawReport.rows.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('No vehicles had a receipted fuel entry in that date range.'), + ) + else ...[ + for (final row in rawReport.rows) + _VehicleSelectionCard( + row: row, + selected: _selectedVehicleIds.contains(row.vehicle.id), + showEstimatedRefund: appState.showEstimatedFuelRefund, + onTap: () => setState(() { + if (_selectedVehicleIds.contains(row.vehicle.id)) { + _selectedVehicleIds.remove(row.vehicle.id); + } else { + _selectedVehicleIds.add(row.vehicle.id); + } + }), + ), + const Divider(height: 32), + if (report!.rows.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text('No vehicles selected — tap at least one above to build a report.'), + ) + else ...[ + ListTile( + title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)), + trailing: Text( + '${report.totalGallons.toStringAsFixed(3)} gal · ' + '${NumberFormat.simpleCurrency().format(report.totalCost)}' + '${appState.showEstimatedFuelRefund ? ' · Est. ' + '${NumberFormat.simpleCurrency().format(estimatedFuelRefund(report.totalGallons))}' : ''}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 16), + Row( + key: OnboardingKeys.reportShareAndPrintRow, + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _preview(report), + icon: const Icon(Icons.visibility_outlined), + label: const Text('Preview'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: _busy ? null : () => _share(report), + icon: const Icon(Icons.share_outlined), + label: const Text('Share'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _print(report), + icon: const Icon(Icons.print_outlined), + label: const Text('Print'), + ), + ), + ], + ), + ], + ], + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + "This app tracks your fuel purchases, but the actual refund claim is " + "filed with the state. Get the official form from Missouri's " + 'Department of Revenue website.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + InkWell( + key: OnboardingKeys.refundFormLink, + onTap: _openRefundFormLink, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 6), + Flexible( + child: Text( + 'Get the Motor Fuel Refund Claim form', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// A per-vehicle row in the vehicle-selection list — tapping the whole +/// card toggles [selected], indicated purely by color rather than a +/// checkbox: [selected] uses the same primary/on-primary theming as the +/// "Share" [FilledButton] below it, [!selected] looks like a plain +/// unselected [Card] (default surface color, normal text colors). +class _VehicleSelectionCard extends StatelessWidget { + final VehicleReportRow row; + final bool selected; + final bool showEstimatedRefund; + final VoidCallback onTap; + + const _VehicleSelectionCard({ + required this.row, + required this.selected, + required this.showEstimatedRefund, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface; + final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; + + return Card( + color: selected ? colorScheme.primary : null, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + row.vehicle.displayLabel, + style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16), + ), + const SizedBox(height: 2), + Text( + 'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted ' + 'entr${row.entryCount == 1 ? 'y' : 'ies'}', + style: TextStyle(color: foregroundVariant), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)), + Text( + NumberFormat.simpleCurrency().format(row.totalCost), + style: TextStyle(color: foreground), + ), + if (showEstimatedRefund) ...[ + const SizedBox(height: 2), + Text( + 'Est. ${NumberFormat.simpleCurrency().format(estimatedFuelRefund(row.totalGallons))}', + style: TextStyle(color: foregroundVariant, fontSize: 12), + ), + ], + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Shows the already-built report PDF in-app, with [PdfPreview]'s own +/// built-in Share/Print actions — so Preview isn't a dead end, the same +/// two actions available back on [ReportScreen] are still reachable from +/// here without backing out first. The report's format is fixed +/// ([buildFuelReportPdf] always renders US Letter), so the page +/// format/orientation controls [PdfPreview] would otherwise offer are +/// turned off since they wouldn't actually change anything. +class _ReportPreviewScreen extends StatelessWidget { + final Uint8List bytes; + final String fileName; + + const _ReportPreviewScreen({required this.bytes, required this.fileName}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Report Preview')), + body: PdfPreview( + build: (format) async => bytes, + pdfFileName: fileName, + canChangePageFormat: false, + canChangeOrientation: false, + canDebug: false, + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 43005b6..fbe258d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,83 +1,133 @@ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; +import '../services/onboarding_keys.dart'; +import '../widgets/cloud_backup_action_button.dart'; +import 'data_settings_screen.dart'; +import 'faq_screen.dart'; +import 'ui_settings_screen.dart'; +import 'user_agreement_screen.dart'; -class SettingsScreen extends StatefulWidget { +/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen] +/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling, +/// advanced/purge) — plus a read-only summary of what's stored locally. +class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - bool _changing = false; - String? _error; - - Future _chooseFolder() async { - setState(() => _error = null); - - final selectedPath = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Choose a folder for receipts and data', - ); - if (selectedPath == null || !mounted) return; - - setState(() => _changing = true); - try { - await context.read().changeSaveDirectory(selectedPath); - } catch (e) { - setState(() => _error = 'Could not switch to that folder: $e'); - } finally { - if (mounted) setState(() => _changing = false); - } - } - @override Widget build(BuildContext context) { final appState = context.watch(); - final saveDir = appState.storage.saveDirectory.path; return Scaffold( - appBar: AppBar(title: const Text('Settings')), + appBar: AppBar( + title: const Text('Settings'), + actions: const [CloudBackupActionButton(), FaqActionButton()], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ + Card( + child: ListTile( + leading: const Icon(Icons.palette_outlined), + title: const Text('UI'), + subtitle: const Text('Appearance'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UiSettingsScreen()), + ), + ), + ), + const SizedBox(height: 16), + Card( + key: OnboardingKeys.settingsDataMenuEntry, + child: ListTile( + leading: const Icon(Icons.storage_outlined), + title: const Text('Data'), + subtitle: const Text('Cloud storage, photo syncing, advanced'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DataSettingsScreen()), + ), + ), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.help_outline), + title: const Text('FAQ'), + subtitle: const Text('Common questions about the app and the refund program'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FaqScreen()), + ), + ), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text('User Agreement'), + subtitle: const Text('Data liability, privacy, and the tax-advice disclaimer'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)), + ), + ), + ), + const SizedBox(height: 16), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Save Location', style: Theme.of(context).textTheme.titleMedium), + Text('Ads', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), - Text( - 'Receipt photos and the data file are stored here:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - SelectableText( - saveDir, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - const SizedBox(height: 16), - if (_error != null) ...[ - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - const SizedBox(height: 8), + if (appState.adsCurrentlyDisabled) ...[ + Text( + 'Ads are disabled until ' + '${DateFormat.yMMMd().format(appState.adFreeUntil!.toLocal())}.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ] else ...[ + Text( + 'Syncing your data to the cloud requires watching a short ad — ' + 'at most once every 5 minutes.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + if (appState.purchaseError != null) ...[ + Text( + appState.purchaseError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 8), + ], + FilledButton.icon( + onPressed: () => context.read().buyAdFreeYear(), + icon: const Icon(Icons.block), + label: Text( + appState.adFreeYearPriceLabel == null + ? 'Remove Ads for a Year' + : 'Remove Ads for a Year — ${appState.adFreeYearPriceLabel}', + ), + ), + TextButton( + onPressed: () { + context.read().restoreAdFreeYear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Checking for a previous purchase on this Google account…", + ), + ), + ); + }, + child: const Text('Restore Purchase'), + ), ], - 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'), - ), ], ), ), diff --git a/lib/screens/ui_settings_screen.dart b/lib/screens/ui_settings_screen.dart new file mode 100644 index 0000000..23b8118 --- /dev/null +++ b/lib/screens/ui_settings_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; + +/// "UI" settings submenu, reached from [SettingsScreen] — currently just +/// Appearance (theme), but its own screen so more display-related settings +/// have somewhere to go without crowding the Settings landing page. +class UiSettingsScreen extends StatelessWidget { + const UiSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('UI')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Appearance', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment(value: ThemeMode.system, label: Text('System')), + ButtonSegment(value: ThemeMode.light, label: Text('Light')), + ButtonSegment(value: ThemeMode.dark, label: Text('Dark')), + ], + selected: {appState.themeMode}, + onSelectionChanged: (selection) => + context.read().setThemeMode(selection.first), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Estimated Fuel Refund'), + subtitle: const Text( + 'Shows an estimate of your Missouri Highway Fuel Tax Refund next to ' + 'your receipts and totals. Based on the current rate — contingent on ' + "it, and not a substitute for confirming the actual amount with the " + 'Missouri Department of Revenue.', + ), + value: appState.showEstimatedFuelRefund, + onChanged: (value) => + context.read().setShowEstimatedFuelRefund(value), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/user_agreement_screen.dart b/lib/screens/user_agreement_screen.dart new file mode 100644 index 0000000..6022ee9 --- /dev/null +++ b/lib/screens/user_agreement_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import 'faq_screen.dart'; + +/// The very first thing shown on a fresh install — before the onboarding +/// tour, before anything else — gating [MainShell] entirely until the user +/// taps "I Agree" (see [AppRoot] in main.dart and +/// [AppState.hasAcceptedUserAgreement]/[AppState.acceptUserAgreement]). +/// Its whole purpose is the liability section below: this app stores data +/// on the user's own device (and, optionally, their own cloud storage +/// account) with no server or backend of ours involved, so we have no +/// ability to recover anything for them if it's lost — plus the privacy +/// section, disclosing that we don't track/log anything ourselves, and +/// that AdMob (only active for users on the free, ad-supported tier) is +/// the sole source of any data collection, governed by Google's own +/// practices rather than anything this app adds. +class UserAgreementScreen extends StatelessWidget { + /// True when reached from Settings (see [SettingsScreen]) to re-read the + /// agreement/privacy policy at any time — required so this content stays + /// reachable in the app beyond the one-time first-launch gate, not just + /// something a user saw once and can never get back to. Hides the + /// Decline/I Agree actions (already answered, and "Decline" quitting the + /// app would be a bizarre side effect of just re-reading this) and shows + /// a normal back button instead of gating navigation away entirely. + final bool isReview; + + const UserAgreementScreen({super.key, this.isReview = false}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: const Text('User Agreement'), + automaticallyImplyLeading: isReview, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Text( + isReview + ? 'This is the agreement you accepted when you first opened Show Me ' + 'The Fuel Refund.' + : 'Please read and accept the following before using Show Me The Fuel ' + 'Refund.', + style: textTheme.bodyLarge, + ), + const SizedBox(height: 20), + _Section( + title: 'Your Data Lives on Your Device', + body: + 'Every vehicle, fuel receipt, and photo you log is stored locally on ' + 'this device. If you connect a cloud storage account (Google Drive, ' + 'Dropbox, OneDrive, or your own WebDAV server) in Settings, a copy is ' + 'also kept there — in storage you control, not on any server we run. ' + "We don't operate a backend, and we don't have a copy of your data.\n\n" + "This is also how sharing works: if the folder you connect to is one " + "you share with someone else, anyone else who connects the app to " + "that same folder sees and can edit the same vehicles, fuel entries, " + "and receipt photos you do. Data in a shared folder isn't private to " + "just you.", + ), + _Section( + title: 'Privacy', + body: + "We don't track or log your data in any way — there's no analytics, no " + "crash reporting, and no server of ours receiving anything you enter. If " + 'you haven\'t purchased "Remove Ads for a Year" and are using the free, ' + 'ad-supported version, the only data collected is whatever Google AdMob ' + 'itself requires to serve ads (e.g. a device advertising identifier) — ' + "that collection is Google's, governed by its own privacy practices, not " + 'something this app adds on top of.', + ), + _Section( + title: 'We Are Not Responsible for Your Data or Any Data Loss', + body: + 'The developer is not responsible for your data, or for any loss, ' + 'corruption, or unavailability of it, for any reason — including but ' + 'not limited to device loss, theft, or damage; an uninstall or app/OS ' + 'update; a bug in this app; or an outage or account issue with a cloud ' + 'storage provider you connect. Backing up your data (by connecting a ' + 'cloud storage account, and by keeping it connected and in good ' + 'standing) is entirely your responsibility. If you never connect a ' + 'backup, the only copy of your data is the one on this device.', + ), + _Section( + title: 'Provided As-Is', + body: + 'This app is provided "as is," without warranty of any kind, express or ' + 'implied. Fuel and gallon values are read automatically from your ' + 'receipt photos and may be misread — always review them before relying ' + 'on them, including for a refund claim.', + ), + _Section( + title: 'Not Tax or Legal Advice', + body: + 'This app is purely informational. Missouri actually offers two ' + 'separate fuel tax refunds — up to 12.5¢ per gallon for highway use, ' + "and up to 29.5¢ per gallon for non-highway use — and it's worth " + "evaluating both to see how you could benefit. This app helps you " + "organize the receipts you'd need for either one.\n\n" + "Nothing here is tax or legal advice, and using this app doesn't " + 'guarantee your eligibility for either refund. For guidance on your ' + 'specific situation, consult a tax professional. Both refunds are set ' + 'by Missouri law, which can change at any time — always confirm ' + 'current eligibility and rates with the Missouri Department of ' + 'Revenue before filing.', + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const FaqScreen()), + ), + icon: const Icon(Icons.help_outline), + label: const Text('See the FAQ for more'), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + if (!isReview) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => SystemNavigator.pop(), + child: const Text('Decline'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => context.read().acceptUserAgreement(), + child: const Text('I Agree'), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} + +class _Section extends StatelessWidget { + final String title; + final String body; + + const _Section({required this.title, required this.body}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + Text(body, style: textTheme.bodyMedium), + ], + ), + ); + } +} diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 84fe3c0..0bfae22 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -1,93 +1,68 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -import '../models/fuel_entry.dart'; import '../services/app_state.dart'; -import '../services/ocr_service.dart'; -import '../services/receipt_parser.dart'; +import '../services/estimated_refund.dart'; +import '../widgets/hero_banner.dart'; +import '../widgets/receipt_capture.dart'; +import '../widgets/receipt_thumbnail.dart'; import 'add_edit_vehicle_screen.dart'; -import 'confirm_fuel_entry_screen.dart'; -import 'receipt_image_screen.dart'; +import 'receipt_detail_screen.dart'; -class VehicleDetailScreen extends StatelessWidget { +/// A swipeable [PageView] over every vehicle (in the same order as the +/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves +/// to the next/previous vehicle, the same "modern navigation" swipe +/// [MainShell] uses for the bottom-nav tabs. Each page is a full, +/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only +/// owns the [PageController]. +class VehicleDetailScreen extends StatefulWidget { final String vehicleId; const VehicleDetailScreen({super.key, required this.vehicleId}); - Future _captureReceipt(BuildContext context) async { - final picker = ImagePicker(); - XFile? photo; - try { - photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not open camera: $e')), - ); - } - return; - } - if (photo == null) return; + @override + State createState() => _VehicleDetailScreenState(); +} - final imageFile = File(photo.path); +class _VehicleDetailScreenState extends State { + late final PageController _pageController; - if (!context.mounted) return; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), - ); - - final ocrService = OcrService(); - String recognizedText = ''; - try { - recognizedText = await ocrService.recognizeText(imageFile); - } catch (_) { - recognizedText = ''; - } finally { - ocrService.dispose(); - } - - final parsed = ReceiptParser.parse(recognizedText); - - if (context.mounted) { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ConfirmFuelEntryScreen( - vehicleId: vehicleId, - imageFile: imageFile, - parsed: parsed, - ), - ), - ); - } + @override + void initState() { + super.initState(); + final vehicles = context.read().vehicles; + final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId); + _pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex); } - Future _deleteEntry(BuildContext context, FuelEntry entry) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete fuel entry?'), - content: const Text('This will remove the entry and its receipt photo.'), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete', style: TextStyle(color: Colors.red)), - ), - ], - ), - ); - if (confirmed == true && context.mounted) { - await context.read().deleteFuelEntry(entry.id); - } + @override + void dispose() { + _pageController.dispose(); + super.dispose(); } + @override + Widget build(BuildContext context) { + final vehicles = context.watch().vehicles; + + if (vehicles.isEmpty) { + return const Scaffold(body: Center(child: Text('Vehicle not found'))); + } + + return PageView.builder( + controller: _pageController, + itemCount: vehicles.length, + itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id), + ); + } +} + +class _VehicleDetailPage extends StatelessWidget { + final String vehicleId; + + const _VehicleDetailPage({required this.vehicleId}); + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -99,12 +74,16 @@ class VehicleDetailScreen extends StatelessWidget { final entries = appState.entriesForVehicle(vehicleId); final totalGallons = appState.totalGallonsForVehicle(vehicleId); + final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost); final dateFormat = DateFormat.yMMMd().add_jm(); final currencyFormat = NumberFormat.simpleCurrency(); + // The hero stat is a big headline number, not a precise figure — round + // to whole gallons there, matching the Receipts tab's hero banner. + final heroGallonsFormat = NumberFormat('#,##0'); return Scaffold( appBar: AppBar( - title: Text(vehicle.displayName), + title: Text(vehicle.displayLabel), actions: [ IconButton( icon: const Icon(Icons.edit_outlined), @@ -117,59 +96,76 @@ class VehicleDetailScreen extends StatelessWidget { ), body: Column( children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge), - Text( - '${totalGallons.toStringAsFixed(3)} gal', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: HeroBanner( + child: HeroStatsRow( + gallonsValue: heroGallonsFormat.format(totalGallons), + costValue: currencyFormat.format(totalCost), + ), ), ), Expanded( child: entries.isEmpty ? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.')) : ListView.separated( + // Keyed by vehicle so each vehicle's scroll position is + // remembered independently as the user swipes between + // pages — PageView.builder can dispose an off-screen + // page's element, which would otherwise reset it. + key: PageStorageKey('vehicle-entries-$vehicleId'), padding: const EdgeInsets.all(12), itemCount: entries.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { final entry = entries[index]; + void openDetail() => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptDetailScreen(entryId: entry.id), + ), + ); return Card( clipBehavior: Clip.antiAlias, - child: ListTile( - leading: entry.receiptImagePath != null - ? GestureDetector( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!), - ), + child: InkWell( + onTap: openDetail, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + // Row cross-axis defaults to center, so the + // thumbnail sits vertically centered against the + // title+subtitle block even though it's three + // lines tall — ListTile's isThreeLine would + // instead force leading/trailing to the top, per + // Material spec (the same issue the vehicle list + // cards had). + child: Row( + children: [ + ReceiptThumbnail(entry: entry, onTap: openDetail), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${entry.gallons.toStringAsFixed(3)} gal • ' + '${currencyFormat.format(entry.totalCost)}', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '${currencyFormat.format(entry.pricePerGallon)}/gal' + '${appState.showEstimatedFuelRefund ? ' · Est. ' + '${currencyFormat.format(estimatedFuelRefund(entry.gallons))}' : ''}' + '\n${dateFormat.format(entry.date)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), - 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)}'), - subtitle: Text( - '${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}', - ), - isThreeLine: true, - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _deleteEntry(context, entry), + ), + const Icon(Icons.chevron_right), + ], + ), ), ), ); @@ -179,7 +175,7 @@ class VehicleDetailScreen extends StatelessWidget { ], ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => _captureReceipt(context), + onPressed: () => captureReceiptForVehicle(context, vehicleId), icon: const Icon(Icons.camera_alt_outlined), label: const Text('Log Fuel Receipt'), ), diff --git a/lib/services/ad_config.dart b/lib/services/ad_config.dart new file mode 100644 index 0000000..c22c83e --- /dev/null +++ b/lib/services/ad_config.dart @@ -0,0 +1,17 @@ +/// The single switch that turns real AdMob ads on. +/// +/// While this is `false`, the app behaves in every observable way as though +/// ads were live — each placement is still *decided* on exactly the same +/// schedule (new-user grace periods, earned credits, the ad-free purchase, +/// the sync gate blocking uploads) — but where a real ad would appear, it +/// shows `AdPlaceholderScreen` instead, and the Google Mobile Ads SDK is +/// never initialized at all. No ad requests go out, so none of them can +/// come back "Account not approved yet" or no-fill. +/// +/// That means the pacing and gating can be exercised and tuned for real +/// before AdMob approval lands. +/// +/// Flipping this to `true` is the only change needed to go live: every +/// placement picks up its real ad unit from [AdService] and the placeholder +/// stops being reachable. Nothing else in the app branches on it. +const bool adsEnabled = false; diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart new file mode 100644 index 0000000..b56685d --- /dev/null +++ b/lib/services/ad_service.dart @@ -0,0 +1,354 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +import '../widgets/ad_placeholder_screen.dart'; +import 'ad_config.dart'; + +/// This app has three ad placements: +/// +/// 1. A rewarded ad gating the *upload* phase of a cloud sync (see +/// [CloudSyncService.syncNow]'s `beforeUpload` hook and +/// [AppState.syncNow]) — deliberately not pulling/merging remote +/// changes down. Rewarded, not a plain interstitial, because AdMob's +/// interstitial policy requires those to sit at natural transition +/// points and never gate access to app functionality — conditioning an +/// in-app benefit on watching an ad through to completion is what the +/// reward formats exist for. See +/// https://support.google.com/admob/answer/6201362 and +/// https://support.google.com/admob/answer/6128543. Plain Rewarded, +/// not Rewarded Interstitial: the app already has its own explicit +/// opt-in trigger for this (the Sync Now button / the "not synced" +/// icon, both direct user taps that call [AppState.syncNow]), so +/// Rewarded Interstitial's main advantage — being safe to show without +/// one — doesn't buy anything extra here, and plain Rewarded gives +/// full control over the pre-ad copy instead of Google's generic +/// built-in opt-in screen. Showing an ad opens a gate that stays open +/// for [gateValidity]; a sync attempted after that window closes has +/// to show (and have the user sit through) another one before it's +/// allowed to push data to the cloud. Selecting/connecting a cloud +/// provider is never gated — only the upload side of a sync is, via +/// [showGateAd]. The very first sync a cloud folder ever sees skips +/// this entirely — see [CloudSyncService.syncNow]'s `everSyncedBefore` +/// parameter. +/// 2. A plain interstitial shown after a report export/share/print +/// completes (see [AppState.maybeShowReportAd], called from +/// `ReportScreen._share`/`_print`). +/// 3. A plain interstitial shown after a fuel entry is saved (see +/// [AppState.addFuelEntry]'s call to its own fuel-save-ad decision +/// logic). +/// +/// Placements 2 and 3 are plain, not rewarded, because nothing is being +/// unlocked — the save/export already happened by the time either fires, +/// so there's no benefit to condition on watching it; they're just natural +/// post-task transitions, which is exactly what plain interstitials are +/// for. Neither ever blocks or gates anything, unlike placement 1. +/// +/// All three are gated behind [adsEnabled] (lib/services/ad_config.dart). +/// While that's false, every method here short-circuits to +/// [showAdPlaceholder] and the Mobile Ads SDK is never touched — no +/// requests, no failures — while the *decisions* about when each placement +/// fires stay exactly as they'd be with real ads, including the sync gate +/// still withholding uploads until the user acts. +/// +/// This class only owns ad-format *mechanics* — load, preload, show — for +/// all three. Deciding *when* each is actually due (grace periods for a +/// new user, earned watch-credits, the ad-free purchase) is [AppState]'s +/// job, not this class's: those decisions need domain state (how long +/// they've used the app, how many fuel entries they've saved) this class +/// has no business knowing about. So every `maybeShowX` method here is +/// unconditional other than "is something preloaded" — callers are +/// expected to have already decided the ad should show before calling. +/// +/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml), +/// [_rewardedAdUnitId], [_reportInterstitialAdUnitId], and +/// [_fuelSaveInterstitialAdUnitId] below are all real ones from your AdMob +/// console — ad unit IDs are tied to the format they were created as, so +/// each placement needs its own unit created under the matching format +/// (Rewarded / Interstitial / Interstitial) in the console, they can't +/// share one. ios/Runner/Info.plist's `GADApplicationIdentifier` +/// is still Google's test iOS App ID, though — an AdMob App ID is +/// registered per-platform, so it needs its own real iOS App ID (and real +/// iOS ad unit IDs here) from the AdMob console if this app ever ships on +/// iOS. +/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the +/// caller may proceed" — they're kept distinct only so [AppState] can tell +/// whether *this* call is the one that actually put a user in front of an +/// ad, which is the trigger for the "remove ads for a year" upsell dialog. +/// [AdGateResult.blocked] means the caller must not proceed. +enum AdGateResult { alreadyOpen, justShown, blocked } + +class AdService { + /// The real *Rewarded* ad unit for the sync gate — distinct from + /// `9482586380` below, which is a plain Interstitial unit and wrong + /// format for [RewardedAd.load]. + static const _rewardedAdUnitId = 'ca-app-pub-9212406812117696/8520582833'; + + /// The one real Interstitial ad unit created under this app so far — + /// shared by both plain-interstitial placements below. Perfectly valid + /// for one ad unit to back multiple `InterstitialAd.load` call sites in + /// the same app; the only downside is AdMob's dashboard won't be able to + /// break impressions/revenue out by placement. Create a second + /// Interstitial unit and give [_fuelSaveInterstitialAdUnitId] its own ID + /// later if that per-placement visibility ends up mattering. + static const _reportInterstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380'; + + static const _fuelSaveInterstitialAdUnitId = _reportInterstitialAdUnitId; + + /// How long a successfully-shown ad keeps the upload gate open before the + /// next sync attempt has to show another one. + static const gateValidity = Duration(minutes: 5); + + /// Safety net against a stuck SDK callback after [_preloadedAd] is shown + /// — not sized to normal watch time, which this deliberately doesn't + /// bound. Generous rather than tight: firing early would wrongly block a + /// user who's still legitimately watching. + static const _earnedRewardSafetyTimeout = Duration(minutes: 5); + + bool _sdkInitialized = false; + DateTime? _lastShownAt; + RewardedAd? _preloadedAd; + Completer? _loadCompleter; + + InterstitialAd? _preloadedReportAd; + InterstitialAd? _preloadedFuelSaveAd; + + bool get _gateOpen { + final lastShownAt = _lastShownAt; + return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity; + } + + /// Starts the Mobile Ads SDK and begins preloading a rewarded ad. + /// Idempotent (a no-op after the first call) and safe to call + /// speculatively — [AppState] calls this from [AppState.syncNow] rather + /// than unconditionally at app startup, so a user who never connects + /// cloud sync never triggers any ad-related network activity at all. + Future initialize() async { + if (!adsEnabled) return; + final alreadyInitialized = _sdkInitialized; + await _ensureSdkInitialized(); + if (!alreadyInitialized) unawaited(_preload()); + } + + Future _preload() { + final completer = Completer(); + _loadCompleter = completer; + RewardedAd.load( + adUnitId: _rewardedAdUnitId, + request: const AdRequest(), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _preloadedAd = ad; + if (!completer.isCompleted) completer.complete(); + }, + onAdFailedToLoad: (error) { + debugPrint( + '[AdService] Rewarded ad failed to load: ${error.code} ${error.domain} ${error.message}'); + if (!completer.isCompleted) completer.complete(); + }, + ), + ); + return completer.future; + } + + /// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an + /// ad was already shown within [gateValidity]; otherwise shows the + /// preloaded rewarded ad and returns [AdGateResult.justShown] + /// once the user actually earns the reward (i.e. watches it through, not + /// just that it opened), or [AdGateResult.blocked] if none was available + /// in time, it failed to show, or the user dismissed it before earning + /// the reward. A [AdGateResult.blocked] result means the caller must not + /// proceed with uploading. + /// + /// The load step is bounded to a few seconds so a slow ad load can never + /// hang a sync indefinitely; the watch step isn't, since the reward is + /// legitimately expected to take as long as the user spends on the ad — + /// [_earnedRewardSafetyTimeout] only guards against a genuinely stuck SDK + /// callback, not normal watch time. Always lines up the next ad + /// afterward, whether this attempt succeeded or not, so the next call — + /// whether that's because this one failed or because [gateValidity] + /// elapsed — has the best chance of a preloaded ad ready to go. + Future showGateAd() async { + if (_gateOpen) return AdGateResult.alreadyOpen; + + // Stand-in for the rewarded ad: the upload still waits on the user + // actively dismissing something, and still opens the same + // [gateValidity] window afterward, so the gate's behavior is unchanged + // apart from what's on screen. See lib/services/ad_config.dart. + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: true); + _lastShownAt = DateTime.now(); + return AdGateResult.justShown; + } + + if (_preloadedAd == null) { + await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {}); + } + + final ad = _preloadedAd; + _preloadedAd = null; + if (ad == null) { + unawaited(_preload()); + return AdGateResult.blocked; + } + + final earnedReward = Completer(); + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + if (!earnedReward.isCompleted) earnedReward.complete(false); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint( + '[AdService] Rewarded ad failed to show: ${error.code} ${error.domain} ${error.message}'); + ad.dispose(); + if (!earnedReward.isCompleted) earnedReward.complete(false); + }, + ); + + await ad.show( + onUserEarnedReward: (ad, reward) { + _lastShownAt = DateTime.now(); + if (!earnedReward.isCompleted) earnedReward.complete(true); + }, + ); + final earned = + await earnedReward.future.timeout(_earnedRewardSafetyTimeout, onTimeout: () => false); + unawaited(_preload()); + return earned ? AdGateResult.justShown : AdGateResult.blocked; + } + + /// Starts the Mobile Ads SDK (if [initialize] hasn't already) and begins + /// preloading the report-export interstitial. Idempotent and safe to call + /// speculatively — [AppState] calls this when the Reports tab is first + /// built, independently of whether cloud sync is ever configured, so the + /// ad is ready by the time the user shares/prints without delaying the + /// export itself. + Future preloadReportAd() async { + if (!adsEnabled) return; + await _ensureSdkInitialized(); + unawaited(_preloadPlainInterstitial( + adUnitId: _reportInterstitialAdUnitId, + logLabel: 'Report interstitial', + onLoaded: (ad) => _preloadedReportAd = ad, + )); + } + + /// Best-effort interstitial shown after a report export/share/print + /// completes. Unlike [showGateAd], this never blocks or gates anything — + /// the export has already happened by the time this is called. Whether + /// this is actually due (grace periods, earned credit) is entirely + /// [AppState]'s call, made before this is ever invoked — this method + /// itself is unconditional: shows whatever's preloaded, or does nothing + /// if nothing was ready in time (this doesn't wait — showing it late, + /// after the user's already moved on, would be worse than not showing it + /// at all). Returns whether an ad actually showed, so the caller knows + /// whether to treat its credit as earned. + Future maybeShowReportAd() async { + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: false); + return true; + } + + final ad = _preloadedReportAd; + _preloadedReportAd = null; + final shown = await _showPlainInterstitial(ad, logLabel: 'Report interstitial'); + unawaited(_preloadPlainInterstitial( + adUnitId: _reportInterstitialAdUnitId, + logLabel: 'Report interstitial', + onLoaded: (ad) => _preloadedReportAd = ad, + )); + return shown; + } + + /// Same shape as [preloadReportAd], for the fuel-save interstitial — + /// [AppState] calls this once the confirm-entry screen is first built. + Future preloadFuelSaveAd() async { + if (!adsEnabled) return; + await _ensureSdkInitialized(); + unawaited(_preloadPlainInterstitial( + adUnitId: _fuelSaveInterstitialAdUnitId, + logLabel: 'Fuel-save interstitial', + onLoaded: (ad) => _preloadedFuelSaveAd = ad, + )); + } + + /// Same shape and caveats as [maybeShowReportAd], for the fuel-save + /// interstitial. + Future maybeShowFuelSaveAd() async { + if (!adsEnabled) { + await showAdPlaceholder(mustConfirm: false); + return true; + } + + final ad = _preloadedFuelSaveAd; + _preloadedFuelSaveAd = null; + final shown = await _showPlainInterstitial(ad, logLabel: 'Fuel-save interstitial'); + unawaited(_preloadPlainInterstitial( + adUnitId: _fuelSaveInterstitialAdUnitId, + logLabel: 'Fuel-save interstitial', + onLoaded: (ad) => _preloadedFuelSaveAd = ad, + )); + return shown; + } + + Future _ensureSdkInitialized() async { + if (_sdkInitialized) return; + _sdkInitialized = true; + await MobileAds.instance.initialize(); + } + + Future _preloadPlainInterstitial({ + required String adUnitId, + required String logLabel, + required void Function(InterstitialAd ad) onLoaded, + }) { + final completer = Completer(); + InterstitialAd.load( + adUnitId: adUnitId, + request: const AdRequest(), + adLoadCallback: InterstitialAdLoadCallback( + onAdLoaded: (ad) { + onLoaded(ad); + if (!completer.isCompleted) completer.complete(); + }, + onAdFailedToLoad: (error) { + debugPrint( + '[AdService] $logLabel failed to load: ${error.code} ${error.domain} ${error.message}'); + if (!completer.isCompleted) completer.complete(); + }, + ), + ); + return completer.future; + } + + /// Shows [ad] if non-null and reports back whether it actually displayed + /// (as opposed to merely being asked to) — the same "watched it" signal + /// [showGateAd] gets from a rewarded ad's earn callback, just sourced + /// from [FullScreenContentCallback.onAdShowedFullScreenContent] since a + /// plain interstitial has no reward callback to key off instead. Bounded + /// the same way [showGateAd]'s load step is: a plain interstitial has no + /// legitimate long "watch" phase the way a rewarded ad does, so 8 seconds + /// is generous rather than a real constraint. + Future _showPlainInterstitial(InterstitialAd? ad, {required String logLabel}) async { + if (ad == null) return false; + + final showed = Completer(); + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdShowedFullScreenContent: (ad) { + if (!showed.isCompleted) showed.complete(true); + }, + onAdDismissedFullScreenContent: (ad) => ad.dispose(), + onAdFailedToShowFullScreenContent: (ad, error) { + debugPrint( + '[AdService] $logLabel failed to show: ${error.code} ${error.domain} ${error.message}'); + ad.dispose(); + if (!showed.isCompleted) showed.complete(false); + }, + ); + + await ad.show(); + return showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false); + } +} diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index c353525..98c622d 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -1,63 +1,691 @@ +import 'dart:async'; import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show ThemeMode; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; -import 'storage_service.dart'; +import 'ad_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'; +import 'purchase_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 _prefsKeyThemeMode = 'theme_mode'; +const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour'; +const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement'; +const _prefsKeyShowEstimatedFuelRefund = 'show_estimated_fuel_refund'; + +const defaultStaleLockMinutes = 10; +const minStaleLockMinutes = 1; +const maxStaleLockMinutes = 60; + +/// Thrown by [AppState.addVehicle] when the given VIN already belongs to +/// an active vehicle — VIN is the primary/unique identifier, so adding a +/// duplicate should be rejected rather than silently overwriting it. +class DuplicateVinException implements Exception { + final String vin; + DuplicateVinException(this.vin); + + @override + String toString() => 'A vehicle with VIN "$vin" already exists.'; +} /// Single source of truth for the app's in-memory data (vehicles + fuel -/// entries), backed by [StorageService] for persistence. Screens read from -/// this via Provider and call its mutating methods, which take care of -/// writing through to disk and notifying listeners. +/// entries), backed by [DatabaseService] (local SQLite) for persistence and +/// a [CloudSyncService] for pushing/pulling the shared copy on whichever +/// [CloudStorageProvider] the user has connected. Screens read from this +/// via Provider and call its mutating methods, which write through to the +/// local database immediately and kick off a best-effort background sync. +/// +/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the +/// database, refreshed after every mutation and after every sync (since +/// sync's merge happens as SQL directly against the database, not by +/// handing back updated Dart objects). class AppState extends ChangeNotifier { - final StorageService storage = StorageService(); + final DatabaseService database = DatabaseService(); + final AdService adService = AdService(); + + /// `late final ... =` (lazy) rather than eagerly constructed like + /// [adService] above, specifically so the field initializer can + /// reference [_grantAdFreeYear]/[_setPurchaseError] as callbacks — by + /// the time anything actually touches this field (including + /// [dispose]), `this` is fully constructed either way. + late final PurchaseService purchaseService = PurchaseService( + onPurchaseGranted: _grantAdFreeYear, + onPurchaseError: _setPurchaseError, + ); + + /// Every storage backend the user can choose from in Settings. + final List availableProviders = [ + GoogleDriveProvider(), + DropboxProvider(), + OneDriveProvider(), + WebDavProvider(), + ]; + + CloudStorageProvider? activeProvider; + CloudSyncService? cloudSync; + final _uuid = const Uuid(); List vehicles = []; List fuelEntries = []; bool isLoading = true; + String? cloudFolderPath; + bool isSyncing = false; + DateTime? lastSyncedAt; + Object? lastSyncError; + + /// Set by the widget tree at startup (see `main.dart`) to show the + /// "remove ads for a year" upsell — [syncNow] calls this the moment an ad + /// was actually just shown to gate an upload, never on a sync that finds + /// the gate already open from a recent prior view. + VoidCallback? onAdWatched; + + 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; + + ThemeMode themeMode = ThemeMode.system; + + /// Whether to show an estimated Missouri Highway Fuel Tax Refund amount + /// alongside gallons/cost figures throughout the app (Receipts hero + /// banner, receipt line items, report totals — see + /// lib/services/estimated_refund.dart). Off by default: it's a rough, + /// rate-contingent estimate, not something every user necessarily wants + /// cluttering their totals. + bool showEstimatedFuelRefund = false; + + /// Whether the first-launch guided tour ([OnboardingTour]) has already + /// been shown. Defaults to `true` here (not `false`) specifically so + /// that widget tests constructing `AppState()` directly and skipping + /// [init] — the established pattern throughout this app's test suite, + /// to avoid init()'s database/platform-channel dependencies — don't get + /// an unexpected full-screen tour overlay blocking every tap. [init] + /// overwrites this from the persisted value (defaulting to `false` + /// there) for real app startups, where a missing pref genuinely means + /// "never shown before." + bool hasSeenOnboardingTour = true; + + /// Whether the user has accepted the user agreement (see + /// lib/screens/user_agreement_screen.dart) — gates every screen in the + /// app, including the onboarding tour, until accepted. Defaults to + /// `true` here for the same widget-test-bypass reason as + /// [hasSeenOnboardingTour] above; [init] overwrites it from the + /// persisted value (defaulting to `false` there) for real app startups. + bool hasAcceptedUserAgreement = true; + + /// 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; + + /// The furthest-known "ads disabled until" date from the "remove ads for + /// a year" purchase — null if never purchased (or the last purchase's + /// year has fully lapsed and nothing newer has been merged in). Synced + /// through the cloud like vehicles/fuel entries — see + /// [DatabaseService.getAdFreeUntil]. + DateTime? adFreeUntil; + + /// A user-facing message from the most recent failed purchase attempt, + /// for the Settings screen to display — mirrors [lastSyncError]'s role + /// for sync failures. + String? purchaseError; + + /// The earliest-known moment this app was ever used — null until [init] + /// loads it. Drives the new-user ad grace period (see + /// [maybeShowFuelSaveAd]/[maybeShowReportAd]); see + /// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced + /// rather than a local-only preference. + DateTime? firstUsedAt; + + /// True if there's local data not yet pushed to the cloud — either + /// nothing has synced yet, or something changed since the last + /// successful push. Refreshed alongside [vehicles]/[fuelEntries] in + /// [_refreshFromDatabase], so it's accurate after every local mutation + /// and every sync attempt. Drives the "not backed up" icon (see + /// `CloudBackupActionButton`) once backup *is* configured — before that, + /// [hasCloudBackupConfigured] alone already covers it. + bool hasUnsyncedChanges = false; + + bool get isCloudConnected => activeProvider?.isSignedIn ?? false; + String? get cloudAccountLabel => activeProvider?.accountLabel; + + /// True once a provider is connected *and* a backup folder has actually + /// been picked — [isCloudConnected] alone isn't enough, since a signed-in + /// provider with no folder chosen yet still won't back anything up (see + /// [CloudSyncService.isConfigured]). Drives the "your data isn't backed + /// up" reminder shown after saving a fuel entry — see + /// lib/widgets/backup_reminder.dart. + bool get hasCloudBackupConfigured => cloudSync?.isConfigured ?? false; + + bool get adsCurrentlyDisabled { + final until = adFreeUntil; + return until != null && DateTime.now().toUtc().isBefore(until); + } + + /// How long after first ever opening this app (see [firstUsedAt]) a new + /// user sees no interstitials at all — shared by the report and + /// fuel-save placements. + static const _newUserAdGracePeriod = Duration(minutes: 5); + + /// Below this many total fuel entries ever saved, the fuel-save + /// interstitial doesn't show — on top of, not instead of, + /// [_newUserAdGracePeriod]. + static const _fuelSaveAdGraceSaves = 4; + + /// Watching the fuel-save interstitial buys a credit that lasts until + /// *either* of these runs out, whichever comes first. + static const _fuelSaveAdCreditDuration = Duration(minutes: 5); + static const _fuelSaveAdCreditSaves = 2; + + /// Watching the report interstitial buys this much flat credit. + static const _reportAdCreditDuration = Duration(minutes: 10); + + DateTime? _lastFuelSaveAdShownAt; + int _fuelSavesSinceAd = 0; + DateTime? _lastReportAdShownAt; + + /// Preloads the report-export interstitial (see [AdService.preloadReportAd]) + /// — call once when the Reports tab is first built, so an ad is ready by + /// the time [maybeShowReportAd] is called. A no-op for a user with an + /// active ad-free purchase. + void preloadReportAd() { + if (!adsCurrentlyDisabled) unawaited(adService.preloadReportAd()); + } + + /// Best-effort ad shown after a report export/share/print completes — + /// see [AdService.maybeShowReportAd]. A no-op for a user with an active + /// ad-free purchase, during the new-user grace period (see + /// [firstUsedAt]), or while a previously-watched report ad's credit is + /// still active. + Future maybeShowReportAd() async { + if (adsCurrentlyDisabled || _inNewUserGracePeriod) return; + + final lastShown = _lastReportAdShownAt; + if (lastShown != null && DateTime.now().difference(lastShown) < _reportAdCreditDuration) { + return; + } + + if (await adService.maybeShowReportAd()) { + _lastReportAdShownAt = DateTime.now(); + } + } + + /// Preloads the fuel-save interstitial (see [AdService.preloadFuelSaveAd]) + /// — call once when the confirm-entry screen is first built. A no-op for + /// a user with an active ad-free purchase. + void preloadFuelSaveAd() { + if (!adsCurrentlyDisabled) unawaited(adService.preloadFuelSaveAd()); + } + + bool get _inNewUserGracePeriod { + final firstUsed = firstUsedAt; + return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod; + } + + /// Ad shown on saving a fuel entry — see [AdService.maybeShowFuelSaveAd]. + /// + /// Called by `ConfirmFuelEntryScreen._save` *after* [addFuelEntry] has + /// already committed the entry, and awaited before that screen pops. So + /// it reads to the user as "tap Save, see the ad, land back on the + /// list", while the entry itself is never actually waiting on the ad — + /// which matters both for their data (nothing is lost if the app dies + /// mid-ad) and for AdMob's rule that a plain interstitial must never + /// gate access to app functionality. That's the same rule that forced + /// the cloud-sync gate to a *rewarded* ad; the difference here is that + /// nothing is being withheld pending the ad. + /// + /// A no-op for a user with an active ad-free purchase, during the + /// new-user grace period or the first [_fuelSaveAdGraceSaves] saves, or + /// while a previously-watched fuel-save ad's credit is still active. + /// + /// Returns whether an ad actually showed. The caller uses that to hold + /// back [showBackupReminderDialog] for this one save: only ever one + /// full-screen surface per save (the "post-save arbitration" rule), but + /// the ad takes precedence rather than being suppressed outright — the + /// reminder recurs on the next save anyway, whereas suppressing meant a + /// user who never configured backup never saw this placement at all. + Future maybeShowFuelSaveAd() async { + if (adsCurrentlyDisabled) return false; + if (_inNewUserGracePeriod) return false; + if (fuelEntries.length <= _fuelSaveAdGraceSaves) return false; + + final lastShown = _lastFuelSaveAdShownAt; + if (lastShown != null && + DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration && + _fuelSavesSinceAd < _fuelSaveAdCreditSaves) { + _fuelSavesSinceAd++; + return false; + } + + if (await adService.maybeShowFuelSaveAd()) { + _lastFuelSaveAdShownAt = DateTime.now(); + _fuelSavesSinceAd = 0; + return true; + } + return false; + } + + /// The store's own formatted, localized price for the ad-free-year + /// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize] + /// has loaded it, or if the product isn't configured in the store yet. + String? get adFreeYearPriceLabel => purchaseService.priceLabel; + + StreamSubscription>? _connectivitySubscription; + Future init() async { - await storage.init(); - final data = await storage.loadData(); - vehicles = data.vehicles; - fuelEntries = data.entries; + isLoading = true; + initError = null; + notifyListeners(); + + try { + await database.init(); + await _refreshFromDatabase(); + firstUsedAt = await database.getOrCreateFirstUsedAt(); + + final prefs = await SharedPreferences.getInstance(); + keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false; + staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; + keepMaxQualityReceiptPhotos = + prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; + themeMode = ThemeMode.values.firstWhere( + (mode) => mode.name == prefs.getString(_prefsKeyThemeMode), + orElse: () => ThemeMode.system, + ); + hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false; + hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false; + showEstimatedFuelRefund = prefs.getBool(_prefsKeyShowEstimatedFuelRefund) ?? false; + } catch (e) { + initError = e; + isLoading = false; + notifyListeners(); + return; + } + isLoading = false; notifyListeners(); + + _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { + if (results.any((r) => r != ConnectivityResult.none)) { + unawaited(syncNow()); + } + }); + + unawaited(_restoreCloudConnection()); + + // Independent of cloud sync (unlike adService, which only ever starts + // once a sync is actually attempted) — a user should be able to buy + // ad-free time whether or not they've ever connected cloud storage, so + // this loads eagerly at startup. Non-blocking: a slow/unavailable + // store connection should never delay the rest of app startup. + unawaited(purchaseService.initialize()); + } + + @override + void dispose() { + _connectivitySubscription?.cancel(); + purchaseService.dispose(); + super.dispose(); + } + + Future _refreshFromDatabase() async { + vehicles = await database.getVehicles(); + fuelEntries = await database.getFuelEntries(); + adFreeUntil = await database.getAdFreeUntil(); + hasUnsyncedChanges = await database.hasDirtyRows(); + } + + CloudStorageProvider? _providerById(CloudProviderId id) { + for (final provider in availableProviders) { + if (provider.id == id) return provider; + } + return null; + } + + Future _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 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(); + + await _assumeRootCloudFolder(); + } + + /// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider] + /// (currently just WebDAV) that needs a server URL/username/password + /// instead of an OAuth browser flow. The caller (Settings) is responsible + /// for collecting those from the user first. + Future connectProviderWithCredentials( + CloudProviderId id, { + required String serverUrl, + required String username, + required String password, + }) async { + final provider = _providerById(id); + if (provider == null || provider is! ManualCredentialCloudStorageProvider) return; + + await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials( + serverUrl: serverUrl, + username: username, + password: password, + ); + activeProvider = provider; + cloudSync = CloudSyncService(provider: provider, databaseService: database); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyActiveProviderId, id.name); + notifyListeners(); + + await _assumeRootCloudFolder(); + } + + /// Called right after a fresh connect: rather than making the user + /// immediately go pick a folder before anything can sync, assume the + /// root of the provider ("My Files") — matching + /// [CloudFolderBrowserScreen]'s own root label — is where the app's + /// folder belongs, the same way [chooseCloudFolder] would if the user + /// had picked it themselves. If they later pick somewhere else, + /// [CloudSyncService.selectAppFolder] moves this same folder (and + /// everything already synced into it) there instead of abandoning it. + Future _assumeRootCloudFolder() => + _selectCloudFolder(parentId: 'root', breadcrumbPath: 'My Files'); + + Future 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 addVehicle({ - required String make, - required String model, - required String color, - required String licensePlate, + Future chooseCloudFolder({ + required String parentId, + required String breadcrumbPath, + String? currentFolderName, + }) => + _selectCloudFolder( + parentId: parentId, + breadcrumbPath: breadcrumbPath, + currentFolderName: currentFolderName, + ); + + Future _selectCloudFolder({ + required String parentId, + required String breadcrumbPath, + String? currentFolderName, }) async { - vehicles.add(Vehicle( - id: _uuid.v4(), - make: make, - model: model, - color: color, - licensePlate: licensePlate, - )); + final sync = cloudSync; + if (sync == null) return; + + final folderId = + await sync.selectAppFolder(parentId, currentFolderName: currentFolderName); + cloudFolderPath = breadcrumbPath; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyCloudFolderId, folderId); + await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath); + notifyListeners(); + + unawaited(syncNow()); + } + + Future syncNow() async { + final sync = cloudSync; + if (isSyncing || sync == null || !sync.isConfigured) return; + + // A user with a currently-active "remove ads for a year" purchase + // skips the gate entirely — beforeUpload stays null (CloudSyncService + // treats that as "nothing to check", same as any other call site that + // never passed one) and the ad SDK isn't even touched this sync. + Future Function({required bool everSyncedBefore})? beforeUpload; + if (!adsCurrentlyDisabled) { + // Idempotent, and only ever reached once sync is actually configured + // — a user who never connects cloud storage never triggers any + // ad-related activity at all. + unawaited(adService.initialize()); + beforeUpload = ({required everSyncedBefore}) async { + // The very first sync a cloud folder ever sees goes through for + // free — see [CloudSyncService.syncNow]'s `everSyncedBefore` doc. + if (!everSyncedBefore) return true; + + final result = await adService.showGateAd(); + // Fired, not awaited: the upsell dialog is purely informational — + // this sync (specifically the upload [beforeUpload] is about to + // unblock) shouldn't wait on however long it takes the user to + // dismiss it. + if (result == AdGateResult.justShown) onAdWatched?.call(); + return result != AdGateResult.blocked; + }; + } + + isSyncing = true; + notifyListeners(); + + final result = await sync.syncNow( + keepLocalReceiptCopies: keepReceiptPhotosLocally, + staleLockAge: Duration(minutes: staleLockMinutes), + beforeUpload: beforeUpload, + ); + + if (result.ranSync) { + await _refreshFromDatabase(); + lastSyncedAt = DateTime.now(); + lastSyncError = null; + } else if (result.adGateBlocked) { + // Whatever the remote side had was still pulled/merged in — only the + // upload was withheld — so the on-screen data should reflect that + // even though this doesn't count as a completed sync. + await _refreshFromDatabase(); + lastSyncError = 'Watch a short ad to finish syncing your data to the cloud.'; + } else if (result.error != null) { + lastSyncError = result.error; + } + + isSyncing = false; + notifyListeners(); + } + + Future setKeepReceiptPhotosLocally(bool value) async { + keepReceiptPhotosLocally = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value); + notifyListeners(); + } + + Future setKeepMaxQualityReceiptPhotos(bool value) async { + keepMaxQualityReceiptPhotos = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value); + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + themeMode = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyThemeMode, mode.name); + notifyListeners(); + } + + Future setShowEstimatedFuelRefund(bool value) async { + showEstimatedFuelRefund = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyShowEstimatedFuelRefund, value); + notifyListeners(); + } + + /// Kicks off the platform purchase UI for a year of no ads — see + /// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted + /// asynchronously, once the store confirms the purchase (see + /// [_grantAdFreeYear]), not immediately when this returns. + Future buyAdFreeYear() => purchaseService.buyAdFreeYear(); + + /// Re-checks Play Billing for an active purchase and, if one is found, + /// re-grants the entitlement locally — see [PurchaseService.restorePurchase]. + /// For a user who reinstalled or switched devices without cloud backup + /// connected, so there was nothing local to sync the entitlement back + /// down from. Safe to call any time; does nothing if there's no active + /// purchase to find. + Future restoreAdFreeYear() => purchaseService.restorePurchase(); + + /// [PurchaseService]'s `onPurchaseGranted` callback: records a year of + /// ad-free time anchored to [purchaseTime] — Play Billing's own record of + /// when the purchase actually happened, not "now" — so restoring a + /// purchase made months ago correctly reflects however much of that year + /// is already gone, rather than handing out a fresh extra year. Buying + /// again before the current year lapses simply resets the clock to a + /// fresh year from that new purchase, rather than stacking. + Future _grantAdFreeYear(DateTime purchaseTime) async { + await database.setAdFreeUntil( + purchaseTime.add(const Duration(days: 365)), + DateTime.now().toUtc(), + ); + purchaseError = null; await _persist(); } - Future updateVehicle(Vehicle updated) async { - final index = vehicles.indexWhere((v) => v.id == updated.id); - if (index != -1) { - vehicles[index] = updated; - await _persist(); - } + /// [PurchaseService]'s `onPurchaseError` callback. + void _setPurchaseError(String message) { + purchaseError = message; + notifyListeners(); } - Future deleteVehicle(String vehicleId) async { - for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { - await storage.deleteReceiptImage(entry.receiptImagePath); + /// Called once the first-launch guided tour finishes or is skipped, so + /// it never shows again on this device. + Future markOnboardingTourSeen() async { + hasSeenOnboardingTour = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyHasSeenOnboardingTour, true); + notifyListeners(); + } + + /// Called once the user taps "I Agree" on the user agreement screen, so + /// it never shows again on this device. + Future acceptUserAgreement() async { + hasAcceptedUserAgreement = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyHasAcceptedUserAgreement, true); + 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 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 addVehicle({ + required String vin, + String? nickname, + }) async { + if (await database.vinExists(vin)) { + throw DuplicateVinException(vin); } - fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); - vehicles.removeWhere((v) => v.id == vehicleId); + final vehicle = Vehicle( + id: _uuid.v4(), + vin: vin, + nickname: nickname, + updatedAt: DateTime.now().toUtc(), + ); + await database.saveVehicle(vehicle); + await _persist(); + } + + /// Throws [DuplicateVinException] if [updated]'s VIN now collides with + /// another active vehicle's — this is the check [addVehicle] does for a + /// new vehicle, but here it also has to exclude the vehicle being edited + /// itself (its VIN obviously still matches its own prior value if it + /// wasn't changed). + Future updateVehicle(Vehicle updated) async { + if (await database.vinExists(updated.vin, excludeId: updated.id)) { + throw DuplicateVinException(updated.vin); + } + await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc())); + await _persist(); + } + + Future deleteVehicle(String id) async { + final now = DateTime.now().toUtc(); + final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now); + for (final path in orphanedLocalPaths) { + await database.deleteReceiptImageFile(path); + } + await database.softDeleteVehicle(id, now); await _persist(); } @@ -72,7 +700,8 @@ class AppState extends ChangeNotifier { final id = _uuid.v4(); String? storedImagePath; 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( @@ -83,16 +712,75 @@ class AppState extends ChangeNotifier { pricePerGallon: pricePerGallon, totalCost: totalCost, receiptImagePath: storedImagePath, + updatedAt: DateTime.now().toUtc(), ); - fuelEntries.add(entry); + await database.saveFuelEntry(entry); await _persist(); return entry; } + /// Corrects the logged values (date, gallons, price/gal, total cost, and + /// which vehicle it's attached to) for an existing fuel entry — the + /// receipt photo itself isn't editable here, only the data recorded + /// about it (e.g. fixing a misread OCR value, or a receipt that got + /// logged under the wrong vehicle). [entryId]'s receipt photo reference + /// (local path and/or cloud file id) carries over untouched even when + /// [vehicleId] changes — the underlying photo file stays exactly where + /// it already is (including whichever vehicle's folder it was filed + /// under locally/in the cloud); only the database's own record of which + /// vehicle owns this entry moves. + Future updateFuelEntry({ + required String entryId, + required String vehicleId, + required DateTime date, + required double gallons, + required double pricePerGallon, + required double totalCost, + }) async { + final existing = fuelEntries.firstWhere((e) => e.id == entryId); + final updated = FuelEntry( + id: existing.id, + vehicleId: vehicleId, + date: date, + gallons: gallons, + pricePerGallon: pricePerGallon, + totalCost: totalCost, + receiptImagePath: existing.receiptImagePath, + receiptDriveFileId: existing.receiptDriveFileId, + updatedAt: DateTime.now().toUtc(), + ); + await database.saveFuelEntry(updated); + await _persist(); + } + Future deleteFuelEntry(String entryId) async { final entry = fuelEntries.firstWhere((e) => e.id == entryId); - await storage.deleteReceiptImage(entry.receiptImagePath); - fuelEntries.removeWhere((e) => e.id == entryId); + await database.deleteReceiptImageFile(entry.receiptImagePath); + await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc()); + await _persist(); + } + + /// Wipes every vehicle, fuel entry, and local receipt photo — "starting + /// completely fresh". Uses the same soft-delete tombstones as any other + /// delete rather than a hard SQL `DELETE`, so if a cloud sync is + /// connected, the deletion pushes on next sync instead of the old data + /// just getting silently re-imported from the remote copy. This is purely + /// local + whatever propagates through sync — it never reaches into the + /// cloud folder directly to delete anything sitting there. + Future purgeAllData() async { + await database.purgeAllData(DateTime.now().toUtc()); + await _persist(); + } + + /// Wipes every fuel entry (and its local receipt photo) dated within + /// [start]–[end] inclusive, across all vehicles. Vehicles themselves are + /// left alone. Same soft-delete/sync-propagation reasoning as + /// [purgeAllData]. + Future purgeFuelEntriesInRange(DateTime start, DateTime end) async { + final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc()); + for (final path in localPaths) { + await database.deleteReceiptImageFile(path); + } await _persist(); } @@ -117,13 +805,43 @@ class AppState extends ChangeNotifier { } } - Future changeSaveDirectory(String newPath) async { - await storage.setSaveDirectory(newPath); - notifyListeners(); + FuelEntry? fuelEntryById(String id) { + try { + return fuelEntries.firstWhere((e) => e.id == id); + } catch (_) { + return null; + } + } + + /// Exact match against an active vehicle's VIN — same comparison + /// [DatabaseService.vinExists] does, not case-insensitive, since VIN + /// isn't normalized to any particular case on manual entry (only OCR + /// scanning uppercases it). + Vehicle? vehicleByVin(String vin) { + try { + return vehicles.firstWhere((v) => v.vin == vin); + } catch (_) { + return null; + } } Future _persist() async { - await storage.saveData(vehicles: vehicles, entries: fuelEntries); + await _refreshFromDatabase(); notifyListeners(); + // Only a paid ("remove ads for a year") user gets synced automatically + // on every change — for anyone else this would mean an ad-gated + // syncNow() firing silently in the background, disconnected from + // anything the user just did, which is exactly the surprise-ad problem + // this app's ad placements are designed to avoid everywhere else. + // Sync still always happens on demand: `Data > Sync Now`, tapping + // `CloudBackupActionButton` when it shows "not synced", and connecting + // a provider/folder for the first time (see [selectAppFolder]) all + // call [syncNow] directly, and the very first sync a folder ever sees + // is free regardless (see [syncNow]'s `everSyncedBefore` handling). + if (adsCurrentlyDisabled) unawaited(syncNow()); } } + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/services/cloud/cloud_storage_provider.dart b/lib/services/cloud/cloud_storage_provider.dart new file mode 100644 index 0000000..4b17d13 --- /dev/null +++ b/lib/services/cloud/cloud_storage_provider.dart @@ -0,0 +1,163 @@ +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 = 'Show Me The Fuel Refund'; +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 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 signIn(); + + Future 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> 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 findOrCreateFolder({required String parentId, required String name}); + + /// Relocates the existing folder [folderId] to be a child of + /// [newParentId] instead of wherever it currently lives, preserving its + /// contents and (name/subtree) intact. Used when the user picks a + /// different location for the app's already-existing folder (see + /// [CloudSyncService.selectAppFolder]), so its synced data and receipts + /// move with it instead of being abandoned in place while a fresh, empty + /// folder is created at the new location. Returns the folder's + /// (possibly new, for a path-addressed provider) id. + Future moveFolder({required String folderId, required String newParentId}); + + /// Looks up a file's ID + versionTag by name within [folderId] without + /// downloading its content, or null if no such file exists yet. + Future findFile({required String folderId, required String name}); + + Future> downloadFileBytes(String fileId); + + /// Creates the file if [existingFileId] is null, otherwise overwrites + /// its content. Returns the (possibly new) file ID and fresh versionTag. + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }); + + Future deleteFile(String fileId); + + Future createLockFile({required String folderId, required String name}); + + Future> 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 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)); +} diff --git a/lib/services/cloud/dropbox_provider.dart b/lib/services/cloud/dropbox_provider.dart new file mode 100644 index 0000000..9282862 --- /dev/null +++ b/lib/services/cloud/dropbox_provider.dart @@ -0,0 +1,375 @@ +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. "/Show Me The Fuel Refund") 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 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 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 _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; + _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 _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; + 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 _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; + _accessToken = json['access_token'] as String; + _accessTokenExpiry = + DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400)); + return _accessToken!; + } + + @override + Future 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> _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> _post(String path, Map 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; + } + + /// 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; + return jsonEncode(body['error']).contains('not_found'); + } catch (_) { + return false; + } + } + + @override + Future> 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? ?? []); + return entries + .cast>() + .where((e) => e['.tag'] == 'folder') + .map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String)) + .toList(); + } + + @override + Future 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; + 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 moveFolder({required String folderId, required String newParentId}) async { + final name = folderId.substring(folderId.lastIndexOf('/') + 1); + final toPath = _childPath(newParentId, name); + if (toPath == folderId) return folderId; + + final json = await _post('/2/files/move_v2', {'from_path': folderId, 'to_path': toPath}); + final metadata = json['metadata'] as Map?; + return metadata?['path_display'] as String? ?? toPath; + } + + @override + Future 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; + if (json['.tag'] != 'file') return null; + return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?); + } + + @override + Future> 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 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; + return CloudFileInfo( + id: json['path_display'] as String? ?? targetPath, + versionTag: json['content_hash'] as String?, + ); + } + + @override + Future 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 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 [], + ); + if (response.statusCode != 200) { + throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}'); + } + return path; + } + + @override + Future> listLockFiles(String folderId) async { + final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)}); + final entries = (json['entries'] as List? ?? []); + + final locks = []; + for (final entry in entries.cast>()) { + 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() {} +} diff --git a/lib/services/cloud/google_drive_provider.dart b/lib/services/cloud/google_drive_provider.dart new file mode 100644 index 0000000..81f3634 --- /dev/null +++ b/lib/services/cloud/google_drive_provider.dart @@ -0,0 +1,277 @@ +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 = ['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 _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 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 signIn() async { + await _ensureInitialized(); + final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes); + _account = account; + await account.authorizationClient.authorizeScopes(_driveScopes); + return account.email; + } + + @override + Future 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 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> 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 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 ?? []; + 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 moveFolder({required String folderId, required String newParentId}) async { + final current = await _api.files.get(folderId, $fields: 'parents') as drive.File; + final oldParents = (current.parents ?? []).join(','); + final updated = await _api.files.update( + drive.File(), + folderId, + addParents: newParentId, + removeParents: oldParents.isEmpty ? null : oldParents, + $fields: 'id', + ); + return updated.id ?? folderId; + } + + @override + Future 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 ?? []; + if (files.isEmpty || files.first.id == null) return null; + return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum); + } + + @override + Future> downloadFileBytes(String fileId) async { + final media = await _api.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + return _collectBytes(media.stream); + } + + @override + Future 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 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 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> 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 = []; + for (final f in result.files ?? []) { + 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> _collectBytes(Stream> stream) async { + final bytes = []; + await for (final chunk in stream) { + bytes.addAll(chunk); + } + return bytes; + } + + @override + void close() => _client.close(); +} diff --git a/lib/services/cloud/oauth_pkce.dart b/lib/services/cloud/oauth_pkce.dart new file mode 100644 index 0000000..b4e801b --- /dev/null +++ b/lib/services/cloud/oauth_pkce.dart @@ -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(); + } +} diff --git a/lib/services/cloud/onedrive_provider.dart b/lib/services/cloud/onedrive_provider.dart new file mode 100644 index 0000000..583d401 --- /dev/null +++ b/lib/services/cloud/onedrive_provider.dart @@ -0,0 +1,366 @@ +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 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 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 _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; + _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 _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; + return (json['mail'] as String?) ?? + (json['userPrincipalName'] as String?) ?? + 'OneDrive account'; + } + + Future _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; + _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 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> _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> 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; + final entries = (json['value'] as List? ?? []).cast>(); + + final folders = []; + 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?; + 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 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; + final entries = (listJson['value'] as List? ?? []).cast>(); + 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': {}, + '@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; + return created['id'] as String; + } + + @override + Future moveFolder({required String folderId, required String newParentId}) async { + final response = await http.patch( + _graph('/me/drive/${_itemSegment(folderId)}'), + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'parentReference': {'id': newParentId}, + }), + ); + if (response.statusCode != 200) { + throw StateError('OneDrive API error (moveFolder): ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + return json['id'] as String? ?? folderId; + } + + @override + Future 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; + return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); + } + + @override + Future> 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 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; + return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); + } + + @override + Future 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 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 [], + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + return json['id'] as String; + } + + @override + Future> 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; + final entries = (json['value'] as List? ?? []).cast>(); + + final locks = []; + 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() {} +} diff --git a/lib/services/cloud/webdav_provider.dart b/lib/services/cloud/webdav_provider.dart new file mode 100644 index 0000000..4df9e71 --- /dev/null +++ b/lib/services/cloud/webdav_provider.dart @@ -0,0 +1,406 @@ +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 = ''' + + + + + +'''; + +/// One `` 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 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 _byLocalName(XmlNode node, String localName) => + node.descendants.whereType().where((e) => e.name.local == localName); + +class _RawResponse { + final int statusCode; + final String body; + final Map 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? 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) 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 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 signIn() { + throw UnsupportedError( + 'WebDAV needs a server URL and credentials — use signInWithCredentials instead.'); + } + + @override + Future 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 _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 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> _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> listFolders({String? parentId, bool sharedWithMe = false}) async { + final uri = _uriFor(parentId ?? 'root'); + final selfPath = _normalizedPath(uri.path); + + List 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 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 moveFolder({required String folderId, required String newParentId}) async { + final name = _nameFromPath(folderId); + final destination = _childUri(_uriFor(newParentId), name); + + final response = await _send('MOVE', _uriFor(folderId), headers: { + 'Authorization': _provider._authHeader, + 'Destination': destination.toString(), + 'Overwrite': 'F', + }); + if (response.statusCode != 201 && response.statusCode != 204) { + throw StateError('WebDAV MOVE failed: ${response.statusCode} ${response.body}'); + } + return destination.path; + } + + @override + Future findFile({required String folderId, required String name}) async { + final fileUri = _childUri(_uriFor(folderId), name); + List 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> 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 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 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 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 [], + ); + 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> listLockFiles(String folderId) async { + final uri = _uriFor(folderId); + final selfPath = _normalizedPath(uri.path); + + List entries; + try { + entries = await _propfind(uri, depth: '1'); + } on _WebDavNotFoundException { + return []; + } + + final locks = []; + 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() {} +} diff --git a/lib/services/cloud_oauth_config.dart b/lib/services/cloud_oauth_config.dart new file mode 100644 index 0000000..2f426f8 --- /dev/null +++ b/lib/services/cloud_oauth_config.dart @@ -0,0 +1,53 @@ +/// 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 = + '482287068996-rea2e3fcbqeb0dq3ogh40ro8e1fm2m5j.apps.googleusercontent.com'; + + /// 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 = 'tmrok3snmkqpu1m'; + + /// 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 = 'mofueltaxback-dropbox://oauth2redirect'; + + // --- 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 = '87ef0bae-89ff-4359-94df-64de333cfc08'; + + /// 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 = 'mofueltaxback-onedrive://auth'; +} diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart new file mode 100644 index 0000000..2f598ed --- /dev/null +++ b/lib/services/cloud_sync_service.dart @@ -0,0 +1,428 @@ +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; + + /// True when pull/merge completed but [CloudSyncService.syncNow]'s + /// `beforeUpload` gate (the app's ad-watch requirement — see + /// [AdService.showGateAd]) came back closed, so nothing local was + /// uploaded. Remote changes, if any, were still merged in. + final bool adGateBlocked; + + SyncResult.skipped() + : ranSync = false, + error = null, + adGateBlocked = false; + + SyncResult.success() + : ranSync = true, + error = null, + adGateBlocked = false; + + SyncResult.failure(this.error) + : ranSync = false, + adGateBlocked = false; + + SyncResult.adGateBlocked() + : ranSync = false, + error = null, + adGateBlocked = true; +} + +/// 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 _vinFolderIds = {}; + final Map _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; + } + + /// Points this service at the `Show Me The Fuel Refund` folder under + /// [parentId] (a folder the user picked in the folder browser, or — + /// see [AppState.connectProvider] — the assumed root location a fresh + /// connection defaults to) and configures this service to use it. + /// Returns the resulting folder ID. + /// + /// If the user picked a folder that's *already* named + /// `Show Me The Fuel Refund` — [currentFolderName] carries that name up + /// from the browser — [parentId] is used directly instead of nesting + /// another same-named folder inside it. Otherwise a folder picked at + /// "My Files" root would end up as `/Show Me The Fuel Refund` on first + /// setup, but re-picking that same folder later (e.g. after reinstalling) + /// would double it up as `/Show Me The Fuel Refund/Show Me The Fuel + /// Refund`. + /// + /// Otherwise, three cases: + /// - A same-named folder already exists directly under [parentId] (e.g. + /// one someone else already set up there to share) — adopt it as-is, + /// the same convergence behavior as before, so two people pointed at + /// the same shared parent end up sharing one app folder either way. + /// - This service already has an app folder configured somewhere else + /// (from a previous [selectAppFolder] call, or the root default a + /// fresh connection starts with) and [parentId] has no folder of its + /// own yet — *move* the existing one there via + /// [CloudStorageSession.moveFolder], preserving its contents, rather + /// than creating a fresh empty folder and abandoning the old one with + /// all its synced data still in it. + /// - Neither of the above (nothing configured yet, nothing at the + /// destination) — create a fresh one, same as always. + Future selectAppFolder(String parentId, {String? currentFolderName}) async { + final session = provider.beginSession(); + try { + if (currentFolderName == appFolderName) { + configure(parentId); + return parentId; + } + + CloudFolder? existingAtDestination; + for (final folder in await session.listFolders(parentId: parentId)) { + if (folder.name == appFolderName) { + existingAtDestination = folder; + break; + } + } + + final String folderId; + if (existingAtDestination != null) { + folderId = existingAtDestination.id; + } else if (_appFolderId != null) { + folderId = await session.moveFolder(folderId: _appFolderId!, newParentId: parentId); + } else { + 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. + /// + /// [beforeUpload], if given, is awaited once — after any pull/merge of + /// remote changes has finished, but before anything local gets uploaded + /// (pending receipt photos or the database snapshot itself) — and its + /// result decides whether the upload happens at all. This is the hook + /// [AppState] uses to gate uploads behind the app's ad placement: a + /// `false` return means the gate is closed (see [AdService.showGateAd]) + /// and this call returns [SyncResult.adGateBlocked] without uploading + /// anything, having still pulled/merged whatever the remote side had. + /// `everSyncedBefore` tells the caller whether a remote data file already + /// existed — `false` means this is the very first sync this shared cloud + /// folder has ever seen, which [AppState] uses to let that one upload + /// through for free, with no ad required. + Future syncNow({ + bool keepLocalReceiptCopies = false, + Duration staleLockAge = const Duration(minutes: 10), + Future Function({required bool everSyncedBefore})? beforeUpload, + }) 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); + } + + if (beforeUpload != null && + !await beforeUpload(everSyncedBefore: remoteInfo != null)) { + return SyncResult.adGateBlocked(); + } + + 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 _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); + + // ad_free_entitlement is a newer table than vehicles/fuel_entries — + // unlike those two (present since the very first schema version, so + // any remote snapshot ever pushed already has them), a remote + // snapshot pushed before this table existed genuinely won't have + // it. Merging against it unconditionally would throw ("no such + // table") on exactly that snapshot; skipping when absent just means + // "that older snapshot has no entitlement info to contribute", + // which is correct — local's own value (if any) is left as-is. + final remoteHasEntitlementTable = (await db.rawQuery( + "SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'ad_free_entitlement'", + )).isNotEmpty; + if (remoteHasEntitlementTable) { + await db.execute(mergeAdFreeEntitlementSql); + } + + // Same reasoning as ad_free_entitlement above — app_usage is newer + // than vehicles/fuel_entries, so an older remote snapshot genuinely + // won't have it. + final remoteHasAppUsageTable = (await db.rawQuery( + "SELECT 1 FROM remote_db.sqlite_master WHERE type = 'table' AND name = 'app_usage'", + )).isNotEmpty; + if (remoteHasAppUsageTable) { + await db.execute(mergeAppUsageSql); + } + } 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 _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//` 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 _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 _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 _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, + ); + } +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart new file mode 100644 index 0000000..2f5380c --- /dev/null +++ b/lib/services/database_service.dart @@ -0,0 +1,402 @@ +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 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: 6, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + } + + Future _onCreate(Database db, int version) async { + await db.execute(createVehiclesTableSql); + await db.execute(createFuelEntriesTableSql); + await db.execute(createFuelEntriesIndexSql); + await db.execute(createAdFreeEntitlementTableSql); + await db.execute(createAppUsageTableSql); + } + + /// 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 _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 4) { + await _migrateToVehicleIdSchema(db); + } + if (oldVersion < 5) { + await db.execute(createAdFreeEntitlementTableSql); + } + if (oldVersion < 6) { + await db.execute(createAppUsageTableSql); + } + } + + Future _migrateToVehicleIdSchema(Database db) async { + const uuid = Uuid(); + + final oldVehicles = await db.query('vehicles'); + final vinToId = {}; + + 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> getVehicles() async { + final rows = await _db.query('vehicles', where: 'deleted_at IS NULL'); + return rows.map(Vehicle.fromMap).toList(); + } + + Future> getFuelEntries() async { + final rows = await _db.query('fuel_entries', where: 'deleted_at IS NULL'); + return rows.map(FuelEntry.fromMap).toList(); + } + + /// The furthest-known "ads disabled until" date — null if no purchase + /// has ever been recorded (locally or merged in from another synced + /// device). See [createAdFreeEntitlementTableSql] for why this is its + /// own small synced table rather than a local-only preference. + Future getAdFreeUntil() async { + final rows = await _db.query('ad_free_entitlement', where: 'id = 1', limit: 1); + if (rows.isEmpty) return null; + final millis = rows.single['ad_free_until'] as int?; + return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true); + } + + /// Records a purchase's grant (or extension) of ad-free time. [until] is + /// intentionally not compared against any existing value here — the + /// caller (a fresh purchase) always means "now plus a year", which is + /// always further out than whatever was there before. + Future setAdFreeUntil(DateTime until, DateTime updatedAt) async { + await _db.insert( + 'ad_free_entitlement', + { + 'id': 1, + 'ad_free_until': until.toUtc().millisecondsSinceEpoch, + 'updated_at': updatedAt.toUtc().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// The earliest-known moment this app was ever used, across every device + /// this account has synced from — see [createAppUsageTableSql]. Recorded + /// once, the first time this is ever called with no existing row (a + /// fresh install with nothing to merge down yet); reinstalling *after* + /// cloud sync was connected instead pulls the original value back down + /// via [mergeAppUsageSql] before this is next called, so the new-user ad + /// grace period can't be replayed by reinstalling. + Future getOrCreateFirstUsedAt() async { + final rows = await _db.query('app_usage', where: 'id = 1', limit: 1); + if (rows.isNotEmpty) { + return DateTime.fromMillisecondsSinceEpoch(rows.single['first_used_at'] as int, isUtc: true); + } + final now = DateTime.now().toUtc(); + await _db.insert('app_usage', {'id': 1, 'first_used_at': now.millisecondsSinceEpoch}); + return now; + } + + /// True if any vehicle or fuel entry row has local changes not yet + /// pushed to the cloud (see the `dirty` column note on this class). + /// Exposed only as this one yes/no signal, never the raw flag — it + /// drives [AppState.hasUnsyncedChanges], which the "not backed up" icon + /// reads. + Future hasDirtyRows() async { + final vehicleRows = await _db.query('vehicles', where: 'dirty = 1', limit: 1); + if (vehicleRows.isNotEmpty) return true; + final entryRows = await _db.query('fuel_entries', where: 'dirty = 1', limit: 1); + return entryRows.isNotEmpty; + } + + /// 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 vinExists(String vin, {String? excludeId}) async { + final where = StringBuffer('vin = ? AND deleted_at IS NULL'); + final whereArgs = [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 saveVehicle(Vehicle vehicle) async { + final map = Map.from(vehicle.toMap())..['dirty'] = 1; + await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future saveFuelEntry(FuelEntry entry) async { + final map = Map.from(entry.toMap())..['dirty'] = 1; + await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future 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 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> 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().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///`, 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 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; + } + + /// Soft-deletes every active vehicle and fuel entry — as if the user had + /// deleted each one individually (see [softDeleteVehicle]/ + /// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the + /// tombstones are what let the deletion propagate to other synced devices + /// on the next sync, rather than the rows just being silently re-imported + /// from the cloud copy). Also empties the local receipts folder outright + /// rather than deleting file-by-file, to catch any orphaned file a + /// tracked path might have missed. + Future purgeAllData(DateTime deletedAt) async { + final ts = deletedAt.millisecondsSinceEpoch; + await _db.update( + 'fuel_entries', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL', + ); + await _db.update( + 'vehicles', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL', + ); + + if (await receiptsDirectory.exists()) { + await receiptsDirectory.delete(recursive: true); + } + await receiptsDirectory.create(recursive: true); + } + + /// Soft-deletes every active fuel entry (across all vehicles) whose + /// [FuelEntry.date] falls within [start]–[end] inclusive, and returns + /// their local receipt image paths so the caller can clean those files up + /// too. Vehicles are left untouched — a date range only makes sense + /// against the fuel entries logged against them, not the vehicles + /// themselves. + Future> purgeFuelEntriesInRange( + DateTime start, + DateTime end, + DateTime deletedAt, + ) async { + final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch; + final rangeEndExclusive = + DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch; + final ts = deletedAt.millisecondsSinceEpoch; + + final rows = await _db.query( + 'fuel_entries', + where: 'deleted_at IS NULL AND date >= ? AND date < ?', + whereArgs: [rangeStart, rangeEndExclusive], + ); + final localPaths = + rows.map((r) => r['receipt_image_path'] as String?).whereType().toList(); + + await _db.update( + 'fuel_entries', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL AND date >= ? AND date < ?', + whereArgs: [rangeStart, rangeEndExclusive], + ); + + return localPaths; + } + + Future deleteReceiptImageFile(String? path) async { + if (path == null) return; + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } +} diff --git a/lib/services/db_schema.dart b/lib/services/db_schema.dart new file mode 100644 index 0000000..cfbfe6c --- /dev/null +++ b/lib/services/db_schema.dart @@ -0,0 +1,135 @@ +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)'; + +/// A single-row table (`id` is always `1`) holding the furthest-known +/// "ads disabled until" date from the one-time "remove ads for a year" +/// purchase — see `PurchaseService`/`AppState.adsCurrentlyDisabled`. +/// +/// This exists as its own synced table (rather than, say, a +/// SharedPreferences value) specifically so it survives an app +/// reinstall: the purchase itself is a *consumable* Play Store product +/// (needed so it can be bought again once the year lapses), and Play +/// Store has no memory of a consumed purchase to "restore" — this table, +/// merged through the same cloud sync as vehicles/fuel_entries, is what +/// lets a user with cloud sync configured get their remaining ad-free +/// time back after reinstalling, instead of losing it. +const createAdFreeEntitlementTableSql = ''' + CREATE TABLE ad_free_entitlement ( + id INTEGER PRIMARY KEY CHECK (id = 1), + ad_free_until INTEGER, + updated_at INTEGER NOT NULL + ) +'''; + +/// A single-row table (`id` is always `1`) holding the earliest-known +/// moment this app was ever used — see `AppState.firstUsedAt` and +/// `DatabaseService.getOrCreateFirstUsedAt`. Synced like +/// `ad_free_entitlement`, for the same reason: without it, reinstalling +/// would reset the new-user ad grace period every time. +const createAppUsageTableSql = ''' + CREATE TABLE app_usage ( + id INTEGER PRIMARY KEY CHECK (id = 1), + first_used_at INTEGER NOT NULL + ) +'''; + +/// Unlike every other merge statement here, this keeps whichever row has +/// the *smaller* `first_used_at` — merging in a device that's had the app +/// longer can only push "first use" earlier, never later. +const mergeAppUsageSql = ''' + INSERT OR REPLACE INTO main.app_usage (id, first_used_at) + SELECT r.id, r.first_used_at + FROM remote_db.app_usage r + LEFT JOIN main.app_usage l ON l.id = r.id + WHERE l.id IS NULL OR r.first_used_at < l.first_used_at +'''; + +/// 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 +'''; + +/// Same "newest `updated_at` wins" rule as [mergeVehiclesSql], applied to +/// the single `id = 1` entitlement row instead of many rows — whichever +/// device most recently purchased (or already had) a further-out +/// `ad_free_until` date wins, so this can never make an entitlement +/// *shorter* by merging in a stale copy from a device that hasn't synced +/// in a while. +const mergeAdFreeEntitlementSql = ''' + INSERT OR REPLACE INTO main.ad_free_entitlement + (id, ad_free_until, updated_at) + SELECT r.id, r.ad_free_until, r.updated_at + FROM remote_db.ad_free_entitlement r + LEFT JOIN main.ad_free_entitlement l ON l.id = r.id + WHERE l.id IS NULL OR r.updated_at > l.updated_at +'''; diff --git a/lib/services/estimated_refund.dart b/lib/services/estimated_refund.dart new file mode 100644 index 0000000..64e79af --- /dev/null +++ b/lib/services/estimated_refund.dart @@ -0,0 +1,26 @@ +/// The Missouri Highway Fuel Tax Refund rate per gallon — the basis for +/// [estimatedFuelRefund]. Set by Missouri law and subject to change at any +/// time; the UI Settings toggle that turns these estimates on says as much, +/// and this is deliberately the highway rate only (not the separate, +/// higher non-highway-use refund) since that's the one that applies to +/// ordinary vehicle fill-ups this app is built around. +const moHighwayFuelTaxRefundRatePerGallon = 0.125; + +/// A rough estimate of the Missouri Highway Fuel Tax Refund for [gallons] +/// of fuel, at the current rate. Purely informational — not tax advice, +/// and not a substitute for confirming the actual claimable amount with +/// the Missouri Department of Revenue. +/// +/// [gallons] is rounded to the nearest whole gallon *before* multiplying — +/// matching how the actual refund calculation works, rather than +/// multiplying the precise (fractional) logged amount. The result is then +/// rounded down to the nearest cent, never up, so this never overstates +/// what's actually claimable. +double estimatedFuelRefund(double gallons) { + final roundedGallons = gallons.roundToDouble(); + final rawRefund = roundedGallons * moHighwayFuelTaxRefundRatePerGallon; + // The tiny epsilon guards against binary floating-point representation + // error nudging an exact cent value (e.g. 2.50) just under its true + // value and floor()ing it down a cent it doesn't actually owe. + return (rawRefund * 100 + 1e-9).floorToDouble() / 100; +} diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart new file mode 100644 index 0000000..de88e3c --- /dev/null +++ b/lib/services/fuel_report.dart @@ -0,0 +1,171 @@ +import '../models/fuel_entry.dart'; +import '../models/vehicle.dart'; + +/// One vehicle's totals — and, for the line-items/receipt-images report +/// sections, its individual entries — within a [FuelReport]'s date range. +/// [entries] is sorted oldest-first, the natural order for a ledger of +/// line items building up to a total. +class VehicleReportRow { + final Vehicle vehicle; + final List entries; + final double totalGallons; + final double totalCost; + + VehicleReportRow({ + required this.vehicle, + required this.entries, + required this.totalGallons, + required this.totalCost, + }); + + int get entryCount => entries.length; +} + +/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows +/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs +/// to show both together (like [FuelReport.allEntries], for a combined +/// cross-vehicle list) needs this pairing. +class ReportEntry { + final FuelEntry entry; + final Vehicle vehicle; + + ReportEntry({required this.entry, required this.vehicle}); +} + +/// Every qualifying entry in a single calendar month, across every vehicle +/// in the report — the "Gallons by month" breakdown is this, one per +/// month present in [FuelReport.monthlyTotals]. +class MonthlyTotal { + /// The first of the month, e.g. `DateTime(2026, 8)` for August 2026. + final DateTime month; + final int fillCount; + final double totalGallons; + final double totalCost; + + MonthlyTotal({ + required this.month, + required this.fillCount, + required this.totalGallons, + required this.totalCost, + }); + + double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons; +} + +/// Per-vehicle fuel totals for entries with a receipt, dated within +/// [startDate]–[endDate] inclusive. See [buildFuelReport]. +class FuelReport { + final DateTime startDate; + final DateTime endDate; + final List rows; + + FuelReport({required this.startDate, required this.endDate, required this.rows}); + + double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons); + double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost); + int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount); + double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons; + + /// Every entry across every vehicle, oldest first — the natural order for + /// a combined "purchase detail" ledger that isn't grouped by vehicle. + List get allEntries { + final list = [ + for (final row in rows) + for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle), + ]; + list.sort((a, b) => a.entry.date.compareTo(b.entry.date)); + return list; + } + + /// [allEntries] grouped by calendar month, in chronological order — the + /// "Purchase detail" section's grouping, and the source data for + /// [monthlyTotals]. + Map> get entriesByMonth { + final map = >{}; + for (final reportEntry in allEntries) { + final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month); + (map[month] ??= []).add(reportEntry); + } + return map; + } + + /// One [MonthlyTotal] per calendar month with qualifying entries, oldest + /// first — the "Gallons by month" chart/table's data. + List get monthlyTotals { + final byMonth = entriesByMonth; + final months = byMonth.keys.toList()..sort(); + return [ + for (final month in months) + MonthlyTotal( + month: month, + fillCount: byMonth[month]!.length, + totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons), + totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost), + ), + ]; + } +} + +/// The date range the Reports tab defaults to on open: a year-long window +/// from July 1 through the following June 30, matching how Missouri's +/// fuel tax refund periods run. Which specific year that window falls in +/// rolls over on August 1: from August of year Y through July of year +/// Y+1, this stays July Y–June (Y+1) throughout — the most recently +/// completed (or currently running) period — rather than jumping to a +/// brand new, still-empty one the moment August 1 hits. +(DateTime start, DateTime end) defaultReportDateRange(DateTime now) { + final fiscalStartYear = now.month >= 8 ? now.year : now.year - 1; + return (DateTime(fiscalStartYear, 7, 1), DateTime(fiscalStartYear + 1, 6, 30)); +} + +/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive, +/// judged by each entry's purchase date, in local time), restricted to +/// entries that actually have a receipt attached — a report meant to +/// substantiate a fuel tax refund claim is only as good as the +/// documentation behind each entry, so an entry logged without a photo +/// isn't something the report should be claiming on your behalf. "Has a +/// receipt" means a local copy or a cloud copy — [FuelEntry.receiptImagePath] +/// or [FuelEntry.receiptDriveFileId] — not specifically an already-synced +/// one, since sync status is a technical detail unrelated to whether the +/// purchase is documented. +/// +/// Vehicles with no qualifying entries in range are omitted rather than +/// shown with an all-zero row. Rows are sorted by [Vehicle.displayLabel]. +FuelReport buildFuelReport({ + required List vehicles, + required List fuelEntries, + required DateTime startDate, + required DateTime endDate, +}) { + final rangeStart = DateTime(startDate.year, startDate.month, startDate.day); + final rangeEndExclusive = + DateTime(endDate.year, endDate.month, endDate.day).add(const Duration(days: 1)); + + final vehiclesById = {for (final v in vehicles) v.id: v}; + final entriesByVehicle = >{}; + + for (final entry in fuelEntries) { + final hasReceipt = entry.receiptImagePath != null || entry.receiptDriveFileId != null; + if (!hasReceipt) continue; + if (entry.date.isBefore(rangeStart) || !entry.date.isBefore(rangeEndExclusive)) continue; + (entriesByVehicle[entry.vehicleId] ??= []).add(entry); + } + + final rows = []; + for (final entry in entriesByVehicle.entries) { + final vehicle = vehiclesById[entry.key]; + // No matching (active) vehicle — e.g. deleted since — nothing sane to + // attribute these entries to. + if (vehicle == null) continue; + final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date)); + rows.add(VehicleReportRow( + vehicle: vehicle, + entries: sortedEntries, + totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons), + totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost), + )); + } + rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel)); + + return FuelReport(startDate: rangeStart, endDate: endDate, rows: rows); +} diff --git a/lib/services/fuel_report_images.dart b/lib/services/fuel_report_images.dart new file mode 100644 index 0000000..64186d3 --- /dev/null +++ b/lib/services/fuel_report_images.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import '../models/fuel_entry.dart'; +import 'cloud/cloud_storage_provider.dart'; +import 'fuel_report.dart'; + +/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved, +/// plus which entries couldn't be — e.g. because the receipt was uploaded +/// through a *different* cloud provider than the one currently connected +/// (its stored reference is meaningless to the active provider's API), or +/// the entry is offline/the remote file's since been deleted. Surfacing +/// [failedEntries] instead of only ever silently dropping them is what +/// makes that distinguishable from "there was never a receipt at all". +class ReceiptImageLoadResult { + final Map bytesByEntryId; + final List failedEntries; + + ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries}); +} + +/// Reads (or downloads) the receipt photo for every entry in [report]. +/// +/// Prefers the local copy when one's still on-device; falls back to +/// downloading via [activeProvider] for entries that have already been +/// uploaded and cleared locally. An entry that fails to download (offline, +/// revoked cloud access, since-deleted remote file, a reference belonging +/// to a different provider than the one currently connected, ...) is left +/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in +/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt +/// photo shouldn't block the rest of the document, but it also shouldn't +/// vanish without a trace. +Future loadReceiptImageBytes({ + required FuelReport report, + required CloudStorageProvider? activeProvider, +}) async { + final bytesByEntryId = {}; + final failedEntries = []; + CloudStorageSession? session; + try { + for (final row in report.rows) { + for (final entry in row.entries) { + final localPath = entry.receiptImagePath; + if (localPath != null) { + final file = File(localPath); + if (await file.exists()) { + bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes()); + continue; + } + } + + final driveFileId = entry.receiptDriveFileId; + if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) { + failedEntries.add(entry); + continue; + } + try { + session ??= activeProvider.beginSession(); + final bytes = await session.downloadFileBytes(driveFileId); + bytesByEntryId[entry.id] = Uint8List.fromList(bytes); + } catch (_) { + failedEntries.add(entry); + } + } + } + } finally { + session?.close(); + } + return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries); +} diff --git a/lib/services/fuel_report_pdf.dart b/lib/services/fuel_report_pdf.dart new file mode 100644 index 0000000..bbdc65e --- /dev/null +++ b/lib/services/fuel_report_pdf.dart @@ -0,0 +1,604 @@ +import 'dart:typed_data'; + +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'fuel_report.dart'; + +final _dateFormat = DateFormat.yMMMd(); +final _dateTimeFormat = DateFormat.yMMMd().add_jm(); +final _monthFormat = DateFormat('MMM'); +final _monthHeadingFormat = DateFormat.yMMMM(); +final _dayMonthFormat = DateFormat('d MMM'); +final _generatedFormat = DateFormat('d MMM yyyy'); +final _currencyFormat = NumberFormat.simpleCurrency(); + +// Palette lifted from the report design mockup — kept in one place so +// every section (banner, cards, tables, watermark) draws from the same +// set of colors rather than each picking its own. +final _bannerBackground = PdfColor.fromHex('#EAF3DE'); +final _bannerText = PdfColor.fromHex('#27500A'); +final _textPrimary = PdfColor.fromHex('#1F1E1C'); +final _textMuted = PdfColor.fromHex('#6B6A64'); +final _textFaint = PdfColor.fromHex('#94938C'); +final _borderLight = PdfColor.fromHex('#E5E3DB'); +final _borderStrong = PdfColor.fromHex('#CFCDC4'); +final _accentGreen = PdfColor.fromHex('#639922'); +final _accentGreenDark = PdfColor.fromHex('#3B6D11'); +final _cardBackground = PdfColor.fromHex('#F7F6F1'); + +final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5); +final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7')); +final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white); +final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground); +final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary); + +/// Renders [report] as a PDF, styled after the app's report design mockup: +/// a branded letterhead banner, a headline total, three at-a-glance stat +/// cards, a "gallons by month" bar chart + table, and a by-vehicle +/// breakdown. When requested, a "Line Items" section (a table per vehicle) +/// and/or a "Receipt Images" section (photos per vehicle, sized to +/// actually be legible — not a small inline thumbnail) follow, each +/// grouped by vehicle and sorted oldest-first within it. When both are on, +/// each line item and its receipt photo are tagged with the same number +/// (assigned once, continuing across vehicles) so the two can be matched +/// up by eye despite living in separate sections. +/// +/// [receiptImages] supplies the actual photo bytes, keyed by +/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in +/// that map (couldn't be read locally or downloaded) is left out of the +/// images section rather than shown as a broken image. +Future buildFuelReportPdf( + FuelReport report, { + bool includeLineItems = false, + bool includeReceiptImages = false, + Map receiptImages = const {}, +}) async { + final doc = pw.Document(); + final generatedAt = DateTime.now(); + + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.letter, + margin: pw.EdgeInsets.zero, + header: (context) => _buildBanner(generatedAt), + footer: (context) => _buildFooter(report, context), + build: (context) => _buildBody( + report, + includeLineItems: includeLineItems, + includeReceiptImages: includeReceiptImages, + receiptImages: receiptImages, + ), + ), + ); + + return doc.save(); +} + +/// A *flat* list of top-level widgets — deliberately not one big [pw.Column] +/// wrapping everything. [pw.MultiPage] paginates by walking its `build:` +/// list one top-level item at a time, resetting its "stuck" bookkeeping +/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which +/// span pages on their own, e.g. a long line-items table or a big receipt +/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead +/// of being its own top-level entry defeats that bookkeeping and throws +/// `TooManyPagesException` on anything long enough to need several page +/// breaks — even though the content itself is perfectly valid. +List _buildBody( + FuelReport report, { + required bool includeLineItems, + required bool includeReceiptImages, + required Map receiptImages, +}) { + pw.Widget pad(pw.Widget child) => + pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child); + + if (report.rows.isEmpty) { + return [ + pw.Padding( + padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0), + child: _buildTitleBlock(report), + ), + pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')), + ]; + } + + final widgets = [ + pw.Padding( + padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0), + child: _buildTitleBlock(report), + ), + pad(_buildHeroStat(report)), + pw.SizedBox(height: 22), + pad(_buildStatCardsRow(report)), + pw.SizedBox(height: 26), + pad(_buildMonthlySection(report)), + pw.SizedBox(height: 26), + pad(_buildVehicleSection(report)), + ]; + + if (includeLineItems || includeReceiptImages) { + widgets.add(pw.SizedBox(height: 26)); + widgets.addAll( + _buildLineItemsAndImagesSections( + report, + includeLineItems: includeLineItems, + includeReceiptImages: includeReceiptImages, + receiptImages: receiptImages, + ).map(pad), + ); + } + + widgets.add(pw.SizedBox(height: 8)); + return widgets; +} + +pw.Widget _buildBanner(DateTime generatedAt) { + // The watermark lives here, inside the banner's own bounded height, + // rather than overlaid on the flowing body content below — a Stack + // wrapping unbounded/multi-page content doesn't paginate (MultiPage + // needs a SpanningWidget or a fixed-size child for that), so scoping it + // to this fixed-height strip is what keeps it safe across page breaks. + return pw.Container( + color: _bannerBackground, + child: pw.Stack( + children: [ + pw.Positioned( + right: 12, + top: -14, + child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Show Me The Fuel Refund', + style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText), + ), + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: pw.BoxDecoration( + color: PdfColors.white, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)), + ), + child: pw.Text( + 'Generated ${_generatedFormat.format(generatedAt)}', + style: pw.TextStyle(fontSize: 10, color: _bannerText), + ), + ), + ], + ), + ), + ], + ), + ); +} + +pw.Widget _buildFooter(FuelReport report, pw.Context context) { + return pw.Container( + padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20), + decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))), + margin: const pw.EdgeInsets.only(top: 6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · ' + '${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · ' + '${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}', + style: pw.TextStyle(fontSize: 9, color: _textFaint), + ), + pw.Text( + 'Page ${context.pageNumber} of ${context.pagesCount}', + style: pw.TextStyle(fontSize: 9, color: _textFaint), + ), + ], + ), + ); +} + +/// A faint brand-colored fuel-can silhouette — purely decorative, +/// matching the report mockup's watermark shape. +pw.Widget _buildWatermark({required double alpha}) { + const svg = ''' + + + +'''; + return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha)); +} + +pw.Widget _buildTitleBlock(FuelReport report) { + final months = report.monthlyTotals.length; + final vehicles = report.rows.length; + final fills = report.fillCount; + final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}'); + if (report.rows.isNotEmpty) { + subtitle.write( + ' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · ' + '$fills fill-up${fills == 1 ? '' : 's'}', + ); + } + + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 20), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)), + pw.SizedBox(height: 3), + pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)), + ], + ), + ); +} + +pw.Widget _buildHeroStat(FuelReport report) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + pw.SizedBox(height: 4), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + report.totalGallons.toStringAsFixed(3), + style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark), + ), + pw.SizedBox(width: 6), + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 5), + child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)), + ), + ], + ), + ], + ); +} + +pw.Widget _buildStatCardsRow(FuelReport report) { + pw.Widget statCard(String label, String value) => pw.Expanded( + child: pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: _cardBackground, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)), + pw.SizedBox(height: 3), + pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)), + ], + ), + ), + ); + + return pw.Row( + children: [ + statCard('Spent at the pump', _currencyFormat.format(report.totalCost)), + pw.SizedBox(width: 10), + statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)), + pw.SizedBox(width: 10), + statCard('Fill-ups logged', report.fillCount.toString()), + ], + ); +} + +pw.Widget _buildMonthlySection(FuelReport report) { + final months = report.monthlyTotals; + if (months.isEmpty) return pw.SizedBox(); + + const chartHeight = 64.0; + final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max); + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + pw.SizedBox(height: 8), + if (months.length > 1) ...[ + pw.SizedBox( + height: chartHeight, + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + for (final month in months) + pw.Expanded( + child: pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3), + child: pw.Container( + height: maxGallons == 0 + ? 2 + : (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight), + decoration: pw.BoxDecoration( + color: _accentGreen, + borderRadius: const pw.BorderRadius.only( + topLeft: pw.Radius.circular(2), + topRight: pw.Radius.circular(2), + ), + ), + ), + ), + ), + ], + ), + ), + pw.SizedBox(height: 4), + pw.Row( + children: [ + for (final month in months) + pw.Expanded( + child: pw.Center( + child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ), + ), + ], + ), + pw.SizedBox(height: 10), + ], + pw.TableHelper.fromTextArray( + headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'], + data: [ + for (final month in months) + [ + _monthHeadingFormat.format(month.month), + month.fillCount.toString(), + month.totalGallons.toStringAsFixed(3), + _currencyFormat.format(month.totalCost), + _currencyFormat.format(month.avgPricePerGallon), + ], + ], + cellAlignments: const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + }, + headerAlignments: const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + }, + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted), + cellStyle: const pw.TextStyle(fontSize: 10), + cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: _tableBorder, + headerDecoration: _tableHeaderDecoration, + rowDecoration: _tableEvenRowDecoration, + oddRowDecoration: _tableOddRowDecoration, + ), + if (months.length > 1) ...[ + pw.SizedBox(height: 6), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)), + pw.Text( + '${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · ' + '${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10), + ), + ], + ), + ], + ], + ); +} + +pw.Widget _buildVehicleSection(FuelReport report) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + for (final row in report.rows) + pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 10), + decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Expanded( + flex: 2, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ], + ), + ), + pw.Expanded( + child: pw.Text( + '${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}', + textAlign: pw.TextAlign.right, + style: pw.TextStyle(fontSize: 11, color: _textMuted), + ), + ), + pw.Expanded( + child: pw.Text( + _currencyFormat.format(row.totalCost), + textAlign: pw.TextAlign.right, + style: const pw.TextStyle(fontSize: 11), + ), + ), + pw.Expanded( + child: pw.Text( + '${row.totalGallons.toStringAsFixed(3)} gal', + textAlign: pw.TextAlign.right, + style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark), + ), + ), + ], + ), + ), + ], + ); +} + +/// Item numbers (when both [includeLineItems] and [includeReceiptImages] +/// are on) are assigned once, in report order — vehicle by vehicle, +/// oldest-entry-first within each — and reused by both the "Line Items" +/// table and the "Receipt Images" section below it, so a photo and its +/// line item carry the same number despite living in separate sections. +List _buildLineItemsAndImagesSections( + FuelReport report, { + required bool includeLineItems, + required bool includeReceiptImages, + required Map receiptImages, +}) { + final showItemNumbers = includeLineItems && includeReceiptImages; + final itemNumbers = {}; + var nextNumber = 1; + for (final row in report.rows) { + for (final entry in row.entries) { + itemNumbers[entry.id] = nextNumber++; + } + } + + final widgets = []; + + if (includeLineItems) { + widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle)); + for (final row in report.rows) { + widgets.add(pw.SizedBox(height: 14)); + widgets.add(_buildVehicleSubheading(row)); + widgets.add(pw.SizedBox(height: 6)); + widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers)); + } + } + + if (includeReceiptImages) { + final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id))); + if (rowsWithImages.isNotEmpty) { + if (includeLineItems) widgets.add(pw.SizedBox(height: 26)); + widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle)); + for (final row in rowsWithImages) { + widgets.add(pw.SizedBox(height: 14)); + widgets.add(_buildVehicleSubheading(row)); + widgets.add(pw.SizedBox(height: 8)); + widgets.add(_buildReceiptImagesWrap( + row, + receiptImages, + showItemNumbers: showItemNumbers, + itemNumbers: itemNumbers, + )); + } + } + } + + return widgets; +} + +pw.Widget _buildVehicleSubheading(VehicleReportRow row) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ], + ); +} + +pw.Widget _buildLineItemsTable( + VehicleReportRow row, { + required bool showItemNumbers, + required Map itemNumbers, +}) { + return pw.TableHelper.fromTextArray( + headers: [ + if (showItemNumbers) '#', + 'Date', + 'Gallons', + 'Price/Gal', + 'Total Cost', + ], + data: [ + for (final entry in row.entries) + [ + if (showItemNumbers) itemNumbers[entry.id].toString(), + _dateTimeFormat.format(entry.date), + entry.gallons.toStringAsFixed(3), + _currencyFormat.format(entry.pricePerGallon), + _currencyFormat.format(entry.totalCost), + ], + ], + cellAlignments: showItemNumbers + ? const { + 0: pw.Alignment.centerLeft, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + } + : const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + }, + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted), + cellStyle: const pw.TextStyle(fontSize: 10), + cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: _tableBorder, + headerDecoration: _tableHeaderDecoration, + rowDecoration: _tableEvenRowDecoration, + oddRowDecoration: _tableOddRowDecoration, + ); +} + +/// 140×140 — comfortably legible (the previous design's inline thumbnails, +/// at ~34×44, were too small to make out) while still fitting several per +/// row on a letter-width page. +const _receiptImageSize = 140.0; + +pw.Widget _buildReceiptImagesWrap( + VehicleReportRow row, + Map receiptImages, { + required bool showItemNumbers, + required Map itemNumbers, +}) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final entry in row.entries) + if (receiptImages[entry.id] case final bytes?) + pw.Column( + children: [ + pw.Container( + width: _receiptImageSize, + height: _receiptImageSize, + decoration: pw.BoxDecoration( + color: _cardBackground, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + border: pw.Border.all(color: _borderLight, width: 0.5), + ), + child: pw.ClipRRect( + horizontalRadius: 6, + verticalRadius: 6, + child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover), + ), + ), + pw.SizedBox(height: 4), + pw.Text( + showItemNumbers + ? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}' + : _dayMonthFormat.format(entry.date), + style: pw.TextStyle(fontSize: 9, color: _textMuted), + ), + ], + ), + ], + ); +} + +/// `fuel-report--.pdf`, e.g. `fuel-report-20260101-20260331.pdf`. +String fuelReportFileName(FuelReport report) { + final fmt = DateFormat('yyyyMMdd'); + return 'fuel-report-${fmt.format(report.startDate)}-${fmt.format(report.endDate)}.pdf'; +} diff --git a/lib/services/lock_coordinator.dart b/lib/services/lock_coordinator.dart new file mode 100644 index 0000000..ffe2df2 --- /dev/null +++ b/lib/services/lock_coordinator.dart @@ -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 acquireLock({ + required String username, + required Future Function(String lockName) createLock, + required Future> Function() listLocks, + required Future Function(String lockId) deleteLock, + DateTime Function()? nowUtc, + Future 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; +} diff --git a/lib/services/onboarding_keys.dart b/lib/services/onboarding_keys.dart new file mode 100644 index 0000000..ce8c50e --- /dev/null +++ b/lib/services/onboarding_keys.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +/// One [GlobalKey] per on-screen element the first-launch guided tour +/// (`lib/widgets/onboarding_tour.dart`, orchestrated from +/// `lib/screens/main_shell.dart`) highlights. Each screen that owns one of +/// these elements assigns the matching key to the actual widget, so the +/// tour can find its on-screen position (via +/// `key.currentContext?.findRenderObject()`) without those screens knowing +/// anything about the tour itself. +class OnboardingKeys { + static final receiptsAddButton = GlobalKey(debugLabel: 'onboarding-receipts-add'); + static final receiptsNavDestination = GlobalKey(debugLabel: 'onboarding-receipts-nav-destination'); + static final vehiclesAddButton = GlobalKey(debugLabel: 'onboarding-vehicles-add'); + static final vehiclesNavDestination = GlobalKey(debugLabel: 'onboarding-vehicles-nav-destination'); + static final reportDateRange = GlobalKey(debugLabel: 'onboarding-report-date-range'); + static final reportsNavDestination = GlobalKey(debugLabel: 'onboarding-reports-nav-destination'); + static final reportShareAndPrintRow = GlobalKey(debugLabel: 'onboarding-report-share-print-row'); + static final refundFormLink = GlobalKey(debugLabel: 'onboarding-refund-form-link'); + static final settingsNavDestination = GlobalKey(debugLabel: 'onboarding-settings-nav-destination'); + static final settingsDataMenuEntry = GlobalKey(debugLabel: 'onboarding-settings-data-menu-entry'); + static final cloudStorageCard = GlobalKey(debugLabel: 'onboarding-cloud-storage-card'); +} diff --git a/lib/services/purchase_service.dart b/lib/services/purchase_service.dart new file mode 100644 index 0000000..e0e0f87 --- /dev/null +++ b/lib/services/purchase_service.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; + +/// Wraps the app's one purchasable product: a one-year, non-auto-renewing +/// *prepaid subscription* that grants a year of no ads (see +/// [AppState.adsCurrentlyDisabled]). +/// +/// Prepaid subscription, specifically — not a consumable, a plain +/// non-consumable, or an auto-renewing subscription: +/// - A *consumable* has to be acknowledged (= consumed, for this product +/// type) within 3 days of purchase or Play Billing auto-refunds it — +/// there's no way to leave it unconsumed so it stays restorable for the +/// whole year. And once consumed, Play Billing forgets it existed, so a +/// user who loses local data (no cloud backup connected) has nothing +/// left to restore from. This is what this product used to be, before +/// that gap was found. +/// - A plain *non-consumable* would only ever let a Google account buy it +/// once, permanently — doesn't fit "buy another year once this one +/// lapses". +/// - An *auto-renewing subscription* would charge the user again every +/// year without them actively choosing to. +/// - A *prepaid* subscription base plan fits all three constraints: it's +/// acknowledged immediately (same 3-day rule, satisfied same as any +/// purchase), stays active — and restorable via [restorePurchase] — for +/// its whole prepaid year without needing to be consumed, then simply +/// expires with no charge and can be bought again. +/// +/// [adFreeYearProductId] is a placeholder — nothing will actually load or +/// be purchasable until a real subscription with this same ID exists in +/// your Google Play Console (Monetize > Products > Subscriptions), with a +/// *prepaid* base plan named [_basePlanId] and whatever price/duration you +/// choose there (Play Billing doesn't take a price from the app itself). +/// Rename either constant to match whatever you actually create, if you'd +/// rather not use these. +class PurchaseService { + static const adFreeYearProductId = 'ad_free_year'; + static const _basePlanId = 'ad-free-year-prepaid'; + + /// Called once for every successful (or restored) purchase of + /// [adFreeYearProductId], with the time Play Billing recorded the + /// purchase actually happening — [AppState] anchors the year of ad-free + /// time to that, not to "now", so restoring an existing purchase doesn't + /// hand out a free extra year on top of time already elapsed. + final void Function(DateTime purchaseTime) onPurchaseGranted; + + /// Called with a user-facing message when a purchase or restore attempt + /// fails — [AppState] surfaces this via [AppState.purchaseError] for the + /// Settings screen to display. + final void Function(String message)? onPurchaseError; + + PurchaseService({required this.onPurchaseGranted, this.onPurchaseError}); + + StreamSubscription>? _subscription; + ProductDetails? _product; + + /// The store's own formatted, localized price string (e.g. `"$4.99"`) + /// once [initialize] has loaded the product — null before that, or if + /// neither the product ID nor base plan ID above matches anything + /// configured in the store yet. + String? get priceLabel => _product?.price; + + Future initialize() async { + final available = await InAppPurchase.instance.isAvailable(); + if (!available) return; + + _subscription = InAppPurchase.instance.purchaseStream.listen( + _handlePurchaseUpdates, + onDone: () => _subscription?.cancel(), + onError: (_) {}, + ); + + final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId}); + _product = _selectOffer(response.productDetails); + } + + /// For a subscription product, [InAppPurchase.queryProductDetails] + /// returns one [ProductDetails] per offer/base-plan combination it has, + /// not one per product ID — picks the prepaid base plan set up for this + /// product specifically, falling back to whichever offer loaded first so + /// a Play Console setup with only the one base plan (the expected, + /// common case here) still works without its name needing to match + /// exactly. + ProductDetails? _selectOffer(List offers) { + for (final offer in offers) { + if (offer is! GooglePlayProductDetails) continue; + final index = offer.subscriptionIndex; + final basePlanId = + index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId; + if (basePlanId == _basePlanId) return offer; + } + return offers.isEmpty ? null : offers.first; + } + + /// Kicks off the platform purchase UI. Does nothing (and reports an + /// error) if the product hasn't loaded — either the store isn't + /// available, or neither [adFreeYearProductId] nor [_basePlanId] matches + /// a real product yet. + Future buyAdFreeYear() async { + final product = _product; + if (product == null) { + onPurchaseError?.call("This purchase isn't available right now."); + return; + } + final purchaseParam = product is GooglePlayProductDetails + ? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken) + : PurchaseParam(productDetails: product); + await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam); + } + + /// Re-derives the local entitlement from Play Billing's own record of an + /// active (not yet expired) prepaid purchase — for a user who reinstalled + /// or switched devices without cloud backup connected, where nothing + /// local survived to sync the entitlement back down. A silent no-op if + /// there's nothing currently active to find, which is the normal outcome + /// for anyone who's never purchased, so that's not treated as an error. + Future restorePurchase() async { + try { + await InAppPurchase.instance.restorePurchases(); + } on InAppPurchaseException catch (e) { + onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later."); + } + } + + Future _handlePurchaseUpdates(List purchases) async { + for (final purchase in purchases) { + switch (purchase.status) { + case PurchaseStatus.purchased: + case PurchaseStatus.restored: + if (purchase.productID == adFreeYearProductId) { + onPurchaseGranted(_purchaseTime(purchase)); + } + if (purchase.pendingCompletePurchase) { + await InAppPurchase.instance.completePurchase(purchase); + } + case PurchaseStatus.error: + onPurchaseError?.call(purchase.error?.message ?? 'The purchase failed.'); + if (purchase.pendingCompletePurchase) { + await InAppPurchase.instance.completePurchase(purchase); + } + case PurchaseStatus.pending: + case PurchaseStatus.canceled: + break; + } + } + } + + /// Play reports this as epoch milliseconds in a string (see + /// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the + /// current time in the shouldn't-happen case that it's missing or + /// malformed, rather than leaving the entitlement ungranted over a + /// parsing hiccup. + DateTime _purchaseTime(PurchaseDetails purchase) { + final millis = int.tryParse(purchase.transactionDate ?? ''); + return millis == null + ? DateTime.now().toUtc() + : DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true); + } + + void dispose() { + _subscription?.cancel(); + } +} diff --git a/lib/services/receipt_parser.dart b/lib/services/receipt_parser.dart index 1e6d17f..07740a4 100644 --- a/lib/services/receipt_parser.dart +++ b/lib/services/receipt_parser.dart @@ -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 = { + 'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas', + 'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware', + 'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho', + 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas', + 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland', + 'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', + 'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska', + 'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey', + 'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina', + 'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon', + 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina', + 'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah', + 'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington', + 'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming', + 'DC': 'District of Columbia', +}; + /// Best-effort values pulled out of OCR'd receipt text. Any field can be /// null if it couldn't be found, and the confirm screen lets the user fill /// in or correct whatever the parser got wrong. @@ -5,12 +27,25 @@ class ParsedReceipt { final double? gallons; final double? pricePerGallon; final double? totalCost; + + /// The transaction date/time printed on the receipt, if found. Null + /// means the confirm screen should fall back to manual entry (it + /// defaults to "now" and lets the user pick a different date/time). + final DateTime? date; + + /// Two-letter state abbreviation of the station's address, if found + /// (e.g. "MO", "TX"). Null means no state could be confidently + /// identified — callers should treat that as "unknown", not "Missouri". + final String? state; + final String rawText; ParsedReceipt({ this.gallons, this.pricePerGallon, this.totalCost, + this.date, + this.state, required this.rawText, }); } @@ -23,36 +58,91 @@ class ParsedReceipt { /// to deriving a missing value from the other two when exactly one is /// missing (total = gallons * price, etc). class ReceiptParser { + // These use [ \t]* rather than \s* between a label and its value: \s + // matches newlines too, which let a number on one line match a + // completely unrelated label several lines further down (e.g. a price + // value followed, many lines later, by an incidental "Gallons" heading + // for a different column) — a real bug this surfaced against an actual + // receipt where "$1.999" ended up matching "...Gallons" two lines below + // it. A label and its own value are always on the same line. static final _gallonsPatterns = [ - RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false), + RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false), ]; static final _pricePerGallonPatterns = [ - RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), ]; static final _totalPatterns = [ - RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'(? 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 patterns, String text) { for (final pattern in patterns) { final match = pattern.firstMatch(text); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart deleted file mode 100644 index d12524a..0000000 --- a/lib/services/storage_service.dart +++ /dev/null @@ -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 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 vehicles, List entries})> loadData() async { - if (!await _dataFile.exists()) { - return (vehicles: [], entries: []); - } - - final raw = await _dataFile.readAsString(); - if (raw.trim().isEmpty) { - return (vehicles: [], entries: []); - } - - final json = jsonDecode(raw) as Map; - final vehicles = (json['vehicles'] as List? ?? []) - .map((v) => Vehicle.fromJson(v as Map)) - .toList(); - final entries = (json['fuelEntries'] as List? ?? []) - .map((e) => FuelEntry.fromJson(e as Map)) - .toList(); - - return (vehicles: vehicles, entries: entries); - } - - Future saveData({ - required List vehicles, - required List 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 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 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 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); - } -} diff --git a/lib/services/vin_parser.dart b/lib/services/vin_parser.dart new file mode 100644 index 0000000..ae40457 --- /dev/null +++ b/lib/services/vin_parser.dart @@ -0,0 +1,50 @@ +/// 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, 0, and 9 — +/// which is exactly the kind of confusion OCR itself is prone to: a +/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject +/// a candidate for containing an I or O, [_normalize] treats it as a +/// misread digit and corrects it — Q is left alone, since it doesn't +/// closely resemble any digit and so is more likely to mean the candidate +/// isn't a VIN at all (e.g. noise from elsewhere on the sticker). +class VinParser { + // V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter) + // styling common on manufacturer compliance/data plates, not just the + // plain "VIN" a door-jamb sticker or title usually uses. + static final _labeledVinPattern = + RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-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-PR-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 _normalize(labeled.group(1)!); + + final bare = _bareVinPattern.firstMatch(text); + if (bare != null) return _normalize(bare.group(1)!); + + // Last resort: OCR sometimes splits a VIN across a stray space (e.g. + // an etched/curved surface gets read as two separate text regions) — + // retry once against the text with all whitespace removed. Tried last, + // after both whitespace-preserving attempts, since collapsing + // whitespace elsewhere in a busy label risks gluing unrelated text + // together into a spurious 17-character run. + final stripped = text.replaceAll(RegExp(r'\s+'), ''); + final strippedMatch = _bareVinPattern.firstMatch(stripped); + if (strippedMatch != null) return _normalize(strippedMatch.group(1)!); + + return null; + } + + static String _normalize(String candidate) => + candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1'); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..bd1a5b0 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +/// Light/dark [ThemeData] built from the forest-green palette in the +/// "Fuel Refund App" theme mockups (`themes_4.html`). Each scheme starts +/// from [ColorScheme.fromSeed] on the mockup's brand green — for full, +/// harmonious Material 3 role coverage (secondary/tertiary/error/etc., +/// which the mockup doesn't specify) — then pins the roles the mockup +/// *does* specify (primary, surface tiers, text, outline) to its exact +/// values, so the dominant, most-visible colors match precisely. +class AppTheme { + AppTheme._(); + + // --- Light tokens (theme-light in themes_4.html) --- + static const _lightPrimary = Color(0xFF24603A); // --brand + static const _lightPrimaryInk = Color(0xFF1C5030); // --brand-ink + static const _lightSurface = Color(0xFFFFFFFF); // --screen / --surface + static const _lightSurfaceContainer = Color(0xFFF4F6F3); // --surface-2 + static const _lightOnSurface = Color(0xFF14181A); // --text + static const _lightOnSurfaceVariant = Color(0xFF6A7377); // --text-2 + static const _lightOutline = Color(0xFF8B9499); // --text-3 + static const _lightOutlineVariant = Color(0xFFE9E9E4); // --line + + // --- Dark tokens (theme-dark in themes_4.html) --- + static const _darkPrimary = Color(0xFF7BC47F); // --brand + static const _darkPrimaryInk = Color(0xFF9BD79E); // --brand-ink + static const _darkSurface = Color(0xFF101110); // --screen + static const _darkSurfaceContainer = Color(0xFF1A1C1A); // --surface + static const _darkSurfaceContainerHigh = Color(0xFF202320); // --surface-2 + static const _darkOnSurface = Color(0xFFF2F4F1); // --text + static const _darkOnSurfaceVariant = Color(0xFF9AA39B); // --text-2 + static const _darkOutline = Color(0xFF7E877F); // --text-3 + static const _darkOutlineVariant = Color(0xFF262926); // --line + + // --- Hero card (used by ReceiptsScreen) --- + static const heroGradientLight = [Color(0xFF316F44), Color(0xFF1B4527)]; // --hero-from/to + static const heroGradientDark = [Color(0xFF2E6B41), Color(0xFF17331F)]; + static const heroText = Colors.white; // --hero-text (same in both themes) + static const heroTextSecondaryLight = Color(0xC7FFFFFF); // --hero-text-2, 78% white + static const heroTextSecondaryDark = Color(0xB8FFFFFF); // --hero-text-2, 72% white + + /// The hero card's gradient colors for whichever brightness is active. + static List heroGradient(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? heroGradientDark : heroGradientLight; + + static Color heroTextSecondary(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? heroTextSecondaryDark : heroTextSecondaryLight; + + static ThemeData get light { + final colorScheme = ColorScheme.fromSeed( + seedColor: _lightPrimary, + brightness: Brightness.light, + ).copyWith( + primary: _lightPrimary, + secondary: _lightPrimaryInk, + surface: _lightSurface, + surfaceContainerHighest: _lightSurfaceContainer, + onSurface: _lightOnSurface, + onSurfaceVariant: _lightOnSurfaceVariant, + outline: _lightOutline, + outlineVariant: _lightOutlineVariant, + ); + return _themeFrom(colorScheme); + } + + static ThemeData get dark { + final colorScheme = ColorScheme.fromSeed( + seedColor: _darkPrimary, + brightness: Brightness.dark, + ).copyWith( + primary: _darkPrimary, + secondary: _darkPrimaryInk, + surface: _darkSurface, + surfaceContainer: _darkSurfaceContainer, + surfaceContainerHighest: _darkSurfaceContainerHigh, + onSurface: _darkOnSurface, + onSurfaceVariant: _darkOnSurfaceVariant, + outline: _darkOutline, + outlineVariant: _darkOutlineVariant, + ); + return _themeFrom(colorScheme); + } + + /// Component-level shaping shared by both brightnesses: the mockup's + /// rounded cards (14–15px), pill-shaped chips/badges, and hairline + /// dividers pulled from --line/--outlineVariant rather than Material's + /// default divider color. + static ThemeData _themeFrom(ColorScheme colorScheme) { + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: colorScheme.surface, + dividerTheme: DividerThemeData(color: colorScheme.outlineVariant, space: 1), + cardTheme: CardThemeData( + color: colorScheme.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + chipTheme: ChipThemeData( + backgroundColor: colorScheme.primary.withValues(alpha: 0.10), + labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w700), + shape: const StadiumBorder(), + side: BorderSide.none, + ), + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + centerTitle: true, + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + listTileTheme: ListTileThemeData(iconColor: colorScheme.onSurfaceVariant), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ); + } +} diff --git a/lib/widgets/ad_free_upsell_dialog.dart b/lib/widgets/ad_free_upsell_dialog.dart new file mode 100644 index 0000000..92e460d --- /dev/null +++ b/lib/widgets/ad_free_upsell_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; + +/// Shown right after a user watches the app's one ad placement (see +/// [AdService.showGateAd] / [AppState.syncNow]) — not on every sync, only +/// the moment an ad was actually just shown to them, since that's when the +/// "you could skip these" pitch is most relevant. Purely informational: it +/// doesn't block or delay the sync that triggered it, which is already +/// proceeding underneath it by the time this appears. +Future showAdFreeUpsellDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Tired of Ads?'), + content: Consumer( + builder: (context, appState, _) => Text( + appState.adFreeYearPriceLabel == null + ? "You can remove ads for a full year with a single one-time purchase." + : 'You can remove ads for a full year for ' + '${appState.adFreeYearPriceLabel} — a single one-time purchase.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Not Now'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().buyAdFreeYear(); + }, + child: const Text('Remove Ads'), + ), + ], + ), + ); +} diff --git a/lib/widgets/ad_placeholder_screen.dart b/lib/widgets/ad_placeholder_screen.dart new file mode 100644 index 0000000..83c5251 --- /dev/null +++ b/lib/widgets/ad_placeholder_screen.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../app_navigator.dart'; + +/// How long a non-gating placeholder stays up before closing itself — +/// short enough not to be a nuisance while testing, long enough to be +/// clearly noticed rather than flickering past. +const _autoDismissAfter = Duration(seconds: 4); + +/// Stands in for a real ad while `adsEnabled` is false — see +/// lib/services/ad_config.dart for why that exists. +/// +/// [mustConfirm] mirrors the difference between this app's two kinds of ad: +/// - `false` (the report-export and fuel-save interstitials): closes +/// itself after [_autoDismissAfter], the same "you didn't have to do +/// anything" feel as a plain interstitial. +/// - `true` (the cloud-sync gate): waits for the user to tap Continue, +/// standing in for a rewarded ad's watch-it-through requirement, since +/// the upload genuinely shouldn't proceed until they've acted. +/// +/// Returns once the placeholder has been dismissed, so callers can treat it +/// exactly like awaiting a real ad's show-and-dismiss cycle. A no-op (and +/// an immediate return) if the navigator isn't mounted yet. +Future showAdPlaceholder({required bool mustConfirm}) async { + final navigator = navigatorKey.currentState; + if (navigator == null) return; + + await navigator.push( + PageRouteBuilder( + opaque: true, + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, _, _) => AdPlaceholderScreen(mustConfirm: mustConfirm), + ), + ); +} + +class AdPlaceholderScreen extends StatefulWidget { + /// See [showAdPlaceholder] — true for the sync gate, false otherwise. + final bool mustConfirm; + + const AdPlaceholderScreen({super.key, required this.mustConfirm}); + + @override + State createState() => _AdPlaceholderScreenState(); +} + +class _AdPlaceholderScreenState extends State { + Timer? _ticker; + late int _secondsLeft = _autoDismissAfter.inSeconds; + + @override + void initState() { + super.initState(); + if (widget.mustConfirm) return; + _ticker = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() => _secondsLeft--); + if (_secondsLeft <= 0) _dismiss(); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + void _dismiss() { + _ticker?.cancel(); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return PopScope( + // A real ad can't be dismissed with the system back gesture either; + // letting it through here would let the sync gate be bypassed. + canPop: false, + child: Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Align( + alignment: Alignment.topCenter, + child: Text( + 'ADVERTISEMENT', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 1.6, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/icon/hero_icon.png', + height: 96, + fit: BoxFit.contain, + ), + const SizedBox(height: 28), + Text( + 'An advertisement will appear here', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + Text( + 'Ads are not running yet. This placeholder stands in ' + 'for one so the rest of the app behaves exactly as it ' + 'will once they are.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + ), + if (widget.mustConfirm) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _dismiss, + child: const Text('Continue'), + ), + ) + else + Text( + 'Closing in $_secondsLeft…', + style: theme.textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/backup_reminder.dart b/lib/widgets/backup_reminder.dart new file mode 100644 index 0000000..ed0017d --- /dev/null +++ b/lib/widgets/backup_reminder.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import '../screens/data_settings_screen.dart'; +import '../screens/faq_screen.dart'; +import '../services/onboarding_keys.dart'; +import 'onboarding_tour_overlay.dart'; + +const _backedUpFaqQuestion = 'Is my data backed up?'; + +/// Shown right after a fuel entry is saved (see +/// `ConfirmFuelEntryScreen._save`) when no cloud backup is configured yet — +/// nudges the user toward Settings > Data before they forget and eventually +/// lose everything to a lost, damaged, or replaced device. Returns true if +/// they chose to set one up now. +Future showBackupReminderDialog(BuildContext context) async { + final wantsSetup = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Your Data Isn't Backed Up"), + content: const Text( + "Until you enable a backup method, everything you log — including this " + "receipt — only lives on this device. If it's lost, damaged, or " + "replaced, that data could be lost with it.\n\n" + 'Would you like to set up a backup method now?', + ), + actions: [ + TextButton( + onPressed: () { + // Resolves as "not now" (they didn't choose to set backup up + // directly), but still takes them somewhere useful about it. + Navigator.of(context).pop(false); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const FaqScreen(initiallyExpandedQuestion: _backedUpFaqQuestion), + ), + ); + }, + child: const Text('Learn More'), + ), + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Not Now')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Set Up Backup')), + ], + ), + ); + return wantsSetup ?? false; +} + +/// Pushes Settings > Data with its cloud storage card spotlighted — the +/// same visual treatment the first-launch tour's "Back Up to the Cloud" +/// step uses (see [OnboardingTourOverlay] / `MainShell`), but as a one-off +/// standalone overlay rather than part of a multi-step tour. +Future openCloudBackupSetup(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _SpotlightedDataSettingsScreen()), + ); +} + +class _SpotlightedDataSettingsScreen extends StatefulWidget { + const _SpotlightedDataSettingsScreen(); + + @override + State<_SpotlightedDataSettingsScreen> createState() => _SpotlightedDataSettingsScreenState(); +} + +class _SpotlightedDataSettingsScreenState extends State<_SpotlightedDataSettingsScreen> { + OverlayEntry? _entry; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _showSpotlight()); + } + + @override + void dispose() { + _entry?.remove(); + super.dispose(); + } + + /// Mirrors `MainShell._waitForTargets`/`_showTourStep` for this one + /// target — duplicated rather than shared because that logic is + /// entangled with the tour's multi-step sequencing, which doesn't apply + /// to this standalone spotlight. + Future _showSpotlight() async { + final key = OnboardingKeys.cloudStorageCard; + for (var attempt = 0; attempt < 30; attempt++) { + if (!mounted) return; + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize) break; + await Future.delayed(const Duration(milliseconds: 16)); + } + if (!mounted) return; + key.currentContext?.findRenderObject()?.showOnScreen(duration: Duration.zero); + for (var i = 0; i < 5; i++) { + if (!mounted) return; + await Future.delayed(const Duration(milliseconds: 16)); + } + if (!mounted) return; + // This screen was reached via Navigator.push (see openCloudBackupSetup + // below) — wait for that push transition to fully settle before + // capturing the card's position, or the spotlight ends up shifted by + // however far the slide-in hadn't yet finished. ModalRoute.of(context) + // correctly resolves to that pushed route here, since this State's + // context lives inside it. + await waitForRouteTransition(ModalRoute.of(context)?.animation); + if (!mounted) return; + // The route's own AnimationController reports AnimationStatus.completed + // at this point, but empirically the render tree's transforms (from + // FadeForwardsPageTransitionsBuilder's SlideTransition, the actual + // Android default as of Flutter 3.44) still reflect a mid-transition + // position for a few more frames after that — confirmed by walking the + // RenderObject ancestor chain and finding an active + // RenderFractionalTranslation still present, with a position matching + // the transition's *starting* offset rather than its resting + // Offset.zero. These extra frames give it time to actually settle + // before the spotlight measures anything. SchedulerBinding.endOfFrame + // (rather than a bare Future.delayed) so this genuinely waits on real + // frames — in a widget test, tester.pumpAndSettle() only keeps pumping + // while something has an actual frame scheduled, which a bare delay + // timer doesn't count as once the route's own transition has already + // finished. + for (var i = 0; i < 10; i++) { + if (!mounted) return; + await SchedulerBinding.instance.endOfFrame; + } + if (!mounted) return; + + _entry = OverlayEntry( + builder: (_) => OnboardingTourOverlay( + targetKeys: [key], + title: 'Back Up to the Cloud', + description: 'Connect a cloud storage account here to back up every vehicle, receipt, ' + "and photo you log — so nothing is lost if this device is lost, damaged, or " + 'replaced.', + stepNumber: 1, + totalSteps: 1, + skipLabel: 'Close', + onNext: _dismiss, + onSkip: _dismiss, + ), + ); + Overlay.of(context, rootOverlay: true).insert(_entry!); + } + + void _dismiss() { + _entry?.remove(); + _entry = null; + } + + @override + Widget build(BuildContext context) => const DataSettingsScreen(); +} diff --git a/lib/widgets/cloud_backup_action_button.dart b/lib/widgets/cloud_backup_action_button.dart new file mode 100644 index 0000000..29993b3 --- /dev/null +++ b/lib/widgets/cloud_backup_action_button.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import 'backup_reminder.dart'; + +/// AppBar action flagging that this device's data isn't fully backed up — +/// the same "shared widget dropped into every tab's AppBar" pattern +/// [FaqActionButton] uses. Renders nothing once backup is configured *and* +/// there's nothing unsynced (see [AppState.hasCloudBackupConfigured] / +/// [AppState.hasUnsyncedChanges]); there's nothing left to flag at that +/// point. Covers two different states behind one icon: +/// - Backup was never set up — tapping opens the setup flow. +/// - Backup *is* set up but something local hasn't been pushed up yet +/// (most users, most of the time, since only a paid user auto-syncs on +/// every change — see [AppState.hasUnsyncedChanges]) — tapping tries a +/// sync right now, going through the normal ad gate. +/// +/// The small play-triangle badge marks this as potentially leading to an +/// ad: syncing needs a rewarded ad to push local changes up on every sync +/// but the first (see `AdService.showGateAd`) — unless the user has an +/// active ad-free purchase, in which case syncing is silent and free. The +/// badge only shows for someone who'd actually see an ad, so a paid user +/// gets a plain icon instead. +class CloudBackupActionButton extends StatelessWidget { + const CloudBackupActionButton({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final configured = appState.hasCloudBackupConfigured; + if (configured && !appState.hasUnsyncedChanges) return const SizedBox.shrink(); + + final showAdBadge = !appState.adsCurrentlyDisabled; + final colors = Theme.of(context).colorScheme; + const icon = Icon(Icons.cloud_off_outlined); + + return IconButton( + tooltip: configured ? 'Not synced yet — tap to sync now' : 'Not backed up — set up cloud backup', + onPressed: () { + if (configured) { + unawaited(appState.syncNow()); + } else { + openCloudBackupSetup(context); + } + }, + icon: showAdBadge + ? Badge( + backgroundColor: colors.tertiary, + label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary), + child: icon, + ) + : icon, + ); + } +} diff --git a/lib/widgets/hero_banner.dart b/lib/widgets/hero_banner.dart new file mode 100644 index 0000000..82ff2f6 --- /dev/null +++ b/lib/widgets/hero_banner.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Green gradient hero banner background shared by every screen that shows +/// a totals summary (Receipts, Vehicle Detail) — callers supply their own +/// stat row / controls as [child], keeping the container/gradient styling +/// itself in one place. +class HeroBanner extends StatelessWidget { + final Widget child; + + const HeroBanner({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppTheme.heroGradient(context), + ), + ), + child: child, + ); + } +} + +/// A single label/value pair inside a [HeroBanner], e.g. "Total Gallons". +class HeroStat extends StatelessWidget { + final String label; + final String value; + final bool alignEnd; + + const HeroStat({super.key, required this.label, required this.value, required this.alignEnd}); + + @override + Widget build(BuildContext context) { + final textAlign = alignEnd ? TextAlign.end : TextAlign.start; + return Column( + crossAxisAlignment: alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Text( + label, + textAlign: textAlign, + style: TextStyle( + color: AppTheme.heroTextSecondary(context), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + value, + textAlign: textAlign, + style: const TextStyle( + color: AppTheme.heroText, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ], + ); + } +} + +/// The icon + two-stat row shared by every hero banner: gallons on the +/// left, the hero icon (or, if [estimatedRefundValue] is given, the +/// estimated fuel refund in its place — see the Receipts tab, the only +/// caller that ever passes it) in the middle, cost on the right. +class HeroStatsRow extends StatelessWidget { + final String gallonsValue; + final String costValue; + final String gallonsLabel; + final String costLabel; + final String? estimatedRefundValue; + + const HeroStatsRow({ + super.key, + required this.gallonsValue, + required this.costValue, + this.gallonsLabel = 'Total Gallons', + this.costLabel = 'Total Fuel Cost', + this.estimatedRefundValue, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: estimatedRefundValue == null + // Tall enough to extend past the bottom of the label+value + // text next to it, not just match the label's height — width + // scales automatically from the source's own aspect ratio + // since only height is given. + ? Image.asset( + 'assets/icon/hero_icon.png', + height: 58, + fit: BoxFit.contain, + ) + : _EstimatedRefundStat(value: estimatedRefundValue!), + ), + Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)), + ], + ); + } +} + +/// The estimated-fuel-refund stat that takes the hero icon's place in the +/// middle of [HeroStatsRow] when that's turned on — smaller/more compact +/// than [HeroStat] since it sits in the narrower middle slot rather than +/// an [Expanded] one. +class _EstimatedRefundStat extends StatelessWidget { + final String value; + + const _EstimatedRefundStat({required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Est. Refund', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.heroTextSecondary(context), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + value, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppTheme.heroText, + fontSize: 30, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/image_source_sheet.dart b/lib/widgets/image_source_sheet.dart new file mode 100644 index 0000000..e14d644 --- /dev/null +++ b/lib/widgets/image_source_sheet.dart @@ -0,0 +1,61 @@ +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. Pass [heading] to label what the photo is for +/// (e.g. receipt capture passes "Receipt From") — omitted by callers where +/// that's already obvious from context. [onInfoTap], if given, adds an +/// info button next to [heading] (e.g. VIN scanning uses it to explain +/// where to find a VIN); ignored if [heading] is null, since there'd be +/// nowhere to put it. +Future chooseImageSource( + BuildContext context, { + String? heading, + VoidCallback? onInfoTap, +}) { + return showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + if (heading != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + heading, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + if (onInfoTap != null) + IconButton( + icon: const Icon(Icons.info_outline), + iconSize: 20, + tooltip: 'Where do I find this?', + onPressed: onInfoTap, + ), + ], + ), + ), + 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), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/onboarding_tour_overlay.dart b/lib/widgets/onboarding_tour_overlay.dart new file mode 100644 index 0000000..65fb024 --- /dev/null +++ b/lib/widgets/onboarding_tour_overlay.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Waits for a route's push/pop transition, if one is still in progress, to +/// finish. Needed before capturing a highlighted target's on-screen +/// position via `RenderBox.localToGlobal` (see `MainShell._waitForTargets` +/// / `_SpotlightedDataSettingsScreen`'s `_showSpotlight` in +/// backup_reminder.dart, both of which push a [MaterialPageRoute] and then +/// spotlight something on it): a [RenderBox] reports `hasSize` — and a +/// real, but not yet final, global position — as soon as its first layout +/// pass completes, which happens well before a [MaterialPageRoute]'s +/// ~300ms slide-in transition actually settles. Capturing the position too +/// early bakes in wherever the page was mid-slide, which is what produced +/// a spotlight rectangle visibly shifted from its target. +/// +/// Takes the [Animation] directly — rather than a [BuildContext] to look +/// one up via `ModalRoute.of` — because the caller doesn't always have a +/// [BuildContext] that's actually inside the route in question: `MainShell` +/// pushes the route from its own (different, already-settled) route, so +/// `ModalRoute.of(mainShellContext)` would resolve to the wrong one. +/// Passing null (nothing to wait for) or an already-[AnimationStatus.completed] +/// animation both resolve immediately, so it's safe to call unconditionally. +Future waitForRouteTransition(Animation? animation) async { + if (animation == null || animation.status == AnimationStatus.completed) return; + final completer = Completer(); + void listener(AnimationStatus status) { + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + completer.complete(); + } + } + + animation.addStatusListener(listener); + await completer.future; + animation.removeStatusListener(listener); +} + +/// The full-screen "spotlight" shown by one step of the first-launch +/// guided tour: a dimmed barrier with a cut-out hole around each of +/// [targetKeys]' current on-screen positions (or, for any key that isn't +/// laid out yet / doesn't resolve, no hole for it — falling back to a +/// plain dimmed barrier with the card centered if none resolve), plus a +/// card explaining the step with Next/Skip controls. Most steps highlight +/// a single element, but [targetKeys] can hold more than one — e.g. the +/// first step highlights both the "+" button and the Receipts tab it +/// lives on, together. +/// +/// This widget is purely presentational — it doesn't know about tabs, +/// routes, or persistence. `MainShell` is responsible for sequencing +/// steps, navigating to the right screen/route before each one, and +/// recording that the tour's been seen once it ends. +class OnboardingTourOverlay extends StatelessWidget { + final List targetKeys; + final String title; + final String description; + final int stepNumber; + final int totalSteps; + final VoidCallback onNext; + final VoidCallback onSkip; + + /// Label for the [onSkip] button — defaults to the multi-step tour's own + /// wording. A standalone, single-step spotlight (e.g. the "back up your + /// data" reminder in lib/widgets/backup_reminder.dart) isn't a "tour", so + /// it passes something more fitting like 'Close' instead. + final String skipLabel; + + const OnboardingTourOverlay({ + super.key, + required this.targetKeys, + required this.title, + required this.description, + required this.stepNumber, + required this.totalSteps, + required this.onNext, + required this.onSkip, + this.skipLabel = 'Skip Tour', + }); + + List _targetRects() { + final rects = []; + for (final key in targetKeys) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize) { + rects.add((renderObject.localToGlobal(Offset.zero) & renderObject.size).inflate(6)); + } + } + return rects; + } + + @override + Widget build(BuildContext context) { + final rects = _targetRects(); + final media = MediaQuery.of(context); + final isLastStep = stepNumber >= totalSteps; + final colorScheme = Theme.of(context).colorScheme; + + final screenMid = media.size.height / 2; + final topGroup = rects.where((r) => r.center.dy < screenMid); + final bottomGroup = rects.where((r) => r.center.dy >= screenMid); + + double? cardTop; + double? cardBottom; + if (topGroup.isNotEmpty && bottomGroup.isNotEmpty) { + // Highlighted elements exist both above and below the screen's + // midpoint (e.g. an AppBar action + a bottom-nav destination) — the + // union of every rect would span almost the full screen height in + // this case, which the plain below/above logic below handles badly. + // Anchor the card just under the topmost group instead, into the + // open band between the two. + cardTop = topGroup.map((r) => r.bottom).reduce((a, b) => a > b ? a : b) + 16; + } else { + final unionRect = rects.isEmpty ? null : rects.reduce((a, b) => a.expandToInclude(b)); + // Prefer placing the card below the highlighted element(s); only + // flip above if there's not enough room below on this screen. + final cardBelow = unionRect == null || (unionRect.bottom + 260) < media.size.height; + if (cardBelow) { + cardTop = (unionRect?.bottom ?? media.size.height * 0.38) + 16; + } else { + cardBottom = media.size.height - unionRect.top + 16; + } + } + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Swallows every tap on the barrier (and the spotlight holes + // themselves) so a highlighted element can't be accidentally + // triggered mid-tour — Next/Skip on the card are the only way + // to advance. + onTap: () {}, + child: CustomPaint( + painter: _SpotlightPainter( + targetRects: rects, + color: Colors.black.withValues(alpha: 0.75), + ), + ), + ), + ), + for (final rect in rects) + Positioned( + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.primary, width: 2.5), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + Positioned( + left: 20, + right: 20, + top: cardTop, + bottom: cardBottom, + child: SafeArea( + child: Card( + elevation: 8, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (totalSteps > 1) ...[ + Text( + 'Step $stepNumber of $totalSteps', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + ], + Text(title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text(description, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton(onPressed: onSkip, child: Text(skipLabel)), + FilledButton(onPressed: onNext, child: Text(isLastStep ? 'Got It' : 'Next')), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +class _SpotlightPainter extends CustomPainter { + final List targetRects; + final Color color; + + const _SpotlightPainter({required this.targetRects, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final barrierPaint = Paint()..color = color; + var path = Path()..addRect(Offset.zero & size); + for (final rect in targetRects) { + final holePath = Path()..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12))); + path = Path.combine(PathOperation.difference, path, holePath); + } + canvas.drawPath(path, barrierPaint); + } + + @override + bool shouldRepaint(covariant _SpotlightPainter oldDelegate) => + !listEquals(oldDelegate.targetRects, targetRects) || oldDelegate.color != color; +} diff --git a/lib/widgets/receipt_capture.dart b/lib/widgets/receipt_capture.dart new file mode 100644 index 0000000..3739d1d --- /dev/null +++ b/lib/widgets/receipt_capture.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +import '../screens/confirm_fuel_entry_screen.dart'; +import '../services/app_state.dart'; +import '../services/ocr_service.dart'; +import '../services/receipt_parser.dart'; +import 'image_source_sheet.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; + +/// Shared by [VehicleDetailScreen]'s "Log Fuel Receipt" button and the +/// Receipts tab's "+" action: pick a photo, OCR it, and — unless the +/// receipt looks like it's from outside Missouri, in which case the user +/// gets a chance to back out first — hand off to [ConfirmFuelEntryScreen] +/// for [vehicleId]. +Future captureReceiptForVehicle(BuildContext context, String vehicleId) async { + final source = await chooseImageSource(context, heading: 'Receipt From'); + if (source == null || !context.mounted) return; + + final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos; + final picker = ImagePicker(); + + // Raised *before* the camera/gallery opens rather than after it returns. + // [ImagePicker.pickImage] doesn't complete when the camera closes — it + // also downscales the captured photo (several MB straight off the + // sensor), which takes seconds on a busy device. With the progress + // shown only afterward, that whole stretch left the user staring at an + // unchanged Receipts list with no sign anything was happening, which + // reads as "the capture did nothing" — and tapping away during it + // genuinely does abandon the capture. + var progressShowing = false; + void showProgress() { + if (progressShowing || !context.mounted) return; + progressShowing = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _ReceiptProgressDialog(), + ); + } + + void hideProgress() { + if (!progressShowing || !context.mounted) return; + progressShowing = false; + Navigator.of(context).pop(); + } + + showProgress(); + + XFile? photo; + try { + photo = await picker.pickImage( + source: source, + imageQuality: keepMaxQuality ? null : receiptImageQuality, + maxWidth: keepMaxQuality ? null : receiptImageMaxDimension, + maxHeight: keepMaxQuality ? null : receiptImageMaxDimension, + ); + } catch (e) { + hideProgress(); + if (context.mounted) { + final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open $sourceLabel: $e')), + ); + } + return; + } + // Cancelled (backed out of the camera, or didn't confirm the shot) — + // nothing to say about that, it was deliberate. + if (photo == null) { + hideProgress(); + return; + } + + final imageFile = File(photo.path); + + if (!context.mounted) return; + + final ocrService = OcrService(); + String recognizedText = ''; + try { + recognizedText = await ocrService.recognizeText(imageFile); + } catch (_) { + recognizedText = ''; + } finally { + ocrService.dispose(); + } + + final parsed = ReceiptParser.parse(recognizedText); + + if (!context.mounted) return; + hideProgress(); + + final state = parsed.state; + if (state != null && state != 'MO') { + final proceed = await _confirmNonMissouriPurchase(context, state); + if (!proceed || !context.mounted) return; + } + + if (context.mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConfirmFuelEntryScreen( + vehicleId: vehicleId, + imageFile: imageFile, + parsed: parsed, + ), + ), + ); + } +} + +/// Shown from the moment the camera/gallery is opened until the parsed +/// receipt is ready to review. Deliberately says what's happening rather +/// than showing a bare spinner: the wait here is mostly downscaling and +/// reading the photo, which is long enough on a slow device that an +/// unexplained spinner invites tapping away mid-capture. +class _ReceiptProgressDialog extends StatelessWidget { + const _ReceiptProgressDialog(); + + @override + Widget build(BuildContext context) { + return PopScope( + // Backing out here wouldn't cancel the underlying capture, it would + // just hide it and strand the user. + canPop: false, + child: Dialog( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text('Reading your receipt…', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 6), + Text( + 'This can take a few seconds.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 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 _confirmNonMissouriPurchase(BuildContext context, String stateCode) async { + final stateName = usStateNames[stateCode] ?? stateCode; + final proceed = await showDialog( + 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; +} diff --git a/lib/widgets/receipt_thumbnail.dart b/lib/widgets/receipt_thumbnail.dart new file mode 100644 index 0000000..21e6112 --- /dev/null +++ b/lib/widgets/receipt_thumbnail.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../models/fuel_entry.dart'; +import '../screens/receipt_image_screen.dart'; + +/// A fuel entry's receipt as a tappable thumbnail: the local photo if one's +/// still on-device, a cloud icon (tap to download-and-view) if it's been +/// uploaded and the local copy cleared, or a plain placeholder if there's +/// no receipt at all. Shared by [VehicleDetailScreen]'s entry list, the +/// cross-vehicle Receipts tab, [ReceiptDetailScreen] (at a larger [size], +/// as its primary preview), and [EditFuelEntryScreen] so all four present a +/// receipt the same way. +/// +/// Tapping it opens the full-screen photo viewer by default; pass [onTap] +/// to send it somewhere else instead (e.g. the list rows send it to +/// [ReceiptDetailScreen] instead of straight to the photo). +class ReceiptThumbnail extends StatelessWidget { + final FuelEntry entry; + final double size; + final VoidCallback? onTap; + + const ReceiptThumbnail({super.key, required this.entry, this.size = 48, this.onTap}); + + @override + Widget build(BuildContext context) { + if (entry.receiptImagePath != null) { + return GestureDetector( + onTap: onTap ?? + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file( + File(entry.receiptImagePath!), + width: size, + height: size, + fit: BoxFit.cover, + ), + ), + ); + } + if (entry.isReceiptUploadedToDrive) { + return GestureDetector( + onTap: onTap ?? + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId), + ), + ), + child: CircleAvatar(radius: size / 2, child: Icon(Icons.cloud_outlined, size: size * 0.5)), + ); + } + final placeholder = CircleAvatar(radius: size / 2, child: Icon(Icons.receipt_long, size: size * 0.5)); + return onTap == null ? placeholder : GestureDetector(onTap: onTap, child: placeholder); + } +} diff --git a/privacy-policy.html b/privacy-policy.html new file mode 100644 index 0000000..8786b66 --- /dev/null +++ b/privacy-policy.html @@ -0,0 +1,263 @@ + + + + + +Privacy Policy — Show Me The Fuel Refund + + + +
+
+ + Show Me The Fuel Refund +
+ +

Privacy Policy

+

Effective date: August 17, 2026

+

+ This policy explains what Show Me The Fuel Refund (the "app") does and does + not do with your data. If anything here is unclear, contact + ohbrer+ShowMeTheFuelRefund@gmail.com. +

+ +
+

Overview

+

+ The app helps you collect and organize Missouri fuel purchase receipts so + you can claim Missouri's Motor Fuel Tax Refund. It has no server or + backend of its own — every vehicle, fuel receipt, and photo you log + is stored locally on your device, and optionally backed up to a cloud + storage account you choose and control. We (the + developer) never receive, see, or have access to a copy of your data. +

+
+ +
+

Data Stored Locally

+

+ The app stores the following on your device only, unless you connect a + cloud storage account (see below): +

+
    +
  • Vehicle information you enter (VIN, nickname)
  • +
  • Fuel purchase records (date, gallons, price, total cost)
  • +
  • Receipt photos you capture or select
  • +
+

+ This data is never transmitted to us. Uninstalling the app, or never + connecting a cloud backup, means this data exists only on that device. +

+
+ +
+

Optional Cloud Backup

+

+ If you connect a cloud storage account in Settings, a copy of the data + above is also stored there — in storage you control: +

+
    +
  • Google Drive — via Google Sign-In and the Drive API
  • +
  • Dropbox — via the Dropbox API
  • +
  • Microsoft OneDrive — via the Microsoft Graph API
  • +
  • WebDAV — your own self-hosted server (Nextcloud, ownCloud, or similar)
  • +
+

+ For OAuth-based providers (Google Drive, Dropbox, OneDrive), the app + requests only the permissions needed to create, read, and write files in + a folder it manages there. We do not read, log, or have access to + anything stored in these accounts — the connection is directly + between your device and the provider you chose. For WebDAV, your server + credentials are stored using your device's OS-level encrypted storage + (Android Keystore) and are used only to connect directly to the server + you specify. +

+
+ +
+

Advertising (Google AdMob)

+

+ Unless you've purchased "Remove Ads for a Year," the app shows a single + interstitial ad, at most once per app session, via Google AdMob. AdMob + may collect data such as an advertising identifier and coarse, + IP-based location to serve and measure ads. This collection is + Google's, governed by + Google's own privacy policy + and + AdMob's data disclosure + — it is not something this app adds on top of, and we have no + access to it. +

+
+ +
+

In-App Purchases

+

+ "Remove Ads for a Year" is processed entirely through Google Play + Billing. We do not receive or store payment information — that's + handled by Google Play directly. +

+
+ +
+

What We Don't Do

+
    +
  • No analytics SDKs
  • +
  • No crash-reporting SDKs
  • +
  • No tracking of your usage or behavior
  • +
  • No server of ours that receives, stores, or processes your data
  • +
  • No selling or sharing of data with anyone, for any reason
  • +
+
+ +
+

Data Deletion

+

+ You can delete your data at any time from Settings > Data > + Advanced (purge all data, or a specific date range), and + disconnect any cloud storage account from Settings > + Data. Deleting the app removes everything stored locally; + anything already backed up to your own cloud storage account remains + there under your control until you delete it yourself. +

+
+ +
+

Children's Privacy

+

+ This app is not directed at children under 13 and does not knowingly + collect data from them. +

+
+ +
+

Changes to This Policy

+

+ If this policy changes, the effective date above will be updated. + Continued use of the app after a change constitutes acceptance of the + updated policy. +

+
+ + +
+ + + diff --git a/pubspec.lock b/pubspec.lock index 605d832..4dce795 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -17,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -33,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -57,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -66,13 +138,21 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -89,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 + url: "https://pub.dev" + source: hosted + version: "0.3.0" fake_async: dependency: transitive description: @@ -105,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -113,14 +209,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -166,6 +254,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -174,6 +270,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" + url: "https://pub.dev" + source: hosted + version: "2.4.8" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -182,16 +286,96 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: "direct dev" + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: a7655829251ee63aae64a748f8512f36670d8eba4657b0055cdf5ef304a9d164 + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter source: sdk 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: dependency: transitive description: @@ -208,6 +392,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.1" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: "8094e1ace8b0da33fe79027ca6959763c96a28855025cb7c8ec60838e1d56ed8" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + 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: dependency: transitive description: @@ -216,8 +456,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - http: + html: dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -232,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -296,6 +552,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: "direct main" + description: + name: in_app_purchase_android + sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "9602e249a0e30351f047d5715957f27709ed7b42f631fba8941dcad51489932a" + url: "https://pub.dev" + source: hosted + version: "0.4.11+1" intl: dependency: "direct main" description: @@ -328,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -400,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -408,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -432,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -465,7 +785,7 @@ packages: source: hosted version: "2.2.2" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" @@ -480,6 +800,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" petitparser: dependency: transitive description: @@ -497,13 +833,29 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" provider: dependency: "direct main" description: @@ -520,6 +872,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" record_use: dependency: transitive description: @@ -597,6 +957,62 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -621,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -645,6 +1069,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -677,14 +1173,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d + url: "https://pub.dev" + source: hosted + version: "4.14.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" win32: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" 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: dependency: transitive description: @@ -694,7 +1230,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 6bfd01d..8c10830 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+5 environment: sdk: ^3.12.2 @@ -44,21 +44,10 @@ dependencies: # On-device OCR text recognition google_mlkit_text_recognition: ^0.15.0 - # Directory picker for configurable save location. - # Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin - # Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers - # it, but that isn't actually wired up yet for library modules in this - # Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11 - # still compiles against compileSdk 36 (matches flutter.compileSdkVersion, - # satisfying flutter_plugin_android_lifecycle's requirement) but applies - # its own Kotlin plugin unconditionally, which works. (10.3.11 itself is - # retracted on pub.dev, hence pinning to 10.3.10.) - file_picker: 10.3.10 - - # App sandbox paths (default save location, temp files) + # App sandbox paths (local offline staging area) 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 # Unique IDs for vehicles/fuel entries @@ -70,10 +59,66 @@ dependencies: # Path joining/manipulation helpers 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: ^5.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 + pdf: ^3.12.0 + printing: ^5.14.3 + url_launcher: ^6.3.2 + + # AdMob rewarded interstitial shown once per gate window, right before the + # first sync's upload-to-cloud phase — see lib/services/ad_service.dart. + google_mobile_ads: ^9.1.0 + + # One-time "remove ads for a year" purchase — see + # lib/services/purchase_service.dart. + in_app_purchase: ^3.3.0 + in_app_purchase_android: ^0.5.2 + dev_dependencies: flutter_test: 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 # 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 @@ -81,6 +126,52 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 + # Generates the Android/iOS launcher icon files from assets/icon/app_icon.png + # — run `dart run flutter_launcher_icons` after changing that source image. + flutter_launcher_icons: ^0.14.4 + + # Generates the native (pre-Dart-engine) splash screen shown at cold + # start — run `dart run flutter_native_splash:create` after changing + # assets/splash/*. Separate from flutter_launcher_icons: without this, + # Android falls back to its own default Splash Screen behavior, which + # wraps the launcher icon in a small icon-in-a-white-circle look we don't + # control at all. + flutter_native_splash: ^2.4.8 + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/app_icon.png" + # The source image is a full-bleed square with its own rounded corners + # already baked in via real alpha transparency (not a white margin), so + # each platform's own icon masking just clips already-transparent pixels + # — no double-rounding artifact. iOS icons must be fully opaque (the App + # Store rejects alpha, and the OS doesn't composite icon transparency the + # way Android does), so flatten alpha to a solid background there only. + remove_alpha_ios: true + +flutter_native_splash: + # Pre-Android-12 (and iOS): no OS-imposed circular mask, so this can be + # sized generously — the brand mark centered on a solid background that + # matches the app icon's own green, so there's no white border/seam. + color: "#24603A" + color_dark: "#101110" + image: assets/splash/splash_logo.png + image_dark: assets/splash/splash_logo.png + + android_12: + # Android 12+ handles splash screens itself: a background color, an + # icon, and (optionally) a separate icon background color — it enforces + # its own circular mask no matter what we provide (clips the outer + # third), so splash_logo_android12.png is pre-fit to stay inside that + # safe circle rather than relying on this package to size it for us. + color: "#24603A" + color_dark: "#101110" + icon_background_color: "#24603A" + icon_background_color_dark: "#101110" + image: assets/splash/splash_logo_android12.png + image_dark: assets/splash/splash_logo_android12.png + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -92,10 +183,14 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + # The brand mark shown between the two stats in the Receipts tab's hero + # banner (see ReceiptsScreen) — distinct from assets/icon/app_icon.png, + # which flutter_launcher_icons reads at build time and never ships inside + # the app bundle itself. + assets: + - assets/icon/hero_icon.png + - assets/vin_help/door_jamb.jpg + - assets/vin_help/windshield.jpg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/store_assets/feature_graphic_1024x500.png b/store_assets/feature_graphic_1024x500.png new file mode 100644 index 0000000..55e65d1 Binary files /dev/null and b/store_assets/feature_graphic_1024x500.png differ diff --git a/store_assets/play_store_icon_256.png b/store_assets/play_store_icon_256.png new file mode 100644 index 0000000..b16d7ac Binary files /dev/null and b/store_assets/play_store_icon_256.png differ diff --git a/store_assets/play_store_icon_512.png b/store_assets/play_store_icon_512.png new file mode 100644 index 0000000..b16d7ac Binary files /dev/null and b/store_assets/play_store_icon_512.png differ diff --git a/store_assets/screenshots/1_receipts.png b/store_assets/screenshots/1_receipts.png new file mode 100644 index 0000000..bef8163 Binary files /dev/null and b/store_assets/screenshots/1_receipts.png differ diff --git a/store_assets/screenshots/2_report.png b/store_assets/screenshots/2_report.png new file mode 100644 index 0000000..113916f Binary files /dev/null and b/store_assets/screenshots/2_report.png differ diff --git a/store_assets/screenshots/3_vehicles.png b/store_assets/screenshots/3_vehicles.png new file mode 100644 index 0000000..009eb3e Binary files /dev/null and b/store_assets/screenshots/3_vehicles.png differ diff --git a/store_assets/screenshots/4_vehicle_detail.png b/store_assets/screenshots/4_vehicle_detail.png new file mode 100644 index 0000000..2d6a2d8 Binary files /dev/null and b/store_assets/screenshots/4_vehicle_detail.png differ diff --git a/store_assets/screenshots/5_report_preview.png b/store_assets/screenshots/5_report_preview.png new file mode 100644 index 0000000..d95dd75 Binary files /dev/null and b/store_assets/screenshots/5_report_preview.png differ diff --git a/test/ad_free_entitlement_test.dart b/test/ad_free_entitlement_test.dart new file mode 100644 index 0000000..0ce40e4 --- /dev/null +++ b/test/ad_free_entitlement_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; +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'; + +/// Covers the "pay to remove ads for a year" entitlement outside of the +/// merge SQL itself (see db_merge_test.dart for that): [DatabaseService]'s +/// storage round-trip, and [AppState.adsCurrentlyDisabled]'s pure +/// expiry-comparison logic. +void main() { + group('DatabaseService ad-free entitlement storage', () { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('ad_free_entitlement_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('getAdFreeUntil is null before any purchase is recorded', () async { + expect(await database.getAdFreeUntil(), isNull); + }); + + test('setAdFreeUntil then getAdFreeUntil round-trips the value', () async { + final until = DateTime.utc(2027, 8, 13); + final now = DateTime.utc(2026, 8, 13); + + await database.setAdFreeUntil(until, now); + + expect(await database.getAdFreeUntil(), until); + }); + + test('setAdFreeUntil overwrites a previous value rather than adding a row', () async { + await database.setAdFreeUntil(DateTime.utc(2027, 1, 1), DateTime.utc(2026, 1, 1)); + await database.setAdFreeUntil(DateTime.utc(2028, 1, 1), DateTime.utc(2027, 1, 1)); + + final rows = await database.rawDb.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(await database.getAdFreeUntil(), DateTime.utc(2028, 1, 1)); + }); + }); + + group('AppState.adsCurrentlyDisabled', () { + test('false when no entitlement has ever been recorded', () { + final appState = AppState(); + expect(appState.adsCurrentlyDisabled, isFalse); + }); + + test('true when the entitlement date is in the future', () { + final appState = AppState(); + appState.adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 30)); + expect(appState.adsCurrentlyDisabled, isTrue); + }); + + test('false once the entitlement date is in the past (the year lapsed)', () { + final appState = AppState(); + appState.adFreeUntil = DateTime.now().toUtc().subtract(const Duration(days: 1)); + expect(appState.adsCurrentlyDisabled, isFalse); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/ad_free_upsell_dialog_test.dart b/test/ad_free_upsell_dialog_test.dart new file mode 100644 index 0000000..309b993 --- /dev/null +++ b/test/ad_free_upsell_dialog_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/widgets/ad_free_upsell_dialog.dart'; + +/// Covers the "remove ads for a year" upsell shown right after a user +/// watches the app's one ad placement — see `AppState.syncNow`'s +/// `onAdWatched` hook, wired up in main.dart. Tested here as a standalone +/// dialog function, matching backup_reminder_test.dart's convention. +void main() { + Future openDialog(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showAdFreeUpsellDialog(context), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('shows the pitch with both actions', (tester) async { + await openDialog(tester, AppState()); + + expect(find.text('Tired of Ads?'), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + expect(find.text('Remove Ads'), findsOneWidget); + }); + + testWidgets('falls back to generic copy when no price is loaded yet', (tester) async { + await openDialog(tester, AppState()); + + expect(find.textContaining('a single one-time purchase'), findsOneWidget); + expect(find.textContaining('\$'), findsNothing); + }); + + testWidgets('Not Now closes the dialog without navigating anywhere', (tester) async { + await openDialog(tester, AppState()); + + await tester.tap(find.text('Not Now')); + await tester.pumpAndSettle(); + + expect(find.text('Tired of Ads?'), findsNothing); + }); + + testWidgets('Remove Ads closes the dialog and starts the purchase flow', (tester) async { + final appState = AppState(); + await openDialog(tester, appState); + + await tester.tap(find.text('Remove Ads')); + await tester.pumpAndSettle(); + + expect(find.text('Tired of Ads?'), findsNothing, reason: 'dialog closed'); + // buyAdFreeYear() delegates to PurchaseService, which — with no real + // store connection in tests — surfaces its failure through + // AppState.purchaseError rather than throwing, so tapping through is + // enough to confirm the button actually invoked it without crashing. + expect(appState.purchaseError, isNotNull); + }); +} diff --git a/test/ad_placeholder_screen_test.dart b/test/ad_placeholder_screen_test.dart new file mode 100644 index 0000000..03980b5 --- /dev/null +++ b/test/ad_placeholder_screen_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/widgets/ad_placeholder_screen.dart'; + +void main() { + /// Pushes the placeholder onto a real navigator so its self-dismissal + /// (which pops its own route) has something to pop. + /// + /// Deliberately avoids [WidgetTester.pumpAndSettle] while the + /// auto-dismiss ticker is alive — that timer reschedules a frame every + /// second, so settling would spin until it timed out rather than + /// returning. + Future pushPlaceholder(WidgetTester tester, {required bool mustConfirm}) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AdPlaceholderScreen(mustConfirm: mustConfirm), + ), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + group('auto-dismissing placeholder (report + fuel-save interstitials)', () { + testWidgets('shows the logo and the future-ad message, with no Continue button', + (tester) async { + await pushPlaceholder(tester, mustConfirm: false); + + expect(find.text('ADVERTISEMENT'), findsOneWidget); + expect(find.byType(Image), findsOneWidget, reason: 'the app logo'); + expect(find.text('An advertisement will appear here'), findsOneWidget); + expect(find.textContaining('Ads are not running yet'), findsOneWidget); + expect(find.text('Continue'), findsNothing, + reason: 'only the sync gate waits on the user'); + + // Let it close so the ticker is cancelled before the test ends. + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + }); + + testWidgets('counts down and closes itself after four seconds', (tester) async { + await pushPlaceholder(tester, mustConfirm: false); + + expect(find.textContaining('Closing in'), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('ADVERTISEMENT'), findsOneWidget, + reason: 'still up three seconds in'); + + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.text('ADVERTISEMENT'), findsNothing, reason: 'closed itself'); + expect(find.text('open'), findsOneWidget, reason: 'back on the page underneath'); + }); + }); + + group('confirming placeholder (the cloud-sync gate)', () { + testWidgets('waits for Continue instead of closing on its own', (tester) async { + await pushPlaceholder(tester, mustConfirm: true); + + expect(find.text('Continue'), findsOneWidget); + expect(find.textContaining('Closing in'), findsNothing, + reason: 'nothing counts down — the upload waits on the user'); + + await tester.pump(const Duration(seconds: 10)); + expect(find.text('ADVERTISEMENT'), findsOneWidget, + reason: 'still up well past the auto-dismiss window'); + + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('ADVERTISEMENT'), findsNothing); + expect(find.text('open'), findsOneWidget); + }); + }); +} diff --git a/test/add_edit_vehicle_screen_test.dart b/test/add_edit_vehicle_screen_test.dart new file mode 100644 index 0000000..4d43509 --- /dev/null +++ b/test/add_edit_vehicle_screen_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/add_edit_vehicle_screen.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "VIN already belongs to another vehicle" dialog on the Add +/// Vehicle form — [AddEditVehicleScreen] driven directly against an +/// [AppState] whose in-memory `vehicles` list is seeded by hand (no +/// database/cloud wiring needed, since the dialog only reads +/// [AppState.vehicleByVin]). +void main() { + final existingVehicle = Vehicle( + id: 'existing-id', + vin: '1FMPU18L1TLB51349', + nickname: 'Red Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + Future pumpAddVehicleScreen(WidgetTester tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [existingVehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: AddEditVehicleScreen()), + ), + ); + return appState; + } + + testWidgets('entering a VIN that already exists shows the dialog', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsOneWidget); + expect( + find.text( + 'Vehicle with the VIN ${existingVehicle.vin} already exists, would you like to open that vehicle?', + ), + findsOneWidget, + ); + }); + + testWidgets('tapping No dismisses the dialog and stays on the create screen', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'No')); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsNothing); + expect(find.text('Add Vehicle'), findsWidgets); + expect( + find.text('A vehicle with VIN "${existingVehicle.vin}" already exists.'), + findsOneWidget, + reason: 'the field itself should be flagged invalid after declining to open the existing vehicle', + ); + }); + + testWidgets('tapping Yes opens the existing vehicle', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Yes')); + await tester.pumpAndSettle(); + + expect(find.byType(VehicleDetailScreen), findsOneWidget); + expect(find.text(existingVehicle.displayLabel), findsOneWidget); + expect(find.byType(AddEditVehicleScreen), findsNothing, + reason: 'the create screen should be popped, not left underneath'); + }); + + testWidgets('editing a vehicle with its own VIN does not trigger the dialog', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [existingVehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: AddEditVehicleScreen(vehicle: existingVehicle)), + ), + ); + + // The field already starts pre-filled with its own VIN; nudge it to + // fire onChanged the same way a real edit would. + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsNothing); + }); + + testWidgets( + 'tapping the VIN scan icon shows "VIN From" with an info button explaining where to find one', + (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.tap(find.byTooltip('Scan VIN from a photo')); + await tester.pumpAndSettle(); + + expect(find.text('VIN From'), findsOneWidget); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Where to find the VIN'), findsOneWidget); + expect(find.byType(Image), findsNWidgets(2), reason: 'door jamb + windshield examples'); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Where to find the VIN'), findsNothing); + }); +} diff --git a/test/backup_reminder_test.dart b/test/backup_reminder_test.dart new file mode 100644 index 0000000..6427e68 --- /dev/null +++ b/test/backup_reminder_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/faq_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.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:fuel_tax_tracker/widgets/backup_reminder.dart'; + +/// Covers the "your data isn't backed up" nudge shown after saving a fuel +/// entry with no cloud backup configured — see +/// `ConfirmFuelEntryScreen._save`. The dialog and the spotlight it can lead +/// to (`showBackupReminderDialog`/`openCloudBackupSetup`) are tested here +/// as standalone functions rather than through a full save, matching +/// settings_purge_test.dart's convention of not exercising actions that +/// need an initialized database. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('showBackupReminderDialog', () { + Future openDialog(WidgetTester tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return result; + } + + testWidgets('shows the warning with both actions', (tester) async { + await openDialog(tester); + + expect(find.text("Your Data Isn't Backed Up"), findsOneWidget); + expect(find.text('Learn More'), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + expect(find.text('Set Up Backup'), findsOneWidget); + }); + + testWidgets('Learn More resolves false, closes the dialog, and opens the FAQ to the ' + 'backup question', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Learn More')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(find.text("Your Data Isn't Backed Up"), findsNothing, reason: 'dialog closed'); + expect(find.byType(FaqScreen), findsOneWidget); + + await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300, + scrollable: find.byType(Scrollable)); + expect(find.textContaining('Not by default'), findsOneWidget, + reason: 'backup question starts expanded'); + }); + + testWidgets('Not Now resolves false and closes the dialog', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Not Now')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + expect(find.text("Your Data Isn't Backed Up"), findsNothing); + }); + + testWidgets('Set Up Backup resolves true and closes the dialog', (tester) async { + bool? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await showBackupReminderDialog(context), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Set Up Backup')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(find.text("Your Data Isn't Backed Up"), findsNothing); + }); + }); + + group('openCloudBackupSetup', () { + testWidgets('pushes Settings > Data with the cloud storage card spotlighted', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => openCloudBackupSetup(context), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsOneWidget); + expect(find.text('Back Up to the Cloud'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget); + }); + + testWidgets('dismissing the spotlight leaves the Data screen up', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => openCloudBackupSetup(context), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Got It')); + await tester.pumpAndSettle(); + + expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'spotlight dismissed'); + expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'still on the Data screen'); + }); + }); + + group('AppState.hasCloudBackupConfigured', () { + test('false with no cloud provider connected at all', () { + final appState = AppState(); + expect(appState.hasCloudBackupConfigured, isFalse); + }); + + test('false when a provider is connected but no folder has been chosen', () { + final appState = AppState() + ..cloudSync = CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService()); + + expect(appState.hasCloudBackupConfigured, isFalse); + }); + + test('true once a backup folder has been configured', () { + final appState = AppState() + ..cloudSync = + (CloudSyncService(provider: _FakeProvider(), databaseService: DatabaseService()) + ..configure('folder-id')); + + expect(appState.hasCloudBackupConfigured, isTrue); + }); + }); +} + +class _FakeProvider implements CloudStorageProvider { + @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 attemptSilentSignIn() async => true; + + @override + Future signIn() async => accountLabel!; + + @override + Future signOut() async {} + + @override + CloudStorageSession beginSession() => throw UnimplementedError(); +} diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart new file mode 100644 index 0000000..aba4e84 --- /dev/null +++ b/test/cloud_sync_service_test.dart @@ -0,0 +1,412 @@ +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:fuel_tax_tracker/services/db_schema.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//', () 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('selectAppFolder creates/finds the app folder under the picked parent', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + + final folderId = await syncService.selectAppFolder('parent-id'); + + expect(folderId, 'parent-id/$appFolderName'); + expect(session.findOrCreateFolderCalls, ['parent-id/$appFolderName']); + }); + + test('selectAppFolder uses the picked folder directly if it is already the app folder', + () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + + final folderId = + await syncService.selectAppFolder('parent-id/$appFolderName', currentFolderName: appFolderName); + + expect(folderId, 'parent-id/$appFolderName'); + expect(session.findOrCreateFolderCalls, isEmpty, + reason: 'should not nest another app folder inside a folder already named that'); + }); + + test( + 'selectAppFolder moves an already-configured app folder to a new parent instead of ' + 'creating a fresh one there', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + // Establishes an app folder under the first parent — e.g. the root + // default a fresh connection assumes (see AppState.connectProvider) — + // before the user picks somewhere else. + await syncService.selectAppFolder('root'); + expect(syncService.isConfigured, isTrue); + + final newFolderId = await syncService.selectAppFolder('new-parent-id'); + + expect(session.moveFolderCalls, ['root/$appFolderName->new-parent-id']); + expect(newFolderId, 'new-parent-id/moved'); + expect(session.findOrCreateFolderCalls, ['root/$appFolderName'], + reason: 'only the very first (root) selection created a folder; the second moved it'); + }); + + test( + 'selectAppFolder adopts a same-named folder already at the destination instead of ' + 'moving onto/alongside it', () async { + final session = _FakeSession() + ..foldersByParent['new-parent-id'] = [CloudFolder(id: 'shared-folder-id', name: appFolderName)]; + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + await syncService.selectAppFolder('root'); + + final folderId = await syncService.selectAppFolder('new-parent-id'); + + expect(folderId, 'shared-folder-id'); + expect(session.moveFolderCalls, isEmpty, reason: 'adopted the existing one, did not move onto it'); + }); + + 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); + }); + + group('beforeUpload hook', () { + test('is awaited after pulling/merging remote changes but before anything is uploaded', + () async { + final calls = []; + // A real copy of the (correctly schema'd, currently empty) local + // database — CloudSyncService ATTACHes and merges against whatever + // downloadFileBytes returns, so it has to be an actual valid SQLite + // database, not arbitrary bytes. + final remoteDbBytes = await File(databaseService.databasePath).readAsBytes(); + final session = _FakeSession(); + session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1'); + session.remoteDbBytes = remoteDbBytes; + session.onDownload = () => calls.add('download'); + session.onUpload = (name) => calls.add('upload:$name'); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + var everSyncedBeforeSeen = false; + final result = await syncService.syncNow( + beforeUpload: ({required everSyncedBefore}) async { + calls.add('beforeUpload'); + everSyncedBeforeSeen = everSyncedBefore; + return true; + }, + ); + + expect(result.ranSync, isTrue); + expect(result.adGateBlocked, isFalse); + expect(calls.first, 'download', reason: 'pull/merge happens first'); + expect(calls[1], 'beforeUpload'); + expect(everSyncedBeforeSeen, isTrue, + reason: 'a remote data file already existed for this sync'); + // Everything after beforeUpload is an upload (the data-file push, + // possibly preceded by pending receipt uploads — none pending here). + expect(calls.skip(2), everyElement(startsWith('upload:'))); + expect(calls, contains('upload:$dataFileName')); + }); + + test('is awaited even when there is nothing to pull (no existing remote data file yet)', + () async { + final calls = []; + final session = _FakeSession()..onUpload = (name) => calls.add('upload:$name'); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + var everSyncedBeforeSeen = true; + await syncService.syncNow( + beforeUpload: ({required everSyncedBefore}) async { + calls.add('beforeUpload'); + everSyncedBeforeSeen = everSyncedBefore; + return true; + }, + ); + + expect(calls.first, 'beforeUpload'); + expect(calls, contains('upload:$dataFileName')); + expect(everSyncedBeforeSeen, isFalse, + reason: 'no remote data file existed — this is the first sync ever'); + }); + + test('a false return blocks the upload but keeps whatever was already pulled/merged', + () async { + final calls = []; + final remoteDbBytes = await File(databaseService.databasePath).readAsBytes(); + final session = _FakeSession(); + session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1'); + session.remoteDbBytes = remoteDbBytes; + session.onDownload = () => calls.add('download'); + session.onUpload = (name) => calls.add('upload:$name'); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final result = + await syncService.syncNow(beforeUpload: ({required everSyncedBefore}) async => false); + + expect(result.ranSync, isFalse); + expect(result.adGateBlocked, isTrue); + expect(calls, ['download'], reason: 'pull/merge still ran; nothing was uploaded'); + expect(session.deletedFileIds, ['lock-id'], + reason: 'the lock must still be released when the gate blocks the upload'); + }); + + test('omitting it changes nothing — every other syncNow call site keeps working', () 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); + }); + }); + + test( + 'pull/merge does not throw against a remote snapshot from before the ' + 'ad_free_entitlement table existed', () async { + // Simulates a real user's pre-existing cloud folder: a DB file uploaded + // by an older app version, with only the schema that existed then (no + // ad_free_entitlement table) — CloudSyncService must skip merging that + // table rather than throw "no such table: remote_db.ad_free_entitlement". + final oldSchemaDbPath = p.join(tempDir.path, 'old_schema_remote.db'); + final oldSchemaDb = await databaseFactory.openDatabase(oldSchemaDbPath); + await oldSchemaDb.execute(createVehiclesTableSql); + await oldSchemaDb.execute(createFuelEntriesTableSql); + await oldSchemaDb.execute(createFuelEntriesIndexSql); + await oldSchemaDb.close(); + + final session = _FakeSession(); + session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1'); + session.remoteDbBytes = await File(oldSchemaDbPath).readAsBytes(); + 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); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} + +class _FakeSession implements CloudStorageSession { + final List createdLockNames = []; + final List deletedFileIds = []; + final List uploadedFileNames = []; + final Map uploadedFileFolders = {}; + final List findOrCreateFolderCalls = []; + CloudFileInfo? existingDataFile; + void Function()? onDownload; + void Function(String name)? onUpload; + + /// Bytes [downloadFileBytes] returns — needs to be a real, attachable + /// SQLite database (with the vehicles/fuel_entries schema) for + /// [CloudSyncService]'s ATTACH-based merge to succeed against it, not + /// just arbitrary bytes. Tests that exercise the pull/merge path set this + /// to a copy of their own [DatabaseService]'s (already correctly + /// schema'd) database file. + List remoteDbBytes = const []; + + @override + bool get supportsSharedWithMe => false; + + /// Folders [listFolders] returns, keyed by the `parentId` they'd be + /// listed under — empty (nothing at any destination) unless a test sets + /// this up, e.g. to simulate a same-named app folder someone else + /// already created at the destination. + final Map> foldersByParent = {}; + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async => + foldersByParent[parentId] ?? []; + + @override + Future findOrCreateFolder({required String parentId, required String name}) async { + findOrCreateFolderCalls.add('$parentId/$name'); + return '$parentId/$name'; + } + + final List moveFolderCalls = []; + + @override + Future moveFolder({required String folderId, required String newParentId}) async { + moveFolderCalls.add('$folderId->$newParentId'); + return '$newParentId/moved'; + } + + @override + Future findFile({required String folderId, required String name}) async { + return name == dataFileName ? existingDataFile : null; + } + + @override + Future> downloadFileBytes(String fileId) async { + onDownload?.call(); + return remoteDbBytes; + } + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + uploadedFileNames.add(name); + uploadedFileFolders[name] = folderId; + onUpload?.call(name); + return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1'); + } + + @override + Future deleteFile(String fileId) async => deletedFileIds.add(fileId); + + @override + Future createLockFile({required String folderId, required String name}) async { + createdLockNames.add(name); + return 'lock-id'; + } + + @override + Future> 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 attemptSilentSignIn() async => true; + + @override + Future signIn() async => accountLabel!; + + @override + Future signOut() async {} + + @override + CloudStorageSession beginSession() => session; +} diff --git a/test/db_merge_test.dart b/test/db_merge_test.dart new file mode 100644 index 0000000..79b0e74 --- /dev/null +++ b/test/db_merge_test.dart @@ -0,0 +1,263 @@ +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 merge() async { + await local.execute("ATTACH DATABASE '${remote.path}' AS remote_db"); + try { + await local.execute(mergeVehiclesSql); + await local.execute(mergeFuelEntriesSql); + await local.execute(mergeAdFreeEntitlementSql); + } 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); + }); + }); + + group('ad_free_entitlement merge', () { + test('pulls in the entitlement when only remote has one', () async { + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 5000, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['id'], 1); + expect(rows.first['ad_free_until'], 5000); + }); + + test('remote wins when strictly newer than local', () async { + await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000)); + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], 9000); + }); + + test('local wins on a tie or when strictly newer', () async { + await local.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 9000, updatedAt: 2000)); + await remote.insert('ad_free_entitlement', _entitlementRow(adFreeUntil: 1000, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], 9000); + }); + + test('a null ad_free_until merges in fine (e.g. purchase consumed/lapsed remotely)', () async { + await remote.insert( + 'ad_free_entitlement', _entitlementRow(adFreeUntil: null, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, hasLength(1)); + expect(rows.first['ad_free_until'], isNull); + }); + + test('no-op when neither side has a row yet', () async { + await merge(); + + final rows = await local.query('ad_free_entitlement'); + expect(rows, isEmpty); + }); + }); +} + +Future _openFreshDb(String path) async { + final db = await databaseFactory.openDatabase(path); + await db.execute(createVehiclesTableSql); + await db.execute(createFuelEntriesTableSql); + await db.execute(createFuelEntriesIndexSql); + await db.execute(createAdFreeEntitlementTableSql); + return db; +} + +Map _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 _entitlementRow({int? adFreeUntil, int updatedAt = 0}) => { + 'id': 1, + 'ad_free_until': adFreeUntil, + 'updated_at': updatedAt, + }; + +Map _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, + }; diff --git a/test/edit_fuel_entry_test.dart b/test/edit_fuel_entry_test.dart new file mode 100644 index 0000000..e974184 --- /dev/null +++ b/test/edit_fuel_entry_test.dart @@ -0,0 +1,222 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +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'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; + +/// Covers editing a fuel entry's logged data (date/gallons/price/total) — +/// not its receipt photo, which [EditFuelEntryScreen] deliberately shows +/// read-only. The database-level overwrite semantics ([DatabaseService] +/// group) are what [AppState.updateFuelEntry] relies on: it just calls +/// [DatabaseService.saveFuelEntry] again with the same id, same as any +/// other upsert in this app (see [DatabaseService.saveVehicle]). +void main() { + final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1)); + final otherVehicle = + Vehicle(id: 'v2', vin: '1HGCM82633A004352', nickname: 'Second Car', updatedAt: DateTime.utc(2026, 1, 1)); + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5, 14, 30), + gallons: 10.5, + pricePerGallon: 3.499, + totalCost: 36.74, + updatedAt: DateTime.utc(2026, 3, 5), + ); + + group('DatabaseService.saveFuelEntry overwrite semantics', () { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('edit_fuel_entry_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('saving again with the same id overwrites gallons/price/total/date, marking it dirty', + () async { + await database.saveVehicle(vehicle); + await database.saveFuelEntry(entry); + // Clear the dirty flag the way a completed sync would, so the + // overwrite below can prove it gets re-marked dirty. + await database.rawDb.update('fuel_entries', {'dirty': 0}, where: 'id = ?', whereArgs: ['e1']); + + await database.saveFuelEntry(FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 6, 9, 0), + gallons: 11.2, + pricePerGallon: 3.599, + totalCost: 40.31, + updatedAt: DateTime.utc(2026, 3, 6), + )); + + final rows = await database.getFuelEntries(); + expect(rows, hasLength(1), reason: 'overwritten, not duplicated'); + final saved = rows.single; + expect(saved.gallons, 11.2); + expect(saved.pricePerGallon, 3.599); + expect(saved.totalCost, 40.31); + expect(saved.date, DateTime(2026, 3, 6, 9, 0)); + + final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single; + expect(rawRow['dirty'], 1); + }); + + test('saving again with a different vehicleId moves the entry to that vehicle', () async { + await database.saveVehicle(vehicle); + await database.saveVehicle(otherVehicle); + await database.saveFuelEntry(entry); + + await database.saveFuelEntry(FuelEntry( + id: entry.id, + vehicleId: otherVehicle.id, + date: entry.date, + gallons: entry.gallons, + pricePerGallon: entry.pricePerGallon, + totalCost: entry.totalCost, + updatedAt: DateTime.utc(2026, 3, 6), + )); + + final saved = (await database.getFuelEntries()).single; + expect(saved.vehicleId, otherVehicle.id); + }); + }); + + group('EditFuelEntryScreen', () { + Future pumpVehicleDetail(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicle.id)), + ), + ); + } + + testWidgets( + 'tapping an entry row opens its receipt detail page, and its edit icon opens ' + 'EditFuelEntryScreen pre-filled', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpVehicleDetail(tester, appState); + + await tester.tap(find.text('10.500 gal • \$36.74')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget); + + await tester.tap(find.byTooltip('Edit fuel entry')); + await tester.pumpAndSettle(); + + expect(find.byType(EditFuelEntryScreen), findsOneWidget); + expect(find.widgetWithText(TextFormField, '10.500'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '3.499'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '36.74'), findsOneWidget); + }); + + testWidgets('clearing a required field shows a validation error instead of saving', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: EditFuelEntryScreen(entry: entry)), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, '10.500'), ''); + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('Required'), findsOneWidget); + expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away'); + }); + + testWidgets('shows a vehicle picker pre-selected to the entry\'s current vehicle', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle, otherVehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: EditFuelEntryScreen(entry: entry)), + ), + ); + + expect(find.widgetWithText(DropdownButtonFormField, vehicle.displayLabel), + findsOneWidget); + }); + + testWidgets('picking a different vehicle updates the selection shown', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle, otherVehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: EditFuelEntryScreen(entry: entry)), + ), + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + // Two matches once the menu is open: the closed field's current + // selection, and the same label repeated as a menu item. + await tester.tap(find.text(otherVehicle.displayLabel).last); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(DropdownButtonFormField, otherVehicle.displayLabel), + findsOneWidget); + expect(find.widgetWithText(DropdownButtonFormField, vehicle.displayLabel), + findsNothing); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/estimated_refund_test.dart b/test/estimated_refund_test.dart new file mode 100644 index 0000000..ebdbb0a --- /dev/null +++ b/test/estimated_refund_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/estimated_refund.dart'; + +void main() { + test('the rate itself is 12.5 cents per gallon', () { + expect(moHighwayFuelTaxRefundRatePerGallon, 0.125); + }); + + test('multiplies whole gallons by the rate', () { + expect(estimatedFuelRefund(10), 1.25); + expect(estimatedFuelRefund(20), 2.50); + expect(estimatedFuelRefund(0), 0); + }); + + test('rounds fractional gallons to the nearest whole gallon before multiplying', () { + // 10.523 rounds up to 11 gallons: 11 * $0.125 = $1.375, floored to $1.37. + expect(estimatedFuelRefund(10.523), 1.37); + // 10.4 rounds down to 10 gallons: 10 * $0.125 = $1.25 exactly. + expect(estimatedFuelRefund(10.4), 1.25); + }); + + test('rounds the resulting cents down, never up', () { + // 1 gallon * $0.125 = $0.125 — rounding to the nearest cent would give + // $0.13, but this must floor to $0.12 instead. + expect(estimatedFuelRefund(1), 0.12); + // 3 gallons * $0.125 = $0.375 — floors to $0.37, not $0.38. + expect(estimatedFuelRefund(3), 0.37); + }); +} diff --git a/test/faq_screen_test.dart b/test/faq_screen_test.dart new file mode 100644 index 0000000..596bbe9 --- /dev/null +++ b/test/faq_screen_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/screens/faq_screen.dart'; + +/// Covers [FaqScreen] itself (question list, collapsed-by-default, +/// deep-linking a specific entry open) and [FaqActionButton], the shared +/// "?" AppBar action reused across every tab — see settings_screen_test.dart +/// for the Settings landing page's own FAQ entry, and +/// backup_reminder_test.dart for the "Learn More" link into a specific +/// entry. +void main() { + testWidgets('every question starts collapsed with no initiallyExpandedQuestion given', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: FaqScreen())); + + expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget); + expect(find.textContaining('12.5¢ per gallon'), findsNothing); + }); + + testWidgets('groups questions under section headers', (tester) async { + await tester.pumpWidget(const MaterialApp(home: FaqScreen())); + + // The first section header is visible without scrolling; the last + // one only reachable by scrolling all the way down — checking both + // confirms headers span the whole list, not just its start. + expect(find.text('Missouri Motor Fuel Tax Refund'), findsOneWidget); + + await tester.scrollUntilVisible(find.text('Advertisements'), 300, + scrollable: find.byType(Scrollable)); + expect(find.text('Advertisements'), findsOneWidget); + }); + + testWidgets('tapping a question expands its answer', (tester) async { + await tester.pumpWidget(const MaterialApp(home: FaqScreen())); + + await tester.scrollUntilVisible(find.text('How do I remove ads?'), 300, + scrollable: find.byType(Scrollable)); + // scrollUntilVisible stops as soon as any part is on-screen, which can + // leave it right at the bottom edge — nudge further so its center + // (what tap() targets) is comfortably inside the viewport. + await tester.drag(find.byType(Scrollable), const Offset(0, -100)); + await tester.pumpAndSettle(); + await tester.tap(find.text('How do I remove ads?')); + await tester.pumpAndSettle(); + + expect(find.textContaining("doesn't auto-renew"), findsOneWidget); + }); + + testWidgets('includes a contact email for problem receipts/VINs/errors', (tester) async { + await tester.pumpWidget(const MaterialApp(home: FaqScreen())); + + await tester.scrollUntilVisible( + find.text("Found a receipt, VIN, or error the app couldn't handle?"), 300, + scrollable: find.byType(Scrollable)); + await tester.tap(find.text("Found a receipt, VIN, or error the app couldn't handle?")); + await tester.pumpAndSettle(); + + expect(find.textContaining('ohbrer+ShowMeTheFuelRefund@gmail.com'), findsOneWidget); + }); + + testWidgets('the refund program question covers both highway and non-highway refunds', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: FaqScreen())); + + await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Highway use — up to 12.5¢ per gallon'), findsOneWidget); + expect(find.textContaining('Non-highway use — up to 29.5¢ per gallon'), findsOneWidget); + expect(find.textContaining("isn't tax advice"), findsOneWidget); + expect(find.textContaining('consult a tax professional'), findsOneWidget); + }); + + testWidgets('initiallyExpandedQuestion starts that one entry open, others collapsed', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: FaqScreen(initiallyExpandedQuestion: 'Is my data backed up?')), + ); + await tester.pumpAndSettle(); + + // Its section (Data Safety) is further down the page now that + // questions are grouped — scroll to it before checking its content. + await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300, + scrollable: find.byType(Scrollable)); + + expect(find.textContaining('Not by default'), findsOneWidget); + expect(find.textContaining('12.5¢ per gallon'), findsNothing, + reason: 'only the targeted question starts expanded'); + }); + + testWidgets('FaqActionButton pushes FaqScreen', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(appBar: AppBar(actions: const [FaqActionButton()])), + ), + ); + + await tester.tap(find.byIcon(Icons.help_outline)); + await tester.pumpAndSettle(); + + expect(find.byType(FaqScreen), findsOneWidget); + }); +} diff --git a/test/fuel_report_images_test.dart b/test/fuel_report_images_test.dart new file mode 100644 index 0000000..f6b3459 --- /dev/null +++ b/test/fuel_report_images_test.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; +import 'package:fuel_tax_tracker/services/fuel_report_images.dart'; + +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('fuel_report_images_test_'); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + FuelReport reportWith(List entries) => FuelReport( + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + rows: [ + VehicleReportRow( + vehicle: vehicle, + entries: entries, + totalGallons: entries.fold(0.0, (sum, e) => sum + e.gallons), + totalCost: entries.fold(0.0, (sum, e) => sum + e.totalCost), + ), + ], + ); + + FuelEntry entry({ + required String id, + String? receiptImagePath, + String? receiptDriveFileId, + }) => + FuelEntry( + id: id, + vehicleId: 'v1', + date: DateTime(2026, 3, 1), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 3, 1), + receiptImagePath: receiptImagePath, + receiptDriveFileId: receiptDriveFileId, + ); + + test('reads bytes from a local file when one exists', () async { + final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([1, 2, 3]); + final report = reportWith([entry(id: 'e1', receiptImagePath: file.path)]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId['e1'], [1, 2, 3]); + expect(result.failedEntries, isEmpty); + }); + + test('falls back to downloading via the cloud provider when there is no local file', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [4, 5, 6]})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId['e1'], [4, 5, 6]); + expect(result.failedEntries, isEmpty); + }); + + test('reports (rather than silently drops) an entry with neither a local file nor a cloud reference', + () async { + final report = reportWith([entry(id: 'e1')]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('reports (rather than throws) when the download fails', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'missing')]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('reports a cloud-only entry when there is no signed-in provider', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('a receipt uploaded via a different provider than the one currently connected fails cleanly', + () async { + // Mirrors the real scenario this was built for: a WebDAV-style path + // stored as receiptDriveFileId, but the currently active provider is + // something else entirely (here, the fake) — the ID is meaningless to + // it and the download call throws, same as any other failed download. + final report = reportWith([ + entry( + id: 'e1', + receiptDriveFileId: + '/remote.php/dav/files/user/Show Me The Fuel Refund/receipts/VIN/e1.jpg', + ), + ]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('mixed success and failure across multiple entries', () async { + final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([9]); + final report = reportWith([ + entry(id: 'local', receiptImagePath: file.path), + entry(id: 'cloud-ok', receiptDriveFileId: 'remote-1'), + entry(id: 'cloud-missing', receiptDriveFileId: 'nope'), + ]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [1]})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.keys, {'local', 'cloud-ok'}); + expect(result.failedEntries.map((e) => e.id), ['cloud-missing']); + }); +} + +class _FakeSession implements CloudStorageSession { + final Map> bytesByFileId; + _FakeSession({required this.bytesByFileId}); + + @override + Future> downloadFileBytes(String fileId) async { + final bytes = bytesByFileId[fileId]; + if (bytes == null) throw StateError('not found: $fileId'); + return bytes; + } + + @override + bool get supportsSharedWithMe => false; + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; + @override + Future findOrCreateFolder({required String parentId, required String name}) async => ''; + @override + Future moveFolder({required String folderId, required String newParentId}) async => + folderId; + @override + Future findFile({required String folderId, required String name}) async => null; + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async => + CloudFileInfo(id: 'x', versionTag: null); + @override + Future deleteFile(String fileId) async {} + @override + Future createLockFile({required String folderId, required String name}) async => 'lock'; + @override + Future> 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 attemptSilentSignIn() async => true; + @override + Future signIn() async => accountLabel!; + @override + Future signOut() async {} + @override + CloudStorageSession beginSession() => session; +} diff --git a/test/fuel_report_pdf_test.dart b/test/fuel_report_pdf_test.dart new file mode 100644 index 0000000..68a4c49 --- /dev/null +++ b/test/fuel_report_pdf_test.dart @@ -0,0 +1,158 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; +import 'package:fuel_tax_tracker/services/fuel_report_pdf.dart'; + +/// PDF content isn't practically assertable byte-for-byte, so this is a +/// smoke test: every include-images/include-line-items combination should +/// render without throwing and produce a well-formed (non-trivial, valid +/// header) document — the real coverage of what each section *contains* +/// lives in fuel_report_test.dart (data) and is exercised by hand. +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + final entryA = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 3, 5), + receiptImagePath: '/local/a.jpg', + ); + final entryB = FuelEntry( + id: 'e2', + vehicleId: 'v1', + date: DateTime(2026, 3, 12), + gallons: 12, + pricePerGallon: 3.6, + totalCost: 43.2, + updatedAt: DateTime.utc(2026, 3, 12), + receiptDriveFileId: 'remote-1', + ); + + final report = FuelReport( + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + rows: [ + VehicleReportRow( + vehicle: vehicle, + entries: [entryA, entryB], + totalGallons: 22, + totalCost: 78.2, + ), + ], + ); + + // A 1x1 red pixel PNG — enough for the pdf package to embed as an image. + final onePixelPng = Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]); + + Future expectValidPdf(Uint8List bytes) async { + expect(bytes.length, greaterThan(100)); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + } + + test('summary only (both toggles off)', () async { + final bytes = await buildFuelReportPdf(report); + await expectValidPdf(bytes); + }); + + test('with line items, no images', () async { + final bytes = await buildFuelReportPdf(report, includeLineItems: true); + await expectValidPdf(bytes); + }); + + test('with images, no line items', () async { + final bytes = await buildFuelReportPdf( + report, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng, 'e2': onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('with both — line items and images each get their own section, cross-referenced by number', + () async { + final bytes = await buildFuelReportPdf( + report, + includeLineItems: true, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng, 'e2': onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('a report with enough entries to span multiple pages does not throw', () async { + // Regression test: the page banner/watermark used to be drawn by + // wrapping the *entire*, unbounded-height body in a Stack, which + // doesn't paginate — MultiPage threw "Widget won't fit into the page" + // for anything long enough to need a page break. Enough months/entries + // here to force at least one break. + final vehicle2 = Vehicle(id: 'v2', vin: '1GNWK5EG4DR302326', updatedAt: DateTime.utc(2026, 1, 1)); + final manyEntries = []; + for (var month = 1; month <= 12; month++) { + for (var day = 2; day <= 26; day += 6) { + final id = 'm$month-d$day'; + manyEntries.add(FuelEntry( + id: id, + vehicleId: month.isEven ? 'v1' : 'v2', + date: DateTime(2026, month, day), + gallons: 20, + pricePerGallon: 3.5, + totalCost: 70, + updatedAt: DateTime.utc(2026, month, day), + receiptDriveFileId: 'remote-$id', + )); + } + } + final bigReport = buildFuelReport( + vehicles: [vehicle, vehicle2], + fuelEntries: manyEntries, + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + ); + + final bytes = await buildFuelReportPdf( + bigReport, + includeLineItems: true, + includeReceiptImages: true, + receiptImages: {for (final e in manyEntries) e.id: onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('receiptImages missing an entry does not crash — that photo is just skipped', () async { + final bytes = await buildFuelReportPdf( + report, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng}, // e2 deliberately missing + ); + await expectValidPdf(bytes); + }); + + test('empty report (no rows) with all sections on', () async { + final empty = FuelReport(startDate: DateTime(2026, 1, 1), endDate: DateTime(2026, 1, 31), rows: []); + final bytes = await buildFuelReportPdf(empty, includeLineItems: true, includeReceiptImages: true); + await expectValidPdf(bytes); + }); + + test('fuelReportFileName formats the date range', () { + expect(fuelReportFileName(report), 'fuel-report-20260301-20260331.pdf'); + }); +} diff --git a/test/fuel_report_test.dart b/test/fuel_report_test.dart new file mode 100644 index 0000000..19f6254 --- /dev/null +++ b/test/fuel_report_test.dart @@ -0,0 +1,312 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; + +void main() { + final truck = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + final car = Vehicle( + id: 'v2', + vin: '1GNWK5EG4DR302326', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + FuelEntry entry({ + required String id, + required String vehicleId, + required DateTime date, + double gallons = 10, + double totalCost = 35, + String? receiptImagePath, + String? receiptDriveFileId, + }) => + FuelEntry( + id: id, + vehicleId: vehicleId, + date: date, + gallons: gallons, + pricePerGallon: totalCost / gallons, + totalCost: totalCost, + updatedAt: date, + receiptImagePath: receiptImagePath, + receiptDriveFileId: receiptDriveFileId, + ); + + test('sums gallons and cost per vehicle, only for entries with a receipt', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + totalCost: 35, + receiptImagePath: '/local/a.jpg'), + entry( + id: 'e2', + vehicleId: 'v1', + date: DateTime(2026, 3, 20), + gallons: 12, + totalCost: 42, + receiptDriveFileId: 'remote-1'), + // No receipt at all — should be excluded even though it's in range. + entry(id: 'e3', vehicleId: 'v1', date: DateTime(2026, 3, 10), gallons: 99, totalCost: 999), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows, hasLength(1)); + expect(report.rows.single.vehicle.id, 'v1'); + expect(report.rows.single.entryCount, 2); + expect(report.rows.single.totalGallons, 22); + expect(report.rows.single.totalCost, 77); + }); + + test('date range is inclusive of both endpoints', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry( + id: 'first-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 1), + receiptImagePath: '/a.jpg'), + entry( + id: 'last-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 31), + receiptImagePath: '/b.jpg'), + entry( + id: 'day-after', + vehicleId: 'v1', + date: DateTime(2026, 4, 1), + receiptImagePath: '/c.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entryCount, 2); + }); + + test('a receipt-photo time-of-day does not push it out of range', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry( + id: 'late-in-day', + vehicleId: 'v1', + date: DateTime(2026, 3, 31, 23, 59), + receiptImagePath: '/a.jpg', + ), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entryCount, 1); + }); + + test('omits vehicles with no qualifying entries rather than a zero row', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows, hasLength(1)); + expect(report.rows.single.vehicle.id, 'v1'); + }); + + test('rows are sorted by vehicle display label', () { + final zVehicle = Vehicle( + id: 'v3', + vin: 'ZZZ00000000000000', + nickname: 'Zeta', + updatedAt: DateTime.utc(2026, 1, 1), + ); + final aVehicle = Vehicle( + id: 'v4', + vin: 'AAA00000000000000', + nickname: 'Alpha', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + final report = buildFuelReport( + vehicles: [zVehicle, aVehicle], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v3', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'e2', vehicleId: 'v4', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.map((r) => r.vehicle.id), ['v4', 'v3']); + }); + + test('report totals sum across all vehicle rows', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + totalCost: 35, + receiptImagePath: '/a.jpg'), + entry( + id: 'e2', + vehicleId: 'v2', + date: DateTime(2026, 3, 6), + gallons: 8, + totalCost: 28, + receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.totalGallons, 18); + expect(report.totalCost, 63); + }); + + test('a row\'s entries are sorted oldest-first, regardless of insertion order', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry(id: 'newest', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/c.jpg'), + entry(id: 'oldest', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'middle', vehicleId: 'v1', date: DateTime(2026, 3, 12), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entries.map((e) => e.id), ['oldest', 'middle', 'newest']); + }); + + test('fillCount and avgPricePerGallon are derived from the totals across all rows', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 40, receiptImagePath: '/a.jpg'), + entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 3, 6), gallons: 10, totalCost: 30, receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.fillCount, 2); + expect(report.avgPricePerGallon, 3.5); // $70 / 20 gal + }); + + test('avgPricePerGallon is 0, not NaN/infinity, for an empty report', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: const [], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.avgPricePerGallon, 0); + }); + + test('allEntries pairs each entry with its vehicle and sorts oldest-first across vehicles', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'truck-later', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/a.jpg'), + entry(id: 'car-earlier', vehicleId: 'v2', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + final all = report.allEntries; + expect(all.map((re) => re.entry.id), ['car-earlier', 'truck-later']); + expect(all[0].vehicle.id, 'v2'); + expect(all[1].vehicle.id, 'v1'); + }); + + test('monthlyTotals buckets entries by calendar month across vehicles, oldest month first', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 35, receiptImagePath: '/a.jpg'), + entry(id: 'mar-2', vehicleId: 'v2', date: DateTime(2026, 3, 20), gallons: 10, totalCost: 35, receiptImagePath: '/b.jpg'), + entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), gallons: 10, totalCost: 40, receiptImagePath: '/c.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 4, 30), + ); + + final months = report.monthlyTotals; + expect(months, hasLength(2)); + expect(months[0].month, DateTime(2026, 3)); + expect(months[0].fillCount, 2); + expect(months[0].totalGallons, 20); + expect(months[0].totalCost, 70); + expect(months[0].avgPricePerGallon, 3.5); + expect(months[1].month, DateTime(2026, 4)); + expect(months[1].fillCount, 1); + }); + + test('entriesByMonth is the grouping monthlyTotals is built from', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 4, 30), + ); + + final byMonth = report.entriesByMonth; + expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']); + expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']); + }); + + group('defaultReportDateRange', () { + test('from August through year-end, defaults to July of that year through the following June', + () { + final (start, end) = defaultReportDateRange(DateTime(2026, 8, 17)); + expect(start, DateTime(2026, 7, 1)); + expect(end, DateTime(2027, 6, 30)); + }); + + test('from January through July, still defaults to the July before through this June', () { + final (start, end) = defaultReportDateRange(DateTime(2027, 7, 15)); + expect(start, DateTime(2026, 7, 1)); + expect(end, DateTime(2027, 6, 30)); + }); + + test('the next cycle (August the following year) rolls the whole range forward a year', () { + final (start, end) = defaultReportDateRange(DateTime(2027, 8, 1)); + expect(start, DateTime(2027, 7, 1)); + expect(end, DateTime(2028, 6, 30)); + }); + + test('July 31 is still the old cycle; August 1 is the new one', () { + final stillOld = defaultReportDateRange(DateTime(2026, 7, 31)); + expect(stillOld.$1, DateTime(2025, 7, 1)); + expect(stillOld.$2, DateTime(2026, 6, 30)); + + final nowNew = defaultReportDateRange(DateTime(2026, 8, 1)); + expect(nowNew.$1, DateTime(2026, 7, 1)); + expect(nowNew.$2, DateTime(2027, 6, 30)); + }); + }); +} diff --git a/test/fuel_save_ad_gate_test.dart b/test/fuel_save_ad_gate_test.dart new file mode 100644 index 0000000..2c33617 --- /dev/null +++ b/test/fuel_save_ad_gate_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/app_navigator.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [AppState.maybeShowFuelSaveAd]'s "when" decision — the grace +/// periods and watch-credits that sit in front of the fuel-save ad — by +/// calling it directly against a hand-built [AppState] rather than through +/// a real save, which would need an initialized database. +/// +/// With `adsEnabled` false (lib/services/ad_config.dart), the underlying +/// [AdService.maybeShowFuelSaveAd] always resolves to showing the +/// placeholder, so what's under test here is purely the gating above it: +/// if a test sees no placeholder, a gate stopped it. +/// +/// These push against the real [navigatorKey], since that's what +/// `showAdPlaceholder` pushes onto — an app that doesn't wire it up would +/// make the placeholder silently do nothing, which is itself one of the +/// failure modes worth pinning down. +void main() { + /// Mirrors a well-used install: past the new-user window and well past + /// the first few saves, with no ad-free purchase. + AppState buildEstablishedUser({int entries = 9}) { + return AppState() + ..firstUsedAt = DateTime.now().subtract(const Duration(days: 3)) + ..adFreeUntil = null + ..fuelEntries = List.generate( + entries, + (i) => FuelEntry( + id: 'e$i', + vehicleId: 'v1', + date: DateTime.now(), + gallons: 13.405, + pricePerGallon: 3.899, + totalCost: 52.27, + updatedAt: DateTime.now(), + ), + ); + } + + Future pumpHost(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + home: const Scaffold(body: Text('receipts')), + ), + ); + } + + /// Runs one save's worth of the gate to completion and reports what it + /// decided, running [whileShowing] at the point a placeholder would be + /// on screen. + /// + /// The gate's future is deliberately *not* awaited up front: when a + /// placeholder does show, that future only completes once the + /// placeholder pops itself, and it's [WidgetTester.pump] that advances + /// the timer doing the popping — awaiting first deadlocks the two + /// against each other. + Future runGate( + WidgetTester tester, + AppState appState, { + void Function()? whileShowing, + }) async { + bool? result; + unawaited(appState.maybeShowFuelSaveAd().then((value) => result = value)); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + whileShowing?.call(); + + // Enough for the placeholder's four-second auto-dismiss, harmless + // when nothing came up. + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + + expect(result, isNotNull, reason: 'the gate should have settled by now'); + return result!; + } + + testWidgets('an established user gets the ad on save', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(shown, isTrue, reason: 'the caller needs this to hold back the backup reminder'); + expect(find.text('receipts'), findsOneWidget, reason: 'placeholder closed itself'); + }); + + testWidgets('cloud backup being unconfigured does not suppress it', (tester) async { + // The regression this is really here for: the gate used to bail out + // whenever backup wasn't set up, which meant a user who never + // configured backup never saw this placement at all. + await pumpHost(tester); + final appState = buildEstablishedUser(); + expect(appState.hasCloudBackupConfigured, isFalse, reason: 'no cloudSync wired up'); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(shown, isTrue); + }); + + testWidgets('no ad during the first five minutes of app usage', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser() + ..firstUsedAt = DateTime.now().subtract(const Duration(minutes: 2)); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('no ad for the first four saves', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(entries: 4); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('an active ad-free purchase suppresses it', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser() + ..adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 300)); + + final shown = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing), + ); + + expect(shown, isFalse); + }); + + testWidgets('the credit bought by one ad covers the next two saves', (tester) async { + await pumpHost(tester); + final appState = buildEstablishedUser(); + + expect(await runGate(tester, appState), isTrue, reason: 'due'); + expect(await runGate(tester, appState), isFalse, reason: 'credited save 1'); + expect(await runGate(tester, appState), isFalse, reason: 'credited save 2'); + + final fourth = await runGate( + tester, + appState, + whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget), + ); + + expect(fourth, isTrue, reason: 'credit spent — due again'); + }); +} diff --git a/test/image_source_sheet_test.dart b/test/image_source_sheet_test.dart new file mode 100644 index 0000000..bf17367 --- /dev/null +++ b/test/image_source_sheet_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/widgets/image_source_sheet.dart'; + +/// Covers the optional [heading] and [onInfoTap] on [chooseImageSource]'s +/// bottom sheet — receipt capture passes "Receipt From" with no info +/// button (see receipt_capture.dart); VIN scanning passes "VIN From" plus +/// an info button that explains where to find one (see +/// add_edit_vehicle_screen.dart). +void main() { + Future pumpSheet( + WidgetTester tester, { + String? heading, + VoidCallback? onInfoTap, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => chooseImageSource(context, heading: heading, onInfoTap: onInfoTap), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + testWidgets('shows the heading above Take Photo / Choose from Gallery when provided', + (tester) async { + await pumpSheet(tester, heading: 'Receipt From'); + + expect(find.text('Receipt From'), findsOneWidget); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + }); + + testWidgets('shows no heading when none is provided', (tester) async { + await pumpSheet(tester); + + expect(find.text('Receipt From'), findsNothing); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + }); + + testWidgets('shows no info button when onInfoTap is omitted, even with a heading', + (tester) async { + await pumpSheet(tester, heading: 'Receipt From'); + + expect(find.byIcon(Icons.info_outline), findsNothing); + }); + + testWidgets('shows an info button next to the heading when onInfoTap is provided, and invokes it', + (tester) async { + var tapped = false; + await pumpSheet(tester, heading: 'VIN From', onInfoTap: () => tapped = true); + + expect(find.text('VIN From'), findsOneWidget); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + }); +} diff --git a/test/lock_coordinator_test.dart b/test/lock_coordinator_test.dart new file mode 100644 index 0000000..d50b55e --- /dev/null +++ b/test/lock_coordinator_test.dart @@ -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 = []; + 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 = []; + + 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 = []; + final deleteCalls = []; + + 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 = []; + + 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']); + }); + }); +} diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart new file mode 100644 index 0000000..96299e8 --- /dev/null +++ b/test/main_shell_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/main_shell.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Confirms the bottom nav actually switches between the four tabs and +/// that each one builds without crashing against a freshly-constructed, +/// not-yet-synced [AppState] — the same "no cloud connected, no vehicles +/// yet" state a real fresh install starts in. +void main() { + testWidgets('bottom nav switches between all four tabs', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + // Receipts is the default tab. + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Vehicles')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Reports')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Settings')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('Receipts tab shows an empty state with no fuel entries yet', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + expect(find.textContaining('No fuel entries'), findsOneWidget); + }); + + testWidgets('a right-to-left swipe on the body advances to the next tab', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + // And a left-to-right swipe goes back. + await tester.fling(find.byType(PageView), const Offset(400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets( + "swiping away from Receipts and back preserves its selected date range " + '(AutomaticKeepAliveClientMixin keeping the tab alive across the PageView, ' + 'not just the old IndexedStack)', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + await tester.tap(find.text('Last 3 Months')); + await tester.pumpAndSettle(); + + // Swipe all the way to Settings and back. + await tester.tap(find.widgetWithText(NavigationDestination, 'Settings')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts')); + await tester.pumpAndSettle(); + + final selectedLabel = tester.widget(find.text('Last 3 Months')); + expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range'); + }); + + group('first-launch onboarding tour', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpShellWithFreshTour(WidgetTester tester) async { + // AppState.hasSeenOnboardingTour defaults to true precisely so that + // ad-hoc AppState() construction elsewhere (every other test in this + // file included) doesn't trigger the tour — these tests explicitly + // flip it to false to reproduce a real fresh install's state. + final appState = AppState() + ..isLoading = false + ..hasSeenOnboardingTour = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + await tester.pumpAndSettle(); + return appState; + } + + testWidgets('shows the "What This App Does" intro automatically on first launch, with the ' + 'refund-rate and lawmakers disclaimer', (tester) async { + await pumpShellWithFreshTour(tester); + + expect(find.text('What This App Does'), findsOneWidget); + expect(find.text('Step 1 of 6'), findsOneWidget); + expect(find.textContaining('12.5¢'), findsOneWidget); + expect(find.textContaining('Disclaimer'), findsOneWidget); + expect(find.textContaining('Missouri lawmakers'), findsOneWidget); + // No specific element to spotlight for the intro — stays on + // whichever tab MainShell defaults to. + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('does not show the tour once hasSeenOnboardingTour is true', (tester) async { + final appState = AppState() + ..isLoading = false + ..hasSeenOnboardingTour = true; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('What This App Does'), findsNothing); + }); + + testWidgets('tapping the dimmed barrier does not dismiss the tour or reach the highlighted button', + (tester) async { + await pumpShellWithFreshTour(tester); + + // Advance to the Receipts step, which is the one with an actual + // highlighted, tappable element (the "+") behind the barrier. + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Add Receipts Here'), findsOneWidget); + + // Top-left corner, away from the card and from the highlighted "+" + // (which sits top-right in the AppBar). + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + expect(find.text('Add Receipts Here'), findsOneWidget, reason: 'tour still showing'); + expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsNothing, + reason: "the receipt '+' — which opens vehicle creation when there are no " + "vehicles yet — was not actually reached"); + }); + + testWidgets( + 'Next advances through all six steps, switching tabs, then pushing Settings > Data', + (tester) async { + await pumpShellWithFreshTour(tester); + + expect(find.text('What This App Does'), findsOneWidget); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Add Receipts Here'), findsOneWidget); + expect(find.text('Step 2 of 6'), findsOneWidget); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Add Vehicles Here'), findsOneWidget); + expect(find.text('Step 3 of 6'), findsOneWidget); + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Choose a Report Date Range'), findsOneWidget); + expect(find.text('Step 4 of 6'), findsOneWidget); + expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Find Data Settings'), findsOneWidget); + expect(find.text('Step 5 of 6'), findsOneWidget); + expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget); + expect(find.byType(DataSettingsScreen), findsNothing, + reason: 'still on the Settings landing page, not pushed into Data yet'); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.text('Back Up to the Cloud'), findsOneWidget); + expect(find.text('Step 6 of 6'), findsOneWidget); + expect(find.byType(DataSettingsScreen), findsOneWidget, reason: 'tour pushed Settings > Data'); + expect(find.text('Cloud Storage'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget, + reason: 'last step reads "Got It", not "Next"'); + }); + + testWidgets( + 'finishing on the last step pops the tour-pushed Data screen and marks the tour seen', + (tester) async { + final appState = await pumpShellWithFreshTour(tester); + + for (var i = 0; i < 5; i++) { + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + } + expect(find.byType(DataSettingsScreen), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Got It')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsNothing, reason: 'tour-pushed screen popped'); + expect(find.text('Back Up to the Cloud'), findsNothing, reason: 'overlay gone'); + expect(appState.hasSeenOnboardingTour, isTrue); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget, + reason: 'returned to Receipts, not left on the Settings tab the tour ended on'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('has_seen_onboarding_tour'), isTrue, + reason: 'persisted so the tour never shows again on this device'); + }); + + testWidgets('Skip Tour on the very first step ends the tour immediately and marks it seen', + (tester) async { + final appState = await pumpShellWithFreshTour(tester); + + await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); + await tester.pumpAndSettle(); + + expect(find.text('What This App Does'), findsNothing); + expect(appState.hasSeenOnboardingTour, isTrue); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('Skip Tour from a later step still returns to Receipts, not the tab it was on', + (tester) async { + await pumpShellWithFreshTour(tester); + + // Two steps in, the tour has already jumped to the Vehicles tab. + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('Skip Tour from the last step pops the tour-pushed Data screen and returns to ' + 'Receipts', (tester) async { + await pumpShellWithFreshTour(tester); + + for (var i = 0; i < 5; i++) { + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + } + expect(find.byType(DataSettingsScreen), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsNothing); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + }); +} diff --git a/test/onboarding_tour_overlay_test.dart b/test/onboarding_tour_overlay_test.dart new file mode 100644 index 0000000..b33298e --- /dev/null +++ b/test/onboarding_tour_overlay_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/widgets/onboarding_tour_overlay.dart'; + +/// Unit-level coverage of the tour's presentational overlay widget, +/// independent of MainShell's step-sequencing/navigation logic (covered +/// in main_shell_test.dart's onboarding-tour group instead). +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + testWidgets('shows the title, description, and step count', (tester) async { + final targetKey = GlobalKey(); + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'Example Title', + description: 'Example description text.', + stepNumber: 2, + totalSteps: 4, + onNext: () {}, + onSkip: () {}, + ), + )); + + expect(find.text('Example Title'), findsOneWidget); + expect(find.text('Example description text.'), findsOneWidget); + expect(find.text('Step 2 of 4'), findsOneWidget); + }); + + testWidgets('shows "Next" before the last step and "Got It" on the last step', (tester) async { + final targetKey = GlobalKey(); + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'T', + description: 'D', + stepNumber: 1, + totalSteps: 3, + onNext: () {}, + onSkip: () {}, + ), + )); + expect(find.widgetWithText(FilledButton, 'Next'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Got It'), findsNothing); + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'T', + description: 'D', + stepNumber: 3, + totalSteps: 3, + onNext: () {}, + onSkip: () {}, + ), + )); + expect(find.widgetWithText(FilledButton, 'Got It'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Next'), findsNothing); + }); + + testWidgets('invokes onNext/onSkip when their buttons are tapped', (tester) async { + var nextTapped = false; + var skipTapped = false; + final targetKey = GlobalKey(); + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'T', + description: 'D', + stepNumber: 1, + totalSteps: 2, + onNext: () => nextTapped = true, + onSkip: () => skipTapped = true, + ), + )); + + await tester.tap(find.widgetWithText(TextButton, 'Skip Tour')); + expect(skipTapped, isTrue); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + expect(nextTapped, isTrue); + }); + + testWidgets( + 'renders without crashing when the target key has no RenderObject yet — falls back to a ' + 'centered card', (tester) async { + final targetKey = GlobalKey(); // never attached to anything + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'T', + description: 'D', + stepNumber: 1, + totalSteps: 1, + onNext: () {}, + onSkip: () {}, + ), + )); + + expect(find.text('T'), findsOneWidget); + }); + + testWidgets('tapping the dimmed barrier does not invoke onNext or onSkip', (tester) async { + var nextTapped = false; + var skipTapped = false; + final targetKey = GlobalKey(); + + await tester.pumpWidget(wrap( + OnboardingTourOverlay( + targetKeys: [targetKey], + title: 'T', + description: 'D', + stepNumber: 1, + totalSteps: 1, + onNext: () => nextTapped = true, + onSkip: () => skipTapped = true, + ), + )); + + // Top-left corner — away from the card, which sits mid/lower-screen. + await tester.tapAt(const Offset(5, 5)); + await tester.pump(); + + expect(nextTapped, isFalse); + expect(skipTapped, isFalse); + }); + + testWidgets('highlights every key in targetKeys, not just the first', (tester) async { + final topKey = GlobalKey(); + final bottomKey = GlobalKey(); + + Widget buildTree({required bool showOverlay}) => MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned(top: 0, left: 0, child: SizedBox(key: topKey, width: 40, height: 40)), + Positioned( + bottom: 0, left: 0, child: SizedBox(key: bottomKey, width: 40, height: 40)), + if (showOverlay) + OnboardingTourOverlay( + targetKeys: [topKey, bottomKey], + title: 'Both Highlighted', + description: 'D', + stepNumber: 1, + totalSteps: 1, + onNext: () {}, + onSkip: () {}, + ), + ], + ), + ), + ); + + // Two frames, matching how MainShell actually uses this: the target + // keys' RenderBoxes need to already exist and be laid out (from a + // prior frame) before the overlay's own build() can find their real + // position — querying them in the very same frame they're first + // mounted finds nothing yet (layout hasn't run for that frame at + // build time). + await tester.pumpWidget(buildTree(showOverlay: false)); + await tester.pumpWidget(buildTree(showOverlay: true)); + + // One highlight border per resolved key. + expect(find.byType(DecoratedBox), findsNWidgets(2)); + expect(find.text('Both Highlighted'), findsOneWidget); + }); + + testWidgets('still renders correctly when only some of targetKeys resolve', (tester) async { + final resolvedKey = GlobalKey(); + final unresolvedKey = GlobalKey(); // never attached to anything + + Widget buildTree({required bool showOverlay}) => MaterialApp( + home: Scaffold( + body: Stack( + children: [ + SizedBox(key: resolvedKey, width: 40, height: 40), + if (showOverlay) + OnboardingTourOverlay( + targetKeys: [resolvedKey, unresolvedKey], + title: 'T', + description: 'D', + stepNumber: 1, + totalSteps: 1, + onNext: () {}, + onSkip: () {}, + ), + ], + ), + ), + ); + + await tester.pumpWidget(buildTree(showOverlay: false)); + await tester.pumpWidget(buildTree(showOverlay: true)); + + expect(find.byType(DecoratedBox), findsNWidgets(1), reason: 'only the resolved key gets a border'); + expect(find.text('T'), findsOneWidget); + }); +} diff --git a/test/pending_receipt_upload_test.dart b/test/pending_receipt_upload_test.dart new file mode 100644 index 0000000..29ec5f3 --- /dev/null +++ b/test/pending_receipt_upload_test.dart @@ -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 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); + }); +} diff --git a/test/purge_data_test.dart b/test/purge_data_test.dart new file mode 100644 index 0000000..0a99422 --- /dev/null +++ b/test/purge_data_test.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.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'; + +/// Both purge paths reuse the same soft-delete tombstones as a normal +/// per-row delete (see [DatabaseService.softDeleteVehicle]) rather than a +/// real SQL `DELETE`, specifically so the deletion is something a cloud +/// sync can push to other devices instead of the rows just getting +/// silently re-imported from the remote copy on the next sync. +void main() { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('purge_data_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + Vehicle vehicle({required String id, required String vin}) => Vehicle( + id: id, + vin: vin, + updatedAt: DateTime.utc(2026, 1, 1), + ); + + FuelEntry entry({ + required String id, + required String vehicleId, + required DateTime date, + String? receiptImagePath, + }) => + FuelEntry( + id: id, + vehicleId: vehicleId, + date: date, + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 1, 1), + receiptImagePath: receiptImagePath, + ); + + group('purgeAllData', () { + test('soft-deletes every active vehicle and fuel entry, marking them dirty', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.saveVehicle(vehicle(id: 'v2', vin: 'VIN2')); + await database.saveFuelEntry(entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 1))); + await database.saveFuelEntry(entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 4, 1))); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + expect(await database.getVehicles(), isEmpty); + expect(await database.getFuelEntries(), isEmpty); + + final vehicleRows = await database.rawDb.query('vehicles'); + final entryRows = await database.rawDb.query('fuel_entries'); + expect(vehicleRows, hasLength(2), reason: 'tombstoned, not hard-deleted'); + expect(entryRows, hasLength(2)); + for (final row in [...vehicleRows, ...entryRows]) { + expect(row['deleted_at'], isNotNull); + expect(row['dirty'], 1); + } + }); + + test('empties the local receipts folder, including untracked files', () async { + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + final storedPath = + await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 1)); + // Simulate an orphaned file the DB never referenced. + final strayFile = File(p.join(database.receiptsDirectory.path, 'stray.jpg')) + ..writeAsBytesSync([9]); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + expect(await File(storedPath).exists(), isFalse); + expect(await strayFile.exists(), isFalse); + expect(await database.receiptsDirectory.exists(), isTrue, + reason: 'recreated empty, ready for future use'); + }); + + test('leaves already-deleted rows alone', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.softDeleteVehicle('v1', DateTime.utc(2026, 1, 2)); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + final rows = await database.rawDb.query('vehicles', where: 'id = ?', whereArgs: ['v1']); + expect(rows.single['deleted_at'], DateTime.utc(2026, 1, 2).millisecondsSinceEpoch); + }); + }); + + group('purgeFuelEntriesInRange', () { + test('soft-deletes only entries dated within the inclusive range', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.saveFuelEntry( + entry(id: 'before', vehicleId: 'v1', date: DateTime(2026, 2, 28)), + ); + await database.saveFuelEntry( + entry(id: 'start-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 1)), + ); + await database.saveFuelEntry( + entry(id: 'inside', vehicleId: 'v1', date: DateTime(2026, 3, 15)), + ); + await database.saveFuelEntry( + entry(id: 'end-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 31)), + ); + await database.saveFuelEntry( + entry(id: 'after', vehicleId: 'v1', date: DateTime(2026, 4, 1)), + ); + + await database.purgeFuelEntriesInRange( + DateTime(2026, 3, 1), + DateTime(2026, 3, 31), + DateTime.utc(2026, 8, 13), + ); + + final remaining = await database.getFuelEntries(); + expect(remaining.map((e) => e.id).toSet(), {'before', 'after'}); + expect(await database.getVehicles(), hasLength(1), reason: 'vehicles are untouched'); + }); + + test('returns local receipt paths for the caller to delete, without touching untracked files', + () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + final storedPath = + await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 15)); + await database.saveFuelEntry( + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 15), receiptImagePath: storedPath), + ); + + final paths = await database.purgeFuelEntriesInRange( + DateTime(2026, 3, 1), + DateTime(2026, 3, 31), + DateTime.utc(2026, 8, 13), + ); + + expect(paths, [storedPath]); + // purgeFuelEntriesInRange only reports paths — deleting the files + // themselves is the caller's job (AppState), mirroring + // softDeleteFuelEntriesForVehicle. + expect(await File(storedPath).exists(), isTrue); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/receipt_detail_screen_test.dart b/test/receipt_detail_screen_test.dart new file mode 100644 index 0000000..5e368fd --- /dev/null +++ b/test/receipt_detail_screen_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_image_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [ReceiptDetailScreen] itself — the single place to view a +/// receipt's photo and logged data together, with edit/delete for the +/// entry. Deliberately never taps through to an actual delete: that would +/// call [AppState.deleteFuelEntry], which needs an initialized database not +/// set up here (see settings_purge_test.dart for the same convention). +void main() { + final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', nickname: 'Red Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14, 9, 30), + gallons: 10.523, + pricePerGallon: 3.499, + totalCost: 36.81, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + + Future pumpDetail(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: ReceiptDetailScreen(entryId: 'e1')), + ), + ); + } + + testWidgets('shows the vehicle, date, gallons, price, and total', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + expect(find.text('Red Truck'), findsOneWidget); + expect(find.text('VIN: 1FMPU18L1TLB51349'), findsOneWidget); + expect(find.text('10.523 gal'), findsOneWidget); + expect(find.text('\$3.50'), findsOneWidget, reason: 'price per gallon'); + expect(find.text('\$36.81'), findsOneWidget, reason: 'total cost'); + }); + + testWidgets('shows a fallback instead of crashing when the entry no longer exists', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = []; + + await pumpDetail(tester, appState); + + expect(find.text('Receipt not found'), findsOneWidget); + }); + + testWidgets('the edit icon opens EditFuelEntryScreen pre-filled', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byTooltip('Edit fuel entry')); + await tester.pumpAndSettle(); + + expect(find.byType(EditFuelEntryScreen), findsOneWidget); + expect(find.widgetWithText(TextFormField, '10.523'), findsOneWidget); + }); + + testWidgets('the delete icon shows a warning dialog; Cancel dismisses it without deleting', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byTooltip('Delete fuel entry')); + await tester.pumpAndSettle(); + + expect(find.text('Delete fuel entry?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Delete fuel entry?'), findsNothing); + expect(appState.fuelEntries, hasLength(1), reason: 'not deleted'); + }); + + testWidgets('tapping the photo opens the full-screen viewer', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byType(GestureDetector).first); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptImageScreen), findsOneWidget); + }); +} diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index 8aeda50..3753534 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -21,6 +21,37 @@ void main() { expect(result.totalCost, 44.44); }); + test('parses a tabular receipt where labels and values sit on separate lines', () { + // Mirrors a real Casey's receipt: "Pump / Gallons / Price" is a + // header row, with the actual values on the next line, and the + // total is phrased "Total Sale" rather than a bare "Total". + const receipt = ''' + Casey's + Store #4021 + 420 W. 6TH STREET + KEARNEY, MO 64060 + Date 08/08/2026 + Time 02:21 + VI + ########8533 + Pump Gallons Price + 07 32.115 \$ 3.759 + Product Amount + 87E10 \$ 120.72 + Total Sale \$ 120.72 + Auth # + Visa + Seq # 008299 + 98 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 32.115); + expect(result.pricePerGallon, 3.759); + expect(result.totalCost, 120.72); + }); + test('does not confuse SUBTOTAL with TOTAL', () { const receipt = ''' SUBTOTAL 10.00 @@ -67,6 +98,386 @@ void main() { expect(result.gallons, isNull); expect(result.pricePerGallon, isNull); expect(result.totalCost, isNull); + expect(result.date, isNull); + }); + }); + + group('ReceiptParser state parsing', () { + test('detects a state abbreviation directly before a ZIP code', () { + const receipt = ''' + Casey's + 420 W. 6TH STREET + KEARNEY, MO 64060 + '''; + + expect(ReceiptParser.parse(receipt).state, 'MO'); + }); + + test('detects a non-Missouri state the same way', () { + const receipt = ''' + Swift Stop #3 + 1809 Hobbs Hwy + Seminole TX 79360 + '''; + + expect(ReceiptParser.parse(receipt).state, 'TX'); + }); + + test('falls back to a state right after a comma when no ZIP is nearby', () { + const receipt = ''' + Buc-ee's + 6988 Buc-ee's Blvd + Leeds, AL + '''; + + expect(ReceiptParser.parse(receipt).state, 'AL'); + }); + + test('does not mistake "VI" (Visa) for the Virgin Islands', () { + const receipt = ''' + Casey's + VI + ########8533 + Pump Gallons Price + 07 32.115 \$ 3.759 + '''; + + expect(ReceiptParser.parse(receipt).state, isNull); + }); + + test('does not mistake the word "In" for Indiana', () { + const receipt = ''' + Gallons 10.000 + Price/Gal \$3.399 + Total Fuel \$33.99 + All Taxes Included + In Fuel Price. + '''; + + expect(ReceiptParser.parse(receipt).state, isNull); + }); + + test('returns null when no address-like state pattern is present', () { + expect(ReceiptParser.parse('THANK YOU FOR YOUR PURCHASE').state, isNull); + }); + }); + + group('ReceiptParser date parsing', () { + test('parses "Date: M/D/YYYY" + "Time: H:MM:SS AM/PM" on separate lines', () { + const receipt = ''' + Date: 8/22/2024 + Time: 10:11:00 AM + Gallons 10.000 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2024, 8, 22, 10, 11)); + }); + + test('parses "DATE M/D/YY H:MM" on the same line', () { + const receipt = ''' + DATE 3/26/22 18:12 + PUMP# 06 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2022, 3, 26, 18, 12)); + }); + + test('parses a 2-digit year and a no-space AM/PM time', () { + const receipt = ''' + Date 01/23/20 + Time 02:11PM + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2020, 1, 23, 14, 11)); + }); + + test('12 AM and 12 PM convert correctly (midnight/noon edge case)', () { + expect(ReceiptParser.parse('Date 1/1/24 12:00 AM').date, DateTime(2024, 1, 1, 0, 0)); + expect(ReceiptParser.parse('Date 1/1/24 12:30 PM').date, DateTime(2024, 1, 1, 12, 30)); + }); + + test('falls back to midnight when a date is found but no time is nearby', () { + // Comfortably more than the 40-character "nearby" search window + // between the date and the unrelated text before a time appears + // much later, regardless of how this string literal is indented. + final filler = 'x' * 100; + final receipt = 'Date 12/14/16\n$filler\nTime 5:11 PM'; + + final result = ReceiptParser.parse(receipt); + + // Too far from the date to count as "nearby", so this should be + // date-only at midnight — still better than no date at all, and the + // confirm screen lets the user fix the time manually either way. + expect(result.date, isNotNull); + expect(result.date!.year, 2016); + expect(result.date!.month, 12); + expect(result.date!.day, 14); + expect(result.date!.hour, 0); + expect(result.date!.minute, 0); + }); + + test('returns null when no date-like pattern is present', () { + final result = ReceiptParser.parse('GALLONS 10.000\nPPG 3.500'); + + expect(result.date, isNull); + }); + }); + + // Transcribed from a batch of real photographed receipts across different + // gas station chains, to catch label/layout variations the handful of + // hand-written cases above don't happen to cover. + group('ReceiptParser against real receipt layouts', () { + test('Break Time — labels adjacent to values on the same line', () { + const receipt = ''' + Date: 8/22/2024 + Time: 10:11:00 AM + OXY87 + Pump Number 22 + Gallons 10.000 + Price/Gal \$3.399 + Total Fuel \$33.99 + Total Sale \$33.99 + Visa \$33.99 + All Taxes Included + In Fuel Price. + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 10.000); + expect(result.pricePerGallon, 3.399); + expect(result.totalCost, 33.99); + }); + + test('Break Time — tabular Pump/Gallons/Price header row', () { + const receipt = ''' + Member# 111795637651 + Invoice# 11733 + Date: 03/23/22 + Time: 10:05 + Auth# 020413 + VI Acct # + ************0344 + Pump Gallons Price + 14 5.116 \$ 5.499 + Product Amount + Regular \$ 28.13 + Total Sale \$ 28.13 + SALE- Contactless + Approved + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 5.116); + expect(result.pricePerGallon, 5.499); + expect(result.totalCost, 28.13); + }); + + test('Buc-ee\'s — abbreviated "PRICE/G" label', () { + const receipt = ''' + BUC-EE'S + 6988 Buc-ee's Blvd + Leeds AL + PUMP No. 63 + GALLONS 9.999 + PRICE/G \$2.499 + TOTAL FUEL \$24.99 + Regular + TOTAL SALE \$24.99 + Visa \$24.99 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 9.999); + expect(result.pricePerGallon, 2.499); + expect(result.totalCost, 24.99); + }); + + test('Costco — tabular header row, "Total Sale" phrasing', () { + const receipt = ''' + Costco #01448 + 1524 Beasie RD + Murfreesboro TN + Date: 01/03/24 + Time: 19:31 + Pump Gallons Price + 11 12.598 \$ 2.399 + Product Amount + Regular \$ 30.22 + Total Sale \$ 30.22 + SALE- Contactless + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 12.598); + expect(result.pricePerGallon, 2.399); + expect(result.totalCost, 30.22); + }); + + test('Murphy USA — "QTY(GAL)" label instead of "GALLONS"', () { + const receipt = ''' + PUMP: 8 + PROD: UNLEAD + PRICE/GAL: \$1.799 + NET/GAL: \$1.799 + QTY(GAL): 13.164 + FUEL TOTAL: \$23.68 + NET TOTAL: \$23.68 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 13.164); + expect(result.pricePerGallon, 1.799); + expect(result.totalCost, 23.68); + }); + + test('Phillips 66 — "PRICE/G" label and "FUEL SALE" as the total', () { + const receipt = ''' + WELCOME + LAWNSIDE PHILLIPS 66 + 355 Warrington Ave + Lawnside NJ 08045 + DATE 3/26/22 18:12 + TRAN# 060757 + PUMP# 06 + SERVICE LEVEL: FULL + PRODUCT: REGULAR + GALLONS 3.642 + PRICE/G \$4.119 + FUEL SALE \$15.00 + CREDIT \$15.00 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 3.642); + expect(result.pricePerGallon, 4.119); + expect(result.totalCost, 15.00); + }); + + test('Phillips 66 — second "FUEL SALE" example', () { + const receipt = ''' + WELCOME + SWIFT STOP #3 + 1809 HOBBS HWY + SEMINOLE TX 79360 + DATE 5/9/23 11:16 + TRAN#9103742 + PUMP# 10 + SERVICE LEVEL: SELF + PRODUCT: DIESEL 2 + GALLONS: 29.854 + PRICE/G: \$3.499 + FUEL SALE \$181.66 + CREDIT \$181.66 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 29.854); + expect(result.pricePerGallon, 3.499); + expect(result.totalCost, 181.66); + }); + + test('Phillips 66 / Murphy-style — "QTY(GAL)" plus "FUEL TOTAL"', () { + const receipt = ''' + Verified by PIN + PIN USED + PUMP: 5 + PROD: UNLEAD + PRICE/GAL: \$3.959 + NET/GAL: \$3.959 + QTY(GAL): 11.068 + FUEL TOTAL: \$43.82 + NET TOTAL: \$43.82 + GET REWARDED! + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 11.068); + expect(result.pricePerGallon, 3.959); + expect(result.totalCost, 43.82); + }); + + test('QuikTrip — tabular header row, total not printed on receipt (derived)', () { + const receipt = ''' + QUIKTRIP #01012 + 383 George Liles PWK + Concord, NC + Invoice # 0000000 + Date 01/23/20 + Time 02:11PM + Auth # 03581I + Acct # + ************1821 + Pump Gallons Price + 14 10.496 \$2.359 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 10.496); + expect(result.pricePerGallon, 2.359); + // Not printed within the visible receipt text — derived from + // gallons * price rather than found via a label. + expect(result.totalCost, closeTo(10.496 * 2.359, 0.01)); + }); + + test('Sinclair — unit letter attached with no space, and a negative ' + 'per-gallon discount line that must not be mistaken for gallons', () { + const receipt = ''' + PUMP# 3 + UNLEADED CR 17.364G + PRICE/GAL \$2.649 + DISCOUNTS BEFORE + FUELING + Debit Di/GAL \$-0.100 + FUEL TOTAL \$46.00 + DEBIT \$46.00 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 17.364); + expect(result.pricePerGallon, 2.649); + expect(result.totalCost, 46.00); + }); + + test('Sinclair — PPG and GALLONS/FUEL TOTAL both split across header/data rows', () { + const receipt = ''' + SINCLAIR + 5905 NW 72ND ST + KANSAS CITY MO + Date 12/14/16 + Time 5:11 PM + Auth 068113 + VISA + Pump Product PPG + 05 UNLD \$1.999 + Gallons Fuel Total + 9.505 \$19.00 + Thank you + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 9.505); + expect(result.pricePerGallon, 1.999); + // "Fuel Total" is split from its value by a newline in this layout, + // so it isn't label-matched — derived from gallons * price instead, + // landing within a cent of the printed $19.00. + expect(result.totalCost, closeTo(19.00, 0.01)); }); }); } diff --git a/test/receipt_storage_test.dart b/test/receipt_storage_test.dart new file mode 100644 index 0000000..252ee5e --- /dev/null +++ b/test/receipt_storage_test.dart @@ -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///` locally (mirroring +/// the `Receipts//` 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///', () 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 getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/receipts_screen_test.dart b/test/receipts_screen_test.dart new file mode 100644 index 0000000..644bafe --- /dev/null +++ b/test/receipts_screen_test.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipts_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Red Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + testWidgets('shows an empty state with no fuel entries', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(find.textContaining('No fuel entries'), findsOneWidget); + }); + + testWidgets('shows the hero total and a receipt row with date/amount/vehicle chip', + (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10.523, + pricePerGallon: 3.5, + totalCost: 32.48, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + // Hero total sums totalCost across all entries — just the one here. + expect(find.text('\$32.48'), findsNWidgets(2)); // hero total + row amount + expect(find.text('Total Fuel Cost'), findsOneWidget, reason: 'no year prefix'); + expect(find.text('Total Gallons'), findsOneWidget); + expect(find.text('11'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons'); + expect(find.text('May 14, 2026'), findsOneWidget); + expect(find.text('10.523 gal'), findsOneWidget, reason: 'row subtitle'); + expect(find.text('Red Truck'), findsOneWidget); + }); + + testWidgets( + 'shows an estimated fuel refund in the hero banner and next to each row\'s gallons ' + 'when the setting is on', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10.523, + pricePerGallon: 3.5, + totalCost: 32.48, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry] + ..showEstimatedFuelRefund = true; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(find.text('Est. Refund'), findsOneWidget, reason: 'replaces the hero icon'); + // 10.523 gal rounds up to 11, * $0.125/gal = $1.375, floored to $1.37 — + // the hero's own standalone stat value... + expect(find.text('\$1.37'), findsOneWidget); + // ...and the same figure again, folded into the row's combined text. + expect(find.textContaining('10.523 gal · Est. \$1.37'), findsOneWidget); + }); + + testWidgets("doesn't show an estimated fuel refund anywhere when the setting is off (default)", + (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10.523, + pricePerGallon: 3.5, + totalCost: 32.48, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(appState.showEstimatedFuelRefund, isFalse, reason: 'off by default'); + expect(find.text('Est. Refund'), findsNothing); + expect(find.textContaining('Est. \$'), findsNothing); + }); + + testWidgets('tapping a receipted row opens the receipt detail page', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.text('May 14, 2026')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget); + }); + + testWidgets('tapping a row with no receipt photo still opens the detail page', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.text('May 14, 2026')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget, + reason: 'the data is still viewable/editable even without a photo'); + }); + + testWidgets('formats large gallon totals with thousands separators', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 8, 5), + gallons: 1234.5678, + pricePerGallon: 3.5, + totalCost: 4320.99, + updatedAt: DateTime.utc(2026, 8, 5), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(find.text('1,235'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons'); + expect(find.text('1,234.568 gal'), findsOneWidget, reason: 'row subtitle keeps full precision'); + }); + + testWidgets('date range selector filters totals and the list, and recolors the active label', + (tester) async { + // These dates are derived from "now" rather than written out, because + // the screen filters against DateTime.now() (see _DateRange.startDate). + // Fixed dates quietly change which bucket they land in as the calendar + // moves — an earlier version of this test hardcoded August dates and + // started failing the moment September arrived. + final now = DateTime.now(); + final format = DateFormat.yMMMd(); + + // The 1st of this month: always inside all three ranges. + final recentDate = DateTime(now.year, now.month, 1); + // The 1st of last month: always inside Last 3 Months, never inside + // Current Month. Also inside Year to Date except in January, when + // "last month" lands in the previous year. + final midDate = DateTime(now.year, now.month - 1, 1); + final midInYearToDate = midDate.year == now.year; + // Jan 1 this year: always inside Year to Date. It only falls *outside* + // Last 3 Months from April onward — before that, Last 3 Months already + // reaches back to Jan 1 or earlier, so no date at all can be "Year to + // Date but not Last 3 Months" and that distinction isn't testable. + final oldDate = DateTime(now.year, 1, 1); + final oldOutsideLast3Months = oldDate.isBefore(DateTime(now.year, now.month - 2, 1)); + // In January the 1st of this month *is* Jan 1, so these two would + // render the same date text and findsOneWidget would double-count. + final oldIsDistinctFromRecent = oldDate != recentDate; + + final recentEntry = FuelEntry( + id: 'recent', + vehicleId: 'v1', + date: recentDate, + gallons: 10, + pricePerGallon: 3, + totalCost: 30, + updatedAt: recentDate.toUtc(), + receiptDriveFileId: 'r1', + ); + final midEntry = FuelEntry( + id: 'mid', + vehicleId: 'v1', + date: midDate, + gallons: 20, + pricePerGallon: 3, + totalCost: 60, + updatedAt: midDate.toUtc(), + receiptDriveFileId: 'r2', + ); + final oldEntry = FuelEntry( + id: 'old', + vehicleId: 'v1', + date: oldDate, + gallons: 40, + pricePerGallon: 3, + totalCost: 120, + updatedAt: oldDate.toUtc(), + receiptDriveFileId: 'r3', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [recentEntry, midEntry, oldEntry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + bool isBold(String label) => tester + .widgetList(find.text(label)) + .any((t) => t.style?.fontWeight == FontWeight.w800); + + // Defaults to Year to Date. + expect(find.text(format.format(recentDate)), findsOneWidget); + if (midInYearToDate) { + expect(find.text(format.format(midDate)), findsOneWidget); + } + if (oldIsDistinctFromRecent) { + expect(find.text(format.format(oldDate)), findsOneWidget); + } + expect(isBold('Year to Date'), isTrue, reason: 'selected by default'); + + await tester.tap(find.text('Last 3 Months')); + await tester.pumpAndSettle(); + + expect(find.text(format.format(recentDate)), findsOneWidget); + expect(find.text(format.format(midDate)), findsOneWidget, + reason: 'last month is always within the last three'); + if (oldOutsideLast3Months) { + expect(find.text(format.format(oldDate)), findsNothing, + reason: 'older than three months back'); + } + expect(isBold('Last 3 Months'), isTrue); + expect(isBold('Year to Date'), isFalse, reason: 'no longer selected'); + + await tester.tap(find.text('Current Month')); + await tester.pumpAndSettle(); + + expect(find.text(format.format(recentDate)), findsOneWidget); + expect(find.text(format.format(midDate)), findsNothing, + reason: 'not in the current month'); + if (oldIsDistinctFromRecent) { + expect(find.text(format.format(oldDate)), findsNothing); + } + }); + + testWidgets( + 'tapping + with no vehicles opens vehicle creation and prompts to create one for the receipt', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget); + expect(find.text('Create a vehicle to attach receipt'), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Create a vehicle to attach receipt'), findsNothing); + expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget, reason: 'still on the form'); + }); + + // Not covered here: that saving the new vehicle continues into receipt + // capture (rather than back to an empty Receipts tab). Exercising that + // needs AddEditVehicleScreen's real _save() to run, which touches a real + // database — and combining a real sqflite_common_ffi database with + // testWidgets (as opposed to a plain test()) reliably deadlocks in this + // project's test environment (confirmed directly: the identical + // `DatabaseService().init()` call returns immediately under test(), but + // times out under testWidgets()). Verified manually on-device instead. +} diff --git a/test/report_screen_test.dart b/test/report_screen_test.dart new file mode 100644 index 0000000..dadaf81 --- /dev/null +++ b/test/report_screen_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/screens/report_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +void main() { + testWidgets('shows both toggles, off by default, with the cross-reference hint only when both are on', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + + expect(find.text('Include Receipt Images'), findsOneWidget); + expect(find.text('Include Receipt Line Items'), findsOneWidget); + expect( + tester.widgetList(find.byType(SwitchListTile)).map((s) => s.value), + [false, false], + ); + expect(find.textContaining('matching number'), findsNothing); + + await tester.tap(find.text('Include Receipt Images')); + await tester.pumpAndSettle(); + expect(find.textContaining('matching number'), findsNothing, reason: 'only one toggle is on'); + + await tester.tap(find.text('Include Receipt Line Items')); + await tester.pumpAndSettle(); + expect(find.textContaining('matching number'), findsOneWidget, reason: 'both toggles now on'); + }); + + testWidgets( + 'shows a link to the state refund claim form, regardless of whether a range is picked', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + + expect(find.text('Need the refund claim form?'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Get the Motor Fuel Refund Claim form'), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.text('Get the Motor Fuel Refund Claim form'), findsOneWidget); + }); +} diff --git a/test/report_vehicle_selection_test.dart b/test/report_vehicle_selection_test.dart new file mode 100644 index 0000000..dab17e4 --- /dev/null +++ b/test/report_vehicle_selection_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/report_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the per-vehicle selection cards on the Reports tab: with a date +/// range in place (now defaulted automatically — see +/// defaultReportDateRange/ReportScreen.initState), every vehicle with a +/// receipted entry in range starts selected (colored like the "Share" +/// button), tapping one to deselect it +/// drops it from the total (and, by extension, the generated report) and +/// reverts its coloring to a plain unselected card, and deselecting every +/// vehicle shows a distinct "nothing selected" message rather than "no +/// entries in range". There's deliberately no checkbox — selection state is +/// conveyed by color alone. +void main() { + final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1)); + + // ReportScreen's date range now defaults to the current fuel-tax-refund + // period (see defaultReportDateRange) as soon as it opens — dating these + // entries to "now" means they always fall inside that default range, + // without these tests needing to drive the date pickers themselves. + final now = DateTime.now(); + + FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry( + id: id, + vehicleId: vehicleId, + date: now, + gallons: gallons, + pricePerGallon: cost / gallons, + totalCost: cost, + updatedAt: now.toUtc(), + receiptDriveFileId: 'remote-$id', + ); + + Future pumpReport(WidgetTester tester, {bool showEstimatedFuelRefund = false}) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB] + ..fuelEntries = [ + entryFor('a', 'e1', 10, 35), + entryFor('b', 'e2', 20, 70), + ] + ..showEstimatedFuelRefund = showEstimatedFuelRefund; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + return appState; + } + + Card cardFor(WidgetTester tester, String vehicleLabel) => tester.widget( + find.ancestor(of: find.text(vehicleLabel), matching: find.byType(Card)), + ); + + testWidgets('both vehicles start colored as selected once a range with entries for both is picked', + (tester) async { + await pumpReport(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + expect(cardFor(tester, 'Truck').color, colorScheme.primary); + expect(cardFor(tester, 'Van').color, colorScheme.primary); + expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total'); + }); + + testWidgets( + 'shows an estimated fuel refund below each vehicle\'s total cost and next to the ' + 'combined total, when the setting is on', (tester) async { + await pumpReport(tester, showEstimatedFuelRefund: true); + + // Truck: 10 gal * $0.125/gal = $1.25. Van: 20 gal * $0.125/gal = $2.50. + expect(find.text('Est. \$1.25'), findsOneWidget); + expect(find.text('Est. \$2.50'), findsOneWidget); + // Combined: 30 gal * $0.125/gal = $3.75, folded into the Total row. + expect(find.text('30.000 gal · \$105.00 · Est. \$3.75'), findsOneWidget); + }); + + testWidgets('shows no estimated fuel refund anywhere when the setting is off (default)', + (tester) async { + await pumpReport(tester); + + expect(find.textContaining('Est. \$'), findsNothing); + }); + + testWidgets('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total', + (tester) async { + await pumpReport(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + + expect(find.text('20.000 gal · \$70.00'), findsOneWidget, reason: "only Van's total remains"); + expect(cardFor(tester, 'Truck').color, isNot(colorScheme.primary), reason: 'deselected'); + expect(cardFor(tester, 'Van').color, colorScheme.primary, reason: 'still selected'); + }); + + testWidgets('tapping a deselected vehicle again reselects it (restores its coloring)', + (tester) async { + await pumpReport(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + + expect(cardFor(tester, 'Truck').color, colorScheme.primary); + expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again'); + }); + + testWidgets('tapping Preview opens the report preview screen', (tester) async { + await pumpReport(tester); + + await tester.scrollUntilVisible(find.text('Preview'), 300, scrollable: find.byType(Scrollable)); + await tester.tap(find.text('Preview')); + // Not pumpAndSettle: PdfPreview's own internal rendering isn't under + // test here (no platform channel mocked for it) — just that Preview + // actually navigates. A couple of bounded pumps are enough for that + // navigation (a synchronous PDF build, no plugin calls) to land. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Report Preview'), findsOneWidget); + }); + + testWidgets('deselecting every vehicle shows a distinct "nothing selected" message', + (tester) async { + await pumpReport(tester); + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Van')); + await tester.pumpAndSettle(); + + expect( + find.text('No vehicles selected — tap at least one above to build a report.'), + findsOneWidget, + ); + expect(find.text('Preview'), findsNothing); + expect(find.text('Share'), findsNothing); + expect(find.text('Print'), findsNothing); + // The cards themselves stay visible so the user can tap one again. + expect(find.text('Truck'), findsOneWidget); + expect(find.text('Van'), findsOneWidget); + }); +} diff --git a/test/settings_purge_test.dart b/test/settings_purge_test.dart new file mode 100644 index 0000000..45f0a5f --- /dev/null +++ b/test/settings_purge_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "Danger Zone" purge UI's warning-dialog gating, now folded +/// into the Settings > Data > Advanced section — the actual delete behavior +/// (soft-delete tombstones, dirty flags, receipt file cleanup) is covered +/// against a real database in purge_data_test.dart. This deliberately never +/// taps a dialog's destructive "Delete" button: doing so would call through +/// to [AppState.purgeAllData]/[AppState.purgeFuelEntriesInRange], which need +/// an initialized database — not set up here, matching every other Settings +/// widget test (see theme_toggle_test.dart) that stops short of a full +/// [AppState.init]. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpSettings(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: DataSettingsScreen()), + ), + ); + } + + // The purge controls sit inside the collapsed "Advanced" ExpansionTile, + // below the fold — every test needs to expand it and scroll it into view + // before tapping anything inside it. + Future scrollToDangerZone(WidgetTester tester) async { + await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable)); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Danger Zone'), 200, scrollable: find.byType(Scrollable)); + await tester.pumpAndSettle(); + } + + testWidgets('Purge All Data shows a warning dialog; Cancel dismisses it without purging', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data')); + await tester.pumpAndSettle(); + + expect(find.text('Purge all data?'), findsOneWidget); + expect(find.textContaining("can't be undone"), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Purge all data?'), findsNothing); + expect(appState.vehicles, isEmpty); + }); + + testWidgets('mentions the connected cloud provider in the warning when one is connected', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data')); + await tester.pumpAndSettle(); + + expect(find.textContaining('connected to'), findsNothing, + reason: 'no cloud provider connected in this test'); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('Purge Range is disabled until both a start and end date are picked', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + final purgeRangeButton = + tester.widget(find.widgetWithText(OutlinedButton, 'Purge Range')); + expect(purgeRangeButton.onPressed, isNull); + }); + + testWidgets('picking a start and end date enables Purge Range, which shows a warning dialog', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.text('Start date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('End date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + final purgeRangeButton = + tester.widget(find.widgetWithText(OutlinedButton, 'Purge Range')); + expect(purgeRangeButton.onPressed, isNotNull); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge Range')); + await tester.pumpAndSettle(); + + expect(find.text('Purge data in range?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); +} diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart new file mode 100644 index 0000000..d1876d0 --- /dev/null +++ b/test/settings_screen_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/faq_screen.dart'; +import 'package:fuel_tax_tracker/screens/settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the Settings landing page's menu wiring: it groups settings into +/// two submenus — [UiSettingsScreen] (appearance) and [DataSettingsScreen] +/// (cloud storage, photo syncing/quality, advanced/purge) — plus keeps a +/// read-only summary on the landing page itself. The submenu screens' +/// actual contents are covered by theme_toggle_test.dart and +/// settings_purge_test.dart respectively. +void main() { + Future pumpSettings(WidgetTester tester) async { + final appState = AppState()..isLoading = false; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: SettingsScreen()), + ), + ); + } + + testWidgets('shows UI, Data, FAQ, and User Agreement menu entries, plus the summary', + (tester) async { + await pumpSettings(tester); + + expect(find.text('UI'), findsOneWidget); + expect(find.text('Data'), findsOneWidget); + expect(find.text('FAQ'), findsOneWidget); + expect(find.text('User Agreement'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Summary'), 300, scrollable: find.byType(Scrollable)); + expect(find.text('Summary'), findsOneWidget); + // None of Data's contents leaked onto the landing page. + expect(find.text('Cloud Storage'), findsNothing); + expect(find.text('Advanced'), findsNothing); + }); + + testWidgets('tapping UI opens UiSettingsScreen', (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('UI')); + await tester.pumpAndSettle(); + + expect(find.byType(UiSettingsScreen), findsOneWidget); + expect(find.text('Appearance'), findsOneWidget); + }); + + testWidgets('tapping Data opens DataSettingsScreen', (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('Data')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsOneWidget); + expect(find.text('Cloud Storage'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable)); + expect(find.text('Advanced'), findsOneWidget); + }); + + testWidgets('tapping FAQ opens FaqScreen with its questions collapsed by default', + (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('FAQ')); + await tester.pumpAndSettle(); + + expect(find.byType(FaqScreen), findsOneWidget); + expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget); + // Answers are inside collapsed ExpansionTiles, not visible until tapped. + expect(find.textContaining('12.5¢ per gallon'), findsNothing); + + await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?')); + await tester.pumpAndSettle(); + + expect(find.textContaining('12.5¢ per gallon'), findsOneWidget); + }); + + testWidgets('tapping User Agreement opens it in review mode (no Decline/I Agree)', + (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('User Agreement')); + await tester.pumpAndSettle(); + + expect(find.byType(UserAgreementScreen), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing); + expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing); + }); + + testWidgets('Ads card offers both buying and restoring when ads are not disabled', + (tester) async { + await pumpSettings(tester); + + // Not tapped — buyAdFreeYear/restoreAdFreeYear reach a real platform + // channel via PurchaseService, which isn't set up in this test; only + // confirming both entry points render. + expect(find.widgetWithText(FilledButton, 'Remove Ads for a Year'), findsOneWidget); + expect(find.widgetWithText(TextButton, 'Restore Purchase'), findsOneWidget); + }); +} diff --git a/test/theme_toggle_test.dart b/test/theme_toggle_test.dart new file mode 100644 index 0000000..3466d24 --- /dev/null +++ b/test/theme_toggle_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the light/dark/system theme toggle on the Settings > UI screen — +/// defaults to System, selecting a segment updates [AppState.themeMode] +/// and persists it to SharedPreferences (so it survives a cold start). +/// +/// Deliberately doesn't round-trip through a second [AppState.init] to +/// verify persistence — that pulls in unrelated subsystems (connectivity +/// listening, cloud-restore) with no test-environment platform mocks, +/// which hangs rather than failing fast. Reading the pref back directly +/// verifies the same thing without that baggage. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('defaults to System and switches to Dark/Light on tap', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + expect(appState.themeMode, ThemeMode.system); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(appState.themeMode, ThemeMode.dark); + + await tester.tap(find.text('Light')); + await tester.pumpAndSettle(); + expect(appState.themeMode, ThemeMode.light); + }); + + testWidgets('persists the choice to SharedPreferences', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + // Mirrors the private `_prefsKeyThemeMode` key in app_state.dart — + // a black-box persistence check necessarily has to know the storage + // key, and this doubles as a guardrail against silently breaking + // already-persisted user settings by renaming it there. + expect(prefs.getString('theme_mode'), 'dark'); + }); + + group('Estimated Fuel Refund toggle', () { + testWidgets('off by default, flips on and persists when tapped', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + expect(appState.showEstimatedFuelRefund, isFalse); + expect(tester.widget(find.byType(SwitchListTile)).value, isFalse); + + await tester.tap(find.text('Estimated Fuel Refund')); + await tester.pumpAndSettle(); + + expect(appState.showEstimatedFuelRefund, isTrue); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('show_estimated_fuel_refund'), isTrue); + }); + + testWidgets('explanation mentions the Missouri Highway Fuel Tax Refund and the current rate', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + expect(find.textContaining('Missouri Highway Fuel Tax Refund'), findsOneWidget); + expect(find.textContaining('current rate'), findsOneWidget); + }); + }); +} diff --git a/test/user_agreement_screen_test.dart b/test/user_agreement_screen_test.dart new file mode 100644 index 0000000..d568a5b --- /dev/null +++ b/test/user_agreement_screen_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/main.dart'; +import 'package:fuel_tax_tracker/screens/faq_screen.dart'; +import 'package:fuel_tax_tracker/screens/main_shell.dart'; +import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [UserAgreementScreen] — the liability gate `main.dart`'s +/// `AppRoot` shows before [MainShell] until [AppState.hasAcceptedUserAgreement] +/// is true, so a fresh install sees it before anything else, including the +/// onboarding tour. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpScreen(WidgetTester tester) async { + // AppState.hasAcceptedUserAgreement defaults to true precisely so that + // ad-hoc AppState() construction in every other screen's tests doesn't + // trigger this gate — these tests explicitly flip it to false to + // reproduce a real fresh install's state. + final appState = AppState() + ..isLoading = false + ..hasAcceptedUserAgreement = false; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UserAgreementScreen()), + ), + ); + return appState; + } + + testWidgets('shows the data-loss liability section and both actions', (tester) async { + await pumpScreen(tester); + + await tester.scrollUntilVisible( + find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300, + scrollable: find.byType(Scrollable)); + expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget); + expect(find.textContaining('developer is not responsible for your data'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'I Agree'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Decline'), findsOneWidget); + }); + + testWidgets('shows the privacy section disclosing no tracking and AdMob-only data collection', + (tester) async { + await pumpScreen(tester); + + expect(find.text('Privacy'), findsOneWidget); + expect(find.textContaining("don't track or log your data"), findsOneWidget); + expect(find.textContaining('Google AdMob'), findsOneWidget); + }); + + testWidgets( + 'shows both refund types and a not-tax-advice / consult-a-professional disclaimer', + (tester) async { + await pumpScreen(tester); + + await tester.scrollUntilVisible(find.text('Not Tax or Legal Advice'), 300, + scrollable: find.byType(Scrollable)); + expect(find.text('Not Tax or Legal Advice'), findsOneWidget); + expect(find.textContaining('12.5¢ per gallon for highway use'), findsOneWidget); + expect(find.textContaining('29.5¢ per gallon for non-highway use'), findsOneWidget); + expect(find.textContaining('Nothing here is tax or legal advice'), findsOneWidget); + expect(find.textContaining('consult a tax professional'), findsOneWidget); + }); + + testWidgets('See the FAQ for more opens FaqScreen', (tester) async { + await pumpScreen(tester); + + await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200, + scrollable: find.byType(Scrollable)); + await tester.ensureVisible(find.text('See the FAQ for more')); + await tester.pumpAndSettle(); + await tester.tap(find.text('See the FAQ for more')); + await tester.pumpAndSettle(); + + expect(find.byType(FaqScreen), findsOneWidget); + }); + + testWidgets('I Agree marks the agreement accepted and persists it', (tester) async { + final appState = await pumpScreen(tester); + + await tester.tap(find.widgetWithText(FilledButton, 'I Agree')); + await tester.pumpAndSettle(); + + expect(appState.hasAcceptedUserAgreement, isTrue); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('has_accepted_user_agreement'), isTrue, + reason: 'persisted so the agreement never shows again on this device'); + }); + + testWidgets('Decline asks the platform to close the app, without accepting', (tester) async { + final appState = await pumpScreen(tester); + + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Decline')); + await tester.pumpAndSettle(); + + expect(calls.map((c) => c.method), contains('SystemNavigator.pop')); + expect(appState.hasAcceptedUserAgreement, isFalse); + }); + + group('isReview mode (reached from Settings, not the first-launch gate)', () { + // Pushed on top of a placeholder route (rather than set as `home` + // directly) so there's actually something to pop back to — matching + // how it's really reached (pushed from Settings) and letting the + // automaticallyImplyLeading back button behave realistically. + Future pumpReview(WidgetTester tester) async { + final appState = AppState()..isLoading = false; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('hides Decline/I Agree and shows the content with a back button instead', + (tester) async { + await pumpReview(tester); + + expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing); + expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing); + expect(find.byType(BackButton), findsOneWidget); + expect( + find.text('This is the agreement you accepted when you first opened Show Me ' + 'The Fuel Refund.'), + findsOneWidget, + ); + // Same legal content still shown, just not gating anything. + await tester.scrollUntilVisible( + find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300, + scrollable: find.byType(Scrollable)); + expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget); + }); + }); + + group('AppRoot gating', () { + Future pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async { + final appState = AppState() + ..isLoading = false + ..hasAcceptedUserAgreement = hasAcceptedUserAgreement; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: AppRoot()), + ), + ); + return appState; + } + + testWidgets('shows UserAgreementScreen instead of MainShell before it is accepted', + (tester) async { + await pumpRoot(tester, hasAcceptedUserAgreement: false); + + expect(find.byType(UserAgreementScreen), findsOneWidget); + expect(find.byType(MainShell), findsNothing); + }); + + testWidgets('shows MainShell once the agreement is accepted', (tester) async { + final appState = await pumpRoot(tester, hasAcceptedUserAgreement: false); + + await tester.tap(find.widgetWithText(FilledButton, 'I Agree')); + await tester.pumpAndSettle(); + + expect(appState.hasAcceptedUserAgreement, isTrue); + expect(find.byType(UserAgreementScreen), findsNothing); + expect(find.byType(MainShell), findsOneWidget); + }); + + testWidgets('shows MainShell directly when already accepted (returning user)', (tester) async { + await pumpRoot(tester, hasAcceptedUserAgreement: true); + + expect(find.byType(UserAgreementScreen), findsNothing); + expect(find.byType(MainShell), findsOneWidget); + }); + }); +} diff --git a/test/vehicle_detail_screen_test.dart b/test/vehicle_detail_screen_test.dart new file mode 100644 index 0000000..a08c8db --- /dev/null +++ b/test/vehicle_detail_screen_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "modern navigation" swipe added to the single-vehicle +/// receipt page: it's a PageView over every vehicle, starting on whichever +/// one was tapped — sliding left/right moves to the next/previous vehicle, +/// same as swiping between MainShell's bottom-nav tabs. +void main() { + final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleC = Vehicle(id: 'c', vin: 'VINCCCCCCCCCCCCCC', nickname: 'Sedan', updatedAt: DateTime.utc(2026, 1, 1)); + + Future pumpDetail(WidgetTester tester, AppState appState, String vehicleId) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicleId)), + ), + ); + } + + testWidgets('starts on the requested vehicle, not necessarily the first in the list', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'b'); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('a right-to-left swipe advances to the next vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'a'); + expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('a left-to-right swipe goes back to the previous vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'c'); + expect(find.widgetWithText(AppBar, 'Sedan'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('swiping does not go past the last vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB]; + + await pumpDetail(tester, appState, 'b'); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget, reason: 'stayed on the last page'); + }); + + testWidgets('a single vehicle has nothing to swipe to and just shows itself', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA]; + + await pumpDetail(tester, appState, 'a'); + + expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget); + }); + + testWidgets('shows an estimated fuel refund next to price/gal when the setting is on', + (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'a', + date: DateTime(2026, 5, 14, 9, 30), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA] + ..fuelEntries = [entry] + ..showEstimatedFuelRefund = true; + + await pumpDetail(tester, appState, 'a'); + + // 10 gal * $0.125/gal = $1.25. + expect(find.textContaining('\$3.50/gal · Est. \$1.25'), findsOneWidget); + }); + + testWidgets('shows no estimated fuel refund when the setting is off (default)', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'a', + date: DateTime(2026, 5, 14, 9, 30), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState, 'a'); + + expect(find.textContaining('Est. \$'), findsNothing); + }); +} diff --git a/test/vehicle_id_migration_test.dart b/test/vehicle_id_migration_test.dart new file mode 100644 index 0000000..c6451a5 --- /dev/null +++ b/test/vehicle_id_migration_test.dart @@ -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 getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/vin_parser_test.dart b/test/vin_parser_test.dart new file mode 100644 index 0000000..ee327ad --- /dev/null +++ b/test/vin_parser_test.dart @@ -0,0 +1,106 @@ +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('normalizes an O to 0 — real VINs never contain one, so it reads as a misread zero', () { + // Real OCR output from a Kia/Hyundai data plate photo: "0" (zero) + // came back as "O" (letter) — this is exactly why I/O/Q are excluded + // from real VINs in the first place (too easily confused with + // 1/0/9), which cuts both ways for OCR reading one back off a plate. + expect(VinParser.parse('V.I.N 5XYZKDAG2BGO50982 TYPE:MPY'), '5XYZKDAG2BG050982'); + }); + + test('normalizes an I to 1 the same way', () { + expect(VinParser.parse('VIN: 1HGCM82633AI04352'), '1HGCM82633A104352'); + }); + + test('does not match a run containing Q — unlike I/O, not a plausible misread digit', () { + expect(VinParser.parse('1HGCM82633AQ04352'), isNull); + }); + + test('retries with whitespace stripped when OCR splits the VIN across a stray space', () { + // Real OCR output from an etched-glass VIN photo: a space landed + // mid-VIN (likely two separate detected text regions on the curved + // surface), so no single unbroken 17-character run exists until the + // whitespace is collapsed. + expect(VinParser.parse('5XYZKD AG2BG050982'), '5XYZKDAG2BG050982'); + }); + + test('a whitespace-stripped retry is only a last resort, after a normal match is preferred', + () { + const text = ''' + PART NO 4F2CZ 58899BA12345 + VIN: 1HGCM82633A004352 + '''; + // The "VIN:"-labeled match (unaffected by the space in the unrelated + // part number above it) should win over anything the fallback might + // have found in the stripped text. + expect(VinParser.parse(text), '1HGCM82633A004352'); + }); + + 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); + }); + + test('matches a "V.I.N" label (period between each letter) — common on compliance/data plates', + () { + // Real text from a Kia/Hyundai-built data plate: "V.I.N" rather than + // "VIN:" — a manufacturer plate styling the labeled pattern didn't + // originally account for, so this fell through to relying on OCR + // reading the bare 17-char run cleanly. + const text = ''' + THIS VEHICLE CONFORMS TO ALL APPLICABLE U.S.A. FEDERAL + MOTOR VEHICLE SAFETY AND THEFT PREVENTION STANDARDS + IN EFFECT ON THE DATE OF MANUFACTURE SHOWN ABOVE + V.I.N 5XYZKDAG2BG050982 TYPE:MPV + '''; + + expect(VinParser.parse(text), '5XYZKDAG2BG050982'); + }); + + test('matches "V.I.N." with a trailing period too', () { + expect(VinParser.parse('V.I.N. 5XYZKDAG2BG050982'), '5XYZKDAG2BG050982'); + }); + + test('a "V.I.N" labeled match is still preferred over another candidate elsewhere', () { + const text = ''' + PART NO 4F2CZ58899BA12345 + V.I.N 5XYZKDAG2BG050982 + '''; + + expect(VinParser.parse(text), '5XYZKDAG2BG050982'); + }); + }); +} diff --git a/test/webdav_provider_credentials_test.dart b/test/webdav_provider_credentials_test.dart new file mode 100644 index 0000000..15371ab --- /dev/null +++ b/test/webdav_provider_credentials_test.dart @@ -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 = ''' + + + / + + + HTTP/1.1 200 OK + + +'''; + +/// 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 _store = {}; + + @override + Future write({required String key, required String value, required Map options}) async { + _store[key] = value; + } + + @override + Future read({required String key, required Map options}) async => _store[key]; + + @override + Future containsKey({required String key, required Map options}) async => + _store.containsKey(key); + + @override + Future delete({required String key, required Map options}) async { + _store.remove(key); + } + + @override + Future> readAll({required Map options}) async => Map.of(_store); + + @override + Future deleteAll({required Map options}) async => _store.clear(); +} diff --git a/test/webdav_provider_test.dart b/test/webdav_provider_test.dart new file mode 100644 index 0000000..414a5e8 --- /dev/null +++ b/test/webdav_provider_test.dart @@ -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 = ''' + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/ + + + + "abc123" + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/receipts/ + + + + "def456" + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/fuel_tax_tracker.db + + + + "ghi789" + + HTTP/1.1 200 OK + + +'''; + + 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 = ''' + + + /dav/MO-Fuel-Tax-Back/ + + + + "xyz111" + + HTTP/1.1 200 OK + + +'''; + + 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 = ''' + + + /dav/MO-Fuel-Tax-Back/lock-file.lock + + + + "lock999" + + HTTP/1.1 200 OK + + +'''; + + 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 = ''' + + + /dav/no-etag.txt + + + + + HTTP/1.1 200 OK + + +'''; + + final entries = parseWebDavMultistatus(xml, baseUrl); + + expect(entries.single.etag, isNull); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 25f65ba..1c2235d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,8 +17,8 @@ void main() { ), ); - expect(find.text('My Vehicles'), findsOneWidget); - expect(find.text('Add Vehicle'), findsOneWidget); + expect(find.text('Vehicles'), findsOneWidget); + expect(find.byTooltip('Add Vehicle'), findsOneWidget); expect(find.textContaining('No vehicles yet'), findsOneWidget); }); }