Compare commits
No commits in common. "feature/reporting" and "main" have entirely different histories.
feature/re
...
main
378
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.<key> IS NULL OR remote.updated_at > local.updated_at` per table
|
||||
(`id` for both tables — vehicles' hidden generated id, not the
|
||||
user-editable VIN). Rows that statement doesn't match — including this
|
||||
device's own not-yet-pushed edits — are left untouched, so no separate
|
||||
"keep local" step is needed.
|
||||
3. Uploads any receipt photos still needing it (a local path but no cloud
|
||||
file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud
|
||||
file ID. The local copy is then deleted or kept depending on the
|
||||
Settings "keep photos on this phone" toggle. If any upload fails, the
|
||||
push step below is skipped entirely this cycle — a row is never pushed
|
||||
while it still holds a local-only, not-yet-uploaded path.
|
||||
4. Reads the local database file directly (sqflite's default journal mode
|
||||
isn't WAL, so the file is complete and consistent as soon as the last
|
||||
write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as
|
||||
cheap insurance) and uploads it as the new remote copy, then clears the
|
||||
`dirty` flag on every row now that local matches what's on the cloud.
|
||||
5. Releases the lock.
|
||||
|
||||
**Deletions propagate correctly**, unlike a naive "union records" merge:
|
||||
deleting sets `deleted_at` instead of removing the row, so a deletion is
|
||||
just another change with its own `updated_at`, and rides the same
|
||||
newest-wins rule as any edit — no separate deletion-handling logic needed.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
|
||||
android/app/src/debug/AndroidManifest.xml, but release builds need
|
||||
it declared here explicitly. -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<application
|
||||
android:label="fuel_tax_tracker"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -32,22 +27,6 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Dropbox & OneDrive OAuth: flutter_web_auth_2 captures the
|
||||
browser redirect via this activity + custom URL scheme(s).
|
||||
These scheme values must exactly match whatever is set as
|
||||
dropboxRedirectUri/oneDriveRedirectUri in
|
||||
lib/services/cloud_oauth_config.dart. -->
|
||||
<activity
|
||||
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="flutter_web_auth_2">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mofueltaxback-dropbox" />
|
||||
<data android:scheme="mofueltaxback-onedrive" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
@ -64,13 +43,5 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Lets url_launcher open the Missouri DOR forms link on the
|
||||
Reports tab — without this, canLaunchUrl/launchUrl can't see
|
||||
any browser app on Android 11+ due to package visibility
|
||||
restrictions. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#101110</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#101110</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -5,10 +5,6 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#24603A</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#24603A</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -5,10 +5,6 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 697 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -441,7 +441,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -498,7 +498,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
|
@ -1 +1,122 @@
|
|||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 725 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "darkbackground.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,56 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
|
@ -16,19 +16,13 @@
|
|||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
|
|
@ -38,7 +32,6 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="1024" height="1024"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fuel Tax Tracker</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
|
@ -70,43 +68,5 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Google Sign-In: required for the OAuth redirect to route back into
|
||||
the app, regardless of whether the client ID itself is set here or
|
||||
passed in Dart (see lib/services/drive_oauth_config.dart). Replace
|
||||
with your iOS OAuth client's REVERSED_CLIENT_ID from Google Cloud
|
||||
Console (reverse of the client ID, e.g.
|
||||
com.googleusercontent.apps.XXXXXXXXXXXX-yyyyyyyyyyyyyyyyyyyyyyyyyyyy). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Dropbox & OneDrive OAuth redirects (flutter_web_auth_2). These
|
||||
scheme values must exactly match dropboxRedirectUri /
|
||||
oneDriveRedirectUri in lib/services/cloud_oauth_config.dart. -->
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-dropbox</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-onedrive</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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<AppState>(
|
||||
builder: (context, appState, _) => MaterialApp(
|
||||
child: MaterialApp(
|
||||
title: 'Fuel Tax Tracker',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: appState.themeMode,
|
||||
home: const AppRoot(),
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object?> toMap() => {
|
||||
Map<String, dynamic> 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<String, Object?> 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<String, dynamic> 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?,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object?> toMap() => {
|
||||
Map<String, dynamic> 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<String, Object?> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
||||
|
|
@ -32,249 +17,52 @@ class AddEditVehicleScreen extends StatefulWidget {
|
|||
|
||||
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void>(
|
||||
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<void>(
|
||||
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<void> _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<void>(
|
||||
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<AppState>().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<void> _checkForExistingVin() async {
|
||||
if (_isEditing || _checkingDuplicateVin) return;
|
||||
|
||||
final vin = _vinController.text.trim();
|
||||
if (vin.isEmpty) return;
|
||||
final existing = context.read<AppState>().vehicleByVin(vin);
|
||||
if (existing == null) return;
|
||||
|
||||
_checkingDuplicateVin = true;
|
||||
final openExisting = await showDialog<bool>(
|
||||
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<void> _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<AppState>();
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
vin: vin,
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
clearNickname: nickname.isEmpty,
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
|
||||
await appState.addVehicle(
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
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 (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
|
|
@ -284,7 +72,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
title: const Text('Delete vehicle?'),
|
||||
content: Text(
|
||||
'This will also delete all fuel entries and receipt photos logged for '
|
||||
'${widget.vehicle!.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<AddEditVehicleScreen> {
|
|||
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),
|
||||
controller: _modelController,
|
||||
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
)
|
||||
: 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(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
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,
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
|
||||
}
|
||||
|
||||
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
|
||||
late final CloudStorageSession _session;
|
||||
|
||||
_BrowseRoot _root = _BrowseRoot.myFiles;
|
||||
final List<CloudFolder> _pathStack = [];
|
||||
|
||||
List<CloudFolder>? _folders;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _confirming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_session = context.read<AppState>().activeProvider!.beginSession();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_session.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me';
|
||||
|
||||
/// The folder ID that "Use This Folder" would act on, or null if the
|
||||
/// current view is a virtual listing (top-level "Shared with me") rather
|
||||
/// than an actual folder.
|
||||
String? get _currentFolderId {
|
||||
if (_pathStack.isNotEmpty) return _pathStack.last.id;
|
||||
if (_root == _BrowseRoot.myFiles) return 'root';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 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<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
List<CloudFolder> folders;
|
||||
if (_pathStack.isNotEmpty) {
|
||||
folders = await _session.listFolders(parentId: _pathStack.last.id);
|
||||
} else if (_root == _BrowseRoot.myFiles) {
|
||||
folders = await _session.listFolders(parentId: 'root');
|
||||
} else {
|
||||
folders = await _session.listFolders(sharedWithMe: true);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_folders = folders;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Could not load folders: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _switchRoot(_BrowseRoot root) {
|
||||
if (root == _root) return;
|
||||
setState(() {
|
||||
_root = root;
|
||||
_pathStack.clear();
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
void _openFolder(CloudFolder folder) {
|
||||
setState(() => _pathStack.add(folder));
|
||||
_load();
|
||||
}
|
||||
|
||||
void _goToBreadcrumb(int index) {
|
||||
// index == -1 means the root label itself.
|
||||
setState(() {
|
||||
if (index < 0) {
|
||||
_pathStack.clear();
|
||||
} else {
|
||||
_pathStack.removeRange(index + 1, _pathStack.length);
|
||||
}
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _useThisFolder() async {
|
||||
final parentId = _currentFolderId;
|
||||
if (parentId == null) return;
|
||||
|
||||
setState(() => _confirming = true);
|
||||
try {
|
||||
await context.read<AppState>().chooseCloudFolder(
|
||||
parentId: parentId,
|
||||
breadcrumbPath: _breadcrumbPath,
|
||||
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<AppState>().activeProvider!.displayName;
|
||||
final canUseCurrentFolder = _currentFolderId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Choose $providerName Folder')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_session.supportsSharedWithMe)
|
||||
SegmentedButton<_BrowseRoot>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')),
|
||||
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
|
||||
],
|
||||
selected: {_root},
|
||||
onSelectionChanged: (selection) => _switchRoot(selection.first),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _goToBreadcrumb(-1),
|
||||
child: Text(_rootLabel),
|
||||
),
|
||||
for (var i = 0; i < _pathStack.length; i++) ...[
|
||||
const Icon(Icons.chevron_right, size: 18),
|
||||
TextButton(
|
||||
onPressed: () => _goToBreadcrumb(i),
|
||||
child: Text(_pathStack[i].name),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder,
|
||||
icon: _confirming
|
||||
? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.check),
|
||||
label: Text('Use "$_breadcrumbPath"'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)));
|
||||
}
|
||||
final folders = _folders ?? [];
|
||||
if (folders.isEmpty) {
|
||||
return const Center(child: Text('No folders here.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: folders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final folder = folders[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(folder.name),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openFolder(folder),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,16 +33,12 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
late final TextEditingController _gallonsController;
|
||||
late final TextEditingController _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<ConfirmFuelEntryScreen> {
|
|||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
// initialDate must fall within [firstDate, lastDate] or the picker
|
||||
// throws — widen the bounds to cover _date in case a misread date from
|
||||
// the receipt landed outside the normal 5-years-back-to-today window.
|
||||
final today = DateTime.now();
|
||||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
||||
? _date
|
||||
: today.subtract(const Duration(days: 365 * 5));
|
||||
final lastDate = _date.isAfter(today) ? _date : today;
|
||||
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: 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<ConfirmFuelEntryScreen> {
|
|||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
||||
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
|
|
|
|||
|
|
@ -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<DataSettingsScreen> createState() => _DataSettingsScreenState();
|
||||
}
|
||||
|
||||
class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
||||
static final _dateFormat = DateFormat.yMMMd();
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
DateTime? _purgeRangeStart;
|
||||
DateTime? _purgeRangeEnd;
|
||||
String? _purgeRangeError;
|
||||
|
||||
Future<void> _connect(CloudProviderId id) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProvider(id);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectManual(CloudProviderId id, String providerName) async {
|
||||
final credentials = await showDialog<_WebDavCredentials>(
|
||||
context: context,
|
||||
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
|
||||
);
|
||||
if (credentials == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProviderWithCredentials(
|
||||
id,
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectCloud();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().syncNow();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<bool> _confirmPurge({required String title, required String message}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _purgeAllData() async {
|
||||
final appState = context.read<AppState>();
|
||||
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<void> _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<void> _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<AppState>();
|
||||
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<AppState>();
|
||||
|
||||
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<AppState>().setKeepReceiptPhotosLocally(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep max quality images'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, receipt photos are downscaled to a reasonable size '
|
||||
'before storing — smaller cloud storage and faster syncs, with '
|
||||
'no loss of legibility for a printed receipt.',
|
||||
),
|
||||
value: appState.keepMaxQualityReceiptPhotos,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"If another device disconnects mid-sync without releasing its lock, "
|
||||
"this is how long to wait before treating it as abandoned and clearing "
|
||||
"it so sync can continue.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Slider(
|
||||
value: appState.staleLockMinutes.toDouble(),
|
||||
min: minStaleLockMinutes.toDouble(),
|
||||
max: maxStaleLockMinutes.toDouble(),
|
||||
divisions: maxStaleLockMinutes - minStaleLockMinutes,
|
||||
label: '${appState.staleLockMinutes} min',
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setStaleLockMinutes(value.round()),
|
||||
),
|
||||
Text('${appState.staleLockMinutes} minute(s)'),
|
||||
const 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<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Navigator.of(context).pop(_WebDavCredentials(
|
||||
serverUrl: _serverController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Connect ${widget.providerName}'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EditFuelEntryScreen> createState() => _EditFuelEntryScreenState();
|
||||
}
|
||||
|
||||
class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await context.read<AppState>().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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MainShell> 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<GlobalKey> targetKeys;
|
||||
final void Function() activate;
|
||||
|
||||
const _OnboardingStep({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.targetKeys,
|
||||
required this.activate,
|
||||
});
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
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<AppState>();
|
||||
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<void> _waitForTargets(List<GlobalKey> 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<void> _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<AppState>().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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AppState>().deleteFuelEntry(entry.id);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
|
||||
}
|
||||
|
||||
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
||||
Uint8List? _downloadedBytes;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.driveFileId != null) {
|
||||
_download();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final provider = context.read<AppState>().activeProvider;
|
||||
if (provider == null) {
|
||||
setState(() {
|
||||
_error = 'Not connected to a cloud storage provider.';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final session = provider.beginSession();
|
||||
try {
|
||||
final bytes = await session.downloadFileBytes(widget.driveFileId!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_downloadedBytes = Uint8List.fromList(bytes);
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Could not download receipt: $e';
|
||||
_loading = false;
|
||||
});
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
const ReceiptImageScreen({super.key, required this.imagePath});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -78,26 +17,11 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReceiptsScreen> createState() => _ReceiptsScreenState();
|
||||
}
|
||||
|
||||
class _ReceiptsScreenState extends State<ReceiptsScreen> 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<void> _addReceipt(BuildContext context) async {
|
||||
final vehicles = context.read<AppState>().vehicles;
|
||||
if (vehicles.isEmpty) {
|
||||
final newVehicleId = await Navigator.of(context).push<String>(
|
||||
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<Vehicle>(
|
||||
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<AppState>();
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ReportScreen> createState() => _ReportScreenState();
|
||||
}
|
||||
|
||||
class _ReportScreenState extends State<ReportScreen> 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<String> _selectedVehicleIds = {};
|
||||
final Set<String> _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<void> _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<Uint8List> _buildPdfBytes(FuelReport report) async {
|
||||
var receiptImages = const <String, Uint8List>{};
|
||||
if (_includeReceiptImages) {
|
||||
final loaded = await loadReceiptImageBytes(
|
||||
report: report,
|
||||
activeProvider: context.read<AppState>().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<void> _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<void> _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<void> _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<AppState>();
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _changing = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _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<AppState>().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<AppState>();
|
||||
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()),
|
||||
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),
|
||||
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()),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<AppState>();
|
||||
|
||||
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<ThemeMode>(
|
||||
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<AppState>().setThemeMode(selection.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
|
||||
}
|
||||
|
||||
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
|
||||
late final PageController _pageController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final vehicles = context.read<AppState>().vehicles;
|
||||
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
|
||||
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vehicles = context.watch<AppState>().vehicles;
|
||||
|
||||
if (vehicles.isEmpty) {
|
||||
return const Scaffold(body: Center(child: Text('Vehicle not found')));
|
||||
}
|
||||
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: vehicles.length,
|
||||
itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
|
||||
Future<void> _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 _VehicleDetailPage extends StatelessWidget {
|
||||
final String vehicleId;
|
||||
final imageFile = File(photo.path);
|
||||
|
||||
const _VehicleDetailPage({required this.vehicleId});
|
||||
if (!context.mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
final ocrService = OcrService();
|
||||
String recognizedText = '';
|
||||
try {
|
||||
recognizedText = await ocrService.recognizeText(imageFile);
|
||||
} catch (_) {
|
||||
recognizedText = '';
|
||||
} finally {
|
||||
ocrService.dispose();
|
||||
}
|
||||
|
||||
final parsed = ReceiptParser.parse(recognizedText);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConfirmFuelEntryScreen(
|
||||
vehicleId: vehicleId,
|
||||
imageFile: imageFile,
|
||||
parsed: parsed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AppState>().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!),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.file(
|
||||
File(entry.receiptImagePath!),
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
)
|
||||
: 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'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<CloudStorageProvider> availableProviders = [
|
||||
GoogleDriveProvider(),
|
||||
DropboxProvider(),
|
||||
OneDriveProvider(),
|
||||
WebDavProvider(),
|
||||
];
|
||||
|
||||
CloudStorageProvider? activeProvider;
|
||||
CloudSyncService? cloudSync;
|
||||
|
||||
final StorageService storage = StorageService();
|
||||
final _uuid = const Uuid();
|
||||
|
||||
List<Vehicle> vehicles = [];
|
||||
List<FuelEntry> 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<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
Future<void> 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;
|
||||
await storage.init();
|
||||
final data = await storage.loadData();
|
||||
vehicles = data.vehicles;
|
||||
fuelEntries = data.entries;
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
||||
if (results.any((r) => r != ConnectivityResult.none)) {
|
||||
unawaited(syncNow());
|
||||
}
|
||||
});
|
||||
|
||||
unawaited(_restoreCloudConnection());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshFromDatabase() async {
|
||||
vehicles = await database.getVehicles();
|
||||
fuelEntries = await database.getFuelEntries();
|
||||
}
|
||||
|
||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||
for (final provider in availableProviders) {
|
||||
if (provider.id == id) return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _restoreCloudConnection() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedProviderName = prefs.getString(_prefsKeyActiveProviderId);
|
||||
if (storedProviderName == null) return;
|
||||
|
||||
final matchingId = CloudProviderId.values
|
||||
.where((id) => id.name == storedProviderName)
|
||||
.firstOrNull;
|
||||
final provider = matchingId == null ? null : _providerById(matchingId);
|
||||
if (provider == null) return;
|
||||
|
||||
final signedIn = await provider.attemptSilentSignIn();
|
||||
if (!signedIn) return;
|
||||
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
||||
if (folderId != null) {
|
||||
cloudSync!.configure(folderId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
if (folderId != null) {
|
||||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectProvider(CloudProviderId id) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null) return;
|
||||
|
||||
await provider.signIn();
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
||||
/// (currently just WebDAV) that needs a server URL/username/password
|
||||
/// instead of an OAuth browser flow. The caller (Settings) is responsible
|
||||
/// for collecting those from the user first.
|
||||
Future<void> connectProviderWithCredentials(
|
||||
CloudProviderId id, {
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
|
||||
|
||||
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disconnectCloud() async {
|
||||
final provider = activeProvider;
|
||||
if (provider == null) return;
|
||||
|
||||
await provider.signOut();
|
||||
cloudSync?.clearConfiguration();
|
||||
activeProvider = null;
|
||||
cloudSync = null;
|
||||
cloudFolderPath = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyActiveProviderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseCloudFolder({
|
||||
required String parentId,
|
||||
required String breadcrumbPath,
|
||||
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<void> syncNow() async {
|
||||
final sync = cloudSync;
|
||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
||||
|
||||
isSyncing = true;
|
||||
notifyListeners();
|
||||
|
||||
final result = await sync.syncNow(
|
||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
||||
);
|
||||
|
||||
if (result.ranSync) {
|
||||
await _refreshFromDatabase();
|
||||
lastSyncedAt = DateTime.now();
|
||||
lastSyncError = null;
|
||||
} else if (result.error != null) {
|
||||
lastSyncError = result.error;
|
||||
}
|
||||
|
||||
isSyncing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepReceiptPhotosLocally(bool value) async {
|
||||
keepReceiptPhotosLocally = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
|
||||
keepMaxQualityReceiptPhotos = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> setStaleLockMinutes(int minutes) async {
|
||||
staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [vin] already belongs to another
|
||||
/// active vehicle — VIN must stay unique even though it's editable, so
|
||||
/// silently letting a duplicate through would be a real correctness bug,
|
||||
/// not just a UX wrinkle.
|
||||
Future<void> addVehicle({
|
||||
required String vin,
|
||||
String? nickname,
|
||||
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<void> updateVehicle(Vehicle updated) async {
|
||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
||||
throw DuplicateVinException(updated.vin);
|
||||
}
|
||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
||||
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
||||
if (index != -1) {
|
||||
vehicles[index] = updated;
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteVehicle(String id) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
||||
for (final path in orphanedLocalPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
await database.softDeleteVehicle(id, now);
|
||||
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
|
||||
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||
}
|
||||
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
|
||||
vehicles.removeWhere((v) => v.id == vehicleId);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -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<void> 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<void> 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<void> 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<void> 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<void> changeSaveDirectory(String newPath) async {
|
||||
await storage.setSaveDirectory(newPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
await _refreshFromDatabase();
|
||||
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||
notifyListeners();
|
||||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool> attemptSilentSignIn();
|
||||
|
||||
/// Interactive sign-in. Must be called from a user-initiated action
|
||||
/// (e.g. a button press). Returns a label to display (email/username).
|
||||
Future<String> signIn();
|
||||
|
||||
Future<void> signOut();
|
||||
|
||||
/// Starts one session's worth of operations (roughly: one sync round,
|
||||
/// or one folder-browsing screen visit). The caller owns its lifecycle —
|
||||
/// call [CloudStorageSession.close] when done with it.
|
||||
CloudStorageSession beginSession();
|
||||
}
|
||||
|
||||
/// Raw operations against one cloud storage backend, scoped to a single
|
||||
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
|
||||
/// opaque per-provider — for most providers a real ID, but for a
|
||||
/// path-addressed API (Dropbox) a session may internally treat the path
|
||||
/// itself as the "id". Callers never need to know which.
|
||||
abstract class CloudStorageSession {
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
|
||||
/// provider supports it) top-level folders shared with the signed-in
|
||||
/// account regardless of parent. Providers that don't have a meaningful
|
||||
/// separate "shared with me" concept may just ignore [sharedWithMe] and
|
||||
/// always list under [parentId].
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
|
||||
|
||||
/// True if this provider has a distinct "Shared with me" browsing mode
|
||||
/// worth showing as a separate tab in the folder picker UI.
|
||||
bool get supportsSharedWithMe;
|
||||
|
||||
/// Finds a folder named [name] directly under [parentId], or creates one
|
||||
/// if none exists. If duplicates exist, the earliest-created one wins.
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name});
|
||||
|
||||
/// Looks up a file's ID + versionTag by name within [folderId] without
|
||||
/// downloading its content, or null if no such file exists yet.
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name});
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId);
|
||||
|
||||
/// Creates the file if [existingFileId] is null, otherwise overwrites
|
||||
/// its content. Returns the (possibly new) file ID and fresh versionTag.
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
});
|
||||
|
||||
Future<void> deleteFile(String fileId);
|
||||
|
||||
Future<String> createLockFile({required String folderId, required String name});
|
||||
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId);
|
||||
|
||||
/// Releases any resources (e.g. closes an underlying HTTP client).
|
||||
void close();
|
||||
}
|
||||
|
||||
/// Implemented by providers that need the user to type in connection
|
||||
/// details (server URL, username, password) instead of completing an
|
||||
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
|
||||
/// central authorization server to redirect to. The Settings screen checks
|
||||
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
|
||||
/// "Connect" opens a small credentials form instead of calling
|
||||
/// [CloudStorageProvider.signIn] directly.
|
||||
abstract class ManualCredentialCloudStorageProvider {
|
||||
Future<String> signInWithCredentials({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
});
|
||||
}
|
||||
|
||||
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
|
||||
/// every provider's [CloudStorageSession.listLockFiles] implementation
|
||||
/// rather than duplicated, since the lock file naming convention itself
|
||||
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
|
||||
(String, DateTime)? parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../cloud_oauth_config.dart';
|
||||
import 'cloud_storage_provider.dart';
|
||||
import 'oauth_pkce.dart';
|
||||
|
||||
/// Dropbox implementation of [CloudStorageProvider].
|
||||
///
|
||||
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
|
||||
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
|
||||
/// folder's own Dropbox path (e.g. "/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<bool> attemptSilentSignIn() async {
|
||||
// The refresh token isn't persisted across app launches in this first
|
||||
// pass (kept in memory only) — see README's Known limitations. Silent
|
||||
// restore always fails; the user reconnects once per cold start until
|
||||
// that's added.
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signIn() async {
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
final redirectUri = CloudOAuthConfig.dropboxRedirectUri;
|
||||
if (appKey == null || redirectUri == null) {
|
||||
throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).');
|
||||
}
|
||||
|
||||
final pkce = PkcePair.generate();
|
||||
final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', {
|
||||
'client_id': appKey,
|
||||
'response_type': 'code',
|
||||
'code_challenge': pkce.codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'redirect_uri': redirectUri,
|
||||
'token_access_type': 'offline',
|
||||
});
|
||||
|
||||
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
|
||||
final resultUrl = await FlutterWebAuth2.authenticate(
|
||||
url: authUrl.toString(),
|
||||
callbackUrlScheme: callbackUrlScheme,
|
||||
);
|
||||
|
||||
final code = Uri.parse(resultUrl).queryParameters['code'];
|
||||
if (code == null) {
|
||||
throw StateError('Dropbox sign-in did not return an authorization code.');
|
||||
}
|
||||
|
||||
await _exchangeCodeForTokens(
|
||||
code: code,
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
appKey: appKey,
|
||||
redirectUri: redirectUri,
|
||||
);
|
||||
_accountLabel = await _fetchAccountEmail();
|
||||
return _accountLabel!;
|
||||
}
|
||||
|
||||
Future<void> _exchangeCodeForTokens({
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String appKey,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': appKey,
|
||||
'code_verifier': codeVerifier,
|
||||
'redirect_uri': redirectUri,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox token exchange failed: ${response.body}');
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_refreshToken = json['refresh_token'] as String?;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
}
|
||||
|
||||
Future<String> _fetchAccountEmail() async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/users/get_current_account'),
|
||||
headers: {'Authorization': 'Bearer $_accessToken'},
|
||||
);
|
||||
if (response.statusCode != 200) return 'Dropbox account';
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return json['email'] as String? ?? 'Dropbox account';
|
||||
}
|
||||
|
||||
/// Refreshes the access token if it's missing or close to expiring.
|
||||
/// Never prompts for UI — suitable for background sync — so throws
|
||||
/// [CloudNotAuthorizedException] if there's no refresh token to use.
|
||||
Future<String> _freshAccessToken() async {
|
||||
final stillValid = _accessToken != null &&
|
||||
_accessTokenExpiry != null &&
|
||||
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
|
||||
if (stillValid) return _accessToken!;
|
||||
|
||||
final refreshToken = _refreshToken;
|
||||
final appKey = CloudOAuthConfig.dropboxAppKey;
|
||||
if (refreshToken == null || appKey == null) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/oauth2/token'),
|
||||
body: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
'client_id': appKey,
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw CloudNotAuthorizedException(displayName);
|
||||
}
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_accessToken = json['access_token'] as String;
|
||||
_accessTokenExpiry =
|
||||
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
|
||||
return _accessToken!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_accessTokenExpiry = null;
|
||||
_accountLabel = null;
|
||||
}
|
||||
|
||||
@override
|
||||
CloudStorageSession beginSession() {
|
||||
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
|
||||
return DropboxSession(this);
|
||||
}
|
||||
}
|
||||
|
||||
class DropboxSession implements CloudStorageSession {
|
||||
final DropboxProvider _provider;
|
||||
DropboxSession(this._provider);
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
|
||||
Future<Map<String, String>> _authHeader() async =>
|
||||
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
|
||||
|
||||
/// Dropbox's root path is `""`, not `"/"` — our generic interface uses
|
||||
/// the literal string `'root'` for "the top of the tree" (matching
|
||||
/// Google Drive's convention), so translate that here.
|
||||
String _normalizePath(String id) => id == 'root' ? '' : id;
|
||||
|
||||
String _childPath(String parentId, String name) {
|
||||
final parent = _normalizePath(parentId);
|
||||
return '$parent/$name';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, Map<String, dynamic> body) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', path),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// True if a Dropbox API error response's `.tag` chain indicates "the
|
||||
/// path doesn't exist" — Dropbox reports this as a normal 409 response
|
||||
/// with a structured error body, not a 404, so it needs its own check
|
||||
/// rather than a status-code check.
|
||||
bool _isPathNotFoundError(http.Response response) {
|
||||
if (response.statusCode != 409) return false;
|
||||
try {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return jsonEncode(body['error']).contains('not_found');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final path = _normalizePath(parentId ?? 'root');
|
||||
final json = await _post('/2/files/list_folder', {'path': path});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
return entries
|
||||
.cast<Map<String, dynamic>>()
|
||||
.where((e) => e['.tag'] == 'folder')
|
||||
.map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
final childPath = _childPath(parentId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': childPath}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] == 'folder') return childPath;
|
||||
} else if (!_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
await _post('/2/files/create_folder_v2', {'path': childPath});
|
||||
return childPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
final filePath = _childPath(folderId, name);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': filePath}),
|
||||
);
|
||||
if (_isPathNotFoundError(response)) return null;
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (json['.tag'] != 'file') return null;
|
||||
return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/download'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': fileId}),
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final targetPath = existingFileId ?? _childPath(folderId, name);
|
||||
final bytes = await localFile.readAsBytes();
|
||||
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: bytes,
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return CloudFileInfo(
|
||||
id: json['path_display'] as String? ?? targetPath,
|
||||
versionTag: json['content_hash'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
final response = await http.post(
|
||||
Uri.https('api.dropboxapi.com', '/2/files/delete_v2'),
|
||||
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'path': fileId}),
|
||||
);
|
||||
if (response.statusCode != 200 && !_isPathNotFoundError(response)) {
|
||||
throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async {
|
||||
final path = _childPath(folderId, name);
|
||||
final response = await http.post(
|
||||
Uri.https('content.dropboxapi.com', '/2/files/upload'),
|
||||
headers: {
|
||||
...await _authHeader(),
|
||||
'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: const <int>[],
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
|
||||
final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)});
|
||||
final entries = (json['entries'] as List<dynamic>? ?? []);
|
||||
|
||||
final locks = <CloudLockFile>[];
|
||||
for (final entry in entries.cast<Map<String, dynamic>>()) {
|
||||
if (entry['.tag'] != 'file') continue;
|
||||
final parsed = parseLockFileName(entry['name'] as String?);
|
||||
if (parsed != null) {
|
||||
locks.add(CloudLockFile(
|
||||
id: entry['path_display'] as String,
|
||||
username: parsed.$1,
|
||||
createdAtUtc: parsed.$2,
|
||||
));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||