Compare commits

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

9 commits

152 changed files with 12865 additions and 791 deletions

378
README.md
View file

@ -3,37 +3,227 @@
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
you confirm or correct the numbers, and it's saved alongside the receipt you confirm or correct the numbers, and it's saved alongside the receipt
photo to a data file in a folder you choose. photo. Vehicles and fuel entries live in a local SQLite database that's kept
in sync with a shared folder on **Google Drive, Dropbox, OneDrive, or your
own WebDAV server** (your choice), so multiple people can log fuel against
the same pool of vehicles from their own phones, offline or online.
## Features ## Features
- Manage a list of vehicles (make, model, color, license plate). - Manage a list of vehicles, identified by **VIN** (required, and unique
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google across active vehicles — but editable, e.g. to fix a typo from a misread
ML Kit — no internet connection or API key required. scan; scan it from a photo of the door-jamb sticker or dashboard plate
- Review/edit the parsed gallons, price per gallon, and total cost before instead of typing all 17 characters). An optional **nickname** ("Mom's
saving (OCR on printed receipts is usually good but not perfect). Car", "Red Ford F-150") is what's shown as the primary label everywhere;
without one, the VIN is shown instead.
- Capture a fuel receipt photo per vehicle — from the camera or an existing
photo in your library — and OCR it on-device with Google ML Kit — no
internet connection or API key required for the OCR itself.
- If a receipt's address shows a state other than Missouri, a warning
explains that the fuel tax refund only covers Missouri purchases and lets
you choose whether to log the entry anyway.
- Review/edit the parsed gallons, price per gallon, total cost, *and*
date/time before saving (OCR on printed receipts is usually good but not
perfect; the date/time picker is always available as a manual fallback
when a date can't be found on the receipt).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
photos, and delete. photos, and delete.
- Settings screen to choose where receipt photos and the data file are - Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV
stored (defaults to the app's own documents folder; existing data is server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
copied over when you change it). endpoint) and pick any folder you have access to — including one someone
else created and shared with you — as the shared storage location. The
app looks for (or creates) a `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 JSON (de)serialization models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
`deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's
`id` is a hidden, generated, immutable primary key — never shown in the
UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
required, unique-among-active-vehicles, but user-editable field, and
`nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
`receiptDriveFileId`, or both at once if "keep photos on this phone" is
on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
distinction.
services/ services/
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path updatedAt on mutations and triggers background sync.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text and the receipts/ folder, always at
ApplicationDocumentsDirectory/FuelTaxTracker.
db_schema.dart CREATE TABLE statements and the merge SQL — shared between
the app and its tests so they can never drift apart.
cloud/
cloud_storage_provider.dart The CloudStorageProvider/CloudStorageSession
interface every backend implements (auth, folder
browsing, file upload/download, lock file ops) —
this is what cloud_sync_service.dart and the rest
of the app talk to; they never know which of the
three providers below is actually active.
google_drive_provider.dart Google sign-in + the googleapis DriveApi client.
dropbox_provider.dart Hand-rolled OAuth2 PKCE + Dropbox's path-addressed
REST API (files/list_folder, files/upload, etc).
onedrive_provider.dart Hand-rolled OAuth2 PKCE + Microsoft Graph
(/me/drive/...), ID-addressed like Drive.
webdav_provider.dart Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE)
over HTTP Basic Auth — no OAuth, no developer
console, just a server URL + username + password.
For self-hosted servers (Nextcloud, ownCloud, a
Synology NAS, etc). Path-addressed like Dropbox;
uses the resource's ETag as its versionTag.
oauth_pkce.dart PKCE code_verifier/code_challenge generation, shared
by the Dropbox and OneDrive providers (Google's own
SDK handles its OAuth flow itself, and WebDAV uses
plain Basic Auth, so neither needs this).
cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever
provider(s) you want to use — see setup below.
cloud_sync_service.dart Orchestrates one sync round against whichever
CloudStorageProvider is active: acquire the
cross-device lock, ATTACH + merge the remote database
into the local one, upload pending receipt photos,
push the local database back up, release the lock.
lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable
logic (independently unit-tested without a real backend).
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
receipt_parser.dart Regex-based extraction of gallons/price/total/date from OCR
text — falls back to manual entry (date picker, or the
gallons*price derivation) for whatever isn't found.
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail, screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
confirm fuel entry, receipt viewer, settings) confirm fuel entry, receipt viewer, settings, cloud folder browser).
``` ```
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/` ## Manual setup required (cloud storage)
subfolder of photos, both inside whatever directory Settings points at.
You only need to complete setup for whichever provider(s) you actually want
to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same
shape: I can't provision cloud resources on your behalf, so each needs an
app/OAuth client you create yourself in that provider's own developer
console, with the resulting IDs pasted into
`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect"
button for one of these three once its config fields are filled in with
real values. **WebDAV needs none of this** — see its section below.
### Google Drive (Google Cloud Console)
In [Google Cloud Console](https://console.cloud.google.com/):
1. Create/select a project, enable the **Google Drive API** (APIs & Services
→ Library).
2. **OAuth consent screen**: set it to **External**, keep it in **Testing**
status, and add your Google account plus everyone else's you're sharing
with as **test users**. This avoids Google's formal verification review,
which the broad `drive` scope would otherwise require — fine for a known,
small group, not a public release.
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
(shows as "restricted/sensitive" — expected, fine in Testing mode).
4. **Credentials → Create Credentials → OAuth client ID**, three times:
- **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the
SHA-1 of your debug keystore (`keytool -list -v -keystore
~/.android/debug.keystore`, password `android`), and later your release
keystore's SHA-1 too. This client ID itself is never referenced in
code — it exists purely so Android's Credential Manager trusts this
specific signed app.
- **Web application**: no redirect URIs needed. Copy its **Client ID** —
this is what Android sign-in actually authenticates against (a
Credential Manager quirk: it needs a *web* client ID, passed as
`serverClientId`, even for a mobile app).
- **iOS**: bundle ID matching the Xcode project. Copy its **Client ID**
and note the reversed form (`com.googleusercontent.apps.<...>`).
5. Fill in `lib/services/cloud_oauth_config.dart`:
- `googleAndroidServerClientId` ← the **Web application** client's ID.
- `googleIosClientId` ← the **iOS** client's ID.
6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` →
`CFBundleURLSchemes` with your iOS client's reversed ID.
### Dropbox (Dropbox App Console)
Dropbox has no official Flutter sign-in SDK, so this uses a hand-built
OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a
public/mobile app) via `flutter_web_auth_2`, which opens the system browser
and catches the redirect through a custom URL scheme already registered in
`AndroidManifest.xml` / `Info.plist`.
1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps).
2. Access type: **Full Dropbox** (not "App folder") — needed because
browsing to and reusing a folder someone *else* created and shared
requires seeing the whole account, the same reasoning as Google's full
`drive` scope above.
3. Under **OAuth 2** → **Redirect URIs**, add
`mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already
wired up in the Android manifest and iOS Info.plist; use a different one
only if you also update those two files to match).
4. Copy the app's **App key** from the Settings tab.
5. Fill in `lib/services/cloud_oauth_config.dart`:
- `dropboxAppKey` ← the App key.
- `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`.
6. While the app is in **Development** status, only your own Dropbox
account can sign in; add teammates under the app's **Permissions** /
member-access settings, or apply for **Production** status, once you're
ready to share it with others.
### OneDrive (Azure Portal / Microsoft Graph)
Same PKCE approach as Dropbox, against the Microsoft identity platform and
Microsoft Graph.
1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** →
**App registrations** → **New registration**.
2. Supported account types: **Personal Microsoft accounts only** (or "any
organizational directory and personal Microsoft accounts" if you also
want work/school accounts to be able to sign in — those may additionally
need their tenant admin's consent for the scopes below).
3. Under **Authentication** → **Add a platform** → **Mobile and desktop
applications**, add the redirect URI `mofueltaxback-onedrive://auth`
(this exact scheme is already wired up in the Android manifest and iOS
Info.plist; use a different one only if you also update those two files
to match).
4. Under **API permissions**, add Microsoft Graph **delegated** permissions
`Files.ReadWrite.All` and `offline_access` (the latter is required to get
a refresh token back from the token endpoint).
5. Copy the **Application (client) ID** from the Overview page.
6. Fill in `lib/services/cloud_oauth_config.dart`:
- `oneDriveClientId` ← the Application (client) ID.
- `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`.
### WebDAV (self-hosted — no developer console needed)
This is the option for a personal cloud server you already run — Nextcloud,
ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare
Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere;
tapping "Connect WebDAV" in Settings just opens a form asking for:
- **Server URL** — the full WebDAV endpoint, e.g.
`https://cloud.example.com/remote.php/dav/files/yourusername/` for
Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you.
`https://` is assumed if you omit the scheme.
- **Username** and **Password** — for servers that support app-specific
passwords (Nextcloud: Settings → Security → "Create new app password"),
use one of those instead of your real account password, so this app can
be revoked independently later.
The app verifies the URL and credentials with a harmless `PROPFIND` request
before treating you as signed in, so a typo or wrong password fails
immediately with a clear error rather than surfacing later during sync.
Nothing needs to be filled in `cloud_oauth_config.dart` or the
Android/iOS manifest files for this provider.
## Running it ## Running it
@ -51,43 +241,145 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
`flutter test` are clean, and `flutter build apk --debug` has been confirmed `flutter test` are clean, and `flutter build apk --debug` has been confirmed
to produce a working APK. to produce a working APK. None of the three providers' sign-in/sync paths
can be exercised end-to-end until you've done that provider's manual OAuth
setup above.
## Permissions ## Permissions
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
set up for receipt capture. set up for receipt capture.
- **Storage**: no explicit storage permission is declared. The default save - **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the
location is inside the app's own sandbox (no permission needed). If you main Android manifest (needed for release builds; debug builds get
point Settings at a location outside the sandbox, the OS-native folder `INTERNET` for free).
picker (via `file_picker`) is what grants access — see the caveat below. - **OAuth redirects**: no manifest changes are needed for `google_sign_in`
itself when not using `google-services.json` — see the Google setup
section above instead. Dropbox and OneDrive's browser-based OAuth redirect
is already wired up via a `flutter_web_auth_2` callback activity
(Android) / extra `CFBundleURLTypes` entries (iOS) for the
`mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you
only need to register the matching redirect URI in each provider's own
console (see setup above), not touch these files, unless you deliberately
choose different scheme names.
## How sync works
Every local change (add/edit a vehicle, log a fuel entry) writes to the
local SQLite database immediately, then triggers a best-effort background
sync — also triggered whenever connectivity comes back or the app starts.
Every row carries `updated_at` (for merge resolution), `deleted_at` (a
soft-delete tombstone — see below), and a local-only `dirty` flag (pending
push to Drive, never itself treated as meaningful sync data). Sync:
1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared cloud
folder, then waits until no *other* lock file older than its own remains
(polling every second, deleting any it finds older than the configured
staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as
orphaned/stale) — a ticket-based mutex using the cloud folder itself as
the coordination point, so two devices never overwrite each other's
edits to the shared database mid-write. This logic (`lock_coordinator.dart`)
is identical regardless of which of the four providers is active.
2. Checks the remote database file's change-detection tag (Drive's
`md5Checksum`, Dropbox's `content_hash`, OneDrive's `cTag`, or a WebDAV
resource's `ETag` — an opaque `versionTag` as far as the sync engine is
concerned) against the last one seen (a metadata-only call, no content
download) to decide if a pull is even needed. If it changed: downloads
it to a temp file, `ATTACH`es it to
the local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE
local.<key> IS NULL OR remote.updated_at > local.updated_at` per table
(`id` for both tables — vehicles' hidden generated id, not the
user-editable VIN). Rows that statement doesn't match — including this
device's own not-yet-pushed edits — are left untouched, so no separate
"keep local" step is needed.
3. Uploads any receipt photos still needing it (a local path but no cloud
file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud
file ID. The local copy is then deleted or kept depending on the
Settings "keep photos on this phone" toggle. If any upload fails, the
push step below is skipped entirely this cycle — a row is never pushed
while it still holds a local-only, not-yet-uploaded path.
4. Reads the local database file directly (sqflite's default journal mode
isn't WAL, so the file is complete and consistent as soon as the last
write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as
cheap insurance) and uploads it as the new remote copy, then clears the
`dirty` flag on every row now that local matches what's on the cloud.
5. Releases the lock.
**Deletions propagate correctly**, unlike a naive "union records" merge:
deleting sets `deleted_at` instead of removing the row, so a deletion is
just another change with its own `updated_at`, and rides the same
newest-wins rule as any edit — no separate deletion-handling logic needed.
## Known caveats / things to revisit ## Known caveats / things to revisit
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions - **Field-level conflicts aren't merged.** Two people editing the *same*
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP record at the same time: whole record, newer `updated_at` wins — not a
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't field-by-field merge.
actually wired up for library modules in this Flutter/AGP combination yet, - **Lock acquisition has no hard timeout** beyond the configurable
so the plugin's own Kotlin sources never get compiled and the build fails staleness reap (Settings → Advanced). Fine at the scale this is built for
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is (a handful of people); could in theory spin under many simultaneous
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time contenders.
you bump dependencies — check the package's CHANGELOG for when this is - **VIN uniqueness is checked, but not race-proof across devices.** Editing
properly resolved upstream. or adding a vehicle checks for an existing active vehicle with the same
- **iOS folder picking and app restarts**: `file_picker`'s directory picker VIN before saving, but two offline devices could still each independently
on iOS uses `UIDocumentPickerViewController`, which hands back a create (or rename into) the same VIN before either has seen the other's
security-scoped URL. This app does not currently persist a security-scoped change — same category of accepted limitation as "field-level conflicts
bookmark for that URL, so if you pick a folder outside the app's own aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after `vin`, specifically so this doesn't corrupt the merge itself if it
an app restart is not guaranteed. Picking the default in-sandbox location, happens — you'd just end up with two vehicle rows sharing a VIN until
or a folder on Android, does not have this limitation. If cross-restart someone notices and fixes it by hand.
external storage on iOS matters for your use case, this needs a proper - **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`). (`serverClientId`), not just the Android client's SHA-1 registration —
see the manual setup section. This is a quirk of the Credential
Manager-based implementation and easy to miss if you're used to older
`google_sign_in` versions.
- **Dropbox and OneDrive's sign-in isn't restored across a cold app
restart.** Their OAuth refresh tokens are kept in memory only for now
(Google's own SDK handles its own persistent session separately, and
WebDAV persists its credentials to OS-encrypted storage — see below — so
this only affects these two hand-built OAuth providers) —
`attemptSilentSignIn()` always returns false for them, so you'll need to
reconnect once per app launch until refresh-token persistence is added.
- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore
on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the
session after a cold restart — it re-verifies them with the same PROPFIND
check `signInWithCredentials` uses rather than trusting the stored values
blindly, since the server or password could have changed since. A wrong
or revoked password just silently fails the restore (same as no
connection ever having been made); there's no proactive UI nudge to
reconnect beyond the sync error that shows up on the next sync attempt.
- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks
the access token's expiry before every call and refreshes proactively,
but a token revoked or invalidated out-of-band (e.g. from that provider's
own "manage app access" page) surfaces as a failed sync (visible in
Settings as "Last sync failed") rather than prompting a fresh sign-in
automatically.
- **Dropbox has no distinct "Shared with me" tab** in the folder browser
(the interface's `sharedWithMe` parameter is a no-op for
`DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into
the account's normal folder tree, so they already show up under "My
Files" in the common case. Revisit if an unmounted/pending share needs to
be browsable directly.
- **WebDAV has no "Shared with me" concept at all** (it's not part of the
base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false,
same as Dropbox, and it's up to the server/user to point the app at
whatever path a share is mounted under. WebDAV credentials are also sent
as HTTP Basic Auth on every request, so an **HTTPS server URL is
effectively required** — the app doesn't block a plain `http://` URL, but
it would send the password in the clear.
- **Receipt parsing is best-effort regex matching** on the OCR'd text - **Receipt parsing is best-effort regex matching** on the OCR'd text
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
parse partially or not at all — the confirm screen always lets you fill in parse partially or not at all — the confirm screen always lets you fill in
or correct whatever wasn't found. or correct whatever wasn't found.
- No automated tests exercise the OCR or camera capture path itself (that - No automated tests exercise the OCR, camera/gallery picker, or real Drive
requires a real device/emulator with a camera); `receipt_parser_test.dart` API calls (those need a real device/emulator and live credentials);
covers the parsing logic against fixed OCR text. `receipt_parser_test.dart`, `lock_coordinator_test.dart`,
`db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter
two using `sqflite_common_ffi` to run real SQL against temp SQLite files
on the Dart VM) cover the pure logic pieces against fixed inputs —
`receipt_parser_test.dart` in particular is transcribed from 13 real
photographed receipts across different gas station chains.
- No migration path exists from the earlier JSON-file storage format —
not needed since no real data had accumulated under it yet, but flag it
if that's no longer true for you.

View file

@ -1,6 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/> <uses-feature android:name="android.hardware.camera" android:required="false"/>
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
android/app/src/debug/AndroidManifest.xml, but release builds need
it declared here explicitly. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application <application
android:label="fuel_tax_tracker" android:label="fuel_tax_tracker"
android:name="${applicationName}" android:name="${applicationName}"
@ -27,6 +32,22 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Dropbox & OneDrive OAuth: flutter_web_auth_2 captures the
browser redirect via this activity + custom URL scheme(s).
These scheme values must exactly match whatever is set as
dropboxRedirectUri/oneDriveRedirectUri in
lib/services/cloud_oauth_config.dart. -->
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mofueltaxback-dropbox" />
<data android:scheme="mofueltaxback-onedrive" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@ -43,5 +64,13 @@
<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,12 +1,9 @@
<?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 android:drawable="?android:colorBackground" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,12 +1,9 @@
<?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 android:drawable="@android:color/white" /> <item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<!-- You can insert your own image assets here --> </item>
<!-- <item> <item>
<bitmap <bitmap android:gravity="center" android:src="@drawable/splash"/>
android:gravity="center" </item>
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
<?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>

View file

@ -5,6 +5,10 @@
<!-- 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

View file

@ -0,0 +1,22 @@
<?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>

View file

@ -5,6 +5,10 @@
<!-- 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

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

BIN
assets/icon/hero_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -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 = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
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 = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
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++";

View file

@ -1,122 +1 @@
{ {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 725 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,23 +1,56 @@
{ {
"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" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 204 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 421 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View file

@ -16,13 +16,19 @@
<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 opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
</imageView> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></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="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints> </constraints>
</view> </view>
</viewController> </viewController>
@ -32,6 +38,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources> </resources>
</document> </document>

View file

@ -1,13 +1,15 @@
<?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>
@ -68,5 +70,43 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
</dict> <!-- Google Sign-In: required for the OAuth redirect to route back into
the app, regardless of whether the client ID itself is set here or
passed in Dart (see lib/services/drive_oauth_config.dart). Replace
with your iOS OAuth client's REVERSED_CLIENT_ID from Google Cloud
Console (reverse of the client ID, e.g.
com.googleusercontent.apps.XXXXXXXXXXXX-yyyyyyyyyyyyyyyyyyyyyyyyyyyy). -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID</string>
</array>
</dict>
<!-- Dropbox & OneDrive OAuth redirects (flutter_web_auth_2). These
scheme values must exactly match dropboxRedirectUri /
oneDriveRedirectUri in lib/services/cloud_oauth_config.dart. -->
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>mofueltaxback-dropbox</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>mofueltaxback-onedrive</string>
</array>
</dict>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>

View file

@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'screens/home_screen.dart'; import 'screens/main_shell.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());
@ -15,16 +16,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (_) => AppState()..init(), create: (_) => AppState()..init(),
child: MaterialApp( child: Consumer<AppState>(
builder: (context, appState, _) => MaterialApp(
title: 'Fuel Tax Tracker', title: 'Fuel Tax Tracker',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), theme: AppTheme.light,
darkTheme: ThemeData( darkTheme: AppTheme.dark,
colorSchemeSeed: Colors.indigo, themeMode: appState.themeMode,
brightness: Brightness.dark,
useMaterial3: true,
),
home: const AppRoot(), home: const AppRoot(),
), ),
),
); );
} }
} }
@ -41,7 +41,33 @@ class AppRoot extends StatelessWidget {
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
} }
return const HomeScreen(); if (appState.initError != null) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
'Could not start the app:\n${appState.initError}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => appState.init(),
child: const Text('Retry'),
),
],
),
),
),
);
}
return const MainShell();
}, },
); );
} }

View file

@ -1,11 +1,21 @@
class FuelEntry { class FuelEntry {
final String id; final String id;
/// References [Vehicle.id] (the hidden, immutable identifier) — not the
/// VIN, which is user-editable and so unsuitable as a foreign key.
final String vehicleId; final String vehicleId;
final DateTime date; final DateTime date;
final double gallons; final double gallons;
final double pricePerGallon; final double pricePerGallon;
final double totalCost; final double totalCost;
final String? receiptImagePath; final String? receiptImagePath;
final String? receiptDriveFileId;
final DateTime updatedAt;
/// Soft-delete tombstone: null means active. See [Vehicle.deletedAt] for
/// why this is a flag rather than an actual row deletion.
final DateTime? deletedAt;
FuelEntry({ FuelEntry({
required this.id, required this.id,
@ -14,26 +24,75 @@ class FuelEntry {
required this.gallons, required this.gallons,
required this.pricePerGallon, required this.pricePerGallon,
required this.totalCost, required this.totalCost,
required this.updatedAt,
this.receiptImagePath, this.receiptImagePath,
this.receiptDriveFileId,
this.deletedAt,
}); });
Map<String, dynamic> toJson() => { /// True once the receipt photo has been uploaded to Drive, regardless of
/// whether a local copy is also being kept (see the "keep photos on this
/// phone" setting). Viewing it locally is preferred when a local copy
/// exists; otherwise it requires downloading from Drive on demand.
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
/// True while a receipt photo still needs to be uploaded to Drive: a
/// local file exists but hasn't made it there yet. Once
/// [receiptDriveFileId] is set this is false — even if a local copy is
/// also being kept — since that field alone is what marks "no longer
/// needs uploading", independent of local retention. A row in this state
/// is never pushed to Drive as data (a local file path is meaningless on
/// another device) — sync uploads the image first, which sets
/// [receiptDriveFileId] and clears this.
bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null;
FuelEntry copyWith({
String? receiptImagePath,
String? receiptDriveFileId,
DateTime? updatedAt,
DateTime? deletedAt,
bool clearReceiptImagePath = false,
}) {
return FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
updatedAt: updatedAt ?? this.updatedAt,
receiptImagePath:
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
deletedAt: deletedAt ?? this.deletedAt,
);
}
Map<String, Object?> toMap() => {
'id': id, 'id': id,
'vehicleId': vehicleId, 'vehicle_id': vehicleId,
'date': date.toIso8601String(), 'date': date.millisecondsSinceEpoch,
'gallons': gallons, 'gallons': gallons,
'pricePerGallon': pricePerGallon, 'price_per_gallon': pricePerGallon,
'totalCost': totalCost, 'total_cost': totalCost,
'receiptImagePath': receiptImagePath, 'receipt_image_path': receiptImagePath,
'receipt_drive_file_id': receiptDriveFileId,
'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry( factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
id: json['id'] as String, id: map['id'] as String,
vehicleId: json['vehicleId'] as String, vehicleId: map['vehicle_id'] as String,
date: DateTime.parse(json['date'] as String), date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
gallons: (json['gallons'] as num).toDouble(), gallons: (map['gallons'] as num).toDouble(),
pricePerGallon: (json['pricePerGallon'] as num).toDouble(), pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(), totalCost: (map['total_cost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?, receiptImagePath: map['receipt_image_path'] as String?,
receiptDriveFileId: map['receipt_drive_file_id'] as String?,
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
deletedAt: map['deleted_at'] != null
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
: null,
); );
} }

View file

@ -1,48 +1,79 @@
class Vehicle { class Vehicle {
/// Hidden, immutable, generated primary key — never shown in the UI and
/// never editable. This is what fuel entries actually reference and what
/// sync merges on, so that [vin] itself is free to be edited without
/// breaking those references or losing sync history.
final String id; final String id;
final String make;
final String model; /// The vehicle's identification number. Required and must be unique
final String color; /// among active (non-deleted) vehicles, but — unlike [id] — the user can
final String licensePlate; /// edit it later (e.g. to fix a typo from a misread VIN scan).
final String vin;
/// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional —
/// when absent, screens fall back to other identifying info instead.
final String? nickname;
final DateTime updatedAt;
/// Soft-delete tombstone: null means active. Deleting sets this instead
/// of removing the row, so the deletion itself can be merged/synced like
/// any other change (newest `updatedAt` wins) instead of silently
/// disappearing and later being resurrected by a device that hasn't seen
/// the deletion yet.
final DateTime? deletedAt;
Vehicle({ Vehicle({
required this.id, required this.id,
required this.make, required this.vin,
required this.model, required this.updatedAt,
required this.color, this.nickname,
required this.licensePlate, this.deletedAt,
}); });
String get displayName => '$color $make $model ($licensePlate)'; /// What screens should show as the vehicle's primary label: the nickname
/// if one was given, otherwise the VIN itself.
String get displayLabel {
final trimmedNickname = nickname?.trim();
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
return trimmedNickname;
}
return vin;
}
/// Note: [id] is intentionally not overridable here — it's the hidden
/// identifier, not an editable field.
Vehicle copyWith({ Vehicle copyWith({
String? make, String? vin,
String? model, String? nickname,
String? color, bool clearNickname = false,
String? licensePlate, DateTime? updatedAt,
DateTime? deletedAt,
}) { }) {
return Vehicle( return Vehicle(
id: id, id: id,
make: make ?? this.make, vin: vin ?? this.vin,
model: model ?? this.model, nickname: clearNickname ? null : (nickname ?? this.nickname),
color: color ?? this.color, updatedAt: updatedAt ?? this.updatedAt,
licensePlate: licensePlate ?? this.licensePlate, deletedAt: deletedAt ?? this.deletedAt,
); );
} }
Map<String, dynamic> toJson() => { Map<String, Object?> toMap() => {
'id': id, 'id': id,
'make': make, 'vin': vin,
'model': model, 'nickname': nickname,
'color': color, 'updated_at': updatedAt.millisecondsSinceEpoch,
'licensePlate': licensePlate, 'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle( factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
id: json['id'] as String, id: map['id'] as String,
make: json['make'] as String, vin: map['vin'] as String,
model: json['model'] as String, nickname: map['nickname'] as String?,
color: json['color'] as String, updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
licensePlate: json['licensePlate'] as String, deletedAt: map['deleted_at'] != null
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
: null,
); );
} }

View file

@ -1,15 +1,30 @@
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. /// omit it to create a new one. [promptToCreateForReceipt] is set when this
/// screen was reached because the user tried to log a receipt with no
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
/// so that's clear, rather than silently landing on the add-vehicle form.
class AddEditVehicleScreen extends StatefulWidget { class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle; final Vehicle? vehicle;
final bool promptToCreateForReceipt;
const AddEditVehicleScreen({super.key, this.vehicle}); const AddEditVehicleScreen({
super.key,
this.vehicle,
this.promptToCreateForReceipt = false,
});
@override @override
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState(); State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
@ -17,52 +32,249 @@ class AddEditVehicleScreen extends StatefulWidget {
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> { class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
late final TextEditingController _makeController; late final TextEditingController _nicknameController;
late final TextEditingController _modelController; late final TextEditingController _vinController;
late final TextEditingController _colorController;
late final TextEditingController _plateController; bool _saving = false;
bool _scanningVin = false;
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();
_makeController = TextEditingController(text: widget.vehicle?.make ?? ''); _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_modelController = TextEditingController(text: widget.vehicle?.model ?? ''); _vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? ''); if (widget.promptToCreateForReceipt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: const Text('Create a vehicle to attach receipt'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
});
}
} }
@override @override
void dispose() { void dispose() {
_makeController.dispose(); _nicknameController.dispose();
_modelController.dispose(); _vinController.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(
make: _makeController.text.trim(), vin: vin,
model: _modelController.text.trim(), nickname: nickname.isEmpty ? null : nickname,
color: _colorController.text.trim(), clearNickname: nickname.isEmpty,
licensePlate: _plateController.text.trim(),
)); ));
} else { } else {
await appState.addVehicle( await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
make: _makeController.text.trim(),
model: _modelController.text.trim(),
color: _colorController.text.trim(),
licensePlate: _plateController.text.trim(),
);
} }
if (mounted) Navigator.of(context).pop(); if (mounted) {
// Reached from "log a receipt with no vehicle yet" — once the
// vehicle exists, continue straight into the receipt capture flow
// the user was actually trying to do, rather than just landing
// back on an (again) empty Receipts tab. The caller does the
// continuing; this just hands back which vehicle to do it for.
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
}
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _saving = false);
}
} }
Future<void> _confirmDelete() async { Future<void> _confirmDelete() async {
@ -72,7 +284,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
title: const Text('Delete vehicle?'), title: const Text('Delete vehicle?'),
content: Text( content: Text(
'This will also delete all fuel entries and receipt photos logged for ' 'This will also delete all fuel entries and receipt photos logged for '
'${widget.vehicle!.displayName}. This cannot be undone.', '${widget.vehicle!.displayLabel}. This cannot be undone.',
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
@ -114,36 +326,49 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
TextFormField( TextFormField(
controller: _makeController, controller: _nicknameController,
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'), decoration: const InputDecoration(
labelText: 'Nickname (optional)',
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
),
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
controller: _modelController, controller: _vinController,
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'), decoration: InputDecoration(
textCapitalization: TextCapitalization.words, labelText: 'VIN *',
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, hintText: 'Vehicle Identification Number',
helperText: 'Required — tap the camera to scan it from a photo',
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,
), ),
const SizedBox(height: 12),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
textCapitalization: TextCapitalization.words,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
), ),
const SizedBox(height: 12),
TextFormField(
controller: _plateController,
decoration: const InputDecoration(labelText: 'License Plate'),
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, validator: _validateVin,
onChanged: _isEditing ? null : (_) => _checkForExistingVin(),
), ),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton( FilledButton(
onPressed: _save, onPressed: _saving ? null : _save,
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
), ),
], ],
), ),

View file

@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
enum _BrowseRoot { myFiles, sharedWithMe }
/// Lets the user navigate whichever cloud storage provider is connected —
/// their own files, and (if the provider supports it) files others have
/// shared with them — and pick a parent location. Confirming looks for (or
/// creates) the `Show Me The Fuel Refund` folder under that location, so
/// two people pointing at the same shared parent converge on the same app
/// folder, regardless of which provider each of them is using. Picking a
/// folder that's already named that directly (e.g. a shared folder set up
/// by someone else) uses it as-is instead of nesting another one inside —
/// see `CloudSyncService.selectAppFolder`.
class CloudFolderBrowserScreen extends StatefulWidget {
const CloudFolderBrowserScreen({super.key});
@override
State<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),
);
},
);
}
}

View file

@ -33,12 +33,16 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
late final TextEditingController _gallonsController; late final TextEditingController _gallonsController;
late final TextEditingController _priceController; late final TextEditingController _priceController;
late final TextEditingController _totalController; late final TextEditingController _totalController;
DateTime _date = DateTime.now(); late DateTime _date;
bool _saving = false; bool _saving = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Prefer the date/time printed on the receipt; fall back to now, same
// as before, when it couldn't be parsed — the date picker below is
// always available either way for manual entry/correction.
_date = widget.parsed.date ?? DateTime.now();
_gallonsController = TextEditingController( _gallonsController = TextEditingController(
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '', text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
); );
@ -69,11 +73,20 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
} }
Future<void> _pickDate() async { Future<void> _pickDate() async {
// initialDate must fall within [firstDate, lastDate] or the picker
// throws — widen the bounds to cover _date in case a misread date from
// the receipt landed outside the normal 5-years-back-to-today window.
final today = DateTime.now();
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
? _date
: today.subtract(const Duration(days: 365 * 5));
final lastDate = _date.isAfter(today) ? _date : today;
final pickedDate = await showDatePicker( final pickedDate = await showDatePicker(
context: context, context: context,
initialDate: _date, initialDate: _date,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)), firstDate: firstDate,
lastDate: DateTime.now(), lastDate: lastDate,
); );
if (pickedDate == null || !mounted) return; if (pickedDate == null || !mounted) return;
@ -136,7 +149,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
GestureDetector( GestureDetector(
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
), ),
), ),
child: ClipRRect( child: ClipRRect(

View file

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

View file

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

View file

@ -3,10 +3,12 @@ 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});
@ -17,13 +19,15 @@ class HomeScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('My Vehicles'), title: const Text('Vehicles'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined), key: OnboardingKeys.vehiclesAddButton,
tooltip: 'Settings', icon: const Icon(Icons.add),
tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push( onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
), ),
), ),
], ],
@ -40,13 +44,6 @@ 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'),
),
); );
} }
} }
@ -59,22 +56,49 @@ class _VehicleCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ListTile( child: InkWell(
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),
],
),
),
), ),
); );
} }

315
lib/screens/main_shell.dart Normal file
View file

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

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
import 'edit_fuel_entry_screen.dart';
/// The single place to view a receipt's photo and its logged data
/// together, with edit/delete for the entry — reached by tapping a receipt
/// row on either the Receipts tab or a vehicle's detail page. Looks up
/// [entryId] fresh from [AppState] on every build (rather than taking a
/// [FuelEntry] directly) so it reflects an edit made via
/// [EditFuelEntryScreen] as soon as that screen is popped back to this one.
class ReceiptDetailScreen extends StatelessWidget {
final String entryId;
const ReceiptDetailScreen({super.key, required this.entryId});
Future<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),
],
),
);
}
}

View file

@ -1,12 +1,73 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Full-screen, pinch-zoomable view of a saved receipt photo. import '../services/app_state.dart';
class ReceiptImageScreen extends StatelessWidget {
final String imagePath;
const ReceiptImageScreen({super.key, required this.imagePath}); /// Full-screen, pinch-zoomable view of a receipt photo. Pass
/// [localImagePath] for a receipt still held locally, or [driveFileId] for
/// one already uploaded to the cloud and removed locally — in which case
/// it's downloaded on demand (no local caching afterward).
class ReceiptImageScreen extends StatefulWidget {
final String? localImagePath;
final String? driveFileId;
const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId})
: assert(localImagePath != null || driveFileId != null,
'Must provide either a local path or a Drive file ID');
@override
State<ReceiptImageScreen> createState() => _ReceiptImageScreenState();
}
class _ReceiptImageScreenState extends State<ReceiptImageScreen> {
Uint8List? _downloadedBytes;
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
if (widget.driveFileId != null) {
_download();
}
}
Future<void> _download() async {
setState(() {
_loading = true;
_error = null;
});
final provider = context.read<AppState>().activeProvider;
if (provider == null) {
setState(() {
_error = 'Not connected to a cloud storage provider.';
_loading = false;
});
return;
}
final session = provider.beginSession();
try {
final bytes = await session.downloadFileBytes(widget.driveFileId!);
if (!mounted) return;
setState(() {
_downloadedBytes = Uint8List.fromList(bytes);
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Could not download receipt: $e';
_loading = false;
});
} finally {
session.close();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -17,11 +78,26 @@ class ReceiptImageScreen extends StatelessWidget {
foregroundColor: Colors.white, foregroundColor: Colors.white,
title: const Text('Receipt'), title: const Text('Receipt'),
), ),
body: Center( body: Center(child: _buildBody()),
child: InteractiveViewer(
child: Image.file(File(imagePath)),
),
),
); );
} }
Widget _buildBody() {
if (widget.localImagePath != null) {
return InteractiveViewer(child: Image.file(File(widget.localImagePath!)));
}
if (_loading) {
return const CircularProgressIndicator(color: Colors.white);
}
if (_error != null) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(_error!, style: const TextStyle(color: Colors.white)),
);
}
if (_downloadedBytes != null) {
return InteractiveViewer(child: Image.memory(_downloadedBytes!));
}
return const SizedBox.shrink();
}
} }

View file

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

View file

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

View file

@ -1,42 +1,20 @@
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';
class SettingsScreen extends StatefulWidget { /// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
/// advanced/purge) — plus a read-only summary of what's stored locally.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); 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')),
@ -44,41 +22,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Card( Card(
child: Padding( child: ListTile(
padding: const EdgeInsets.all(16), leading: const Icon(Icons.palette_outlined),
child: Column( title: const Text('UI'),
crossAxisAlignment: CrossAxisAlignment.start, subtitle: const Text('Appearance'),
children: [ trailing: const Icon(Icons.chevron_right),
Text('Save Location', style: Theme.of(context).textTheme.titleMedium), onTap: () => Navigator.of(context).push(
const SizedBox(height: 8), MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
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),
if (_error != null) ...[ Card(
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), key: OnboardingKeys.settingsDataMenuEntry,
const SizedBox(height: 8), child: ListTile(
], leading: const Icon(Icons.storage_outlined),
FilledButton.icon( title: const Text('Data'),
onPressed: _changing ? null : _chooseFolder, subtitle: const Text('Cloud storage, photo syncing, advanced'),
icon: _changing trailing: const Icon(Icons.chevron_right),
? const SizedBox( onTap: () => Navigator.of(context).push(
height: 16, MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: const Text('Choose Folder'),
),
],
), ),
), ),
), ),

View file

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

View file

@ -1,92 +1,66 @@
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 '../services/ocr_service.dart'; import '../widgets/hero_banner.dart';
import '../services/receipt_parser.dart'; import '../widgets/receipt_capture.dart';
import '../widgets/receipt_thumbnail.dart';
import 'add_edit_vehicle_screen.dart'; import 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart'; import 'receipt_detail_screen.dart';
import 'receipt_image_screen.dart';
class VehicleDetailScreen extends StatelessWidget { /// A swipeable [PageView] over every vehicle (in the same order as the
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
/// to the next/previous vehicle, the same "modern navigation" swipe
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
/// owns the [PageController].
class VehicleDetailScreen extends StatefulWidget {
final String vehicleId; final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId}); const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async { @override
final picker = ImagePicker(); State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
XFile? photo; }
try {
photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
} catch (e) { late final PageController _pageController;
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( @override
SnackBar(content: Text('Could not open camera: $e')), void initState() {
super.initState();
final vehicles = context.read<AppState>().vehicles;
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vehicles = context.watch<AppState>().vehicles;
if (vehicles.isEmpty) {
return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
return PageView.builder(
controller: _pageController,
itemCount: vehicles.length,
itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
); );
} }
return; }
}
if (photo == null) return;
final imageFile = File(photo.path); class _VehicleDetailPage extends StatelessWidget {
final String vehicleId;
if (!context.mounted) return; const _VehicleDetailPage({required this.vehicleId});
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) {
@ -99,12 +73,16 @@ class VehicleDetailScreen 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.displayName), title: Text(vehicle.displayLabel),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
@ -117,59 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
), ),
body: Column( body: Column(
children: [ children: [
Container( Padding(
width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
padding: const EdgeInsets.all(16), child: HeroBanner(
color: Theme.of(context).colorScheme.surfaceContainerHighest, child: HeroStatsRow(
child: Column( gallonsValue: heroGallonsFormat.format(totalGallons),
crossAxisAlignment: CrossAxisAlignment.start, costValue: currencyFormat.format(totalCost),
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: ListTile( child: InkWell(
leading: entry.receiptImagePath != null onTap: openDetail,
? GestureDetector( child: Padding(
onTap: () => Navigator.of(context).push( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
MaterialPageRoute( // Row cross-axis defaults to center, so the
builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!), // thumbnail sits vertically centered against the
// title+subtitle block even though it's three
// lines tall — ListTile's isThreeLine would
// instead force leading/trailing to the top, per
// Material spec (the same issue the vehicle list
// cards had).
child: Row(
children: [
ReceiptThumbnail(entry: entry, onTap: openDetail),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.gallons.toStringAsFixed(3)} gal • '
'${currencyFormat.format(entry.totalCost)}',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
'${dateFormat.format(entry.date)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
child: 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),
), ),
), ),
); );
@ -179,7 +172,7 @@ class VehicleDetailScreen extends StatelessWidget {
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () => _captureReceipt(context), onPressed: () => captureReceiptForVehicle(context, vehicleId),
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'),
), ),

View file

@ -1,63 +1,382 @@
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 'storage_service.dart'; import 'cloud/cloud_storage_provider.dart';
import 'cloud/dropbox_provider.dart';
import 'cloud/google_drive_provider.dart';
import 'cloud/onedrive_provider.dart';
import 'cloud/webdav_provider.dart';
import 'cloud_sync_service.dart';
import 'database_service.dart';
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
const _prefsKeyCloudFolderId = 'cloud_folder_id';
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _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 [StorageService] for persistence. Screens read from /// entries), backed by [DatabaseService] (local SQLite) for persistence and
/// this via Provider and call its mutating methods, which take care of /// a [CloudSyncService] for pushing/pulling the shared copy on whichever
/// writing through to disk and notifying listeners. /// [CloudStorageProvider] the user has connected. Screens read from this
/// via Provider and call its mutating methods, which write through to the
/// local database immediately and kick off a best-effort background sync.
///
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
/// database, refreshed after every mutation and after every sync (since
/// sync's merge happens as SQL directly against the database, not by
/// handing back updated Dart objects).
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
final StorageService storage = StorageService(); final DatabaseService database = DatabaseService();
/// Every storage backend the user can choose from in Settings.
final List<CloudStorageProvider> availableProviders = [
GoogleDriveProvider(),
DropboxProvider(),
OneDriveProvider(),
WebDavProvider(),
];
CloudStorageProvider? activeProvider;
CloudSyncService? cloudSync;
final _uuid = const Uuid(); final _uuid = const Uuid();
List<Vehicle> vehicles = []; List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = []; List<FuelEntry> fuelEntries = [];
bool isLoading = true; bool isLoading = true;
String? cloudFolderPath;
bool isSyncing = false;
DateTime? lastSyncedAt;
Object? lastSyncError;
bool keepReceiptPhotosLocally = false;
int staleLockMinutes = defaultStaleLockMinutes;
/// When false (default), a newly captured receipt photo is downscaled
/// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality]
/// before it's stored — receipts are just photos of small printed text,
/// so a full-resolution original (often several MB on a modern phone
/// camera) buys nothing but cloud storage and sync bandwidth. When true,
/// the original camera/gallery image is kept as-is.
bool keepMaxQualityReceiptPhotos = false;
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 {
await storage.init(); isLoading = true;
final data = await storage.loadData(); initError = null;
vehicles = data.vehicles; notifyListeners();
fuelEntries = data.entries;
try {
await database.init();
await _refreshFromDatabase();
final prefs = await SharedPreferences.getInstance();
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
keepMaxQualityReceiptPhotos =
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
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();
} }
Future<void> addVehicle({ /// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
required String make, /// (currently just WebDAV) that needs a server URL/username/password
required String model, /// instead of an OAuth browser flow. The caller (Settings) is responsible
required String color, /// for collecting those from the user first.
required String licensePlate, Future<void> connectProviderWithCredentials(
CloudProviderId id, {
required String serverUrl,
required String username,
required String password,
}) async { }) async {
vehicles.add(Vehicle( final provider = _providerById(id);
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
serverUrl: serverUrl,
username: username,
password: password,
);
activeProvider = provider;
cloudSync = CloudSyncService(provider: provider, databaseService: database);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyActiveProviderId, id.name);
notifyListeners();
}
Future<void> disconnectCloud() async {
final provider = activeProvider;
if (provider == null) return;
await provider.signOut();
cloudSync?.clearConfiguration();
activeProvider = null;
cloudSync = null;
cloudFolderPath = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKeyActiveProviderId);
await prefs.remove(_prefsKeyCloudFolderId);
await prefs.remove(_prefsKeyCloudFolderPath);
notifyListeners();
}
Future<void> chooseCloudFolder({
required String parentId,
required String breadcrumbPath,
String? currentFolderName,
}) async {
final sync = cloudSync;
if (sync == null) return;
final folderId =
await sync.selectAppFolder(parentId, currentFolderName: currentFolderName);
cloudFolderPath = breadcrumbPath;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyCloudFolderId, folderId);
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
notifyListeners();
unawaited(syncNow());
}
Future<void> syncNow() async {
final sync = cloudSync;
if (isSyncing || sync == null || !sync.isConfigured) return;
isSyncing = true;
notifyListeners();
final result = await sync.syncNow(
keepLocalReceiptCopies: keepReceiptPhotosLocally,
staleLockAge: Duration(minutes: staleLockMinutes),
);
if (result.ranSync) {
await _refreshFromDatabase();
lastSyncedAt = DateTime.now();
lastSyncError = null;
} else if (result.error != null) {
lastSyncError = result.error;
}
isSyncing = false;
notifyListeners();
}
Future<void> setKeepReceiptPhotosLocally(bool value) async {
keepReceiptPhotosLocally = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
notifyListeners();
}
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
keepMaxQualityReceiptPhotos = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
themeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyThemeMode, mode.name);
notifyListeners();
}
/// Called once the first-launch guided tour finishes or is skipped, so
/// it never shows again on this device.
Future<void> markOnboardingTourSeen() async {
hasSeenOnboardingTour = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyHasSeenOnboardingTour, true);
notifyListeners();
}
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
/// Settings "Advanced" section, which restricts the picker to that range
/// anyway; this is a defensive backstop for any other caller.
Future<void> setStaleLockMinutes(int minutes) async {
staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes);
notifyListeners();
}
/// Throws [DuplicateVinException] if [vin] already belongs to another
/// active vehicle — VIN must stay unique even though it's editable, so
/// silently letting a duplicate through would be a real correctness bug,
/// not just a UX wrinkle.
Future<void> addVehicle({
required String vin,
String? nickname,
}) async {
if (await database.vinExists(vin)) {
throw DuplicateVinException(vin);
}
final vehicle = Vehicle(
id: _uuid.v4(), id: _uuid.v4(),
make: make, vin: vin,
model: model, nickname: nickname,
color: color, updatedAt: DateTime.now().toUtc(),
licensePlate: licensePlate, );
)); await database.saveVehicle(vehicle);
await _persist(); await _persist();
} }
/// Throws [DuplicateVinException] if [updated]'s VIN now collides with
/// another active vehicle's — this is the check [addVehicle] does for a
/// new vehicle, but here it also has to exclude the vehicle being edited
/// itself (its VIN obviously still matches its own prior value if it
/// wasn't changed).
Future<void> updateVehicle(Vehicle updated) async { Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id); if (await database.vinExists(updated.vin, excludeId: updated.id)) {
if (index != -1) { throw DuplicateVinException(updated.vin);
vehicles[index] = updated; }
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
await _persist(); await _persist();
} }
}
Future<void> deleteVehicle(String vehicleId) async { Future<void> deleteVehicle(String id) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { final now = DateTime.now().toUtc();
await storage.deleteReceiptImage(entry.receiptImagePath); final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
for (final path in orphanedLocalPaths) {
await database.deleteReceiptImageFile(path);
} }
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); await database.softDeleteVehicle(id, now);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist(); await _persist();
} }
@ -72,7 +391,8 @@ class AppState extends ChangeNotifier {
final id = _uuid.v4(); final id = _uuid.v4();
String? storedImagePath; String? storedImagePath;
if (receiptImage != null) { if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id); final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
} }
final entry = FuelEntry( final entry = FuelEntry(
@ -83,16 +403,69 @@ class AppState extends ChangeNotifier {
pricePerGallon: pricePerGallon, pricePerGallon: pricePerGallon,
totalCost: totalCost, totalCost: totalCost,
receiptImagePath: storedImagePath, receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(),
); );
fuelEntries.add(entry); await database.saveFuelEntry(entry);
await _persist(); await _persist();
return entry; return entry;
} }
/// 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 storage.deleteReceiptImage(entry.receiptImagePath); await database.deleteReceiptImageFile(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId); await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
await _persist();
}
/// Wipes every vehicle, fuel entry, and local receipt photo — "starting
/// completely fresh". Uses the same soft-delete tombstones as any other
/// delete rather than a hard SQL `DELETE`, so if a cloud sync is
/// connected, the deletion pushes on next sync instead of the old data
/// just getting silently re-imported from the remote copy. This is purely
/// local + whatever propagates through sync — it never reaches into the
/// cloud folder directly to delete anything sitting there.
Future<void> purgeAllData() async {
await database.purgeAllData(DateTime.now().toUtc());
await _persist();
}
/// Wipes every fuel entry (and its local receipt photo) dated within
/// [start]–[end] inclusive, across all vehicles. Vehicles themselves are
/// left alone. Same soft-delete/sync-propagation reasoning as
/// [purgeAllData].
Future<void> purgeFuelEntriesInRange(DateTime start, DateTime end) async {
final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc());
for (final path in localPaths) {
await database.deleteReceiptImageFile(path);
}
await _persist(); await _persist();
} }
@ -117,13 +490,33 @@ class AppState extends ChangeNotifier {
} }
} }
Future<void> changeSaveDirectory(String newPath) async { FuelEntry? fuelEntryById(String id) {
await storage.setSaveDirectory(newPath); try {
notifyListeners(); return fuelEntries.firstWhere((e) => e.id == id);
} catch (_) {
return null;
}
}
/// Exact match against an active vehicle's VIN — same comparison
/// [DatabaseService.vinExists] does, not case-insensitive, since VIN
/// isn't normalized to any particular case on manual entry (only OCR
/// scanning uppercases it).
Vehicle? vehicleByVin(String vin) {
try {
return vehicles.firstWhere((v) => v.vin == vin);
} catch (_) {
return null;
}
} }
Future<void> _persist() async { Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries); await _refreshFromDatabase();
notifyListeners(); notifyListeners();
unawaited(syncNow());
} }
} }
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View file

@ -0,0 +1,153 @@
import 'dart:io';
/// Shared naming convention across all providers: whichever cloud storage
/// backend is active, the app looks for (or creates) a folder with this
/// exact name wherever the user points it, so two devices pointed at the
/// same shared parent location converge on the same data regardless of
/// which provider they're using.
const appFolderName = '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));
}

View file

@ -0,0 +1,364 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
import 'oauth_pkce.dart';
/// Dropbox implementation of [CloudStorageProvider].
///
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
/// folder's own Dropbox path (e.g. "/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() {}
}

Some files were not shown because too many files have changed in this diff Show more