diff --git a/README.md b/README.md index fce924e..f160e3b 100644 --- a/README.md +++ b/README.md @@ -3,227 +3,37 @@ A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt -photo. Vehicles and fuel entries live in a local SQLite database that's kept -in sync with a shared 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. +photo to a data file in a folder you choose. ## Features -- 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). +- 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). - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete. -- 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. +- 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). ## Project layout ``` lib/ - models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization - (toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and - `deletedAt` (a soft-delete tombstone, so deletions sync too). 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. + models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization services/ - 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. + 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 screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail, - confirm fuel entry, receipt viewer, settings, cloud folder browser). + confirm fuel entry, receipt viewer, settings) ``` -## Manual setup required (cloud storage) - -You only need to complete setup for whichever provider(s) you actually want -to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same -shape: I can't provision cloud resources on your behalf, so each needs an -app/OAuth client you create yourself in that provider's own developer -console, with the resulting IDs pasted into -`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect" -button for one of these three once its config fields are filled in with -real values. **WebDAV needs none of this** — see its section below. - -### Google Drive (Google Cloud Console) - -In [Google Cloud Console](https://console.cloud.google.com/): - -1. Create/select a project, enable the **Google Drive API** (APIs & Services - → Library). -2. **OAuth consent screen**: set it to **External**, keep it in **Testing** - status, and add your Google account plus everyone else's you're sharing - with as **test users**. This avoids Google's formal verification review, - which the broad `drive` scope would otherwise require — fine for a known, - small group, not a public release. -3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen - (shows as "restricted/sensitive" — expected, fine in Testing mode). -4. **Credentials → Create Credentials → OAuth client ID**, three times: - - **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the - SHA-1 of your debug keystore (`keytool -list -v -keystore - ~/.android/debug.keystore`, password `android`), and later your release - keystore's SHA-1 too. This client ID itself is never referenced in - code — it exists purely so Android's Credential Manager trusts this - specific signed app. - - **Web application**: no redirect URIs needed. Copy its **Client ID** — - this is what Android sign-in actually authenticates against (a - Credential Manager quirk: it needs a *web* client ID, passed as - `serverClientId`, even for a mobile app). - - **iOS**: bundle ID matching the Xcode project. Copy its **Client ID** - and note the reversed form (`com.googleusercontent.apps.<...>`). -5. Fill in `lib/services/cloud_oauth_config.dart`: - - `googleAndroidServerClientId` ← the **Web application** client's ID. - - `googleIosClientId` ← the **iOS** client's ID. -6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` → - `CFBundleURLSchemes` with your iOS client's reversed ID. - -### Dropbox (Dropbox App Console) - -Dropbox has no official Flutter sign-in SDK, so this uses a hand-built -OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a -public/mobile app) via `flutter_web_auth_2`, which opens the system browser -and catches the redirect through a custom URL scheme already registered in -`AndroidManifest.xml` / `Info.plist`. - -1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps). -2. Access type: **Full Dropbox** (not "App folder") — needed because - browsing to and reusing a folder someone *else* created and shared - requires seeing the whole account, the same reasoning as Google's full - `drive` scope above. -3. Under **OAuth 2** → **Redirect URIs**, add - `mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already - wired up in the Android manifest and iOS Info.plist; use a different one - only if you also update those two files to match). -4. Copy the app's **App key** from the Settings tab. -5. Fill in `lib/services/cloud_oauth_config.dart`: - - `dropboxAppKey` ← the App key. - - `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`. -6. While the app is in **Development** status, only your own Dropbox - account can sign in; add teammates under the app's **Permissions** / - member-access settings, or apply for **Production** status, once you're - ready to share it with others. - -### OneDrive (Azure Portal / Microsoft Graph) - -Same PKCE approach as Dropbox, against the Microsoft identity platform and -Microsoft Graph. - -1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → - **App registrations** → **New registration**. -2. Supported account types: **Personal Microsoft accounts only** (or "any - organizational directory and personal Microsoft accounts" if you also - want work/school accounts to be able to sign in — those may additionally - need their tenant admin's consent for the scopes below). -3. Under **Authentication** → **Add a platform** → **Mobile and desktop - applications**, add the redirect URI `mofueltaxback-onedrive://auth` - (this exact scheme is already wired up in the Android manifest and iOS - Info.plist; use a different one only if you also update those two files - to match). -4. Under **API permissions**, add Microsoft Graph **delegated** permissions - `Files.ReadWrite.All` and `offline_access` (the latter is required to get - a refresh token back from the token endpoint). -5. Copy the **Application (client) ID** from the Overview page. -6. Fill in `lib/services/cloud_oauth_config.dart`: - - `oneDriveClientId` ← the Application (client) ID. - - `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`. - -### WebDAV (self-hosted — no developer console needed) - -This is the option for a personal cloud server you already run — Nextcloud, -ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare -Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere; -tapping "Connect WebDAV" in Settings just opens a form asking for: - -- **Server URL** — the full WebDAV endpoint, e.g. - `https://cloud.example.com/remote.php/dav/files/yourusername/` for - Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you. - `https://` is assumed if you omit the scheme. -- **Username** and **Password** — for servers that support app-specific - passwords (Nextcloud: Settings → Security → "Create new app password"), - use one of those instead of your real account password, so this app can - be revoked independently later. - -The app verifies the URL and credentials with a harmless `PROPFIND` request -before treating you as signed in, so a typo or wrong password fails -immediately with a clear error rather than surfacing later during sync. -Nothing needs to be filled in `cloud_oauth_config.dart` or the -Android/iOS manifest files for this provider. +Data is stored as a single `fuel_tax_data.json` file plus a `receipts/` +subfolder of photos, both inside whatever directory Settings points at. ## Running it @@ -241,145 +51,43 @@ flutter build ios --release # iOS (requires a full Xcode install + signing This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and `flutter test` are clean, and `flutter build apk --debug` has been confirmed -to produce a working APK. None of the three providers' sign-in/sync paths -can be exercised end-to-end until you've done that provider's manual OAuth -setup above. +to produce a working APK. ## Permissions - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already set up for receipt capture. -- **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. +- **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. ## Known caveats / things to revisit -- **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. +- **`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`). - **Receipt parsing is best-effort regex matching** on the OCR'd text (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may parse partially or not at all — the confirm screen always lets you fill in or correct whatever wasn't found. -- No automated tests exercise the OCR, camera/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. +- 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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c63541b..ab01282 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,6 @@ - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png deleted file mode 100644 index a6ab1bf..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png deleted file mode 100644 index cd370fb..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png deleted file mode 100644 index b3b4db8..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png deleted file mode 100644 index b261793..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null 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 deleted file mode 100644 index a6ab1bf..0000000 Binary files a/android/app/src/main/res/drawable-night-hdpi/android12splash.png and /dev/null 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 deleted file mode 100644 index cd370fb..0000000 Binary files a/android/app/src/main/res/drawable-night-hdpi/splash.png and /dev/null 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 deleted file mode 100644 index b3b4db8..0000000 Binary files a/android/app/src/main/res/drawable-night-mdpi/android12splash.png and /dev/null 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 deleted file mode 100644 index b261793..0000000 Binary files a/android/app/src/main/res/drawable-night-mdpi/splash.png and /dev/null 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 deleted file mode 100644 index 649db16..0000000 Binary files a/android/app/src/main/res/drawable-night-v21/background.png and /dev/null 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 deleted file mode 100644 index 3cc4948..0000000 --- a/android/app/src/main/res/drawable-night-v21/launch_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png deleted file mode 100644 index 77c3f18..0000000 Binary files a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and /dev/null 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 deleted file mode 100644 index a5169e6..0000000 Binary files a/android/app/src/main/res/drawable-night-xhdpi/splash.png and /dev/null 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 deleted file mode 100644 index c406a4e..0000000 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and /dev/null 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 deleted file mode 100644 index e9835d6..0000000 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/splash.png and /dev/null 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 deleted file mode 100644 index 7aad901..0000000 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and /dev/null 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 deleted file mode 100644 index 63ef10c..0000000 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png deleted file mode 100644 index 649db16..0000000 Binary files a/android/app/src/main/res/drawable-night/background.png and /dev/null 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 deleted file mode 100644 index 3cc4948..0000000 --- a/android/app/src/main/res/drawable-night/launch_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png deleted file mode 100644 index 8e24958..0000000 Binary files a/android/app/src/main/res/drawable-v21/background.png and /dev/null 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 3cc4948..f74085f 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,9 +1,12 @@ + - - - - - - + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png deleted file mode 100644 index 77c3f18..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png deleted file mode 100644 index a5169e6..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png deleted file mode 100644 index c406a4e..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png deleted file mode 100644 index e9835d6..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png deleted file mode 100644 index 7aad901..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index 63ef10c..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png deleted file mode 100644 index 8e24958..0000000 Binary files a/android/app/src/main/res/drawable/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 3cc4948..304732f 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,9 +1,12 @@ + - - - - - - + + + + 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 65ca23d..db77bb4 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 25fe56c..17987b7 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 41a03e6..09d4391 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 358883b..d5f1c8d 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 97050a2..4d6372e 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 deleted file mode 100644 index 34cb63f..0000000 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index dbc9ea9..06952be 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -5,10 +5,6 @@ @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 0d1fa8f..cb1ef88 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,10 +5,6 @@ @drawable/launch_background - false - false - false - shortEdges - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID - - - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - mofueltaxback-dropbox - - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - mofueltaxback-onedrive - - - - UIStatusBarHidden + 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 + + diff --git a/lib/main.dart b/lib/main.dart index f1afe70..5733b13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'screens/main_shell.dart'; +import 'screens/home_screen.dart'; import 'services/app_state.dart'; -import 'theme/app_theme.dart'; void main() { runApp(const FuelTaxTrackerApp()); @@ -16,14 +15,15 @@ class FuelTaxTrackerApp extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => AppState()..init(), - child: Consumer( - builder: (context, appState, _) => MaterialApp( - title: 'Fuel Tax Tracker', - theme: AppTheme.light, - darkTheme: AppTheme.dark, - themeMode: appState.themeMode, - home: const AppRoot(), + 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(), ), ); } @@ -41,33 +41,7 @@ class AppRoot extends StatelessWidget { body: Center(child: CircularProgressIndicator()), ); } - if (appState.initError != null) { - return Scaffold( - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - const SizedBox(height: 16), - Text( - 'Could not start the app:\n${appState.initError}', - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - FilledButton( - onPressed: () => appState.init(), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ); - } - return const MainShell(); + return const HomeScreen(); }, ); } diff --git a/lib/models/fuel_entry.dart b/lib/models/fuel_entry.dart index 730c886..1dcf87f 100644 --- a/lib/models/fuel_entry.dart +++ b/lib/models/fuel_entry.dart @@ -1,21 +1,11 @@ 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, @@ -24,75 +14,26 @@ class FuelEntry { required this.gallons, required this.pricePerGallon, required this.totalCost, - required this.updatedAt, this.receiptImagePath, - this.receiptDriveFileId, - this.deletedAt, }); - /// 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() => { + Map toJson() => { 'id': id, - 'vehicle_id': vehicleId, - 'date': date.millisecondsSinceEpoch, + 'vehicleId': vehicleId, + 'date': date.toIso8601String(), 'gallons': gallons, - 'price_per_gallon': pricePerGallon, - 'total_cost': totalCost, - 'receipt_image_path': receiptImagePath, - 'receipt_drive_file_id': receiptDriveFileId, - 'updated_at': updatedAt.millisecondsSinceEpoch, - 'deleted_at': deletedAt?.millisecondsSinceEpoch, + 'pricePerGallon': pricePerGallon, + 'totalCost': totalCost, + 'receiptImagePath': receiptImagePath, }; - 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, + 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?, ); } diff --git a/lib/models/vehicle.dart b/lib/models/vehicle.dart index ca3b57a..42ea698 100644 --- a/lib/models/vehicle.dart +++ b/lib/models/vehicle.dart @@ -1,79 +1,48 @@ 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; - - /// 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; + final String make; + final String model; + final String color; + final String licensePlate; Vehicle({ required this.id, - required this.vin, - required this.updatedAt, - this.nickname, - this.deletedAt, + required this.make, + required this.model, + required this.color, + required this.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; - } + String get displayName => '$color $make $model ($licensePlate)'; - /// Note: [id] is intentionally not overridable here — it's the hidden - /// identifier, not an editable field. Vehicle copyWith({ - String? vin, - String? nickname, - bool clearNickname = false, - DateTime? updatedAt, - DateTime? deletedAt, + String? make, + String? model, + String? color, + String? licensePlate, }) { return Vehicle( id: id, - vin: vin ?? this.vin, - nickname: clearNickname ? null : (nickname ?? this.nickname), - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt ?? this.deletedAt, + make: make ?? this.make, + model: model ?? this.model, + color: color ?? this.color, + licensePlate: licensePlate ?? this.licensePlate, ); } - Map toMap() => { + Map toJson() => { 'id': id, - 'vin': vin, - 'nickname': nickname, - 'updated_at': updatedAt.millisecondsSinceEpoch, - 'deleted_at': deletedAt?.millisecondsSinceEpoch, + 'make': make, + 'model': model, + 'color': color, + 'licensePlate': licensePlate, }; - 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, + 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, ); } diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart index 5b08f8d..4390db9 100644 --- a/lib/screens/add_edit_vehicle_screen.dart +++ b/lib/screens/add_edit_vehicle_screen.dart @@ -1,30 +1,15 @@ -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. [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. +/// omit it to create a new one. class AddEditVehicleScreen extends StatefulWidget { final Vehicle? vehicle; - final bool promptToCreateForReceipt; - const AddEditVehicleScreen({ - super.key, - this.vehicle, - this.promptToCreateForReceipt = false, - }); + const AddEditVehicleScreen({super.key, this.vehicle}); @override State createState() => _AddEditVehicleScreenState(); @@ -32,249 +17,52 @@ class AddEditVehicleScreen extends StatefulWidget { class _AddEditVehicleScreenState extends State { final _formKey = GlobalKey(); - late final TextEditingController _nicknameController; - late final TextEditingController _vinController; - - bool _saving = false; - bool _scanningVin = false; - bool _checkingDuplicateVin = false; - String? _error; + late final TextEditingController _makeController; + late final TextEditingController _modelController; + late final TextEditingController _colorController; + late final TextEditingController _plateController; bool get _isEditing => widget.vehicle != null; @override void initState() { super.initState(); - _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'), - ), - ], - ), - ); - }); - } + _makeController = TextEditingController(text: widget.vehicle?.make ?? ''); + _modelController = TextEditingController(text: widget.vehicle?.model ?? ''); + _colorController = TextEditingController(text: widget.vehicle?.color ?? ''); + _plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? ''); } @override void dispose() { - _nicknameController.dispose(); - _vinController.dispose(); + _makeController.dispose(); + _modelController.dispose(); + _colorController.dispose(); + _plateController.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; - setState(() { - _saving = true; - _error = null; - }); - - 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); + 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(), + ); } + + if (mounted) Navigator.of(context).pop(); } Future _confirmDelete() async { @@ -284,7 +72,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!.displayLabel}. This cannot be undone.', + '${widget.vehicle!.displayName}. This cannot be undone.', ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), @@ -326,49 +114,36 @@ class _AddEditVehicleScreenState extends State { padding: const EdgeInsets.all(16), children: [ TextFormField( - controller: _nicknameController, - decoration: const InputDecoration( - labelText: 'Nickname (optional)', - hintText: 'e.g. Mom\'s Car, Red Ford F-150', - ), + controller: _makeController, + decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'), textCapitalization: TextCapitalization.words, + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), const SizedBox(height: 12), TextFormField( - 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: _validateVin, - onChanged: _isEditing ? null : (_) => _checkForExistingVin(), + 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'), + textCapitalization: TextCapitalization.characters, + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), - if (_error != null) ...[ - const SizedBox(height: 12), - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], const SizedBox(height: 24), FilledButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), + onPressed: _save, + child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), ), ], ), diff --git a/lib/screens/cloud_folder_browser_screen.dart b/lib/screens/cloud_folder_browser_screen.dart deleted file mode 100644 index 3756208..0000000 --- a/lib/screens/cloud_folder_browser_screen.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../services/app_state.dart'; -import '../services/cloud/cloud_storage_provider.dart'; - -enum _BrowseRoot { myFiles, sharedWithMe } - -/// Lets the user navigate whichever cloud storage provider is connected — -/// their own files, and (if the provider supports it) files others have -/// shared with them — and pick a parent location. Confirming looks for (or -/// creates) the `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 a1290bf..554dc56 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -33,16 +33,12 @@ class _ConfirmFuelEntryScreenState extends State { late final TextEditingController _gallonsController; late final TextEditingController _priceController; late final TextEditingController _totalController; - late DateTime _date; + DateTime _date = DateTime.now(); 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) ?? '', ); @@ -73,20 +69,11 @@ 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: firstDate, - lastDate: lastDate, + firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)), + lastDate: DateTime.now(), ); if (pickedDate == null || !mounted) return; @@ -149,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State { GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path), + builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), ), ), child: ClipRRect( diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart deleted file mode 100644 index f0a06d2..0000000 --- a/lib/screens/data_settings_screen.dart +++ /dev/null @@ -1,552 +0,0 @@ -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); - } - } - - /// 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)) - : const Icon(Icons.sync), - label: const Text('Sync Now'), - ), - TextButton.icon( - onPressed: _busy ? null : _disconnect, - icon: const Icon(Icons.logout), - label: const Text('Disconnect'), - ), - ], - ), - const SizedBox(height: 8), - 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 deleted file mode 100644 index c331158..0000000 --- a/lib/screens/edit_fuel_entry_screen.dart +++ /dev/null @@ -1,186 +0,0 @@ -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) — 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; - bool _saving = false; - - @override - void initState() { - super.initState(); - _date = widget.entry.date; - _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, - 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(); - - 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), - 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/home_screen.dart b/lib/screens/home_screen.dart index 983a34e..cc5e1fb 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,12 +3,10 @@ import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; -import '../services/onboarding_keys.dart'; import 'add_edit_vehicle_screen.dart'; +import 'settings_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}); @@ -19,15 +17,13 @@ class HomeScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Vehicles'), - actionsPadding: const EdgeInsets.only(right: 20), + title: const Text('My Vehicles'), actions: [ IconButton( - key: OnboardingKeys.vehiclesAddButton, - icon: const Icon(Icons.add), - tooltip: 'Add Vehicle', + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()), + MaterialPageRoute(builder: (_) => const SettingsScreen()), ), ), ], @@ -44,6 +40,13 @@ 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'), + ), ); } } @@ -56,49 +59,22 @@ 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: InkWell( + 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), 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 deleted file mode 100644 index be381c7..0000000 --- a/lib/screens/main_shell.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.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; - final void 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 — currently 12.5¢ back " - 'per gallon.\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: () {}, - ), - _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), - ), - _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), - ), - _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), - ), - _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), - ), - _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; - // 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) is what actually waits for this screen to be ready. - unawaited(Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DataSettingsScreen()), - )); - }, - ), - ]; - - @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) 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)); - } - } - - Future _showTourStep() async { - final step = _tourSteps[_tourStepIndex]; - step.activate(); - await _waitForTargets(step.targetKeys); - 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; - } - 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 deleted file mode 100644 index 929c91e..0000000 --- a/lib/screens/receipt_detail_screen.dart +++ /dev/null @@ -1,128 +0,0 @@ -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 1913605..8a1408a 100644 --- a/lib/screens/receipt_image_screen.dart +++ b/lib/screens/receipt_image_screen.dart @@ -1,73 +1,12 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/app_state.dart'; +/// Full-screen, pinch-zoomable view of a saved receipt photo. +class ReceiptImageScreen extends StatelessWidget { + final String imagePath; -/// Full-screen, pinch-zoomable view of a receipt photo. Pass -/// [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(); - } - } + const ReceiptImageScreen({super.key, required this.imagePath}); @override Widget build(BuildContext context) { @@ -78,26 +17,11 @@ class _ReceiptImageScreenState extends State { foregroundColor: Colors.white, title: const Text('Receipt'), ), - body: Center(child: _buildBody()), + body: Center( + child: InteractiveViewer( + child: Image.file(File(imagePath)), + ), + ), ); } - - Widget _buildBody() { - if (widget.localImagePath != null) { - return InteractiveViewer(child: Image.file(File(widget.localImagePath!))); - } - if (_loading) { - return const CircularProgressIndicator(color: Colors.white); - } - if (_error != null) { - return Padding( - padding: const EdgeInsets.all(24), - child: Text(_error!, style: const TextStyle(color: Colors.white)), - ); - } - if (_downloadedBytes != null) { - return InteractiveViewer(child: Image.memory(_downloadedBytes!)); - } - return const SizedBox.shrink(); - } } diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart deleted file mode 100644 index 48b5c7b..0000000 --- a/lib/screens/receipts_screen.dart +++ /dev/null @@ -1,334 +0,0 @@ -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/onboarding_keys.dart'; -import '../theme/app_theme.dart'; -import '../widgets/hero_banner.dart'; -import '../widgets/receipt_capture.dart'; -import 'add_edit_vehicle_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), - ), - ], - ), - 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), - ), - 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, - 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; - - const _HeroTotal({ - required this.totalCost, - required this.totalGallons, - required this.currencyFormat, - required this.gallonsFormat, - required this.selectedRange, - required this.onRangeSelected, - }); - - @override - Widget build(BuildContext context) { - return HeroBanner( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HeroStatsRow( - gallonsValue: gallonsFormat.format(totalGallons), - costValue: currencyFormat.format(totalCost), - ), - 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 VoidCallback onTap; - - const _ReceiptRow({ - required this.entry, - required this.vehicle, - required this.dateFormat, - required this.currencyFormat, - required this.gallonsFormat, - 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( - '${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 deleted file mode 100644 index 2cb4a32..0000000 --- a/lib/screens/report_screen.dart +++ /dev/null @@ -1,390 +0,0 @@ -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/fuel_report.dart'; -import '../services/fuel_report_images.dart'; -import '../services/fuel_report_pdf.dart'; -import '../services/onboarding_keys.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; - - Future _pickDate({required bool isStart}) async { - final now = DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: (isStart ? _startDate : _endDate) ?? now, - firstDate: DateTime(2000), - lastDate: now, - ); - 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)); - } 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)); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - 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')), - 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), - 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)}', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 16), - Row( - key: OnboardingKeys.reportShareAndPrintRow, - children: [ - 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 VoidCallback onTap; - - const _VehicleSelectionCard({required this.row, required this.selected, 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), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6b86714..43005b6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,20 +1,42 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; -import '../services/onboarding_keys.dart'; -import 'data_settings_screen.dart'; -import 'ui_settings_screen.dart'; -/// 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 { +class SettingsScreen extends StatefulWidget { 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')), @@ -22,26 +44,41 @@ class SettingsScreen extends StatelessWidget { 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()), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Save Location', 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), + ], + 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 deleted file mode 100644 index e183c25..0000000 --- a/lib/screens/ui_settings_screen.dart +++ /dev/null @@ -1,47 +0,0 @@ -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), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 90ae960..84fe3c0 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -1,66 +1,92 @@ +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 '../widgets/hero_banner.dart'; -import '../widgets/receipt_capture.dart'; -import '../widgets/receipt_thumbnail.dart'; +import '../services/ocr_service.dart'; +import '../services/receipt_parser.dart'; import 'add_edit_vehicle_screen.dart'; -import 'receipt_detail_screen.dart'; +import 'confirm_fuel_entry_screen.dart'; +import 'receipt_image_screen.dart'; -/// 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 { +class VehicleDetailScreen extends StatelessWidget { final String vehicleId; const VehicleDetailScreen({super.key, required this.vehicleId}); - @override - State createState() => _VehicleDetailScreenState(); -} + 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; -class _VehicleDetailScreenState extends State { - late final PageController _pageController; + final imageFile = File(photo.path); - @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); - } + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); - @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'))); + final ocrService = OcrService(); + String recognizedText = ''; + try { + recognizedText = await ocrService.recognizeText(imageFile); + } catch (_) { + recognizedText = ''; + } finally { + ocrService.dispose(); } - return PageView.builder( - controller: _pageController, - itemCount: vehicles.length, - itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id), - ); + 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, + ), + ), + ); + } } -} -class _VehicleDetailPage extends StatelessWidget { - final String vehicleId; - - const _VehicleDetailPage({required this.vehicleId}); + 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 Widget build(BuildContext context) { @@ -73,16 +99,12 @@ class _VehicleDetailPage 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.displayLabel), + title: Text(vehicle.displayName), actions: [ IconButton( icon: const Icon(Icons.edit_outlined), @@ -95,74 +117,59 @@ class _VehicleDetailPage extends StatelessWidget { ), body: Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: HeroBanner( - child: HeroStatsRow( - gallonsValue: heroGallonsFormat.format(totalGallons), - costValue: currencyFormat.format(totalCost), - ), + 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, + ), + ], ), ), 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: 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\n' - '${dateFormat.format(entry.date)}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + child: ListTile( + leading: entry.receiptImagePath != null + ? GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!), + ), ), - ), - const Icon(Icons.chevron_right), - ], - ), + 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), ), ), ); @@ -172,7 +179,7 @@ class _VehicleDetailPage extends StatelessWidget { ], ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => captureReceiptForVehicle(context, vehicleId), + onPressed: () => _captureReceipt(context), icon: const Icon(Icons.camera_alt_outlined), label: const Text('Log Fuel Receipt'), ), diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index bd6ff94..c353525 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -1,382 +1,63 @@ -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 'cloud/cloud_storage_provider.dart'; -import 'cloud/dropbox_provider.dart'; -import 'cloud/google_drive_provider.dart'; -import 'cloud/onedrive_provider.dart'; -import 'cloud/webdav_provider.dart'; -import 'cloud_sync_service.dart'; -import 'database_service.dart'; - -const _prefsKeyActiveProviderId = 'active_cloud_provider_id'; -const _prefsKeyCloudFolderId = 'cloud_folder_id'; -const _prefsKeyCloudFolderPath = 'cloud_folder_path'; -const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally'; -const _prefsKeyStaleLockMinutes = 'stale_lock_minutes'; -const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos'; -const _prefsKeyThemeMode = 'theme_mode'; -const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour'; - -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.'; -} +import 'storage_service.dart'; /// Single source of truth for the app's in-memory data (vehicles + fuel -/// 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). +/// 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. class AppState extends ChangeNotifier { - final DatabaseService database = DatabaseService(); - - /// Every storage backend the user can choose from in Settings. - final List availableProviders = [ - GoogleDriveProvider(), - DropboxProvider(), - OneDriveProvider(), - WebDavProvider(), - ]; - - CloudStorageProvider? activeProvider; - CloudSyncService? cloudSync; - + final StorageService storage = StorageService(); final _uuid = const Uuid(); List vehicles = []; List fuelEntries = []; bool isLoading = true; - String? cloudFolderPath; - bool isSyncing = false; - DateTime? lastSyncedAt; - Object? lastSyncError; - - bool keepReceiptPhotosLocally = false; - int staleLockMinutes = defaultStaleLockMinutes; - - /// When false (default), a newly captured receipt photo is downscaled - /// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality] - /// before it's stored — receipts are just photos of small printed text, - /// so a full-resolution original (often several MB on a modern phone - /// camera) buys nothing but cloud storage and sync bandwidth. When true, - /// the original camera/gallery image is kept as-is. - bool keepMaxQualityReceiptPhotos = false; - - ThemeMode themeMode = ThemeMode.system; - - /// 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; - - /// Set if [init] fails. The UI shows this (with a retry option) instead - /// of spinning forever — an unhandled exception here previously left - /// `isLoading` stuck at true with no feedback at all. - Object? initError; - - bool get isCloudConnected => activeProvider?.isSignedIn ?? false; - String? get cloudAccountLabel => activeProvider?.accountLabel; - - StreamSubscription>? _connectivitySubscription; - Future init() async { - isLoading = true; - initError = null; - notifyListeners(); - - try { - await database.init(); - await _refreshFromDatabase(); - - final prefs = await SharedPreferences.getInstance(); - keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false; - staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; - keepMaxQualityReceiptPhotos = - prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; - themeMode = ThemeMode.values.firstWhere( - (mode) => mode.name == prefs.getString(_prefsKeyThemeMode), - orElse: () => ThemeMode.system, - ); - hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false; - } catch (e) { - initError = e; - isLoading = false; - notifyListeners(); - return; - } - + await storage.init(); + final data = await storage.loadData(); + vehicles = data.vehicles; + fuelEntries = data.entries; isLoading = false; notifyListeners(); - - _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { - if (results.any((r) => r != ConnectivityResult.none)) { - unawaited(syncNow()); - } - }); - - unawaited(_restoreCloudConnection()); } - @override - void dispose() { - _connectivitySubscription?.cancel(); - super.dispose(); - } - - Future _refreshFromDatabase() async { - vehicles = await database.getVehicles(); - fuelEntries = await database.getFuelEntries(); - } - - CloudStorageProvider? _providerById(CloudProviderId id) { - for (final provider in availableProviders) { - if (provider.id == id) return provider; - } - return null; - } - - Future _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(); - } - - /// 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(); - } - - 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 chooseCloudFolder({ - required String parentId, - required String breadcrumbPath, - String? currentFolderName, - }) async { - 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; - - isSyncing = true; - notifyListeners(); - - final result = await sync.syncNow( - keepLocalReceiptCopies: keepReceiptPhotosLocally, - staleLockAge: Duration(minutes: staleLockMinutes), - ); - - if (result.ranSync) { - await _refreshFromDatabase(); - lastSyncedAt = DateTime.now(); - lastSyncError = null; - } else if (result.error != null) { - lastSyncError = result.error; - } - - isSyncing = false; - notifyListeners(); - } - - Future 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(); - } - - /// 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(); - } - - /// 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, + required String make, + required String model, + required String color, + required String licensePlate, }) async { - if (await database.vinExists(vin)) { - throw DuplicateVinException(vin); - } - final vehicle = Vehicle( + vehicles.add(Vehicle( id: _uuid.v4(), - vin: vin, - nickname: nickname, - updatedAt: DateTime.now().toUtc(), - ); - await database.saveVehicle(vehicle); + make: make, + model: model, + color: color, + licensePlate: licensePlate, + )); 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); + final index = vehicles.indexWhere((v) => v.id == updated.id); + if (index != -1) { + vehicles[index] = updated; + await _persist(); } - 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); + Future deleteVehicle(String vehicleId) async { + for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { + await storage.deleteReceiptImage(entry.receiptImagePath); } - await database.softDeleteVehicle(id, now); + fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); + vehicles.removeWhere((v) => v.id == vehicleId); await _persist(); } @@ -391,8 +72,7 @@ class AppState extends ChangeNotifier { final id = _uuid.v4(); String? storedImagePath; if (receiptImage != null) { - final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin; - storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date); + storedImagePath = await storage.storeReceiptImage(receiptImage, id); } final entry = FuelEntry( @@ -403,69 +83,16 @@ class AppState extends ChangeNotifier { pricePerGallon: pricePerGallon, totalCost: totalCost, receiptImagePath: storedImagePath, - updatedAt: DateTime.now().toUtc(), ); - await database.saveFuelEntry(entry); + fuelEntries.add(entry); await _persist(); return entry; } - /// Corrects the logged values (date, gallons, price/gal, total cost) 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). - /// [entryId]'s receipt photo reference (local path and/or cloud file id) - /// carries over untouched. - Future updateFuelEntry({ - required String entryId, - 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: existing.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 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 storage.deleteReceiptImage(entry.receiptImagePath); + fuelEntries.removeWhere((e) => e.id == entryId); await _persist(); } @@ -490,33 +117,13 @@ class AppState extends ChangeNotifier { } } - 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 changeSaveDirectory(String newPath) async { + await storage.setSaveDirectory(newPath); + notifyListeners(); } Future _persist() async { - await _refreshFromDatabase(); + await storage.saveData(vehicles: vehicles, entries: fuelEntries); notifyListeners(); - 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 deleted file mode 100644 index 358412b..0000000 --- a/lib/services/cloud/cloud_storage_provider.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:io'; - -/// Shared naming convention across all providers: whichever cloud storage -/// backend is active, the app looks for (or creates) a folder with this -/// exact name wherever the user points it, so two devices pointed at the -/// same shared parent location converge on the same data regardless of -/// which provider they're using. -const appFolderName = '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}); - - /// 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 deleted file mode 100644 index 48fbfb5..0000000 --- a/lib/services/cloud/dropbox_provider.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; -import 'package:http/http.dart' as http; - -import '../cloud_oauth_config.dart'; -import 'cloud_storage_provider.dart'; -import 'oauth_pkce.dart'; - -/// Dropbox implementation of [CloudStorageProvider]. -/// -/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed, -/// not ID-addressed. Rather than fight that, [DropboxSession] treats a -/// folder's own Dropbox path (e.g. "/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 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 deleted file mode 100644 index 98825bc..0000000 --- a/lib/services/cloud/google_drive_provider.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'dart:async'; -import 'dart:io' show File, Platform; - -import 'package:google_sign_in/google_sign_in.dart'; -import 'package:googleapis/drive/v3.dart' as drive; -import 'package:http/http.dart' as http; - -import '../cloud_oauth_config.dart'; -import 'cloud_storage_provider.dart'; - -/// Full Drive access is required (not the narrower `drive.file` scope) -/// because users need to browse to and reuse folders that someone else -/// created and shared with them, not just folders/files this app itself -/// created. See the plan doc for the tradeoffs (this requires Google -/// Cloud Console "Testing" mode with explicit test users, to avoid needing -/// a full OAuth verification review). -const _driveScopes = ['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 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 deleted file mode 100644 index b4e801b..0000000 --- a/lib/services/cloud/oauth_pkce.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:crypto/crypto.dart'; - -/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's -/// OAuth2 Authorization Code flow — proves to the token endpoint that the -/// app completing the exchange is the same one that started the browser -/// redirect, without needing an embedded client secret (appropriate for a -/// public/mobile client, since a secret can't actually be kept secret in -/// a distributed app binary). -class PkcePair { - final String codeVerifier; - final String codeChallenge; - - PkcePair._(this.codeVerifier, this.codeChallenge); - - factory PkcePair.generate() { - final verifier = _randomUrlSafeString(64); - final challenge = - base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', ''); - return PkcePair._(verifier, challenge); - } - - static String _randomUrlSafeString(int length) { - // RFC 7636's unreserved character set for a code_verifier. - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; - final random = Random.secure(); - return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); - } -} diff --git a/lib/services/cloud/onedrive_provider.dart b/lib/services/cloud/onedrive_provider.dart deleted file mode 100644 index 18c8259..0000000 --- a/lib/services/cloud/onedrive_provider.dart +++ /dev/null @@ -1,350 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; -import 'package:http/http.dart' as http; - -import '../cloud_oauth_config.dart'; -import 'cloud_storage_provider.dart'; -import 'oauth_pkce.dart'; - -const _graphScopes = 'offline_access Files.ReadWrite.All'; - -/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph. -/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the -/// interface directly with no path-based workaround. -class OneDriveProvider implements CloudStorageProvider { - String? _accessToken; - String? _refreshToken; - DateTime? _accessTokenExpiry; - String? _accountLabel; - - @override - CloudProviderId get id => CloudProviderId.oneDrive; - - @override - String get displayName => 'OneDrive'; - - @override - bool get isSignedIn => _refreshToken != null; - - @override - String? get accountLabel => _accountLabel; - - @override - Future 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 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 deleted file mode 100644 index 1fd91f8..0000000 --- a/lib/services/cloud/webdav_provider.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; -import 'package:xml/xml.dart'; - -import 'cloud_storage_provider.dart'; - -const _secureStorageKeyServerUrl = 'webdav_server_url'; -const _secureStorageKeyUsername = 'webdav_username'; -const _secureStorageKeyPassword = 'webdav_password'; - -const _propfindRequestBody = ''' - - - - - -'''; - -/// 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 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 deleted file mode 100644 index f85e0a9..0000000 --- a/lib/services/cloud_oauth_config.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// Fill these in once the corresponding OAuth apps/registrations exist — -/// see README.md "Manual setup required" for exact steps per provider. -class CloudOAuthConfig { - // --- Google Drive --- - // - // - Android sign-in (Credential Manager-based, as of google_sign_in v7) - // authenticates using a *Web application* type OAuth client's ID, not - // the Android client's own ID. The separate Android OAuth client - // (registered with the package name + debug/release SHA-1) is still - // required, but only to let Credential Manager verify this specific - // signed app — its client ID itself is never referenced here. - // - iOS uses its own iOS-type OAuth client ID directly. - - /// The Web application OAuth client ID. Required for sign-in to work on - /// Android. - static const String? googleAndroidServerClientId = - '871282023812-v230gah5qimen6kaa2mh1usl8l5otnio.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 = 'x79aoq4zhhmu0a0'; - - /// 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 = '0574b661-1b12-44d8-86f0-ee59361fad1c'; - - /// 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 deleted file mode 100644 index 535af37..0000000 --- a/lib/services/cloud_sync_service.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'cloud/cloud_storage_provider.dart'; -import 'database_service.dart'; -import 'db_schema.dart'; -import 'lock_coordinator.dart' as lock; - -class SyncResult { - final bool ranSync; - final Object? error; - - SyncResult.skipped() - : ranSync = false, - error = null; - - SyncResult.success() - : ranSync = true, - error = null; - - SyncResult.failure(this.error) : ranSync = false; -} - -/// Orchestrates one round of sync against the shared cloud folder — same -/// behavior no matter which [CloudStorageProvider] it's wired to: acquires -/// the cross-device lock, pulls + merges the remote database if it -/// changed, uploads any pending receipt photos, pushes the local database -/// back up, then releases the lock. -/// -/// The merge itself runs as SQL directly against the local database with -/// the downloaded remote copy `ATTACH`ed, rather than decoding records into -/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's -/// new to us or has a newer `updated_at` than our copy. Rows we haven't -/// touched (including our own not-yet-pushed edits) are left alone by that -/// statement, so no separate "keep local" step is needed — see the README -/// for the full reasoning. -class CloudSyncService { - final CloudStorageProvider provider; - final DatabaseService databaseService; - - String? _appFolderId; - String? _receiptsFolderId; - final Map _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; - } - - /// Finds-or-creates the `Show Me The Fuel Refund` folder under [parentId] - /// (a folder the user picked in the folder browser) 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`. - Future selectAppFolder(String parentId, {String? currentFolderName}) async { - final session = provider.beginSession(); - try { - final folderId = currentFolderName == appFolderName - ? parentId - : await session.findOrCreateFolder(parentId: parentId, name: appFolderName); - configure(folderId); - return folderId; - } finally { - session.close(); - } - } - - /// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a - /// receipt photo's local copy is left in place after it's uploaded - /// (useful for offline viewing / an on-device backup); when false - /// (default), it's deleted once the cloud has it, matching the original - /// "local is just a staging area" design. - /// - /// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout: - /// how old another device's lock file has to be before this device - /// treats it as abandoned (e.g. that device crashed or went offline - /// mid-sync) and deletes it rather than waiting forever. Defaults to 10 - /// minutes, matching [defaultStaleLockMinutes] in app_state.dart. - Future syncNow({ - bool keepLocalReceiptCopies = false, - Duration staleLockAge = const Duration(minutes: 10), - }) async { - final appFolderId = _appFolderId; - if (!provider.isSignedIn || appFolderId == null) { - return SyncResult.skipped(); - } - - CloudStorageSession? session; - String? lockFileId; - - try { - session = provider.beginSession(); - - lockFileId = await _acquireLock( - session, - appFolderId: appFolderId, - username: provider.accountLabel!, - staleAge: staleLockAge, - ); - - _receiptsFolderId ??= - await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName); - - final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName); - _dataFileId = remoteInfo?.id; - if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) { - await _pullAndMerge(session, remoteInfo.id); - } - - final allReceiptsUploaded = - await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies); - - // Only push if every pending receipt made it up — otherwise we'd - // either upload a row with a local-only file path (meaningless on - // another device) or prematurely mark it clean. - if (allReceiptsUploaded) { - final pushedInfo = await _pushSnapshot(session, appFolderId); - _lastKnownRemoteVersionTag = pushedInfo.versionTag; - } - - return SyncResult.success(); - } catch (e) { - return SyncResult.failure(e); - } finally { - if (lockFileId != null && session != null) { - try { - await session.deleteFile(lockFileId); - } catch (_) { - // Best-effort: if this fails, the staleness reap on other - // devices' next sync attempt will clean it up. - } - } - session?.close(); - } - } - - Future _pullAndMerge(CloudStorageSession session, String remoteFileId) async { - final bytes = await session.downloadFileBytes(remoteFileId); - final tempDir = await getTemporaryDirectory(); - final tempPath = - p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db'); - final tempFile = File(tempPath); - await tempFile.writeAsBytes(bytes, flush: true); - - try { - final db = databaseService.rawDb; - await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db"); - try { - await db.execute(mergeVehiclesSql); - await db.execute(mergeFuelEntriesSql); - } finally { - try { - await db.execute('DETACH DATABASE remote_db'); - } catch (_) { - // Don't let a detach failure mask a real merge error above. - } - } - } finally { - if (await tempFile.exists()) { - await tempFile.delete(); - } - } - } - - /// Uploads any receipt images still needing it (a local path but no - /// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true - /// only if every pending image made it up — see [syncNow] for why a - /// partial failure here blocks the push step entirely rather than - /// pushing something with a leftover local-only path. - /// - /// Deliberately filters on `receipt_drive_file_id IS NULL` (see - /// [pendingReceiptUploadWhereClause]), not just "has a local path": once - /// [keepLocalCopies] is honored below, an already-uploaded row can still - /// have a local path (kept on purpose), and re-matching it here would - /// re-upload the same photo as a duplicate cloud file on every sync. - Future _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 deleted file mode 100644 index dfcac2c..0000000 --- a/lib/services/database_service.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:uuid/uuid.dart'; - -import '../models/fuel_entry.dart'; -import '../models/vehicle.dart'; -import 'db_schema.dart'; - -const dbFileName = 'fuel_tax_tracker.db'; - -/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in -/// the vehicle form doesn't enforce that — so anything outside -/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory -/// name or cloud folder name, to keep it a safe single path segment (no -/// `/`, `..`, etc.). -String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); - -/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed -/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel -/// entry's purchase date ([FuelEntry.date]), not when the photo happened to -/// be taken or synced. -String monthFolderName(DateTime date) => - '${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}'; - -/// Name of the *local* on-device staging folder for not-yet-uploaded -/// receipt photos — distinct from (though coincidentally the same string -/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which -/// names the corresponding subfolder inside the shared cloud app folder. -const localReceiptsFolderName = 'receipts'; - -/// Owns the local SQLite database (vehicles + fuel_entries) and the -/// `receipts/` folder of not-yet-uploaded receipt photos, both under -/// `ApplicationDocumentsDirectory/FuelTaxTracker`. -/// -/// Every row carries `updated_at` (for newest-wins merging), `deleted_at` -/// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt] -/// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not -/// yet pushed to the cloud"). `dirty` is intentionally not exposed on the -/// domain model classes — it's sync bookkeeping the UI layer never needs to -/// know about; only [CloudSyncService] reads/clears it. -class DatabaseService { - late Directory _rootDirectory; - late Database _db; - - Directory get rootDirectory => _rootDirectory; - - Directory get receiptsDirectory => - Directory(p.join(_rootDirectory.path, localReceiptsFolderName)); - - /// Raw handle for [CloudSyncService], which needs to run ATTACH-based - /// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD. - Database get rawDb => _db; - - String get databasePath => _db.path; - - Future init() async { - final docsDir = await getApplicationDocumentsDirectory(); - _rootDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); - await _rootDirectory.create(recursive: true); - await receiptsDirectory.create(recursive: true); - - final dbPath = p.join(_rootDirectory.path, dbFileName); - _db = await openDatabase( - dbPath, - version: 4, - onCreate: _onCreate, - onUpgrade: _onUpgrade, - ); - } - - Future _onCreate(Database db, int version) async { - await db.execute(createVehiclesTableSql); - await db.execute(createFuelEntriesTableSql); - await db.execute(createFuelEntriesIndexSql); - } - - /// Versions 2/3 (VIN as primary key, then dropping license plate) were - /// rebuilt fresh on upgrade rather than migrated, since at the time that - /// only affected local, not-yet-synced test data. Version 4 (VIN becomes - /// editable, with a new hidden `id` taking over as the actual primary - /// key/merge key) is the first schema change made after real usage had - /// likely accumulated, so this one preserves existing rows instead of - /// dropping them: each vehicle gets a freshly generated `id`, and - /// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via - /// that mapping. - Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - if (oldVersion < 4) { - await _migrateToVehicleIdSchema(db); - } - } - - 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(); - } - - /// 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 deleted file mode 100644 index 1082cf3..0000000 --- a/lib/services/db_schema.dart +++ /dev/null @@ -1,77 +0,0 @@ -const createVehiclesTableSql = ''' - CREATE TABLE vehicles ( - id TEXT PRIMARY KEY, - vin TEXT NOT NULL, - nickname TEXT, - updated_at INTEGER NOT NULL, - deleted_at INTEGER, - dirty INTEGER NOT NULL DEFAULT 1 - ) -'''; - -const createFuelEntriesTableSql = ''' - CREATE TABLE fuel_entries ( - id TEXT PRIMARY KEY, - vehicle_id TEXT NOT NULL, - date INTEGER NOT NULL, - gallons REAL NOT NULL, - price_per_gallon REAL NOT NULL, - total_cost REAL NOT NULL, - receipt_image_path TEXT, - receipt_drive_file_id TEXT, - updated_at INTEGER NOT NULL, - deleted_at INTEGER, - dirty INTEGER NOT NULL DEFAULT 1 - ) -'''; - -const createFuelEntriesIndexSql = - 'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)'; - -/// Selects fuel entries whose receipt photo still needs uploading to -/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just -/// "has a local path": once a row is uploaded, its local copy may still be -/// kept around (see the "keep photos on this phone" setting) — matching on -/// the local path alone would re-upload that same photo as a duplicate -/// Drive file on every subsequent sync. -const pendingReceiptUploadWhereClause = 'dirty = 1 AND receipt_image_path IS NOT NULL ' - 'AND receipt_drive_file_id IS NULL AND deleted_at IS NULL'; - -/// Merges attached `remote_db` rows into `main` (the live local database): -/// any remote row that's new to us, or newer than our copy, replaces ours. -/// Rows this doesn't touch — including our own not-yet-pushed edits — are -/// left alone, since the WHERE clause only matches rows remote should win; -/// no separate "keep local" statement is needed. -/// -/// Merges on `id` (the hidden, immutable identifier), not `vin` — VIN is -/// user-editable, so it can't be relied on as a stable merge key. VIN -/// uniqueness among active vehicles is enforced at the app layer instead -/// (see `DatabaseService.vinExists`), not by a database constraint here, -/// since two devices could in principle each independently add a vehicle -/// with the same VIN while offline; that's an accepted rare-conflict edge -/// case (see the "field-level conflicts aren't merged" caveat in the -/// README) rather than something this merge statement tries to resolve. -const mergeVehiclesSql = ''' - INSERT OR REPLACE INTO main.vehicles - (id, vin, nickname, updated_at, deleted_at, dirty) - SELECT r.id, r.vin, r.nickname, r.updated_at, r.deleted_at, 0 - FROM remote_db.vehicles r - LEFT JOIN main.vehicles l ON l.id = r.id - WHERE l.id IS NULL OR r.updated_at > l.updated_at -'''; - -/// Same rule as [mergeVehiclesSql]. `receipt_image_path` is always forced -/// to NULL on the merged-in copy — it's a local filesystem path, meaningless -/// (and never valid) on another device, and rows are only ever pushed to -/// Drive once their pending receipt has already been uploaded there, so a -/// remote row should never legitimately have one anyway. -const mergeFuelEntriesSql = ''' - INSERT OR REPLACE INTO main.fuel_entries - (id, vehicle_id, date, gallons, price_per_gallon, total_cost, - receipt_image_path, receipt_drive_file_id, updated_at, deleted_at, dirty) - SELECT r.id, r.vehicle_id, r.date, r.gallons, r.price_per_gallon, r.total_cost, - NULL, r.receipt_drive_file_id, r.updated_at, r.deleted_at, 0 - FROM remote_db.fuel_entries r - LEFT JOIN main.fuel_entries l ON l.id = r.id - WHERE l.id IS NULL OR r.updated_at > l.updated_at -'''; diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart deleted file mode 100644 index ea4342d..0000000 --- a/lib/services/fuel_report.dart +++ /dev/null @@ -1,159 +0,0 @@ -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), - ), - ]; - } -} - -/// 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 deleted file mode 100644 index 64186d3..0000000 --- a/lib/services/fuel_report_images.dart +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index bbdc65e..0000000 --- a/lib/services/fuel_report_pdf.dart +++ /dev/null @@ -1,604 +0,0 @@ -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 deleted file mode 100644 index ffe2df2..0000000 --- a/lib/services/lock_coordinator.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'cloud/cloud_storage_provider.dart' show CloudLockFile; - -/// Ticket-based mutex using timestamped lock files as the coordination -/// point: create a lock named after our own timestamp, then wait until no -/// *other* lock older than ours remains — reaping ("deleting") any it -/// finds older than [staleAge] along the way, as a backstop against a -/// device that crashed/went offline before releasing its own lock. -/// -/// Pure orchestration over injected operations (rather than a concrete -/// cloud storage dependency) so the state machine is unit-testable without -/// a real network connection, and works identically no matter which -/// [CloudStorageProvider] it's wired to. -Future 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 deleted file mode 100644 index ce8c50e..0000000 --- a/lib/services/onboarding_keys.dart +++ /dev/null @@ -1,22 +0,0 @@ -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/receipt_parser.dart b/lib/services/receipt_parser.dart index 07740a4..1e6d17f 100644 --- a/lib/services/receipt_parser.dart +++ b/lib/services/receipt_parser.dart @@ -1,25 +1,3 @@ -/// Two-letter USPS abbreviation to full name, for the 50 states + DC — -/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular -/// shows up on real receipts as a "Visa" abbreviation, and including it as -/// a valid state code would misfire on that. -const usStateNames = { - '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. @@ -27,25 +5,12 @@ 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, }); } @@ -58,91 +23,36 @@ 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?[ \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), + 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), ]; static final _pricePerGallonPatterns = [ - RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', + RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), - 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})', + 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})', caseSensitive: false), - RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', + RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), ]; static final _totalPatterns = [ - RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false), - RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false), - // Allows for words between TOTAL and the amount ("Total Sale $120.72", - // "Fuel Total: $44.44"), not just a bare colon/dash. - RegExp(r'(? 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 new file mode 100644 index 0000000..d12524a --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/fuel_entry.dart'; +import '../models/vehicle.dart'; + +/// Owns the on-disk layout for the app: a `data.json` file holding vehicles +/// and fuel entries, plus a `receipts/` subfolder holding receipt photos. +/// The parent directory containing both is user-configurable (see +/// [setSaveDirectory]) and persisted across launches via SharedPreferences. +class StorageService { + static const _prefsKey = 'save_directory_path'; + static const _dataFileName = 'fuel_tax_data.json'; + static const _receiptsFolderName = 'receipts'; + + late Directory _saveDirectory; + + Directory get saveDirectory => _saveDirectory; + + Directory get receiptsDirectory => + Directory(p.join(_saveDirectory.path, _receiptsFolderName)); + + File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName)); + + /// Must be called once before any other method. Loads the previously + /// chosen save directory, falling back to the app's own documents + /// directory the first time the app runs. + Future 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 deleted file mode 100644 index ae40457..0000000 --- a/lib/services/vin_parser.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb -/// sticker, dashboard plate, title, etc). -/// -/// Real VINs never contain the letters I, O, or Q (they're excluded from -/// the standard specifically so they can't be confused with 1, 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 deleted file mode 100644 index bd1a5b0..0000000 --- a/lib/theme/app_theme.dart +++ /dev/null @@ -1,133 +0,0 @@ -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/hero_banner.dart b/lib/widgets/hero_banner.dart deleted file mode 100644 index 0a5d0c5..0000000 --- a/lib/widgets/hero_banner.dart +++ /dev/null @@ -1,108 +0,0 @@ -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 in the middle, cost on the right. -class HeroStatsRow extends StatelessWidget { - final String gallonsValue; - final String costValue; - final String gallonsLabel; - final String costLabel; - - const HeroStatsRow({ - super.key, - required this.gallonsValue, - required this.costValue, - this.gallonsLabel = 'Total Gallons', - this.costLabel = 'Total Fuel Cost', - }); - - @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), - // 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. - child: Image.asset( - 'assets/icon/hero_icon.png', - height: 58, - fit: BoxFit.contain, - ), - ), - Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)), - ], - ); - } -} diff --git a/lib/widgets/image_source_sheet.dart b/lib/widgets/image_source_sheet.dart deleted file mode 100644 index e14d644..0000000 --- a/lib/widgets/image_source_sheet.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; - -/// Bottom sheet letting the user pick a photo from the camera or their -/// existing library. Shared by any screen that needs to snap or choose a -/// photo (receipt capture, VIN scanning) so the choice looks and behaves -/// the same everywhere. 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 deleted file mode 100644 index 2af2bd7..0000000 --- a/lib/widgets/onboarding_tour_overlay.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -/// 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; - - 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, - }); - - 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: [ - 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: const Text('Skip Tour')), - 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 deleted file mode 100644 index 3eaef49..0000000 --- a/lib/widgets/receipt_capture.dart +++ /dev/null @@ -1,115 +0,0 @@ -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(); - XFile? photo; - try { - photo = await picker.pickImage( - source: source, - imageQuality: keepMaxQuality ? null : receiptImageQuality, - maxWidth: keepMaxQuality ? null : receiptImageMaxDimension, - maxHeight: keepMaxQuality ? null : receiptImageMaxDimension, - ); - } catch (e) { - if (context.mounted) { - final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not open $sourceLabel: $e')), - ); - } - return; - } - if (photo == null) return; - - final imageFile = File(photo.path); - - 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) return; - Navigator.of(context).pop(); // close the OCR loading spinner - - final state = parsed.state; - if (state != null && state != 'MO') { - final proceed = await _confirmNonMissouriPurchase(context, state); - if (!proceed || !context.mounted) return; - } - - if (context.mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ConfirmFuelEntryScreen( - vehicleId: vehicleId, - imageFile: imageFile, - parsed: parsed, - ), - ), - ); - } -} - -/// 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 deleted file mode 100644 index 21e6112..0000000 --- a/lib/widgets/receipt_thumbnail.dart +++ /dev/null @@ -1,61 +0,0 @@ -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/pubspec.lock b/pubspec.lock index ff92767..605d832 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,30 +1,6 @@ # 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: @@ -41,22 +17,6 @@ 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: @@ -73,22 +33,6 @@ 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: @@ -113,22 +57,6 @@ 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: @@ -138,21 +66,13 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: "direct main" + dependency: transitive 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: @@ -169,14 +89,6 @@ 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: @@ -193,14 +105,6 @@ 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: @@ -209,6 +113,14 @@ 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: @@ -254,14 +166,6 @@ 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: @@ -270,14 +174,6 @@ 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: @@ -286,96 +182,16 @@ 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: @@ -392,54 +208,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.1" - google_sign_in: - dependency: "direct main" - description: - name: google_sign_in - sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" - url: "https://pub.dev" - source: hosted - version: "7.2.16" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 - url: "https://pub.dev" - source: hosted - version: "6.3.0" - google_sign_in_platform_interface: - dependency: transitive - description: - name: google_sign_in_platform_interface - sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - google_sign_in_web: - dependency: transitive - description: - name: google_sign_in_web - sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - googleapis: - dependency: "direct main" - description: - name: googleapis - sha256: "5c9e0f25be1dec13d8d2158263141104c51b5ba83487537c17a2330581e505ee" - url: "https://pub.dev" - source: hosted - version: "14.0.0" hooks: dependency: transitive description: @@ -448,16 +216,8 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -472,14 +232,6 @@ 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: @@ -576,14 +328,6 @@ 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: @@ -656,14 +400,6 @@ 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: @@ -672,14 +408,6 @@ 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: @@ -704,14 +432,6 @@ 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: @@ -745,7 +465,7 @@ packages: source: hosted version: "2.2.2" path_provider_platform_interface: - dependency: "direct dev" + dependency: transitive description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" @@ -760,22 +480,6 @@ 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: @@ -793,29 +497,13 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: "direct dev" + dependency: transitive 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: @@ -832,14 +520,6 @@ 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: @@ -917,62 +597,6 @@ 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: @@ -997,14 +621,6 @@ 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: @@ -1029,78 +645,6 @@ 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: @@ -1137,18 +681,10 @@ packages: dependency: transitive description: name: win32 - sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.4.0" - window_to_front: - dependency: transitive - description: - name: window_to_front - sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" - url: "https://pub.dev" - source: hosted - version: "0.0.4" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1158,7 +694,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: "direct main" + dependency: transitive description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 8941af5..6bfd01d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,10 +44,21 @@ dependencies: # On-device OCR text recognition google_mlkit_text_recognition: ^0.15.0 - # App sandbox paths (local offline staging area) + # Directory picker for configurable save location. + # Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin + # Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers + # it, but that isn't actually wired up yet for library modules in this + # Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11 + # still compiles against compileSdk 36 (matches flutter.compileSdkVersion, + # satisfying flutter_plugin_android_lifecycle's requirement) but applies + # its own Kotlin plugin unconditionally, which works. (10.3.11 itself is + # retracted on pub.dev, hence pinning to 10.3.10.) + file_picker: 10.3.10 + + # App sandbox paths (default save location, temp files) path_provider: ^2.1.5 - # Persist small settings (Drive connection state, last synced time) + # Persist small settings like chosen save directory shared_preferences: ^2.3.4 # Unique IDs for vehicles/fuel entries @@ -59,57 +70,10 @@ 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 - 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 @@ -117,52 +81,6 @@ 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 @@ -174,14 +92,10 @@ flutter: # the material Icons class. uses-material-design: true - # 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 + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/add_edit_vehicle_screen_test.dart b/test/add_edit_vehicle_screen_test.dart deleted file mode 100644 index 4d43509..0000000 --- a/test/add_edit_vehicle_screen_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -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/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart deleted file mode 100644 index 928e393..0000000 --- a/test/cloud_sync_service_test.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; -import 'package:fuel_tax_tracker/services/cloud_sync_service.dart'; -import 'package:fuel_tax_tracker/services/database_service.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Confirms [CloudSyncService]'s orchestration (lock → check remote → -/// upload → release) works against *any* [CloudStorageProvider], using a -/// fake one — coverage for the provider-agnostic logic itself, independent -/// of which real backend (Google Drive, Dropbox, OneDrive) it's wired to. -void main() { - late Directory tempDir; - late DatabaseService databaseService; - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('cloud_sync_service_test_'); - PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); - databaseService = DatabaseService(); - await databaseService.init(); - }); - - tearDown(() async { - await databaseService.rawDb.close(); - await tempDir.delete(recursive: true); - }); - - test('syncNow acquires a lock, uploads the data file, and releases the lock', () async { - final session = _FakeSession(); - final provider = _FakeProvider(session); - final syncService = CloudSyncService(provider: provider, databaseService: databaseService); - syncService.configure('app-folder-id'); - - final result = await syncService.syncNow(); - - expect(result.ranSync, isTrue); - expect(session.createdLockNames, hasLength(1)); - expect(session.createdLockNames.first, startsWith('tester@example.com-')); - expect(session.uploadedFileNames, contains(dataFileName)); - expect(session.deletedFileIds, ['lock-id'], reason: 'the lock must be released afterward'); - }); - - test('syncNow uploads a pending receipt into Receipts//', () 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('syncNow is a no-op when not configured with a folder yet', () async { - final session = _FakeSession(); - final provider = _FakeProvider(session); - final syncService = CloudSyncService(provider: provider, databaseService: databaseService); - - final result = await syncService.syncNow(); - - expect(result.ranSync, isFalse); - expect(session.createdLockNames, isEmpty); - }); -} - -class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { - final String tempPath; - _FakePathProviderPlatform(this.tempPath); - - @override - Future 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; - - @override - bool get supportsSharedWithMe => false; - - @override - Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; - - @override - Future findOrCreateFolder({required String parentId, required String name}) async { - findOrCreateFolderCalls.add('$parentId/$name'); - return '$parentId/$name'; - } - - @override - Future findFile({required String folderId, required String name}) async { - return name == dataFileName ? existingDataFile : null; - } - - @override - Future> downloadFileBytes(String fileId) async => []; - - @override - Future uploadFile({ - required String folderId, - required String name, - String? existingFileId, - required File localFile, - required String contentType, - }) async { - uploadedFileNames.add(name); - uploadedFileFolders[name] = folderId; - return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1'); - } - - @override - Future 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 deleted file mode 100644 index 17bddda..0000000 --- a/test/db_merge_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/db_schema.dart'; -import 'package:path/path.dart' as p; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs -/// (see [db_schema.dart] / CloudSyncService._pullAndMerge), against real -/// temporary SQLite files via sqflite_common_ffi — the pure-Dart backend -/// that lets sqflite run on the Dart VM for tests, without a real device. -void main() { - late Directory tempDir; - late Database local; - late Database remote; - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('db_merge_test_'); - local = await _openFreshDb(p.join(tempDir.path, 'local.db')); - remote = await _openFreshDb(p.join(tempDir.path, 'remote.db')); - }); - - tearDown(() async { - await local.close(); - await remote.close(); - await tempDir.delete(recursive: true); - }); - - Future merge() async { - await local.execute("ATTACH DATABASE '${remote.path}' AS remote_db"); - try { - await local.execute(mergeVehiclesSql); - await local.execute(mergeFuelEntriesSql); - } finally { - await local.execute('DETACH DATABASE remote_db'); - } - } - - group('vehicle merge', () { - test('pulls in a vehicle that only exists on remote', () async { - await remote.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['id'], 'v1'); - expect(rows.first['vin'], 'VIN1'); - expect(rows.first['dirty'], 0); - }); - - test('leaves a local-only dirty vehicle untouched', () async { - await local.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000, dirty: 1)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['dirty'], 1); - }); - - test('remote wins when strictly newer than local', () async { - await local.insert('vehicles', - _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'OldNickname', updatedAt: 1000, dirty: 1)); - await remote.insert('vehicles', - _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'NewNickname', updatedAt: 2000)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['nickname'], 'NewNickname'); - expect(rows.first['dirty'], 0); - }); - - test('local wins on a tie or when strictly newer', () async { - await local.insert('vehicles', - _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'MineNewer', updatedAt: 2000, dirty: 1)); - await remote.insert('vehicles', - _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'TheirsOlder', updatedAt: 1000)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['nickname'], 'MineNewer'); - expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten'); - }); - - test('a null nickname merges in fine (nickname is optional)', () async { - await remote.insert( - 'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', nickname: null, updatedAt: 1000)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['nickname'], isNull); - }); - - test('the same vehicle edited on two devices merges by id, not vin', () async { - // VIN is user-editable, so it can't be the merge key — this is the - // scenario that motivated switching the merge key to a hidden id: - // the local device renamed the VIN (a legitimate edit) while the - // remote copy still has the old VIN and an older updated_at. - await local.insert('vehicles', - _vehicleRow(id: 'v1', vin: 'VIN1-CORRECTED', nickname: 'Mine', updatedAt: 2000, dirty: 1)); - await remote.insert( - 'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1-TYPO', nickname: 'Mine', updatedAt: 1000)); - - await merge(); - - final rows = await local.query('vehicles'); - expect(rows, hasLength(1)); - expect(rows.first['vin'], 'VIN1-CORRECTED', reason: 'local was newer, so its VIN edit wins'); - }); - }); - - group('fuel entry merge', () { - test('adopts a newer tombstone from remote (deletion propagates)', () async { - await local.insert('fuel_entries', - _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000, dirty: 0)); - await remote.insert( - 'fuel_entries', - _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 2000, deletedAt: 2000), - ); - - await merge(); - - final rows = await local.query('fuel_entries'); - expect(rows, hasLength(1)); - expect(rows.first['deleted_at'], 2000); - }); - - test('never adopts a receipt_image_path value from remote', () async { - // Defensive case: even if a remote row somehow had a local-looking - // path in that column, the merge must not copy it onto this device. - await remote.insert( - 'fuel_entries', - _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000) - ..['receipt_image_path'] = '/some/other/devices/path.jpg', - ); - - await merge(); - - final rows = await local.query('fuel_entries'); - expect(rows, hasLength(1)); - expect(rows.first['receipt_image_path'], isNull); - }); - }); -} - -Future _openFreshDb(String path) async { - final db = await databaseFactory.openDatabase(path); - await db.execute(createVehiclesTableSql); - await db.execute(createFuelEntriesTableSql); - await db.execute(createFuelEntriesIndexSql); - return db; -} - -Map _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 _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 deleted file mode 100644 index 322f08f..0000000 --- a/test/edit_fuel_entry_test.dart +++ /dev/null @@ -1,157 +0,0 @@ -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 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); - }); - }); - - 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'); - }); - }); -} - -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/fuel_report_images_test.dart b/test/fuel_report_images_test.dart deleted file mode 100644 index 3328a4c..0000000 --- a/test/fuel_report_images_test.dart +++ /dev/null @@ -1,201 +0,0 @@ -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 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 deleted file mode 100644 index 68a4c49..0000000 --- a/test/fuel_report_pdf_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index d73df3d..0000000 --- a/test/fuel_report_test.dart +++ /dev/null @@ -1,281 +0,0 @@ -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']); - }); -} diff --git a/test/image_source_sheet_test.dart b/test/image_source_sheet_test.dart deleted file mode 100644 index bf17367..0000000 --- a/test/image_source_sheet_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index d50b55e..0000000 --- a/test/lock_coordinator_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart' show CloudLockFile; -import 'package:fuel_tax_tracker/services/lock_coordinator.dart'; - -void main() { - group('acquireLock', () { - test('creates a lock file named {username}-{epochMillis}.lock', () async { - final now = DateTime.utc(2024, 1, 1, 12, 0, 0); - String? capturedName; - - await acquireLock( - username: 'me@example.com', - nowUtc: () => now, - createLock: (name) async { - capturedName = name; - return 'id'; - }, - listLocks: () async => - [CloudLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)], - deleteLock: (_) async {}, - delay: (_) async {}, - ); - - expect(capturedName, 'me@example.com-${now.millisecondsSinceEpoch}.lock'); - }); - - test('proceeds immediately when no other lock is older', () async { - var listCallCount = 0; - final delayCalls = []; - 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 deleted file mode 100644 index 6ef3b15..0000000 --- a/test/main_shell_test.dart +++ /dev/null @@ -1,279 +0,0 @@ -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); - - 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, - reason: 'stayed on the tab it started on'); - }); - - testWidgets('Skip Tour from the last step still pops the tour-pushed Data screen', - (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); - }); - }); -} diff --git a/test/onboarding_tour_overlay_test.dart b/test/onboarding_tour_overlay_test.dart deleted file mode 100644 index b33298e..0000000 --- a/test/onboarding_tour_overlay_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -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 deleted file mode 100644 index 29ec5f3..0000000 --- a/test/pending_receipt_upload_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/db_schema.dart'; -import 'package:path/path.dart' as p; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Regression coverage for a real bug: the "needs upload" query used to -/// match on `receipt_image_path IS NOT NULL` alone, which would have kept -/// re-uploading the same photo as a duplicate Drive file on every sync -/// once a "keep photos on this phone" option let an already-uploaded row -/// keep its local path. See [pendingReceiptUploadWhereClause]. -void main() { - late Directory tempDir; - late Database db; - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('pending_receipt_test_'); - db = await databaseFactory.openDatabase(p.join(tempDir.path, 'test.db')); - await db.execute(createFuelEntriesTableSql); - }); - - tearDown(() async { - await db.close(); - await tempDir.delete(recursive: true); - }); - - Future 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 deleted file mode 100644 index 0a99422..0000000 --- a/test/purge_data_test.dart +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index 5e368fd..0000000 --- a/test/receipt_detail_screen_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -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 3753534..8aeda50 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -21,37 +21,6 @@ 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 @@ -98,386 +67,6 @@ 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 deleted file mode 100644 index 252ee5e..0000000 --- a/test/receipt_storage_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/database_service.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Receipts are filed under `receipts///` 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 deleted file mode 100644 index cc650e4..0000000 --- a/test/receipts_screen_test.dart +++ /dev/null @@ -1,260 +0,0 @@ -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/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('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 { - // Aug 5 2026: within Current Month, Last 3 Months, and Year to Date. - final recentEntry = FuelEntry( - id: 'recent', - vehicleId: 'v1', - date: DateTime(2026, 8, 5), - gallons: 10, - pricePerGallon: 3, - totalCost: 30, - updatedAt: DateTime.utc(2026, 8, 5), - receiptDriveFileId: 'r1', - ); - // Jul 1 2026: within Last 3 Months and Year to Date, not Current Month. - final midEntry = FuelEntry( - id: 'mid', - vehicleId: 'v1', - date: DateTime(2026, 7, 1), - gallons: 20, - pricePerGallon: 3, - totalCost: 60, - updatedAt: DateTime.utc(2026, 7, 1), - receiptDriveFileId: 'r2', - ); - // Mar 1 2026: within Year to Date only. - final oldEntry = FuelEntry( - id: 'old', - vehicleId: 'v1', - date: DateTime(2026, 3, 1), - gallons: 40, - pricePerGallon: 3, - totalCost: 120, - updatedAt: DateTime.utc(2026, 3, 1), - 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: all three visible. - expect(find.text('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsOneWidget); - expect(find.text('Mar 1, 2026'), 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('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsOneWidget); - expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 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('Aug 5, 2026'), findsOneWidget); - expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month'); - expect(find.text('Mar 1, 2026'), 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 deleted file mode 100644 index dadaf81..0000000 --- a/test/report_screen_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 372921e..0000000 --- a/test/report_vehicle_selection_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -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: once a date -/// range is picked, 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)); - - // showDatePicker's initial date (when none is set yet) defaults to - // "now" — dating these entries to "now" too, rather than a fixed date, - // means just accepting that default on both pickers (below) already - // covers them, without needing to drive the calendar UI to a specific day. - 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 pickRange(WidgetTester tester) async { - 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(); - } - - Future pumpReport(WidgetTester tester) async { - final appState = AppState() - ..isLoading = false - ..vehicles = [vehicleA, vehicleB] - ..fuelEntries = [ - entryFor('a', 'e1', 10, 35), - entryFor('b', 'e2', 20, 70), - ]; - - 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); - await pickRange(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('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total', - (tester) async { - await pumpReport(tester); - await pickRange(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); - await pickRange(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('deselecting every vehicle shows a distinct "nothing selected" message', - (tester) async { - await pumpReport(tester); - await pickRange(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('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 deleted file mode 100644 index 45f0a5f..0000000 --- a/test/settings_purge_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 7d63c96..0000000 --- a/test/settings_screen_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -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/settings_screen.dart'; -import 'package:fuel_tax_tracker/screens/ui_settings_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 and Data menu entries, plus the summary', (tester) async { - await pumpSettings(tester); - - expect(find.text('UI'), findsOneWidget); - expect(find.text('Data'), findsOneWidget); - 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); - }); -} diff --git a/test/theme_toggle_test.dart b/test/theme_toggle_test.dart deleted file mode 100644 index 331551c..0000000 --- a/test/theme_toggle_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -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'); - }); -} diff --git a/test/vehicle_detail_screen_test.dart b/test/vehicle_detail_screen_test.dart deleted file mode 100644 index 779647f..0000000 --- a/test/vehicle_detail_screen_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -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/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); - }); -} diff --git a/test/vehicle_id_migration_test.dart b/test/vehicle_id_migration_test.dart deleted file mode 100644 index c6451a5..0000000 --- a/test/vehicle_id_migration_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/database_service.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Confirms the version 3 → 4 migration (VIN becomes editable; a hidden -/// `id` takes over as vehicles' primary key/merge key) preserves existing -/// local data rather than dropping it, unlike the earlier VIN-as-primary-key -/// migration which was a from-scratch rebuild. Builds a real on-disk -/// version-3 database by hand (the old schema, pre-dating this change), then -/// runs [DatabaseService.init] against it and checks what comes out. -void main() { - late Directory tempDir; - - setUpAll(() { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - }); - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('vehicle_id_migration_test_'); - PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); - }); - - tearDown(() async { - await tempDir.delete(recursive: true); - }); - - test('migrates a version-3 database, preserving vehicles and fuel entries', () async { - final dbDir = Directory(p.join(tempDir.path, 'FuelTaxTracker')); - await dbDir.create(recursive: true); - final dbPath = p.join(dbDir.path, dbFileName); - - final oldDb = await databaseFactory.openDatabase( - dbPath, - options: OpenDatabaseOptions( - version: 3, - onCreate: (db, version) async { - await db.execute(''' - CREATE TABLE vehicles ( - vin TEXT PRIMARY KEY, - nickname TEXT, - updated_at INTEGER NOT NULL, - deleted_at INTEGER, - dirty INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute(''' - CREATE TABLE fuel_entries ( - id TEXT PRIMARY KEY, - vehicle_vin TEXT NOT NULL, - date INTEGER NOT NULL, - gallons REAL NOT NULL, - price_per_gallon REAL NOT NULL, - total_cost REAL NOT NULL, - receipt_image_path TEXT, - receipt_drive_file_id TEXT, - updated_at INTEGER NOT NULL, - deleted_at INTEGER, - dirty INTEGER NOT NULL DEFAULT 1 - ) - '''); - }, - ), - ); - await oldDb.insert('vehicles', { - 'vin': 'VIN1', - 'nickname': "Mom's Car", - 'updated_at': 1000, - 'deleted_at': null, - 'dirty': 0, - }); - await oldDb.insert('vehicles', { - 'vin': 'VIN2', - 'nickname': null, - 'updated_at': 1500, - 'deleted_at': 2000, // a soft-deleted vehicle, should still migrate - 'dirty': 0, - }); - await oldDb.insert('fuel_entries', { - 'id': 'f1', - 'vehicle_vin': 'VIN1', - 'date': 1200, - 'gallons': 12.5, - 'price_per_gallon': 3.2, - 'total_cost': 40.0, - 'receipt_image_path': '/local/receipt.jpg', - 'receipt_drive_file_id': null, - 'updated_at': 1200, - 'deleted_at': null, - 'dirty': 0, - }); - await oldDb.close(); - - final databaseService = DatabaseService(); - await databaseService.init(); - addTearDown(() => databaseService.rawDb.close()); - - final vehicles = await databaseService.getVehicles(); - expect(vehicles, hasLength(1), reason: 'the soft-deleted vehicle should not show as active'); - expect(vehicles.first.vin, 'VIN1'); - expect(vehicles.first.nickname, "Mom's Car"); - expect(vehicles.first.id, isNotEmpty); - - final allVehicleRows = await databaseService.rawDb.query('vehicles'); - expect(allVehicleRows, hasLength(2), reason: 'the soft-deleted vehicle should still exist'); - final ids = {for (final row in allVehicleRows) row['vin']: row['id'] as String}; - expect(ids['VIN1'], isNotEmpty); - expect(ids['VIN2'], isNotEmpty); - expect(ids['VIN1'], isNot(ids['VIN2']), reason: 'each vehicle gets its own generated id'); - for (final row in allVehicleRows) { - expect(row['dirty'], 1, reason: 'migrated rows must be re-pushed under the new schema'); - } - - final fuelEntries = await databaseService.getFuelEntries(); - expect(fuelEntries, hasLength(1)); - expect(fuelEntries.first.vehicleId, ids['VIN1'], - reason: "the fuel entry's foreign key should now point at VIN1's new id"); - expect(fuelEntries.first.gallons, 12.5); - expect(fuelEntries.first.receiptImagePath, '/local/receipt.jpg'); - }); -} - -class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { - final String tempPath; - _FakePathProviderPlatform(this.tempPath); - - @override - Future getApplicationDocumentsPath() async => tempPath; - - @override - Future getTemporaryPath() async => tempPath; -} diff --git a/test/vin_parser_test.dart b/test/vin_parser_test.dart deleted file mode 100644 index ee327ad..0000000 --- a/test/vin_parser_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/vin_parser.dart'; - -void main() { - group('VinParser', () { - test('parses a bare 17-character VIN', () { - const text = ''' - VEHICLE IDENTIFICATION NUMBER - 1HGCM82633A004352 - MFD BY HONDA MFG CO - '''; - - expect(VinParser.parse(text), '1HGCM82633A004352'); - }); - - test('prefers a "VIN:" labeled match over another candidate elsewhere', () { - const text = ''' - PART NO 4F2CZ58899BA12345 - VIN: 1HGCM82633A004352 - '''; - - expect(VinParser.parse(text), '1HGCM82633A004352'); - }); - - test('normalizes to uppercase', () { - expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352'); - }); - - test('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 deleted file mode 100644 index 15371ab..0000000 --- a/test/webdav_provider_credentials_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart'; - -const _multistatusBody = ''' - - - / - - - 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 deleted file mode 100644 index 414a5e8..0000000 --- a/test/webdav_provider_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final baseUrl = Uri.parse('https://cloud.example.com/'); - - group('parseWebDavMultistatus', () { - // Real WebDAV servers disagree on namespace prefix for the same "DAV:" - // namespace — Nextcloud defaults to `d:`, many Apache mod_dav setups - // use `D:`, and some use no prefix at all with a default xmlns. All - // three must parse identically since the parser matches by local name. - test('parses a lowercase d: prefixed response (Nextcloud-style)', () { - final xml = ''' - - - /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 1c2235d..25f65ba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,8 +17,8 @@ void main() { ), ); - expect(find.text('Vehicles'), findsOneWidget); - expect(find.byTooltip('Add Vehicle'), findsOneWidget); + expect(find.text('My Vehicles'), findsOneWidget); + expect(find.text('Add Vehicle'), findsOneWidget); expect(find.textContaining('No vehicles yet'), findsOneWidget); }); }