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
|
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
|
||||||
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
|
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
|
||||||
you confirm or correct the numbers, and it's saved alongside the receipt
|
you confirm or correct the numbers, and it's saved alongside the receipt
|
||||||
photo. Vehicles and fuel entries live in a local SQLite database that's kept
|
photo to a data file in a folder you choose.
|
||||||
in sync with a shared folder on **Google Drive, Dropbox, OneDrive, or your
|
|
||||||
own WebDAV server** (your choice), so multiple people can log fuel against
|
|
||||||
the same pool of vehicles from their own phones, offline or online.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Manage a list of vehicles, identified by **VIN** (required, and unique
|
- Manage a list of vehicles (make, model, color, license plate).
|
||||||
across active vehicles — but editable, e.g. to fix a typo from a misread
|
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google
|
||||||
scan; scan it from a photo of the door-jamb sticker or dashboard plate
|
ML Kit — no internet connection or API key required.
|
||||||
instead of typing all 17 characters). An optional **nickname** ("Mom's
|
- Review/edit the parsed gallons, price per gallon, and total cost before
|
||||||
Car", "Red Ford F-150") is what's shown as the primary label everywhere;
|
saving (OCR on printed receipts is usually good but not perfect).
|
||||||
without one, the VIN is shown instead.
|
|
||||||
- Capture a fuel receipt photo per vehicle — from the camera or an existing
|
|
||||||
photo in your library — and OCR it on-device with Google ML Kit — no
|
|
||||||
internet connection or API key required for the OCR itself.
|
|
||||||
- If a receipt's address shows a state other than Missouri, a warning
|
|
||||||
explains that the fuel tax refund only covers Missouri purchases and lets
|
|
||||||
you choose whether to log the entry anyway.
|
|
||||||
- Review/edit the parsed gallons, price per gallon, total cost, *and*
|
|
||||||
date/time before saving (OCR on printed receipts is usually good but not
|
|
||||||
perfect; the date/time picker is always available as a manual fallback
|
|
||||||
when a date can't be found on the receipt).
|
|
||||||
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
|
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
|
||||||
photos, and delete.
|
photos, and delete.
|
||||||
- Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV
|
- Settings screen to choose where receipt photos and the data file are
|
||||||
server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
|
stored (defaults to the app's own documents folder; existing data is
|
||||||
endpoint) and pick any folder you have access to — including one someone
|
copied over when you change it).
|
||||||
else created and shared with you — as the shared storage location. The
|
|
||||||
app looks for (or creates) a `Show Me The Fuel Refund` subfolder there
|
|
||||||
(using it directly, without nesting, if the folder you picked is already
|
|
||||||
named that), so anyone pointed at the same shared parent converges on the
|
|
||||||
same data automatically, regardless of which of the four providers each
|
|
||||||
person is using. WebDAV is the only one of the four that needs no
|
|
||||||
developer-console setup at all — just a server URL, username, and
|
|
||||||
password.
|
|
||||||
- Works fully offline: writes always land in the local SQLite database
|
|
||||||
first; a background sync pushes changes to the cloud and pulls others'
|
|
||||||
changes down once online.
|
|
||||||
- Settings lets you choose whether a receipt photo's local copy is deleted
|
|
||||||
once it's safely uploaded to the cloud (the default — keeps the phone's
|
|
||||||
storage footprint small) or kept on the phone for offline viewing.
|
|
||||||
- Settings → Advanced lets you tune the sync lock's staleness timeout (how
|
|
||||||
long before another device's abandoned lock is cleared so sync isn't
|
|
||||||
stuck waiting forever), from 1–60 minutes.
|
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
|
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization
|
||||||
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
|
|
||||||
`deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's
|
|
||||||
`id` is a hidden, generated, immutable primary key — never shown in the
|
|
||||||
UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
|
|
||||||
required, unique-among-active-vehicles, but user-editable field, and
|
|
||||||
`nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
|
|
||||||
`receiptDriveFileId`, or both at once if "keep photos on this phone" is
|
|
||||||
on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
|
|
||||||
distinction.
|
|
||||||
services/
|
services/
|
||||||
app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps
|
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider
|
||||||
updatedAt on mutations and triggers background sync.
|
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path
|
||||||
database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
|
ocr_service.dart Thin wrapper around google_mlkit_text_recognition
|
||||||
and the receipts/ folder, always at
|
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text
|
||||||
ApplicationDocumentsDirectory/FuelTaxTracker.
|
|
||||||
db_schema.dart CREATE TABLE statements and the merge SQL — shared between
|
|
||||||
the app and its tests so they can never drift apart.
|
|
||||||
cloud/
|
|
||||||
cloud_storage_provider.dart The CloudStorageProvider/CloudStorageSession
|
|
||||||
interface every backend implements (auth, folder
|
|
||||||
browsing, file upload/download, lock file ops) —
|
|
||||||
this is what cloud_sync_service.dart and the rest
|
|
||||||
of the app talk to; they never know which of the
|
|
||||||
three providers below is actually active.
|
|
||||||
google_drive_provider.dart Google sign-in + the googleapis DriveApi client.
|
|
||||||
dropbox_provider.dart Hand-rolled OAuth2 PKCE + Dropbox's path-addressed
|
|
||||||
REST API (files/list_folder, files/upload, etc).
|
|
||||||
onedrive_provider.dart Hand-rolled OAuth2 PKCE + Microsoft Graph
|
|
||||||
(/me/drive/...), ID-addressed like Drive.
|
|
||||||
webdav_provider.dart Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE)
|
|
||||||
over HTTP Basic Auth — no OAuth, no developer
|
|
||||||
console, just a server URL + username + password.
|
|
||||||
For self-hosted servers (Nextcloud, ownCloud, a
|
|
||||||
Synology NAS, etc). Path-addressed like Dropbox;
|
|
||||||
uses the resource's ETag as its versionTag.
|
|
||||||
oauth_pkce.dart PKCE code_verifier/code_challenge generation, shared
|
|
||||||
by the Dropbox and OneDrive providers (Google's own
|
|
||||||
SDK handles its OAuth flow itself, and WebDAV uses
|
|
||||||
plain Basic Auth, so neither needs this).
|
|
||||||
cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever
|
|
||||||
provider(s) you want to use — see setup below.
|
|
||||||
cloud_sync_service.dart Orchestrates one sync round against whichever
|
|
||||||
CloudStorageProvider is active: acquire the
|
|
||||||
cross-device lock, ATTACH + merge the remote database
|
|
||||||
into the local one, upload pending receipt photos,
|
|
||||||
push the local database back up, release the lock.
|
|
||||||
lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable
|
|
||||||
logic (independently unit-tested without a real backend).
|
|
||||||
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
|
|
||||||
receipt_parser.dart Regex-based extraction of gallons/price/total/date from OCR
|
|
||||||
text — falls back to manual entry (date picker, or the
|
|
||||||
gallons*price derivation) for whatever isn't found.
|
|
||||||
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
|
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
|
||||||
confirm fuel entry, receipt viewer, settings, cloud folder browser).
|
confirm fuel entry, receipt viewer, settings)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual setup required (cloud storage)
|
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
|
||||||
|
subfolder of photos, both inside whatever directory Settings points at.
|
||||||
You only need to complete setup for whichever provider(s) you actually want
|
|
||||||
to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same
|
|
||||||
shape: I can't provision cloud resources on your behalf, so each needs an
|
|
||||||
app/OAuth client you create yourself in that provider's own developer
|
|
||||||
console, with the resulting IDs pasted into
|
|
||||||
`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect"
|
|
||||||
button for one of these three once its config fields are filled in with
|
|
||||||
real values. **WebDAV needs none of this** — see its section below.
|
|
||||||
|
|
||||||
### Google Drive (Google Cloud Console)
|
|
||||||
|
|
||||||
In [Google Cloud Console](https://console.cloud.google.com/):
|
|
||||||
|
|
||||||
1. Create/select a project, enable the **Google Drive API** (APIs & Services
|
|
||||||
→ Library).
|
|
||||||
2. **OAuth consent screen**: set it to **External**, keep it in **Testing**
|
|
||||||
status, and add your Google account plus everyone else's you're sharing
|
|
||||||
with as **test users**. This avoids Google's formal verification review,
|
|
||||||
which the broad `drive` scope would otherwise require — fine for a known,
|
|
||||||
small group, not a public release.
|
|
||||||
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
|
|
||||||
(shows as "restricted/sensitive" — expected, fine in Testing mode).
|
|
||||||
4. **Credentials → Create Credentials → OAuth client ID**, three times:
|
|
||||||
- **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the
|
|
||||||
SHA-1 of your debug keystore (`keytool -list -v -keystore
|
|
||||||
~/.android/debug.keystore`, password `android`), and later your release
|
|
||||||
keystore's SHA-1 too. This client ID itself is never referenced in
|
|
||||||
code — it exists purely so Android's Credential Manager trusts this
|
|
||||||
specific signed app.
|
|
||||||
- **Web application**: no redirect URIs needed. Copy its **Client ID** —
|
|
||||||
this is what Android sign-in actually authenticates against (a
|
|
||||||
Credential Manager quirk: it needs a *web* client ID, passed as
|
|
||||||
`serverClientId`, even for a mobile app).
|
|
||||||
- **iOS**: bundle ID matching the Xcode project. Copy its **Client ID**
|
|
||||||
and note the reversed form (`com.googleusercontent.apps.<...>`).
|
|
||||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `googleAndroidServerClientId` ← the **Web application** client's ID.
|
|
||||||
- `googleIosClientId` ← the **iOS** client's ID.
|
|
||||||
6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` →
|
|
||||||
`CFBundleURLSchemes` with your iOS client's reversed ID.
|
|
||||||
|
|
||||||
### Dropbox (Dropbox App Console)
|
|
||||||
|
|
||||||
Dropbox has no official Flutter sign-in SDK, so this uses a hand-built
|
|
||||||
OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a
|
|
||||||
public/mobile app) via `flutter_web_auth_2`, which opens the system browser
|
|
||||||
and catches the redirect through a custom URL scheme already registered in
|
|
||||||
`AndroidManifest.xml` / `Info.plist`.
|
|
||||||
|
|
||||||
1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps).
|
|
||||||
2. Access type: **Full Dropbox** (not "App folder") — needed because
|
|
||||||
browsing to and reusing a folder someone *else* created and shared
|
|
||||||
requires seeing the whole account, the same reasoning as Google's full
|
|
||||||
`drive` scope above.
|
|
||||||
3. Under **OAuth 2** → **Redirect URIs**, add
|
|
||||||
`mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already
|
|
||||||
wired up in the Android manifest and iOS Info.plist; use a different one
|
|
||||||
only if you also update those two files to match).
|
|
||||||
4. Copy the app's **App key** from the Settings tab.
|
|
||||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `dropboxAppKey` ← the App key.
|
|
||||||
- `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`.
|
|
||||||
6. While the app is in **Development** status, only your own Dropbox
|
|
||||||
account can sign in; add teammates under the app's **Permissions** /
|
|
||||||
member-access settings, or apply for **Production** status, once you're
|
|
||||||
ready to share it with others.
|
|
||||||
|
|
||||||
### OneDrive (Azure Portal / Microsoft Graph)
|
|
||||||
|
|
||||||
Same PKCE approach as Dropbox, against the Microsoft identity platform and
|
|
||||||
Microsoft Graph.
|
|
||||||
|
|
||||||
1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** →
|
|
||||||
**App registrations** → **New registration**.
|
|
||||||
2. Supported account types: **Personal Microsoft accounts only** (or "any
|
|
||||||
organizational directory and personal Microsoft accounts" if you also
|
|
||||||
want work/school accounts to be able to sign in — those may additionally
|
|
||||||
need their tenant admin's consent for the scopes below).
|
|
||||||
3. Under **Authentication** → **Add a platform** → **Mobile and desktop
|
|
||||||
applications**, add the redirect URI `mofueltaxback-onedrive://auth`
|
|
||||||
(this exact scheme is already wired up in the Android manifest and iOS
|
|
||||||
Info.plist; use a different one only if you also update those two files
|
|
||||||
to match).
|
|
||||||
4. Under **API permissions**, add Microsoft Graph **delegated** permissions
|
|
||||||
`Files.ReadWrite.All` and `offline_access` (the latter is required to get
|
|
||||||
a refresh token back from the token endpoint).
|
|
||||||
5. Copy the **Application (client) ID** from the Overview page.
|
|
||||||
6. Fill in `lib/services/cloud_oauth_config.dart`:
|
|
||||||
- `oneDriveClientId` ← the Application (client) ID.
|
|
||||||
- `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`.
|
|
||||||
|
|
||||||
### WebDAV (self-hosted — no developer console needed)
|
|
||||||
|
|
||||||
This is the option for a personal cloud server you already run — Nextcloud,
|
|
||||||
ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare
|
|
||||||
Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere;
|
|
||||||
tapping "Connect WebDAV" in Settings just opens a form asking for:
|
|
||||||
|
|
||||||
- **Server URL** — the full WebDAV endpoint, e.g.
|
|
||||||
`https://cloud.example.com/remote.php/dav/files/yourusername/` for
|
|
||||||
Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you.
|
|
||||||
`https://` is assumed if you omit the scheme.
|
|
||||||
- **Username** and **Password** — for servers that support app-specific
|
|
||||||
passwords (Nextcloud: Settings → Security → "Create new app password"),
|
|
||||||
use one of those instead of your real account password, so this app can
|
|
||||||
be revoked independently later.
|
|
||||||
|
|
||||||
The app verifies the URL and credentials with a harmless `PROPFIND` request
|
|
||||||
before treating you as signed in, so a typo or wrong password fails
|
|
||||||
immediately with a clear error rather than surfacing later during sync.
|
|
||||||
Nothing needs to be filled in `cloud_oauth_config.dart` or the
|
|
||||||
Android/iOS manifest files for this provider.
|
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
|
|
@ -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
|
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
|
||||||
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
|
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
|
||||||
to produce a working APK. None of the three providers' sign-in/sync paths
|
to produce a working APK.
|
||||||
can be exercised end-to-end until you've done that provider's manual OAuth
|
|
||||||
setup above.
|
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
||||||
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
||||||
set up for receipt capture.
|
set up for receipt capture.
|
||||||
- **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the
|
- **Storage**: no explicit storage permission is declared. The default save
|
||||||
main Android manifest (needed for release builds; debug builds get
|
location is inside the app's own sandbox (no permission needed). If you
|
||||||
`INTERNET` for free).
|
point Settings at a location outside the sandbox, the OS-native folder
|
||||||
- **OAuth redirects**: no manifest changes are needed for `google_sign_in`
|
picker (via `file_picker`) is what grants access — see the caveat below.
|
||||||
itself when not using `google-services.json` — see the Google setup
|
|
||||||
section above instead. Dropbox and OneDrive's browser-based OAuth redirect
|
|
||||||
is already wired up via a `flutter_web_auth_2` callback activity
|
|
||||||
(Android) / extra `CFBundleURLTypes` entries (iOS) for the
|
|
||||||
`mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you
|
|
||||||
only need to register the matching redirect URI in each provider's own
|
|
||||||
console (see setup above), not touch these files, unless you deliberately
|
|
||||||
choose different scheme names.
|
|
||||||
|
|
||||||
## How sync works
|
|
||||||
|
|
||||||
Every local change (add/edit a vehicle, log a fuel entry) writes to the
|
|
||||||
local SQLite database immediately, then triggers a best-effort background
|
|
||||||
sync — also triggered whenever connectivity comes back or the app starts.
|
|
||||||
Every row carries `updated_at` (for merge resolution), `deleted_at` (a
|
|
||||||
soft-delete tombstone — see below), and a local-only `dirty` flag (pending
|
|
||||||
push to Drive, never itself treated as meaningful sync data). Sync:
|
|
||||||
|
|
||||||
1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared cloud
|
|
||||||
folder, then waits until no *other* lock file older than its own remains
|
|
||||||
(polling every second, deleting any it finds older than the configured
|
|
||||||
staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as
|
|
||||||
orphaned/stale) — a ticket-based mutex using the cloud folder itself as
|
|
||||||
the coordination point, so two devices never overwrite each other's
|
|
||||||
edits to the shared database mid-write. This logic (`lock_coordinator.dart`)
|
|
||||||
is identical regardless of which of the four providers is active.
|
|
||||||
2. Checks the remote database file's change-detection tag (Drive's
|
|
||||||
`md5Checksum`, Dropbox's `content_hash`, OneDrive's `cTag`, or a WebDAV
|
|
||||||
resource's `ETag` — an opaque `versionTag` as far as the sync engine is
|
|
||||||
concerned) against the last one seen (a metadata-only call, no content
|
|
||||||
download) to decide if a pull is even needed. If it changed: downloads
|
|
||||||
it to a temp file, `ATTACH`es it to
|
|
||||||
the local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE
|
|
||||||
local.<key> IS NULL OR remote.updated_at > local.updated_at` per table
|
|
||||||
(`id` for both tables — vehicles' hidden generated id, not the
|
|
||||||
user-editable VIN). Rows that statement doesn't match — including this
|
|
||||||
device's own not-yet-pushed edits — are left untouched, so no separate
|
|
||||||
"keep local" step is needed.
|
|
||||||
3. Uploads any receipt photos still needing it (a local path but no cloud
|
|
||||||
file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud
|
|
||||||
file ID. The local copy is then deleted or kept depending on the
|
|
||||||
Settings "keep photos on this phone" toggle. If any upload fails, the
|
|
||||||
push step below is skipped entirely this cycle — a row is never pushed
|
|
||||||
while it still holds a local-only, not-yet-uploaded path.
|
|
||||||
4. Reads the local database file directly (sqflite's default journal mode
|
|
||||||
isn't WAL, so the file is complete and consistent as soon as the last
|
|
||||||
write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as
|
|
||||||
cheap insurance) and uploads it as the new remote copy, then clears the
|
|
||||||
`dirty` flag on every row now that local matches what's on the cloud.
|
|
||||||
5. Releases the lock.
|
|
||||||
|
|
||||||
**Deletions propagate correctly**, unlike a naive "union records" merge:
|
|
||||||
deleting sets `deleted_at` instead of removing the row, so a deletion is
|
|
||||||
just another change with its own `updated_at`, and rides the same
|
|
||||||
newest-wins rule as any edit — no separate deletion-handling logic needed.
|
|
||||||
|
|
||||||
## Known caveats / things to revisit
|
## Known caveats / things to revisit
|
||||||
|
|
||||||
- **Field-level conflicts aren't merged.** Two people editing the *same*
|
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions
|
||||||
record at the same time: whole record, newer `updated_at` wins — not a
|
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP
|
||||||
field-by-field merge.
|
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't
|
||||||
- **Lock acquisition has no hard timeout** beyond the configurable
|
actually wired up for library modules in this Flutter/AGP combination yet,
|
||||||
staleness reap (Settings → Advanced). Fine at the scale this is built for
|
so the plugin's own Kotlin sources never get compiled and the build fails
|
||||||
(a handful of people); could in theory spin under many simultaneous
|
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is
|
||||||
contenders.
|
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time
|
||||||
- **VIN uniqueness is checked, but not race-proof across devices.** Editing
|
you bump dependencies — check the package's CHANGELOG for when this is
|
||||||
or adding a vehicle checks for an existing active vehicle with the same
|
properly resolved upstream.
|
||||||
VIN before saving, but two offline devices could still each independently
|
- **iOS folder picking and app restarts**: `file_picker`'s directory picker
|
||||||
create (or rename into) the same VIN before either has seen the other's
|
on iOS uses `UIDocumentPickerViewController`, which hands back a
|
||||||
change — same category of accepted limitation as "field-level conflicts
|
security-scoped URL. This app does not currently persist a security-scoped
|
||||||
aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not
|
bookmark for that URL, so if you pick a folder outside the app's own
|
||||||
`vin`, specifically so this doesn't corrupt the merge itself if it
|
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after
|
||||||
happens — you'd just end up with two vehicle rows sharing a VIN until
|
an app restart is not guaranteed. Picking the default in-sandbox location,
|
||||||
someone notices and fixes it by hand.
|
or a folder on Android, does not have this limitation. If cross-restart
|
||||||
- **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
|
external storage on iOS matters for your use case, this needs a proper
|
||||||
(`serverClientId`), not just the Android client's SHA-1 registration —
|
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
|
||||||
see the manual setup section. This is a quirk of the Credential
|
|
||||||
Manager-based implementation and easy to miss if you're used to older
|
|
||||||
`google_sign_in` versions.
|
|
||||||
- **Dropbox and OneDrive's sign-in isn't restored across a cold app
|
|
||||||
restart.** Their OAuth refresh tokens are kept in memory only for now
|
|
||||||
(Google's own SDK handles its own persistent session separately, and
|
|
||||||
WebDAV persists its credentials to OS-encrypted storage — see below — so
|
|
||||||
this only affects these two hand-built OAuth providers) —
|
|
||||||
`attemptSilentSignIn()` always returns false for them, so you'll need to
|
|
||||||
reconnect once per app launch until refresh-token persistence is added.
|
|
||||||
- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore
|
|
||||||
on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the
|
|
||||||
session after a cold restart — it re-verifies them with the same PROPFIND
|
|
||||||
check `signInWithCredentials` uses rather than trusting the stored values
|
|
||||||
blindly, since the server or password could have changed since. A wrong
|
|
||||||
or revoked password just silently fails the restore (same as no
|
|
||||||
connection ever having been made); there's no proactive UI nudge to
|
|
||||||
reconnect beyond the sync error that shows up on the next sync attempt.
|
|
||||||
- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks
|
|
||||||
the access token's expiry before every call and refreshes proactively,
|
|
||||||
but a token revoked or invalidated out-of-band (e.g. from that provider's
|
|
||||||
own "manage app access" page) surfaces as a failed sync (visible in
|
|
||||||
Settings as "Last sync failed") rather than prompting a fresh sign-in
|
|
||||||
automatically.
|
|
||||||
- **Dropbox has no distinct "Shared with me" tab** in the folder browser
|
|
||||||
(the interface's `sharedWithMe` parameter is a no-op for
|
|
||||||
`DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into
|
|
||||||
the account's normal folder tree, so they already show up under "My
|
|
||||||
Files" in the common case. Revisit if an unmounted/pending share needs to
|
|
||||||
be browsable directly.
|
|
||||||
- **WebDAV has no "Shared with me" concept at all** (it's not part of the
|
|
||||||
base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false,
|
|
||||||
same as Dropbox, and it's up to the server/user to point the app at
|
|
||||||
whatever path a share is mounted under. WebDAV credentials are also sent
|
|
||||||
as HTTP Basic Auth on every request, so an **HTTPS server URL is
|
|
||||||
effectively required** — the app doesn't block a plain `http://` URL, but
|
|
||||||
it would send the password in the clear.
|
|
||||||
- **Receipt parsing is best-effort regex matching** on the OCR'd text
|
- **Receipt parsing is best-effort regex matching** on the OCR'd text
|
||||||
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
|
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
|
||||||
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
|
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
|
||||||
parse partially or not at all — the confirm screen always lets you fill in
|
parse partially or not at all — the confirm screen always lets you fill in
|
||||||
or correct whatever wasn't found.
|
or correct whatever wasn't found.
|
||||||
- No automated tests exercise the OCR, camera/gallery picker, or real Drive
|
- No automated tests exercise the OCR or camera capture path itself (that
|
||||||
API calls (those need a real device/emulator and live credentials);
|
requires a real device/emulator with a camera); `receipt_parser_test.dart`
|
||||||
`receipt_parser_test.dart`, `lock_coordinator_test.dart`,
|
covers the parsing logic against fixed OCR text.
|
||||||
`db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter
|
|
||||||
two using `sqflite_common_ffi` to run real SQL against temp SQLite files
|
|
||||||
on the Dart VM) cover the pure logic pieces against fixed inputs —
|
|
||||||
`receipt_parser_test.dart` in particular is transcribed from 13 real
|
|
||||||
photographed receipts across different gas station chains.
|
|
||||||
- No migration path exists from the earlier JSON-file storage format —
|
|
||||||
not needed since no real data had accumulated under it yet, but flag it
|
|
||||||
if that's no longer true for you.
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
|
|
||||||
android/app/src/debug/AndroidManifest.xml, but release builds need
|
|
||||||
it declared here explicitly. -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
|
||||||
<application
|
<application
|
||||||
android:label="fuel_tax_tracker"
|
android:label="fuel_tax_tracker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -32,22 +27,6 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Dropbox & OneDrive OAuth: flutter_web_auth_2 captures the
|
|
||||||
browser redirect via this activity + custom URL scheme(s).
|
|
||||||
These scheme values must exactly match whatever is set as
|
|
||||||
dropboxRedirectUri/oneDriveRedirectUri in
|
|
||||||
lib/services/cloud_oauth_config.dart. -->
|
|
||||||
<activity
|
|
||||||
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter android:label="flutter_web_auth_2">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="mofueltaxback-dropbox" />
|
|
||||||
<data android:scheme="mofueltaxback-onedrive" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
@ -64,13 +43,5 @@
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
</manifest>
|
</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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item android:drawable="?android:colorBackground" />
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
<!-- You can insert your own image assets here -->
|
||||||
<item>
|
<!-- <item>
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
<bitmap
|
||||||
</item>
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item android:drawable="@android:color/white" />
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
<!-- You can insert your own image assets here -->
|
||||||
<item>
|
<!-- <item>
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
<bitmap
|
||||||
</item>
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</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
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
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
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<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>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
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;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -498,7 +498,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
"filename" : "LaunchImage.png",
|
"filename" : "LaunchImage.png",
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "LaunchImageDark.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "LaunchImage@2x.png",
|
"filename" : "LaunchImage@2x.png",
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "LaunchImageDark@2x.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "LaunchImage@3x.png",
|
"filename" : "LaunchImage@3x.png",
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filename" : "LaunchImageDark@3x.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"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">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<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 opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
<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"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
|
|
@ -38,7 +32,6 @@
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="1024" height="1024"/>
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
<image name="LaunchBackground" width="1" height="1"/>
|
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Fuel Tax Tracker</string>
|
<string>Fuel Tax Tracker</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
|
@ -70,43 +68,5 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Google Sign-In: required for the OAuth redirect to route back into
|
</dict>
|
||||||
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>
|
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/main_shell.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'services/app_state.dart';
|
import 'services/app_state.dart';
|
||||||
import 'theme/app_theme.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const FuelTaxTrackerApp());
|
runApp(const FuelTaxTrackerApp());
|
||||||
|
|
@ -16,14 +15,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (_) => AppState()..init(),
|
create: (_) => AppState()..init(),
|
||||||
child: Consumer<AppState>(
|
child: MaterialApp(
|
||||||
builder: (context, appState, _) => MaterialApp(
|
|
||||||
title: 'Fuel Tax Tracker',
|
title: 'Fuel Tax Tracker',
|
||||||
theme: AppTheme.light,
|
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
|
||||||
darkTheme: AppTheme.dark,
|
darkTheme: ThemeData(
|
||||||
themeMode: appState.themeMode,
|
colorSchemeSeed: Colors.indigo,
|
||||||
home: const AppRoot(),
|
brightness: Brightness.dark,
|
||||||
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
|
home: const AppRoot(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -41,33 +41,7 @@ class AppRoot extends StatelessWidget {
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (appState.initError != null) {
|
return const HomeScreen();
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,11 @@
|
||||||
class FuelEntry {
|
class FuelEntry {
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
/// References [Vehicle.id] (the hidden, immutable identifier) — not the
|
|
||||||
/// VIN, which is user-editable and so unsuitable as a foreign key.
|
|
||||||
final String vehicleId;
|
final String vehicleId;
|
||||||
|
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
final double gallons;
|
final double gallons;
|
||||||
final double pricePerGallon;
|
final double pricePerGallon;
|
||||||
final double totalCost;
|
final double totalCost;
|
||||||
final String? receiptImagePath;
|
final String? receiptImagePath;
|
||||||
final String? receiptDriveFileId;
|
|
||||||
final DateTime updatedAt;
|
|
||||||
|
|
||||||
/// Soft-delete tombstone: null means active. See [Vehicle.deletedAt] for
|
|
||||||
/// why this is a flag rather than an actual row deletion.
|
|
||||||
final DateTime? deletedAt;
|
|
||||||
|
|
||||||
FuelEntry({
|
FuelEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -24,75 +14,26 @@ class FuelEntry {
|
||||||
required this.gallons,
|
required this.gallons,
|
||||||
required this.pricePerGallon,
|
required this.pricePerGallon,
|
||||||
required this.totalCost,
|
required this.totalCost,
|
||||||
required this.updatedAt,
|
|
||||||
this.receiptImagePath,
|
this.receiptImagePath,
|
||||||
this.receiptDriveFileId,
|
|
||||||
this.deletedAt,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True once the receipt photo has been uploaded to Drive, regardless of
|
Map<String, dynamic> toJson() => {
|
||||||
/// whether a local copy is also being kept (see the "keep photos on this
|
|
||||||
/// phone" setting). Viewing it locally is preferred when a local copy
|
|
||||||
/// exists; otherwise it requires downloading from Drive on demand.
|
|
||||||
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
|
|
||||||
|
|
||||||
/// True while a receipt photo still needs to be uploaded to Drive: a
|
|
||||||
/// local file exists but hasn't made it there yet. Once
|
|
||||||
/// [receiptDriveFileId] is set this is false — even if a local copy is
|
|
||||||
/// also being kept — since that field alone is what marks "no longer
|
|
||||||
/// needs uploading", independent of local retention. A row in this state
|
|
||||||
/// is never pushed to Drive as data (a local file path is meaningless on
|
|
||||||
/// another device) — sync uploads the image first, which sets
|
|
||||||
/// [receiptDriveFileId] and clears this.
|
|
||||||
bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null;
|
|
||||||
|
|
||||||
FuelEntry copyWith({
|
|
||||||
String? receiptImagePath,
|
|
||||||
String? receiptDriveFileId,
|
|
||||||
DateTime? updatedAt,
|
|
||||||
DateTime? deletedAt,
|
|
||||||
bool clearReceiptImagePath = false,
|
|
||||||
}) {
|
|
||||||
return FuelEntry(
|
|
||||||
id: id,
|
|
||||||
vehicleId: vehicleId,
|
|
||||||
date: date,
|
|
||||||
gallons: gallons,
|
|
||||||
pricePerGallon: pricePerGallon,
|
|
||||||
totalCost: totalCost,
|
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
|
||||||
receiptImagePath:
|
|
||||||
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
|
|
||||||
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
|
|
||||||
deletedAt: deletedAt ?? this.deletedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> toMap() => {
|
|
||||||
'id': id,
|
'id': id,
|
||||||
'vehicle_id': vehicleId,
|
'vehicleId': vehicleId,
|
||||||
'date': date.millisecondsSinceEpoch,
|
'date': date.toIso8601String(),
|
||||||
'gallons': gallons,
|
'gallons': gallons,
|
||||||
'price_per_gallon': pricePerGallon,
|
'pricePerGallon': pricePerGallon,
|
||||||
'total_cost': totalCost,
|
'totalCost': totalCost,
|
||||||
'receipt_image_path': receiptImagePath,
|
'receiptImagePath': receiptImagePath,
|
||||||
'receipt_drive_file_id': receiptDriveFileId,
|
|
||||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
|
||||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
|
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
|
||||||
id: map['id'] as String,
|
id: json['id'] as String,
|
||||||
vehicleId: map['vehicle_id'] as String,
|
vehicleId: json['vehicleId'] as String,
|
||||||
date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
|
date: DateTime.parse(json['date'] as String),
|
||||||
gallons: (map['gallons'] as num).toDouble(),
|
gallons: (json['gallons'] as num).toDouble(),
|
||||||
pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
|
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
|
||||||
totalCost: (map['total_cost'] as num).toDouble(),
|
totalCost: (json['totalCost'] as num).toDouble(),
|
||||||
receiptImagePath: map['receipt_image_path'] as String?,
|
receiptImagePath: json['receiptImagePath'] as String?,
|
||||||
receiptDriveFileId: map['receipt_drive_file_id'] as String?,
|
|
||||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
|
||||||
deletedAt: map['deleted_at'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,48 @@
|
||||||
class Vehicle {
|
class Vehicle {
|
||||||
/// Hidden, immutable, generated primary key — never shown in the UI and
|
|
||||||
/// never editable. This is what fuel entries actually reference and what
|
|
||||||
/// sync merges on, so that [vin] itself is free to be edited without
|
|
||||||
/// breaking those references or losing sync history.
|
|
||||||
final String id;
|
final String id;
|
||||||
|
final String make;
|
||||||
/// The vehicle's identification number. Required and must be unique
|
final String model;
|
||||||
/// among active (non-deleted) vehicles, but — unlike [id] — the user can
|
final String color;
|
||||||
/// edit it later (e.g. to fix a typo from a misread VIN scan).
|
final String licensePlate;
|
||||||
final String vin;
|
|
||||||
|
|
||||||
/// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional —
|
|
||||||
/// when absent, screens fall back to other identifying info instead.
|
|
||||||
final String? nickname;
|
|
||||||
|
|
||||||
final DateTime updatedAt;
|
|
||||||
|
|
||||||
/// Soft-delete tombstone: null means active. Deleting sets this instead
|
|
||||||
/// of removing the row, so the deletion itself can be merged/synced like
|
|
||||||
/// any other change (newest `updatedAt` wins) instead of silently
|
|
||||||
/// disappearing and later being resurrected by a device that hasn't seen
|
|
||||||
/// the deletion yet.
|
|
||||||
final DateTime? deletedAt;
|
|
||||||
|
|
||||||
Vehicle({
|
Vehicle({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.vin,
|
required this.make,
|
||||||
required this.updatedAt,
|
required this.model,
|
||||||
this.nickname,
|
required this.color,
|
||||||
this.deletedAt,
|
required this.licensePlate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// What screens should show as the vehicle's primary label: the nickname
|
String get displayName => '$color $make $model ($licensePlate)';
|
||||||
/// if one was given, otherwise the VIN itself.
|
|
||||||
String get displayLabel {
|
|
||||||
final trimmedNickname = nickname?.trim();
|
|
||||||
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
|
|
||||||
return trimmedNickname;
|
|
||||||
}
|
|
||||||
return vin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Note: [id] is intentionally not overridable here — it's the hidden
|
|
||||||
/// identifier, not an editable field.
|
|
||||||
Vehicle copyWith({
|
Vehicle copyWith({
|
||||||
String? vin,
|
String? make,
|
||||||
String? nickname,
|
String? model,
|
||||||
bool clearNickname = false,
|
String? color,
|
||||||
DateTime? updatedAt,
|
String? licensePlate,
|
||||||
DateTime? deletedAt,
|
|
||||||
}) {
|
}) {
|
||||||
return Vehicle(
|
return Vehicle(
|
||||||
id: id,
|
id: id,
|
||||||
vin: vin ?? this.vin,
|
make: make ?? this.make,
|
||||||
nickname: clearNickname ? null : (nickname ?? this.nickname),
|
model: model ?? this.model,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
color: color ?? this.color,
|
||||||
deletedAt: deletedAt ?? this.deletedAt,
|
licensePlate: licensePlate ?? this.licensePlate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> toMap() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'vin': vin,
|
'make': make,
|
||||||
'nickname': nickname,
|
'model': model,
|
||||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
'color': color,
|
||||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
'licensePlate': licensePlate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
|
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||||
id: map['id'] as String,
|
id: json['id'] as String,
|
||||||
vin: map['vin'] as String,
|
make: json['make'] as String,
|
||||||
nickname: map['nickname'] as String?,
|
model: json['model'] as String,
|
||||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
color: json['color'] as String,
|
||||||
deletedAt: map['deleted_at'] != null
|
licensePlate: json['licensePlate'] as String,
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,15 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/ocr_service.dart';
|
|
||||||
import '../services/vin_parser.dart';
|
|
||||||
import '../widgets/image_source_sheet.dart';
|
|
||||||
import 'vehicle_detail_screen.dart';
|
|
||||||
|
|
||||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||||
/// omit it to create a new one. [promptToCreateForReceipt] is set when this
|
/// omit it to create a new one.
|
||||||
/// screen was reached because the user tried to log a receipt with no
|
|
||||||
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
|
|
||||||
/// so that's clear, rather than silently landing on the add-vehicle form.
|
|
||||||
class AddEditVehicleScreen extends StatefulWidget {
|
class AddEditVehicleScreen extends StatefulWidget {
|
||||||
final Vehicle? vehicle;
|
final Vehicle? vehicle;
|
||||||
final bool promptToCreateForReceipt;
|
|
||||||
|
|
||||||
const AddEditVehicleScreen({
|
const AddEditVehicleScreen({super.key, this.vehicle});
|
||||||
super.key,
|
|
||||||
this.vehicle,
|
|
||||||
this.promptToCreateForReceipt = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
||||||
|
|
@ -32,249 +17,52 @@ class AddEditVehicleScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
late final TextEditingController _nicknameController;
|
late final TextEditingController _makeController;
|
||||||
late final TextEditingController _vinController;
|
late final TextEditingController _modelController;
|
||||||
|
late final TextEditingController _colorController;
|
||||||
bool _saving = false;
|
late final TextEditingController _plateController;
|
||||||
bool _scanningVin = false;
|
|
||||||
bool _checkingDuplicateVin = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
bool get _isEditing => widget.vehicle != null;
|
bool get _isEditing => widget.vehicle != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
|
||||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
|
||||||
|
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
|
||||||
if (widget.promptToCreateForReceipt) {
|
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nicknameController.dispose();
|
_makeController.dispose();
|
||||||
_vinController.dispose();
|
_modelController.dispose();
|
||||||
|
_colorController.dispose();
|
||||||
|
_plateController.dispose();
|
||||||
super.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 {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
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>();
|
final appState = context.read<AppState>();
|
||||||
|
|
||||||
try {
|
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||||
vin: vin,
|
make: _makeController.text.trim(),
|
||||||
nickname: nickname.isEmpty ? null : nickname,
|
model: _modelController.text.trim(),
|
||||||
clearNickname: nickname.isEmpty,
|
color: _colorController.text.trim(),
|
||||||
|
licensePlate: _plateController.text.trim(),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
await appState.addVehicle(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) {
|
if (mounted) Navigator.of(context).pop();
|
||||||
// Reached from "log a receipt with no vehicle yet" — once the
|
|
||||||
// vehicle exists, continue straight into the receipt capture flow
|
|
||||||
// the user was actually trying to do, rather than just landing
|
|
||||||
// back on an (again) empty Receipts tab. The caller does the
|
|
||||||
// continuing; this just hands back which vehicle to do it for.
|
|
||||||
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
|
|
||||||
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
|
|
||||||
}
|
|
||||||
} on DuplicateVinException catch (e) {
|
|
||||||
setState(() => _error = e.toString());
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _saving = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete() async {
|
Future<void> _confirmDelete() async {
|
||||||
|
|
@ -284,7 +72,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
title: const Text('Delete vehicle?'),
|
title: const Text('Delete vehicle?'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'This will also delete all fuel entries and receipt photos logged for '
|
'This will also delete all fuel entries and receipt photos logged for '
|
||||||
'${widget.vehicle!.displayLabel}. This cannot be undone.',
|
'${widget.vehicle!.displayName}. This cannot be undone.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
||||||
|
|
@ -326,49 +114,36 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nicknameController,
|
controller: _makeController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
|
||||||
labelText: 'Nickname (optional)',
|
|
||||||
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
|
|
||||||
),
|
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _vinController,
|
controller: _modelController,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
|
||||||
labelText: 'VIN *',
|
textCapitalization: TextCapitalization.words,
|
||||||
hintText: 'Vehicle Identification Number',
|
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||||
helperText: 'Required — tap the camera to scan it from a photo',
|
|
||||||
suffixIcon: _scanningVin
|
|
||||||
? const Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
icon: const Icon(Icons.camera_alt_outlined),
|
|
||||||
tooltip: 'Scan VIN from a photo',
|
|
||||||
onPressed: _scanVin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
validator: _validateVin,
|
|
||||||
onChanged: _isEditing ? null : (_) => _checkForExistingVin(),
|
|
||||||
),
|
|
||||||
if (_error != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
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),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _save,
|
||||||
child: _saving
|
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,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 _gallonsController;
|
||||||
late final TextEditingController _priceController;
|
late final TextEditingController _priceController;
|
||||||
late final TextEditingController _totalController;
|
late final TextEditingController _totalController;
|
||||||
late DateTime _date;
|
DateTime _date = DateTime.now();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Prefer the date/time printed on the receipt; fall back to now, same
|
|
||||||
// as before, when it couldn't be parsed — the date picker below is
|
|
||||||
// always available either way for manual entry/correction.
|
|
||||||
_date = widget.parsed.date ?? DateTime.now();
|
|
||||||
_gallonsController = TextEditingController(
|
_gallonsController = TextEditingController(
|
||||||
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
||||||
);
|
);
|
||||||
|
|
@ -73,20 +69,11 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate() async {
|
Future<void> _pickDate() async {
|
||||||
// initialDate must fall within [firstDate, lastDate] or the picker
|
|
||||||
// throws — widen the bounds to cover _date in case a misread date from
|
|
||||||
// the receipt landed outside the normal 5-years-back-to-today window.
|
|
||||||
final today = DateTime.now();
|
|
||||||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
|
||||||
? _date
|
|
||||||
: today.subtract(const Duration(days: 365 * 5));
|
|
||||||
final lastDate = _date.isAfter(today) ? _date : today;
|
|
||||||
|
|
||||||
final pickedDate = await showDatePicker(
|
final pickedDate = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _date,
|
initialDate: _date,
|
||||||
firstDate: firstDate,
|
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
|
||||||
lastDate: lastDate,
|
lastDate: DateTime.now(),
|
||||||
);
|
);
|
||||||
if (pickedDate == null || !mounted) return;
|
if (pickedDate == null || !mounted) return;
|
||||||
|
|
||||||
|
|
@ -149,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|
|
||||||
|
|
@ -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 '../models/vehicle.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../services/onboarding_keys.dart';
|
|
||||||
import 'add_edit_vehicle_screen.dart';
|
import 'add_edit_vehicle_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
import 'vehicle_detail_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 {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -19,15 +17,13 @@ class HomeScreen extends StatelessWidget {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Vehicles'),
|
title: const Text('My Vehicles'),
|
||||||
actionsPadding: const EdgeInsets.only(right: 20),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
key: OnboardingKeys.vehiclesAddButton,
|
icon: const Icon(Icons.settings_outlined),
|
||||||
icon: const Icon(Icons.add),
|
tooltip: 'Settings',
|
||||||
tooltip: 'Add Vehicle',
|
|
||||||
onPressed: () => Navigator.of(context).push(
|
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);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
|
|
||||||
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: 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(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
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:io';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../services/app_state.dart';
|
/// Full-screen, pinch-zoomable view of a saved receipt photo.
|
||||||
|
class ReceiptImageScreen extends StatelessWidget {
|
||||||
|
final String imagePath;
|
||||||
|
|
||||||
/// Full-screen, pinch-zoomable view of a receipt photo. Pass
|
const ReceiptImageScreen({super.key, required this.imagePath});
|
||||||
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
|
|
||||||
/// one already uploaded to the cloud and removed locally — in which case
|
|
||||||
/// it's downloaded on demand (no local caching afterward).
|
|
||||||
class ReceiptImageScreen extends StatefulWidget {
|
|
||||||
final String? localImagePath;
|
|
||||||
final String? driveFileId;
|
|
||||||
|
|
||||||
const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId})
|
|
||||||
: assert(localImagePath != null || driveFileId != null,
|
|
||||||
'Must provide either a local path or a Drive file ID');
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
|
||||||
Uint8List? _downloadedBytes;
|
|
||||||
bool _loading = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
if (widget.driveFileId != null) {
|
|
||||||
_download();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _download() async {
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
final provider = context.read<AppState>().activeProvider;
|
|
||||||
if (provider == null) {
|
|
||||||
setState(() {
|
|
||||||
_error = 'Not connected to a cloud storage provider.';
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = provider.beginSession();
|
|
||||||
try {
|
|
||||||
final bytes = await session.downloadFileBytes(widget.driveFileId!);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_downloadedBytes = Uint8List.fromList(bytes);
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_error = 'Could not download receipt: $e';
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
session.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -78,26 +17,11 @@ class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
title: const Text('Receipt'),
|
title: const Text('Receipt'),
|
||||||
),
|
),
|
||||||
body: Center(child: _buildBody()),
|
body: Center(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
child: Image.file(File(imagePath)),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (widget.localImagePath != null) {
|
|
||||||
return InteractiveViewer(child: Image.file(File(widget.localImagePath!)));
|
|
||||||
}
|
|
||||||
if (_loading) {
|
|
||||||
return const CircularProgressIndicator(color: Colors.white);
|
|
||||||
}
|
|
||||||
if (_error != null) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text(_error!, style: const TextStyle(color: Colors.white)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_downloadedBytes != null) {
|
|
||||||
return InteractiveViewer(child: Image.memory(_downloadedBytes!));
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../services/app_state.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]
|
class SettingsScreen extends StatefulWidget {
|
||||||
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
|
|
||||||
/// advanced/purge) — plus a read-only summary of what's stored locally.
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
|
||||||
const SettingsScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
|
final saveDir = appState.storage.saveDirectory.path;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
|
@ -22,26 +44,41 @@ class SettingsScreen extends StatelessWidget {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
leading: const Icon(Icons.palette_outlined),
|
padding: const EdgeInsets.all(16),
|
||||||
title: const Text('UI'),
|
child: Column(
|
||||||
subtitle: const Text('Appearance'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
children: [
|
||||||
onTap: () => Navigator.of(context).push(
|
Text('Save Location', style: Theme.of(context).textTheme.titleMedium),
|
||||||
MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
|
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),
|
const SizedBox(height: 16),
|
||||||
Card(
|
if (_error != null) ...[
|
||||||
key: OnboardingKeys.settingsDataMenuEntry,
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
child: ListTile(
|
const SizedBox(height: 8),
|
||||||
leading: const Icon(Icons.storage_outlined),
|
],
|
||||||
title: const Text('Data'),
|
FilledButton.icon(
|
||||||
subtitle: const Text('Cloud storage, photo syncing, advanced'),
|
onPressed: _changing ? null : _chooseFolder,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
icon: _changing
|
||||||
onTap: () => Navigator.of(context).push(
|
? const SizedBox(
|
||||||
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
|
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:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/fuel_entry.dart';
|
||||||
import '../services/app_state.dart';
|
import '../services/app_state.dart';
|
||||||
import '../widgets/hero_banner.dart';
|
import '../services/ocr_service.dart';
|
||||||
import '../widgets/receipt_capture.dart';
|
import '../services/receipt_parser.dart';
|
||||||
import '../widgets/receipt_thumbnail.dart';
|
|
||||||
import 'add_edit_vehicle_screen.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
|
class VehicleDetailScreen extends StatelessWidget {
|
||||||
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
|
|
||||||
/// to the next/previous vehicle, the same "modern navigation" swipe
|
|
||||||
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
|
|
||||||
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
|
|
||||||
/// owns the [PageController].
|
|
||||||
class VehicleDetailScreen extends StatefulWidget {
|
|
||||||
final String vehicleId;
|
final String vehicleId;
|
||||||
|
|
||||||
const VehicleDetailScreen({super.key, required this.vehicleId});
|
const VehicleDetailScreen({super.key, required this.vehicleId});
|
||||||
|
|
||||||
@override
|
Future<void> _captureReceipt(BuildContext context) async {
|
||||||
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
|
final picker = ImagePicker();
|
||||||
}
|
XFile? photo;
|
||||||
|
try {
|
||||||
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
|
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
||||||
late final PageController _pageController;
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
@override
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
void initState() {
|
SnackBar(content: Text('Could not open camera: $e')),
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
if (photo == null) return;
|
||||||
|
|
||||||
class _VehicleDetailPage extends StatelessWidget {
|
final imageFile = File(photo.path);
|
||||||
final String vehicleId;
|
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -73,16 +99,12 @@ class _VehicleDetailPage extends StatelessWidget {
|
||||||
|
|
||||||
final entries = appState.entriesForVehicle(vehicleId);
|
final entries = appState.entriesForVehicle(vehicleId);
|
||||||
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
|
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
|
||||||
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
|
|
||||||
final dateFormat = DateFormat.yMMMd().add_jm();
|
final dateFormat = DateFormat.yMMMd().add_jm();
|
||||||
final currencyFormat = NumberFormat.simpleCurrency();
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(vehicle.displayLabel),
|
title: Text(vehicle.displayName),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
|
@ -95,74 +117,59 @@ class _VehicleDetailPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
width: double.infinity,
|
||||||
child: HeroBanner(
|
padding: const EdgeInsets.all(16),
|
||||||
child: HeroStatsRow(
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
gallonsValue: heroGallonsFormat.format(totalGallons),
|
child: Column(
|
||||||
costValue: currencyFormat.format(totalCost),
|
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(
|
Expanded(
|
||||||
child: entries.isEmpty
|
child: entries.isEmpty
|
||||||
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
|
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
|
||||||
: ListView.separated(
|
: 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),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: entries.length,
|
itemCount: entries.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
void openDetail() => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ReceiptDetailScreen(entryId: entry.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: ListTile(
|
||||||
onTap: openDetail,
|
leading: entry.receiptImagePath != null
|
||||||
child: Padding(
|
? GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
onTap: () => Navigator.of(context).push(
|
||||||
// Row cross-axis defaults to center, so the
|
MaterialPageRoute(
|
||||||
// thumbnail sits vertically centered against the
|
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!),
|
||||||
// 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: 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(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => captureReceiptForVehicle(context, vehicleId),
|
onPressed: () => _captureReceipt(context),
|
||||||
icon: const Icon(Icons.camera_alt_outlined),
|
icon: const Icon(Icons.camera_alt_outlined),
|
||||||
label: const Text('Log Fuel Receipt'),
|
label: const Text('Log Fuel Receipt'),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,382 +1,63 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart' show ThemeMode;
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/fuel_entry.dart';
|
import '../models/fuel_entry.dart';
|
||||||
import '../models/vehicle.dart';
|
import '../models/vehicle.dart';
|
||||||
import 'cloud/cloud_storage_provider.dart';
|
import 'storage_service.dart';
|
||||||
import 'cloud/dropbox_provider.dart';
|
|
||||||
import 'cloud/google_drive_provider.dart';
|
|
||||||
import 'cloud/onedrive_provider.dart';
|
|
||||||
import 'cloud/webdav_provider.dart';
|
|
||||||
import 'cloud_sync_service.dart';
|
|
||||||
import 'database_service.dart';
|
|
||||||
|
|
||||||
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
|
||||||
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
|
||||||
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
|
||||||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
|
||||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
|
||||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
|
||||||
const _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.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||||
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
|
/// entries), backed by [StorageService] for persistence. Screens read from
|
||||||
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
|
/// this via Provider and call its mutating methods, which take care of
|
||||||
/// [CloudStorageProvider] the user has connected. Screens read from this
|
/// writing through to disk and notifying listeners.
|
||||||
/// via Provider and call its mutating methods, which write through to the
|
|
||||||
/// local database immediately and kick off a best-effort background sync.
|
|
||||||
///
|
|
||||||
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
|
|
||||||
/// database, refreshed after every mutation and after every sync (since
|
|
||||||
/// sync's merge happens as SQL directly against the database, not by
|
|
||||||
/// handing back updated Dart objects).
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
final DatabaseService database = DatabaseService();
|
final StorageService storage = StorageService();
|
||||||
|
|
||||||
/// Every storage backend the user can choose from in Settings.
|
|
||||||
final List<CloudStorageProvider> availableProviders = [
|
|
||||||
GoogleDriveProvider(),
|
|
||||||
DropboxProvider(),
|
|
||||||
OneDriveProvider(),
|
|
||||||
WebDavProvider(),
|
|
||||||
];
|
|
||||||
|
|
||||||
CloudStorageProvider? activeProvider;
|
|
||||||
CloudSyncService? cloudSync;
|
|
||||||
|
|
||||||
final _uuid = const Uuid();
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
List<Vehicle> vehicles = [];
|
List<Vehicle> vehicles = [];
|
||||||
List<FuelEntry> fuelEntries = [];
|
List<FuelEntry> fuelEntries = [];
|
||||||
bool isLoading = true;
|
bool isLoading = true;
|
||||||
|
|
||||||
String? cloudFolderPath;
|
|
||||||
bool isSyncing = false;
|
|
||||||
DateTime? lastSyncedAt;
|
|
||||||
Object? lastSyncError;
|
|
||||||
|
|
||||||
bool keepReceiptPhotosLocally = false;
|
|
||||||
int staleLockMinutes = defaultStaleLockMinutes;
|
|
||||||
|
|
||||||
/// When false (default), a newly captured receipt photo is downscaled
|
|
||||||
/// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality]
|
|
||||||
/// before it's stored — receipts are just photos of small printed text,
|
|
||||||
/// so a full-resolution original (often several MB on a modern phone
|
|
||||||
/// camera) buys nothing but cloud storage and sync bandwidth. When true,
|
|
||||||
/// the original camera/gallery image is kept as-is.
|
|
||||||
bool keepMaxQualityReceiptPhotos = false;
|
|
||||||
|
|
||||||
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 {
|
Future<void> init() async {
|
||||||
isLoading = true;
|
await storage.init();
|
||||||
initError = null;
|
final data = await storage.loadData();
|
||||||
notifyListeners();
|
vehicles = data.vehicles;
|
||||||
|
fuelEntries = data.entries;
|
||||||
try {
|
|
||||||
await database.init();
|
|
||||||
await _refreshFromDatabase();
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
|
||||||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
|
||||||
keepMaxQualityReceiptPhotos =
|
|
||||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
|
||||||
themeMode = ThemeMode.values.firstWhere(
|
|
||||||
(mode) => mode.name == prefs.getString(_prefsKeyThemeMode),
|
|
||||||
orElse: () => ThemeMode.system,
|
|
||||||
);
|
|
||||||
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
|
|
||||||
} catch (e) {
|
|
||||||
initError = e;
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
|
||||||
if (results.any((r) => r != ConnectivityResult.none)) {
|
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
unawaited(_restoreCloudConnection());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_connectivitySubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshFromDatabase() async {
|
|
||||||
vehicles = await database.getVehicles();
|
|
||||||
fuelEntries = await database.getFuelEntries();
|
|
||||||
}
|
|
||||||
|
|
||||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
|
||||||
for (final provider in availableProviders) {
|
|
||||||
if (provider.id == id) return provider;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _restoreCloudConnection() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final storedProviderName = prefs.getString(_prefsKeyActiveProviderId);
|
|
||||||
if (storedProviderName == null) return;
|
|
||||||
|
|
||||||
final matchingId = CloudProviderId.values
|
|
||||||
.where((id) => id.name == storedProviderName)
|
|
||||||
.firstOrNull;
|
|
||||||
final provider = matchingId == null ? null : _providerById(matchingId);
|
|
||||||
if (provider == null) return;
|
|
||||||
|
|
||||||
final signedIn = await provider.attemptSilentSignIn();
|
|
||||||
if (!signedIn) return;
|
|
||||||
|
|
||||||
activeProvider = provider;
|
|
||||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
|
||||||
|
|
||||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
|
||||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
|
||||||
if (folderId != null) {
|
|
||||||
cloudSync!.configure(folderId);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
if (folderId != null) {
|
|
||||||
unawaited(syncNow());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> connectProvider(CloudProviderId id) async {
|
|
||||||
final provider = _providerById(id);
|
|
||||||
if (provider == null) return;
|
|
||||||
|
|
||||||
await provider.signIn();
|
|
||||||
activeProvider = provider;
|
|
||||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
|
||||||
/// (currently just WebDAV) that needs a server URL/username/password
|
|
||||||
/// instead of an OAuth browser flow. The caller (Settings) is responsible
|
|
||||||
/// for collecting those from the user first.
|
|
||||||
Future<void> connectProviderWithCredentials(
|
|
||||||
CloudProviderId id, {
|
|
||||||
required String serverUrl,
|
|
||||||
required String username,
|
|
||||||
required String password,
|
|
||||||
}) async {
|
|
||||||
final provider = _providerById(id);
|
|
||||||
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
|
|
||||||
|
|
||||||
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
|
|
||||||
serverUrl: serverUrl,
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
activeProvider = provider;
|
|
||||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> disconnectCloud() async {
|
|
||||||
final provider = activeProvider;
|
|
||||||
if (provider == null) return;
|
|
||||||
|
|
||||||
await provider.signOut();
|
|
||||||
cloudSync?.clearConfiguration();
|
|
||||||
activeProvider = null;
|
|
||||||
cloudSync = null;
|
|
||||||
cloudFolderPath = null;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove(_prefsKeyActiveProviderId);
|
|
||||||
await prefs.remove(_prefsKeyCloudFolderId);
|
|
||||||
await prefs.remove(_prefsKeyCloudFolderPath);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> chooseCloudFolder({
|
|
||||||
required String parentId,
|
|
||||||
required String breadcrumbPath,
|
|
||||||
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({
|
Future<void> addVehicle({
|
||||||
required String vin,
|
required String make,
|
||||||
String? nickname,
|
required String model,
|
||||||
|
required String color,
|
||||||
|
required String licensePlate,
|
||||||
}) async {
|
}) async {
|
||||||
if (await database.vinExists(vin)) {
|
vehicles.add(Vehicle(
|
||||||
throw DuplicateVinException(vin);
|
|
||||||
}
|
|
||||||
final vehicle = Vehicle(
|
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
vin: vin,
|
make: make,
|
||||||
nickname: nickname,
|
model: model,
|
||||||
updatedAt: DateTime.now().toUtc(),
|
color: color,
|
||||||
);
|
licensePlate: licensePlate,
|
||||||
await database.saveVehicle(vehicle);
|
));
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throws [DuplicateVinException] if [updated]'s VIN now collides with
|
|
||||||
/// another active vehicle's — this is the check [addVehicle] does for a
|
|
||||||
/// new vehicle, but here it also has to exclude the vehicle being edited
|
|
||||||
/// itself (its VIN obviously still matches its own prior value if it
|
|
||||||
/// wasn't changed).
|
|
||||||
Future<void> updateVehicle(Vehicle updated) async {
|
Future<void> updateVehicle(Vehicle updated) async {
|
||||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
||||||
throw DuplicateVinException(updated.vin);
|
if (index != -1) {
|
||||||
}
|
vehicles[index] = updated;
|
||||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteVehicle(String id) async {
|
|
||||||
final now = DateTime.now().toUtc();
|
|
||||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
|
||||||
for (final path in orphanedLocalPaths) {
|
|
||||||
await database.deleteReceiptImageFile(path);
|
|
||||||
}
|
}
|
||||||
await database.softDeleteVehicle(id, now);
|
|
||||||
|
Future<void> deleteVehicle(String vehicleId) async {
|
||||||
|
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
|
||||||
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||||
|
}
|
||||||
|
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
|
||||||
|
vehicles.removeWhere((v) => v.id == vehicleId);
|
||||||
await _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,8 +72,7 @@ class AppState extends ChangeNotifier {
|
||||||
final id = _uuid.v4();
|
final id = _uuid.v4();
|
||||||
String? storedImagePath;
|
String? storedImagePath;
|
||||||
if (receiptImage != null) {
|
if (receiptImage != null) {
|
||||||
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
|
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
|
||||||
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final entry = FuelEntry(
|
final entry = FuelEntry(
|
||||||
|
|
@ -403,69 +83,16 @@ class AppState extends ChangeNotifier {
|
||||||
pricePerGallon: pricePerGallon,
|
pricePerGallon: pricePerGallon,
|
||||||
totalCost: totalCost,
|
totalCost: totalCost,
|
||||||
receiptImagePath: storedImagePath,
|
receiptImagePath: storedImagePath,
|
||||||
updatedAt: DateTime.now().toUtc(),
|
|
||||||
);
|
);
|
||||||
await database.saveFuelEntry(entry);
|
fuelEntries.add(entry);
|
||||||
await _persist();
|
await _persist();
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
Future<void> deleteFuelEntry(String entryId) async {
|
||||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||||
await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
|
fuelEntries.removeWhere((e) => e.id == entryId);
|
||||||
await _persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 _persist();
|
await _persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,33 +117,13 @@ class AppState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FuelEntry? fuelEntryById(String id) {
|
Future<void> changeSaveDirectory(String newPath) async {
|
||||||
try {
|
await storage.setSaveDirectory(newPath);
|
||||||
return fuelEntries.firstWhere((e) => e.id == id);
|
notifyListeners();
|
||||||
} 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> _persist() async {
|
Future<void> _persist() async {
|
||||||
await _refreshFromDatabase();
|
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||||
notifyListeners();
|
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() {}
|
|
||||||
}
|
|
||||||