Compare commits
No commits in common. "feature/financing" and "main" have entirely different histories.
feature/fi
...
main
7
.gitignore
vendored
|
|
@ -43,10 +43,3 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Release signing credentials — never commit. The keystore itself lives
|
||||
# outside the repo entirely (~/.android/), but the passwords in here are
|
||||
# sensitive regardless.
|
||||
/android/key.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
|
|
|||
432
HOMEPAGE.html
|
|
@ -1,92 +0,0 @@
|
|||
# Privacy Policy — Show Me The Fuel Refund
|
||||
|
||||
**Effective date:** August 17, 2026
|
||||
|
||||
This policy explains what "Show Me The Fuel Refund" (the "app") does and does not
|
||||
do with your data. If anything here is unclear, contact
|
||||
**ohbrer+ShowMeTheFuelRefund@gmail.com**.
|
||||
|
||||
## Overview
|
||||
|
||||
The app helps you collect and organize Missouri fuel purchase receipts so you can
|
||||
claim Missouri's Motor Fuel Tax Refund. It has no server or backend of its own —
|
||||
every vehicle, fuel receipt, and photo you log is stored locally on your device,
|
||||
and optionally backed up to a cloud storage account **you** choose and control.
|
||||
We (the developer) never receive, see, or have access to a copy of your data.
|
||||
|
||||
## Data Stored Locally
|
||||
|
||||
The app stores the following on your device only, unless you connect a cloud
|
||||
storage account (see below):
|
||||
|
||||
- Vehicle information you enter (VIN, nickname)
|
||||
- Fuel purchase records (date, gallons, price, total cost)
|
||||
- Receipt photos you capture or select
|
||||
|
||||
This data is never transmitted to us. Uninstalling the app, or never connecting a
|
||||
cloud backup, means this data exists only on that device.
|
||||
|
||||
## Optional Cloud Backup
|
||||
|
||||
If you connect a cloud storage account in Settings, a copy of the data above is
|
||||
also stored there — in storage **you** control:
|
||||
|
||||
- **Google Drive** — via Google Sign-In and the Drive API
|
||||
- **Dropbox** — via the Dropbox API
|
||||
- **Microsoft OneDrive** — via the Microsoft Graph API
|
||||
- **WebDAV** — your own self-hosted server (Nextcloud, ownCloud, or similar)
|
||||
|
||||
For OAuth-based providers (Google Drive, Dropbox, OneDrive), the app requests
|
||||
only the permissions needed to create, read, and write files in a folder it
|
||||
manages there. We do not read, log, or have access to anything stored in these
|
||||
accounts — the connection is directly between your device and the provider you
|
||||
chose. For WebDAV, your server credentials are stored using your device's
|
||||
OS-level encrypted storage (Android Keystore) and are used only to connect
|
||||
directly to the server you specify.
|
||||
|
||||
## Advertising (Google AdMob)
|
||||
|
||||
Unless you've purchased "Remove Ads for a Year," the app shows a single
|
||||
interstitial ad, at most once per app session, via Google AdMob. AdMob may
|
||||
collect data such as an advertising identifier and coarse, IP-based location to
|
||||
serve and measure ads. This collection is Google's, governed by
|
||||
[Google's own privacy policy](https://policies.google.com/privacy) and
|
||||
[AdMob's data disclosure](https://support.google.com/admob/answer/6128543) — it
|
||||
is not something this app adds on top of, and we have no access to it.
|
||||
|
||||
## In-App Purchases
|
||||
|
||||
"Remove Ads for a Year" is processed entirely through Google Play Billing. We do
|
||||
not receive or store payment information — that's handled by Google Play
|
||||
directly.
|
||||
|
||||
## What We Don't Do
|
||||
|
||||
- No analytics SDKs
|
||||
- No crash-reporting SDKs
|
||||
- No tracking of your usage or behavior
|
||||
- No server of ours that receives, stores, or processes your data
|
||||
- No selling or sharing of data with anyone, for any reason
|
||||
|
||||
## Data Deletion
|
||||
|
||||
You can delete your data at any time from **Settings > Data > Advanced**
|
||||
(purge all data, or a specific date range), and disconnect any cloud storage
|
||||
account from **Settings > Data**. Deleting the app removes everything stored
|
||||
locally; anything already backed up to your own cloud storage account remains
|
||||
there under your control until you delete it yourself.
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
This app is not directed at children under 13 and does not knowingly collect
|
||||
data from them.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
If this policy changes, the effective date above will be updated. Continued use
|
||||
of the app after a change constitutes acceptance of the updated policy.
|
||||
|
||||
## Contact
|
||||
|
||||
Questions, problem receipts/VINs, or error reports:
|
||||
**ohbrer+ShowMeTheFuelRefund@gmail.com**
|
||||
378
README.md
|
|
@ -3,227 +3,37 @@
|
|||
A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a
|
||||
photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device,
|
||||
you confirm or correct the numbers, and it's saved alongside the receipt
|
||||
photo. Vehicles and fuel entries live in a local SQLite database that's kept
|
||||
in sync with a shared folder on **Google Drive, Dropbox, OneDrive, or your
|
||||
own WebDAV server** (your choice), so multiple people can log fuel against
|
||||
the same pool of vehicles from their own phones, offline or online.
|
||||
photo to a data file in a folder you choose.
|
||||
|
||||
## Features
|
||||
|
||||
- Manage a list of vehicles, identified by **VIN** (required, and unique
|
||||
across active vehicles — but editable, e.g. to fix a typo from a misread
|
||||
scan; scan it from a photo of the door-jamb sticker or dashboard plate
|
||||
instead of typing all 17 characters). An optional **nickname** ("Mom's
|
||||
Car", "Red Ford F-150") is what's shown as the primary label everywhere;
|
||||
without one, the VIN is shown instead.
|
||||
- Capture a fuel receipt photo per vehicle — from the camera or an existing
|
||||
photo in your library — and OCR it on-device with Google ML Kit — no
|
||||
internet connection or API key required for the OCR itself.
|
||||
- If a receipt's address shows a state other than Missouri, a warning
|
||||
explains that the fuel tax refund only covers Missouri purchases and lets
|
||||
you choose whether to log the entry anyway.
|
||||
- Review/edit the parsed gallons, price per gallon, total cost, *and*
|
||||
date/time before saving (OCR on printed receipts is usually good but not
|
||||
perfect; the date/time picker is always available as a manual fallback
|
||||
when a date can't be found on the receipt).
|
||||
- Manage a list of vehicles (make, model, color, license plate).
|
||||
- Capture a fuel receipt photo per vehicle and OCR it on-device with Google
|
||||
ML Kit — no internet connection or API key required.
|
||||
- Review/edit the parsed gallons, price per gallon, and total cost before
|
||||
saving (OCR on printed receipts is usually good but not perfect).
|
||||
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
|
||||
photos, and delete.
|
||||
- Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV
|
||||
server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
|
||||
endpoint) and pick any folder you have access to — including one someone
|
||||
else created and shared with you — as the shared storage location. The
|
||||
app looks for (or creates) a `Show Me The Fuel Refund` subfolder there
|
||||
(using it directly, without nesting, if the folder you picked is already
|
||||
named that), so anyone pointed at the same shared parent converges on the
|
||||
same data automatically, regardless of which of the four providers each
|
||||
person is using. WebDAV is the only one of the four that needs no
|
||||
developer-console setup at all — just a server URL, username, and
|
||||
password.
|
||||
- Works fully offline: writes always land in the local SQLite database
|
||||
first; a background sync pushes changes to the cloud and pulls others'
|
||||
changes down once online.
|
||||
- Settings lets you choose whether a receipt photo's local copy is deleted
|
||||
once it's safely uploaded to the cloud (the default — keeps the phone's
|
||||
storage footprint small) or kept on the phone for offline viewing.
|
||||
- Settings → Advanced lets you tune the sync lock's staleness timeout (how
|
||||
long before another device's abandoned lock is cleared so sync isn't
|
||||
stuck waiting forever), from 1–60 minutes.
|
||||
- Settings screen to choose where receipt photos and the data file are
|
||||
stored (defaults to the app's own documents folder; existing data is
|
||||
copied over when you change it).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
lib/
|
||||
models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
|
||||
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
|
||||
`deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's
|
||||
`id` is a hidden, generated, immutable primary key — never shown in the
|
||||
UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
|
||||
required, unique-among-active-vehicles, but user-editable field, and
|
||||
`nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
|
||||
`receiptDriveFileId`, or both at once if "keep photos on this phone" is
|
||||
on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
|
||||
distinction.
|
||||
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization
|
||||
services/
|
||||
app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps
|
||||
updatedAt on mutations and triggers background sync.
|
||||
database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
|
||||
and the receipts/ folder, always at
|
||||
ApplicationDocumentsDirectory/FuelTaxTracker.
|
||||
db_schema.dart CREATE TABLE statements and the merge SQL — shared between
|
||||
the app and its tests so they can never drift apart.
|
||||
cloud/
|
||||
cloud_storage_provider.dart The CloudStorageProvider/CloudStorageSession
|
||||
interface every backend implements (auth, folder
|
||||
browsing, file upload/download, lock file ops) —
|
||||
this is what cloud_sync_service.dart and the rest
|
||||
of the app talk to; they never know which of the
|
||||
three providers below is actually active.
|
||||
google_drive_provider.dart Google sign-in + the googleapis DriveApi client.
|
||||
dropbox_provider.dart Hand-rolled OAuth2 PKCE + Dropbox's path-addressed
|
||||
REST API (files/list_folder, files/upload, etc).
|
||||
onedrive_provider.dart Hand-rolled OAuth2 PKCE + Microsoft Graph
|
||||
(/me/drive/...), ID-addressed like Drive.
|
||||
webdav_provider.dart Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE)
|
||||
over HTTP Basic Auth — no OAuth, no developer
|
||||
console, just a server URL + username + password.
|
||||
For self-hosted servers (Nextcloud, ownCloud, a
|
||||
Synology NAS, etc). Path-addressed like Dropbox;
|
||||
uses the resource's ETag as its versionTag.
|
||||
oauth_pkce.dart PKCE code_verifier/code_challenge generation, shared
|
||||
by the Dropbox and OneDrive providers (Google's own
|
||||
SDK handles its OAuth flow itself, and WebDAV uses
|
||||
plain Basic Auth, so neither needs this).
|
||||
cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever
|
||||
provider(s) you want to use — see setup below.
|
||||
cloud_sync_service.dart Orchestrates one sync round against whichever
|
||||
CloudStorageProvider is active: acquire the
|
||||
cross-device lock, ATTACH + merge the remote database
|
||||
into the local one, upload pending receipt photos,
|
||||
push the local database back up, release the lock.
|
||||
lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable
|
||||
logic (independently unit-tested without a real backend).
|
||||
ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
|
||||
receipt_parser.dart Regex-based extraction of gallons/price/total/date from OCR
|
||||
text — falls back to manual entry (date picker, or the
|
||||
gallons*price derivation) for whatever isn't found.
|
||||
app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider
|
||||
storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path
|
||||
ocr_service.dart Thin wrapper around google_mlkit_text_recognition
|
||||
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text
|
||||
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
|
||||
confirm fuel entry, receipt viewer, settings, cloud folder browser).
|
||||
confirm fuel entry, receipt viewer, settings)
|
||||
```
|
||||
|
||||
## Manual setup required (cloud storage)
|
||||
|
||||
You only need to complete setup for whichever provider(s) you actually want
|
||||
to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same
|
||||
shape: I can't provision cloud resources on your behalf, so each needs an
|
||||
app/OAuth client you create yourself in that provider's own developer
|
||||
console, with the resulting IDs pasted into
|
||||
`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect"
|
||||
button for one of these three once its config fields are filled in with
|
||||
real values. **WebDAV needs none of this** — see its section below.
|
||||
|
||||
### Google Drive (Google Cloud Console)
|
||||
|
||||
In [Google Cloud Console](https://console.cloud.google.com/):
|
||||
|
||||
1. Create/select a project, enable the **Google Drive API** (APIs & Services
|
||||
→ Library).
|
||||
2. **OAuth consent screen**: set it to **External**, keep it in **Testing**
|
||||
status, and add your Google account plus everyone else's you're sharing
|
||||
with as **test users**. This avoids Google's formal verification review,
|
||||
which the broad `drive` scope would otherwise require — fine for a known,
|
||||
small group, not a public release.
|
||||
3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen
|
||||
(shows as "restricted/sensitive" — expected, fine in Testing mode).
|
||||
4. **Credentials → Create Credentials → OAuth client ID**, three times:
|
||||
- **Android**: package name `com.ohbrer.show_me_the_fuel_refund` + the
|
||||
SHA-1 of your debug keystore (`keytool -list -v -keystore
|
||||
~/.android/debug.keystore`, password `android`), and later your release
|
||||
keystore's SHA-1 too. This client ID itself is never referenced in
|
||||
code — it exists purely so Android's Credential Manager trusts this
|
||||
specific signed app.
|
||||
- **Web application**: no redirect URIs needed. Copy its **Client ID** —
|
||||
this is what Android sign-in actually authenticates against (a
|
||||
Credential Manager quirk: it needs a *web* client ID, passed as
|
||||
`serverClientId`, even for a mobile app).
|
||||
- **iOS**: bundle ID matching the Xcode project. Copy its **Client ID**
|
||||
and note the reversed form (`com.googleusercontent.apps.<...>`).
|
||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
||||
- `googleAndroidServerClientId` ← the **Web application** client's ID.
|
||||
- `googleIosClientId` ← the **iOS** client's ID.
|
||||
6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` →
|
||||
`CFBundleURLSchemes` with your iOS client's reversed ID.
|
||||
|
||||
### Dropbox (Dropbox App Console)
|
||||
|
||||
Dropbox has no official Flutter sign-in SDK, so this uses a hand-built
|
||||
OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a
|
||||
public/mobile app) via `flutter_web_auth_2`, which opens the system browser
|
||||
and catches the redirect through a custom URL scheme already registered in
|
||||
`AndroidManifest.xml` / `Info.plist`.
|
||||
|
||||
1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps).
|
||||
2. Access type: **Full Dropbox** (not "App folder") — needed because
|
||||
browsing to and reusing a folder someone *else* created and shared
|
||||
requires seeing the whole account, the same reasoning as Google's full
|
||||
`drive` scope above.
|
||||
3. Under **OAuth 2** → **Redirect URIs**, add
|
||||
`mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already
|
||||
wired up in the Android manifest and iOS Info.plist; use a different one
|
||||
only if you also update those two files to match).
|
||||
4. Copy the app's **App key** from the Settings tab.
|
||||
5. Fill in `lib/services/cloud_oauth_config.dart`:
|
||||
- `dropboxAppKey` ← the App key.
|
||||
- `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`.
|
||||
6. While the app is in **Development** status, only your own Dropbox
|
||||
account can sign in; add teammates under the app's **Permissions** /
|
||||
member-access settings, or apply for **Production** status, once you're
|
||||
ready to share it with others.
|
||||
|
||||
### OneDrive (Azure Portal / Microsoft Graph)
|
||||
|
||||
Same PKCE approach as Dropbox, against the Microsoft identity platform and
|
||||
Microsoft Graph.
|
||||
|
||||
1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** →
|
||||
**App registrations** → **New registration**.
|
||||
2. Supported account types: **Personal Microsoft accounts only** (or "any
|
||||
organizational directory and personal Microsoft accounts" if you also
|
||||
want work/school accounts to be able to sign in — those may additionally
|
||||
need their tenant admin's consent for the scopes below).
|
||||
3. Under **Authentication** → **Add a platform** → **Mobile and desktop
|
||||
applications**, add the redirect URI `mofueltaxback-onedrive://auth`
|
||||
(this exact scheme is already wired up in the Android manifest and iOS
|
||||
Info.plist; use a different one only if you also update those two files
|
||||
to match).
|
||||
4. Under **API permissions**, add Microsoft Graph **delegated** permissions
|
||||
`Files.ReadWrite.All` and `offline_access` (the latter is required to get
|
||||
a refresh token back from the token endpoint).
|
||||
5. Copy the **Application (client) ID** from the Overview page.
|
||||
6. Fill in `lib/services/cloud_oauth_config.dart`:
|
||||
- `oneDriveClientId` ← the Application (client) ID.
|
||||
- `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`.
|
||||
|
||||
### WebDAV (self-hosted — no developer console needed)
|
||||
|
||||
This is the option for a personal cloud server you already run — Nextcloud,
|
||||
ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare
|
||||
Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere;
|
||||
tapping "Connect WebDAV" in Settings just opens a form asking for:
|
||||
|
||||
- **Server URL** — the full WebDAV endpoint, e.g.
|
||||
`https://cloud.example.com/remote.php/dav/files/yourusername/` for
|
||||
Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you.
|
||||
`https://` is assumed if you omit the scheme.
|
||||
- **Username** and **Password** — for servers that support app-specific
|
||||
passwords (Nextcloud: Settings → Security → "Create new app password"),
|
||||
use one of those instead of your real account password, so this app can
|
||||
be revoked independently later.
|
||||
|
||||
The app verifies the URL and credentials with a harmless `PROPFIND` request
|
||||
before treating you as signed in, so a typo or wrong password fails
|
||||
immediately with a clear error rather than surfacing later during sync.
|
||||
Nothing needs to be filled in `cloud_oauth_config.dart` or the
|
||||
Android/iOS manifest files for this provider.
|
||||
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
|
||||
subfolder of photos, both inside whatever directory Settings points at.
|
||||
|
||||
## Running it
|
||||
|
||||
|
|
@ -241,145 +51,43 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
|
|||
|
||||
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
|
||||
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
|
||||
to produce a working APK. None of the three providers' sign-in/sync paths
|
||||
can be exercised end-to-end until you've done that provider's manual OAuth
|
||||
setup above.
|
||||
to produce a working APK.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
|
||||
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
|
||||
set up for receipt capture.
|
||||
- **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the
|
||||
main Android manifest (needed for release builds; debug builds get
|
||||
`INTERNET` for free).
|
||||
- **OAuth redirects**: no manifest changes are needed for `google_sign_in`
|
||||
itself when not using `google-services.json` — see the Google setup
|
||||
section above instead. Dropbox and OneDrive's browser-based OAuth redirect
|
||||
is already wired up via a `flutter_web_auth_2` callback activity
|
||||
(Android) / extra `CFBundleURLTypes` entries (iOS) for the
|
||||
`mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you
|
||||
only need to register the matching redirect URI in each provider's own
|
||||
console (see setup above), not touch these files, unless you deliberately
|
||||
choose different scheme names.
|
||||
|
||||
## How sync works
|
||||
|
||||
Every local change (add/edit a vehicle, log a fuel entry) writes to the
|
||||
local SQLite database immediately, then triggers a best-effort background
|
||||
sync — also triggered whenever connectivity comes back or the app starts.
|
||||
Every row carries `updated_at` (for merge resolution), `deleted_at` (a
|
||||
soft-delete tombstone — see below), and a local-only `dirty` flag (pending
|
||||
push to Drive, never itself treated as meaningful sync data). Sync:
|
||||
|
||||
1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared cloud
|
||||
folder, then waits until no *other* lock file older than its own remains
|
||||
(polling every second, deleting any it finds older than the configured
|
||||
staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as
|
||||
orphaned/stale) — a ticket-based mutex using the cloud folder itself as
|
||||
the coordination point, so two devices never overwrite each other's
|
||||
edits to the shared database mid-write. This logic (`lock_coordinator.dart`)
|
||||
is identical regardless of which of the four providers is active.
|
||||
2. Checks the remote database file's change-detection tag (Drive's
|
||||
`md5Checksum`, Dropbox's `content_hash`, OneDrive's `cTag`, or a WebDAV
|
||||
resource's `ETag` — an opaque `versionTag` as far as the sync engine is
|
||||
concerned) against the last one seen (a metadata-only call, no content
|
||||
download) to decide if a pull is even needed. If it changed: downloads
|
||||
it to a temp file, `ATTACH`es it to
|
||||
the local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE
|
||||
local.<key> IS NULL OR remote.updated_at > local.updated_at` per table
|
||||
(`id` for both tables — vehicles' hidden generated id, not the
|
||||
user-editable VIN). Rows that statement doesn't match — including this
|
||||
device's own not-yet-pushed edits — are left untouched, so no separate
|
||||
"keep local" step is needed.
|
||||
3. Uploads any receipt photos still needing it (a local path but no cloud
|
||||
file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud
|
||||
file ID. The local copy is then deleted or kept depending on the
|
||||
Settings "keep photos on this phone" toggle. If any upload fails, the
|
||||
push step below is skipped entirely this cycle — a row is never pushed
|
||||
while it still holds a local-only, not-yet-uploaded path.
|
||||
4. Reads the local database file directly (sqflite's default journal mode
|
||||
isn't WAL, so the file is complete and consistent as soon as the last
|
||||
write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as
|
||||
cheap insurance) and uploads it as the new remote copy, then clears the
|
||||
`dirty` flag on every row now that local matches what's on the cloud.
|
||||
5. Releases the lock.
|
||||
|
||||
**Deletions propagate correctly**, unlike a naive "union records" merge:
|
||||
deleting sets `deleted_at` instead of removing the row, so a deletion is
|
||||
just another change with its own `updated_at`, and rides the same
|
||||
newest-wins rule as any edit — no separate deletion-handling logic needed.
|
||||
- **Storage**: no explicit storage permission is declared. The default save
|
||||
location is inside the app's own sandbox (no permission needed). If you
|
||||
point Settings at a location outside the sandbox, the OS-native folder
|
||||
picker (via `file_picker`) is what grants access — see the caveat below.
|
||||
|
||||
## Known caveats / things to revisit
|
||||
|
||||
- **Field-level conflicts aren't merged.** Two people editing the *same*
|
||||
record at the same time: whole record, newer `updated_at` wins — not a
|
||||
field-by-field merge.
|
||||
- **Lock acquisition has no hard timeout** beyond the configurable
|
||||
staleness reap (Settings → Advanced). Fine at the scale this is built for
|
||||
(a handful of people); could in theory spin under many simultaneous
|
||||
contenders.
|
||||
- **VIN uniqueness is checked, but not race-proof across devices.** Editing
|
||||
or adding a vehicle checks for an existing active vehicle with the same
|
||||
VIN before saving, but two offline devices could still each independently
|
||||
create (or rename into) the same VIN before either has seen the other's
|
||||
change — same category of accepted limitation as "field-level conflicts
|
||||
aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not
|
||||
`vin`, specifically so this doesn't corrupt the merge itself if it
|
||||
happens — you'd just end up with two vehicle rows sharing a VIN until
|
||||
someone notices and fixes it by hand.
|
||||
- **`google_sign_in` v7's Android path requires a *Web* OAuth client ID**
|
||||
(`serverClientId`), not just the Android client's SHA-1 registration —
|
||||
see the manual setup section. This is a quirk of the Credential
|
||||
Manager-based implementation and easy to miss if you're used to older
|
||||
`google_sign_in` versions.
|
||||
- **Dropbox and OneDrive's sign-in isn't restored across a cold app
|
||||
restart.** Their OAuth refresh tokens are kept in memory only for now
|
||||
(Google's own SDK handles its own persistent session separately, and
|
||||
WebDAV persists its credentials to OS-encrypted storage — see below — so
|
||||
this only affects these two hand-built OAuth providers) —
|
||||
`attemptSilentSignIn()` always returns false for them, so you'll need to
|
||||
reconnect once per app launch until refresh-token persistence is added.
|
||||
- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore
|
||||
on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the
|
||||
session after a cold restart — it re-verifies them with the same PROPFIND
|
||||
check `signInWithCredentials` uses rather than trusting the stored values
|
||||
blindly, since the server or password could have changed since. A wrong
|
||||
or revoked password just silently fails the restore (same as no
|
||||
connection ever having been made); there's no proactive UI nudge to
|
||||
reconnect beyond the sync error that shows up on the next sync attempt.
|
||||
- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks
|
||||
the access token's expiry before every call and refreshes proactively,
|
||||
but a token revoked or invalidated out-of-band (e.g. from that provider's
|
||||
own "manage app access" page) surfaces as a failed sync (visible in
|
||||
Settings as "Last sync failed") rather than prompting a fresh sign-in
|
||||
automatically.
|
||||
- **Dropbox has no distinct "Shared with me" tab** in the folder browser
|
||||
(the interface's `sharedWithMe` parameter is a no-op for
|
||||
`DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into
|
||||
the account's normal folder tree, so they already show up under "My
|
||||
Files" in the common case. Revisit if an unmounted/pending share needs to
|
||||
be browsable directly.
|
||||
- **WebDAV has no "Shared with me" concept at all** (it's not part of the
|
||||
base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false,
|
||||
same as Dropbox, and it's up to the server/user to point the app at
|
||||
whatever path a share is mounted under. WebDAV credentials are also sent
|
||||
as HTTP Basic Auth on every request, so an **HTTPS server URL is
|
||||
effectively required** — the app doesn't block a plain `http://` URL, but
|
||||
it would send the password in the clear.
|
||||
- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions
|
||||
11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP
|
||||
9+ (assuming AGP's built-in Kotlin support handles it), but that isn't
|
||||
actually wired up for library modules in this Flutter/AGP combination yet,
|
||||
so the plugin's own Kotlin sources never get compiled and the build fails
|
||||
with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is
|
||||
retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time
|
||||
you bump dependencies — check the package's CHANGELOG for when this is
|
||||
properly resolved upstream.
|
||||
- **iOS folder picking and app restarts**: `file_picker`'s directory picker
|
||||
on iOS uses `UIDocumentPickerViewController`, which hands back a
|
||||
security-scoped URL. This app does not currently persist a security-scoped
|
||||
bookmark for that URL, so if you pick a folder outside the app's own
|
||||
sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after
|
||||
an app restart is not guaranteed. Picking the default in-sandbox location,
|
||||
or a folder on Android, does not have this limitation. If cross-restart
|
||||
external storage on iOS matters for your use case, this needs a proper
|
||||
bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`).
|
||||
- **Receipt parsing is best-effort regex matching** on the OCR'd text
|
||||
(`lib/services/receipt_parser.dart`), tuned against common receipt phrasing
|
||||
("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may
|
||||
parse partially or not at all — the confirm screen always lets you fill in
|
||||
or correct whatever wasn't found.
|
||||
- No automated tests exercise the OCR, camera/gallery picker, or real Drive
|
||||
API calls (those need a real device/emulator and live credentials);
|
||||
`receipt_parser_test.dart`, `lock_coordinator_test.dart`,
|
||||
`db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter
|
||||
two using `sqflite_common_ffi` to run real SQL against temp SQLite files
|
||||
on the Dart VM) cover the pure logic pieces against fixed inputs —
|
||||
`receipt_parser_test.dart` in particular is transcribed from 13 real
|
||||
photographed receipts across different gas station chains.
|
||||
- No migration path exists from the earlier JSON-file storage format —
|
||||
not needed since no real data had accumulated under it yet, but flag it
|
||||
if that's no longer true for you.
|
||||
- No automated tests exercise the OCR or camera capture path itself (that
|
||||
requires a real device/emulator with a camera); `receipt_parser_test.dart`
|
||||
covers the parsing logic against fixed OCR text.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Release signing credentials — see key.properties (gitignored; the
|
||||
// keystore file itself lives outside the repo, at the path it points to).
|
||||
// Missing entirely just means a debug-signed release build, same as
|
||||
// before, so this doesn't break local dev if key.properties isn't set up.
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.ohbrer.show_me_the_fuel_refund"
|
||||
namespace = "com.courtneyarnold.fuel_tax_tracker"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
|
|
@ -28,7 +15,8 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.ohbrer.show_me_the_fuel_refund"
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.courtneyarnold.fuel_tax_tracker"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
|
@ -37,31 +25,11 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Real release signing once key.properties exists (see above);
|
||||
// falls back to the debug key otherwise, so `flutter run
|
||||
// --release` still works for local testing without it.
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
android/app/proguard-rules.pro
vendored
|
|
@ -1,35 +0,0 @@
|
|||
# google_mlkit_text_recognition's plugin code references all of ML Kit's
|
||||
# regional script recognizers (Chinese/Devanagari/Japanese/Korean)
|
||||
# generically, even though this app only depends on (and only ever uses)
|
||||
# the default Latin recognizer. R8 can't resolve the others since their
|
||||
# artifacts genuinely aren't on the classpath — safe to silence, they're
|
||||
# never called at runtime here.
|
||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions
|
||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder
|
||||
-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions
|
||||
|
||||
# The -dontwarn rules above only silence build-time warnings — they don't
|
||||
# stop R8 from stripping/renaming classes the Latin recognizer actually
|
||||
# does use at runtime via reflection. Without an explicit -keep, release
|
||||
# builds installed and launched fine but every OCR attempt threw
|
||||
# "Attempt to invoke virtual method 'java.lang.Class
|
||||
# java.lang.Object.getClass()' on a null object reference" from inside the
|
||||
# ML Kit plugin, silently swallowed by receipt_capture.dart's catch block
|
||||
# and surfaced to the user as "Couldn't automatically read this receipt."
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
-keep class com.google.android.gms.internal.mlkit_vision_text_common.** { *; }
|
||||
|
||||
# AndroidX WorkManager (pulled in transitively by one of the Google SDKs,
|
||||
# not used directly by this app) initializes its Room-backed WorkDatabase
|
||||
# reflectively at process startup via androidx.startup.InitializationProvider
|
||||
# — R8 was stripping/renaming those generated Room implementation classes,
|
||||
# crashing every release build before Flutter even started ("Failed to
|
||||
# create an instance of androidx.work.impl.WorkDatabase"). Keeping the
|
||||
# whole (small, self-contained) impl package is the standard fix.
|
||||
-keep class androidx.work.impl.** { *; }
|
||||
-keep class androidx.room.** { *; }
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
|
||||
android/app/src/debug/AndroidManifest.xml, but release builds need
|
||||
it declared here explicitly. -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<application
|
||||
android:label="Receipt Tracker"
|
||||
android:label="fuel_tax_tracker"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
@ -32,28 +27,6 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Dropbox & OneDrive OAuth: flutter_web_auth_2 captures the
|
||||
browser redirect via this activity + custom URL scheme(s).
|
||||
These scheme values must exactly match whatever is set as
|
||||
dropboxRedirectUri/oneDriveRedirectUri in
|
||||
lib/services/cloud_oauth_config.dart. -->
|
||||
<activity
|
||||
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="flutter_web_auth_2">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mofueltaxback-dropbox" />
|
||||
<data android:scheme="mofueltaxback-onedrive" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Real AdMob App ID (from your own AdMob console). The interstitial
|
||||
unit served under it (lib/services/ad_service.dart) is also a
|
||||
real one from the same account, not Google's test ID. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-9212406812117696~6413414381"/>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
@ -70,13 +43,5 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- Lets url_launcher open the Missouri DOR forms link on the
|
||||
Reports tab — without this, canLaunchUrl/launchUrl can't see
|
||||
any browser app on Android 11+ due to package visibility
|
||||
restrictions. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.ohbrer.show_me_the_fuel_refund
|
||||
package com.courtneyarnold.fuel_tax_tracker
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,9 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#101110</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#101110</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -5,10 +5,6 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#24603A</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#24603A</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -5,10 +5,6 @@
|
|||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
google.com, pub-9212406812117696, DIRECT, f08c47fec0942fa0
|
||||
|
Before Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 697 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -1,425 +0,0 @@
|
|||
# Application class diagram
|
||||
|
||||
Fuel Tax Tracker is a Flutter app. Screens read and mutate through `AppState` (a `ChangeNotifier` provided at the root). Persistence is `DatabaseService` (SQLite); cloud I/O goes through `CloudStorageProvider` so sync never depends on a specific backend.
|
||||
|
||||
Private Flutter `State` / painter classes are omitted. Functions that are not classes (`buildFuelReport`, `estimatedFuelRefund`, `acquireLock`) are noted where they sit in the design.
|
||||
|
||||
## Domain and application facade
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Vehicle {
|
||||
+String id
|
||||
+String vin
|
||||
+String? nickname
|
||||
+DateTime updatedAt
|
||||
+DateTime? deletedAt
|
||||
+String displayLabel
|
||||
+copyWith() Vehicle
|
||||
+toMap() Map
|
||||
+fromMap(map) Vehicle
|
||||
}
|
||||
|
||||
class FuelEntry {
|
||||
+String id
|
||||
+String vehicleId
|
||||
+DateTime date
|
||||
+double gallons
|
||||
+double pricePerGallon
|
||||
+double totalCost
|
||||
+String? receiptImagePath
|
||||
+String? receiptDriveFileId
|
||||
+DateTime updatedAt
|
||||
+DateTime? deletedAt
|
||||
+bool isReceiptUploadedToDrive
|
||||
+bool needsReceiptUpload
|
||||
+copyWith() FuelEntry
|
||||
+toMap() Map
|
||||
+fromMap(map) FuelEntry
|
||||
}
|
||||
|
||||
class DuplicateVinException {
|
||||
+String vin
|
||||
}
|
||||
|
||||
class AppState {
|
||||
+DatabaseService database
|
||||
+AdService adService
|
||||
+PurchaseService purchaseService
|
||||
+List~CloudStorageProvider~ availableProviders
|
||||
+CloudStorageProvider? activeProvider
|
||||
+CloudSyncService? cloudSync
|
||||
+List~Vehicle~ vehicles
|
||||
+List~FuelEntry~ fuelEntries
|
||||
+DateTime? adFreeUntil
|
||||
+bool adsCurrentlyDisabled
|
||||
+bool isCloudConnected
|
||||
+bool hasCloudBackupConfigured
|
||||
+init()
|
||||
+addVehicle()
|
||||
+updateVehicle()
|
||||
+deleteVehicle()
|
||||
+addFuelEntry() FuelEntry
|
||||
+updateFuelEntry()
|
||||
+deleteFuelEntry()
|
||||
+syncNow()
|
||||
+connectProvider()
|
||||
+disconnectCloud()
|
||||
+purgeAllData()
|
||||
}
|
||||
|
||||
class DatabaseService {
|
||||
+Directory rootDirectory
|
||||
+Database rawDb
|
||||
+init()
|
||||
+getVehicles() List~Vehicle~
|
||||
+getFuelEntries() List~FuelEntry~
|
||||
+getAdFreeUntil() DateTime?
|
||||
+setAdFreeUntil()
|
||||
+vinExists() bool
|
||||
+saveVehicle()
|
||||
+saveFuelEntry()
|
||||
+softDeleteVehicle()
|
||||
+softDeleteFuelEntry()
|
||||
+storeReceiptImage() String
|
||||
+purgeAllData()
|
||||
}
|
||||
|
||||
Vehicle "1" <-- "0..*" FuelEntry : vehicleId
|
||||
AppState "1" *-- "1" DatabaseService
|
||||
AppState "1" --> "*" Vehicle : caches
|
||||
AppState "1" --> "*" FuelEntry : caches
|
||||
AppState ..> DuplicateVinException : throws
|
||||
DatabaseService --> Vehicle
|
||||
DatabaseService --> FuelEntry
|
||||
AppState --|> ChangeNotifier
|
||||
```
|
||||
|
||||
`ad_free_entitlement` has **no** Dart model. `DatabaseService.getAdFreeUntil` / `setAdFreeUntil` read and write that singleton row; `AppState.adFreeUntil` is the in-memory cache.
|
||||
|
||||
## Cloud storage and sync
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class CloudProviderId {
|
||||
<<enumeration>>
|
||||
googleDrive
|
||||
dropbox
|
||||
oneDrive
|
||||
webdav
|
||||
}
|
||||
|
||||
class CloudStorageProvider {
|
||||
<<abstract>>
|
||||
+CloudProviderId id
|
||||
+String displayName
|
||||
+bool isSignedIn
|
||||
+String? accountLabel
|
||||
+attemptSilentSignIn() bool
|
||||
+signIn() String
|
||||
+signOut()
|
||||
+beginSession() CloudStorageSession
|
||||
}
|
||||
|
||||
class CloudStorageSession {
|
||||
<<abstract>>
|
||||
+bool supportsSharedWithMe
|
||||
+listFolders() List~CloudFolder~
|
||||
+findOrCreateFolder() String
|
||||
+moveFolder() String
|
||||
+findFile() CloudFileInfo?
|
||||
+downloadFileBytes() List~int~
|
||||
+uploadFile() CloudFileInfo
|
||||
+deleteFile()
|
||||
+createLockFile() String
|
||||
+listLockFiles() List~CloudLockFile~
|
||||
+close()
|
||||
}
|
||||
|
||||
class ManualCredentialCloudStorageProvider {
|
||||
<<abstract>>
|
||||
+signInWithCredentials() String
|
||||
}
|
||||
|
||||
class GoogleDriveProvider
|
||||
class DropboxProvider
|
||||
class OneDriveProvider
|
||||
class WebDavProvider
|
||||
|
||||
class GoogleDriveSession
|
||||
class DropboxSession
|
||||
class OneDriveSession
|
||||
class WebDavSession
|
||||
|
||||
class CloudFolder {
|
||||
+String id
|
||||
+String name
|
||||
}
|
||||
|
||||
class CloudFileInfo {
|
||||
+String id
|
||||
+String? versionTag
|
||||
}
|
||||
|
||||
class CloudLockFile {
|
||||
+String id
|
||||
+String username
|
||||
+DateTime createdAtUtc
|
||||
}
|
||||
|
||||
class CloudNotAuthorizedException {
|
||||
+String providerName
|
||||
}
|
||||
|
||||
class CloudSyncService {
|
||||
+CloudStorageProvider provider
|
||||
+DatabaseService databaseService
|
||||
+bool isConfigured
|
||||
+configure(appFolderId)
|
||||
+clearConfiguration()
|
||||
+selectAppFolder() String
|
||||
+syncNow() SyncResult
|
||||
}
|
||||
|
||||
class SyncResult {
|
||||
+bool ranSync
|
||||
+Object? error
|
||||
+bool adGateBlocked
|
||||
+skipped() SyncResult
|
||||
+success() SyncResult
|
||||
+failure(error) SyncResult
|
||||
+adGateBlocked() SyncResult
|
||||
}
|
||||
|
||||
CloudStorageProvider <|.. GoogleDriveProvider
|
||||
CloudStorageProvider <|.. DropboxProvider
|
||||
CloudStorageProvider <|.. OneDriveProvider
|
||||
CloudStorageProvider <|.. WebDavProvider
|
||||
ManualCredentialCloudStorageProvider <|.. WebDavProvider
|
||||
|
||||
CloudStorageSession <|.. GoogleDriveSession
|
||||
CloudStorageSession <|.. DropboxSession
|
||||
CloudStorageSession <|.. OneDriveSession
|
||||
CloudStorageSession <|.. WebDavSession
|
||||
|
||||
CloudStorageProvider --> CloudStorageSession : beginSession()
|
||||
CloudStorageProvider --> CloudProviderId
|
||||
CloudStorageSession --> CloudFolder
|
||||
CloudStorageSession --> CloudFileInfo
|
||||
CloudStorageSession --> CloudLockFile
|
||||
|
||||
CloudSyncService --> CloudStorageProvider
|
||||
CloudSyncService --> DatabaseService
|
||||
CloudSyncService --> SyncResult
|
||||
AppState o-- CloudStorageProvider : activeProvider
|
||||
AppState o-- CloudSyncService : cloudSync
|
||||
AppState "1" *-- "*" CloudStorageProvider : availableProviders
|
||||
```
|
||||
|
||||
Lock acquisition is a free function, `acquireLock` in `lock_coordinator.dart`, injected with create/list/delete callbacks so it can be unit-tested without a network. `CloudSyncService` is the only production caller.
|
||||
|
||||
OAuth helpers (`PkcePair`, `CloudOAuthConfig`) support Dropbox and OneDrive sign-in. Google uses its own SDK; WebDAV uses HTTP Basic Auth.
|
||||
|
||||
## Reports, OCR, ads, purchases
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class FuelReport {
|
||||
+DateTime startDate
|
||||
+DateTime endDate
|
||||
+List~VehicleReportRow~ rows
|
||||
+double totalGallons
|
||||
+double totalCost
|
||||
+int fillCount
|
||||
+List~ReportEntry~ allEntries
|
||||
+List~MonthlyTotal~ monthlyTotals
|
||||
}
|
||||
|
||||
class VehicleReportRow {
|
||||
+Vehicle vehicle
|
||||
+List~FuelEntry~ entries
|
||||
+double totalGallons
|
||||
+double totalCost
|
||||
+int entryCount
|
||||
}
|
||||
|
||||
class ReportEntry {
|
||||
+FuelEntry entry
|
||||
+Vehicle vehicle
|
||||
}
|
||||
|
||||
class MonthlyTotal {
|
||||
+DateTime month
|
||||
+int fillCount
|
||||
+double totalGallons
|
||||
+double totalCost
|
||||
+double avgPricePerGallon
|
||||
}
|
||||
|
||||
class ParsedReceipt {
|
||||
+double? gallons
|
||||
+double? pricePerGallon
|
||||
+double? totalCost
|
||||
+DateTime? date
|
||||
+String? state
|
||||
+String rawText
|
||||
}
|
||||
|
||||
class ReceiptParser {
|
||||
+parse(text) ParsedReceipt$
|
||||
}
|
||||
|
||||
class OcrService {
|
||||
+recognizeText(imageFile) String
|
||||
+dispose()
|
||||
}
|
||||
|
||||
class VinParser {
|
||||
+parse(text) String?$
|
||||
}
|
||||
|
||||
class AdService {
|
||||
+initialize()
|
||||
+showGateAd() AdGateResult
|
||||
}
|
||||
|
||||
class AdGateResult {
|
||||
<<enumeration>>
|
||||
alreadyOpen
|
||||
justShown
|
||||
blocked
|
||||
}
|
||||
|
||||
class PurchaseService {
|
||||
+String adFreeYearProductId$
|
||||
+String? priceLabel
|
||||
+initialize()
|
||||
+buyAdFreeYear()
|
||||
+dispose()
|
||||
}
|
||||
|
||||
class ReceiptImageLoadResult {
|
||||
+Map bytesByEntryId
|
||||
+List~FuelEntry~ failedEntries
|
||||
}
|
||||
|
||||
FuelReport "1" *-- "*" VehicleReportRow
|
||||
VehicleReportRow --> Vehicle
|
||||
VehicleReportRow --> FuelEntry
|
||||
FuelReport --> ReportEntry
|
||||
ReportEntry --> FuelEntry
|
||||
ReportEntry --> Vehicle
|
||||
FuelReport --> MonthlyTotal
|
||||
ReceiptParser --> ParsedReceipt
|
||||
OcrService ..> ReceiptParser : raw text in
|
||||
AdService --> AdGateResult
|
||||
AppState *-- AdService
|
||||
AppState *-- PurchaseService
|
||||
```
|
||||
|
||||
`buildFuelReport(...)` (in `fuel_report.dart`) constructs a `FuelReport` from the in-memory vehicle/entry lists. `buildFuelReportPdf(...)` renders it; `loadReceiptImageBytes(...)` resolves local or cloud receipt photos into a `ReceiptImageLoadResult`. `estimatedFuelRefund(gallons)` is a pure function (Missouri highway rate, $0.125/gal).
|
||||
|
||||
## UI structure
|
||||
|
||||
Widgets consume `AppState` via `Provider` / `context.watch`. They do not talk to SQLite or cloud APIs directly.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class FuelTaxTrackerApp {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class AppRoot {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class UserAgreementScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class MainShell {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class ReceiptsScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class HomeScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class ReportScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class SettingsScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class DataSettingsScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class UiSettingsScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class FaqScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class AddEditVehicleScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class VehicleDetailScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class ConfirmFuelEntryScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class EditFuelEntryScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class ReceiptDetailScreen {
|
||||
<<StatelessWidget>>
|
||||
}
|
||||
class ReceiptImageScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
class CloudFolderBrowserScreen {
|
||||
<<StatefulWidget>>
|
||||
}
|
||||
|
||||
FuelTaxTrackerApp --> AppRoot
|
||||
FuelTaxTrackerApp --> AppState : ChangeNotifierProvider
|
||||
AppRoot --> UserAgreementScreen : if not accepted
|
||||
AppRoot --> MainShell : after agreement
|
||||
MainShell --> ReceiptsScreen
|
||||
MainShell --> HomeScreen
|
||||
MainShell --> ReportScreen
|
||||
MainShell --> SettingsScreen
|
||||
SettingsScreen --> DataSettingsScreen
|
||||
SettingsScreen --> UiSettingsScreen
|
||||
SettingsScreen --> FaqScreen
|
||||
DataSettingsScreen --> CloudFolderBrowserScreen
|
||||
HomeScreen --> AddEditVehicleScreen
|
||||
HomeScreen --> VehicleDetailScreen
|
||||
HomeScreen --> FaqScreen
|
||||
VehicleDetailScreen --> ConfirmFuelEntryScreen
|
||||
VehicleDetailScreen --> EditFuelEntryScreen
|
||||
VehicleDetailScreen --> ReceiptImageScreen
|
||||
ReceiptsScreen --> ConfirmFuelEntryScreen
|
||||
ReceiptsScreen --> ReceiptDetailScreen
|
||||
ReceiptsScreen --> EditFuelEntryScreen
|
||||
ReceiptDetailScreen --> ReceiptImageScreen
|
||||
ReportScreen --> FuelReport : buildFuelReport()
|
||||
```
|
||||
|
||||
Shared widgets (not expanded above): `HeroBanner`, `ReceiptThumbnail`, `ReceiptCapture`, `OnboardingTourOverlay`, `BackupReminder`, `AdFreeUpsellDialog`, `ImageSourceSheet`. Theme tokens live on `AppTheme`.
|
||||
|
||||
## How the layers connect
|
||||
|
||||
```
|
||||
UI screens/widgets
|
||||
│ Provider
|
||||
▼
|
||||
AppState ──────────► PurchaseService
|
||||
│ AdService
|
||||
├── DatabaseService ── SQLite (vehicles, fuel_entries, ad_free_entitlement)
|
||||
│ └── local receipts/ folder
|
||||
└── CloudSyncService ── CloudStorageProvider
|
||||
├── GoogleDriveProvider
|
||||
├── DropboxProvider
|
||||
├── OneDriveProvider
|
||||
└── WebDavProvider
|
||||
```
|
||||
73
docs/erd.md
|
|
@ -1,73 +0,0 @@
|
|||
# SQLite entity-relationship diagram
|
||||
|
||||
Local database: `ApplicationDocumentsDirectory/FuelTaxTracker/fuel_tax_tracker.db`
|
||||
Schema version: **5** (defined in `lib/services/database_service.dart` / `lib/services/db_schema.dart`)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
VEHICLES ||--o{ FUEL_ENTRIES : "owns (vehicle_id)"
|
||||
|
||||
VEHICLES {
|
||||
TEXT id PK "UUID, hidden, immutable"
|
||||
TEXT vin "required; unique among active rows, app-enforced"
|
||||
TEXT nickname "nullable display label"
|
||||
INTEGER updated_at "UTC millis; newest-wins merge key"
|
||||
INTEGER deleted_at "nullable soft-delete tombstone"
|
||||
INTEGER dirty "local-only; 1 = not yet pushed"
|
||||
}
|
||||
|
||||
FUEL_ENTRIES {
|
||||
TEXT id PK "UUID"
|
||||
TEXT vehicle_id FK "references vehicles.id — no SQLite FK"
|
||||
INTEGER date "purchase datetime, local millis"
|
||||
REAL gallons
|
||||
REAL price_per_gallon
|
||||
REAL total_cost
|
||||
TEXT receipt_image_path "nullable on-device path"
|
||||
TEXT receipt_drive_file_id "nullable cloud file id"
|
||||
INTEGER updated_at "UTC millis; newest-wins merge key"
|
||||
INTEGER deleted_at "nullable soft-delete tombstone"
|
||||
INTEGER dirty "local-only; 1 = not yet pushed"
|
||||
}
|
||||
|
||||
AD_FREE_ENTITLEMENT {
|
||||
INTEGER id PK "always 1 (CHECK id = 1)"
|
||||
INTEGER ad_free_until "nullable UTC millis"
|
||||
INTEGER updated_at "UTC millis; newest-wins merge key"
|
||||
}
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
| From | To | Cardinality | Enforced by |
|
||||
| --- | --- | --- | --- |
|
||||
| `vehicles` | `fuel_entries` | 1 : 0..n | Application (`FuelEntry.vehicleId` → `Vehicle.id`). There is **no** `FOREIGN KEY` clause. |
|
||||
| `ad_free_entitlement` | — | singleton | `PRIMARY KEY CHECK (id = 1)` |
|
||||
|
||||
VIN is **not** the foreign key. Fuel entries reference the hidden vehicle `id` so the user can edit a VIN without breaking receipts or sync history.
|
||||
|
||||
## Indexes and constraints
|
||||
|
||||
- `vehicles.id` — `PRIMARY KEY`
|
||||
- `fuel_entries.id` — `PRIMARY KEY`
|
||||
- `idx_fuel_entries_vehicle_id` on `fuel_entries(vehicle_id)`
|
||||
- `ad_free_entitlement.id` — `PRIMARY KEY CHECK (id = 1)`
|
||||
- VIN uniqueness among **active** (`deleted_at IS NULL`) vehicles is enforced in `DatabaseService.vinExists` / `AppState.addVehicle` / `AppState.updateVehicle`, not by a unique index. Two offline devices can independently add the same VIN; that rare conflict is accepted rather than merged field-by-field.
|
||||
|
||||
## Sync and lifecycle columns
|
||||
|
||||
Every synced table carries `updated_at`. Cloud merge is last-write-wins on that timestamp (`INSERT OR REPLACE` of a remote row that is new or newer). See `mergeVehiclesSql`, `mergeFuelEntriesSql`, and `mergeAdFreeEntitlementSql` in `db_schema.dart`.
|
||||
|
||||
| Column | Scope | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `updated_at` | all three tables | Newest value wins when merging a remote copy. |
|
||||
| `deleted_at` | `vehicles`, `fuel_entries` | Soft-delete tombstone. Rows are never hard-deleted, so a deletion can propagate to other devices instead of being resurrected by a stale remote copy. |
|
||||
| `dirty` | `vehicles`, `fuel_entries` only | Local bookkeeping: `1` = not yet pushed. Cleared to `0` on merge-in. **Not** mapped onto the Dart models. |
|
||||
| `receipt_image_path` | `fuel_entries` | Device-local filesystem path. Forced to `NULL` when merging a remote row — a path from another phone is never valid here. |
|
||||
| `receipt_drive_file_id` | `fuel_entries` | Opaque id of the uploaded receipt on the active cloud provider. Presence means “already uploaded”; a pending upload is `path IS NOT NULL AND drive_file_id IS NULL`. |
|
||||
|
||||
`ad_free_entitlement` has no `dirty` or `deleted_at`. It is a single row that exists so a consumable Play Store purchase (which cannot be restored after consume) can survive reinstall via the same cloud merge as vehicles and fuel entries.
|
||||
|
||||
## What is *not* in SQLite
|
||||
|
||||
Preferences live in `SharedPreferences`, not this database: theme, onboarding/agreement flags, cloud provider + folder ids, keep-photos-locally, stale-lock timeout, estimated-refund toggle.
|
||||
|
|
@ -441,7 +441,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -498,7 +498,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
|
@ -1 +1,122 @@
|
|||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 725 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "background.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "darkbackground.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 69 B |
|
|
@ -1,56 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "LaunchImageDark@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
|
@ -16,19 +16,13 @@
|
|||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
|
|
@ -38,7 +32,6 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="1024" height="1024"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@
|
|||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Receipt Tracker</string>
|
||||
<string>Fuel Tax Tracker</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
@ -70,49 +68,5 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Google Sign-In: required for the OAuth redirect to route back into
|
||||
the app, regardless of whether the client ID itself is set here or
|
||||
passed in Dart (see lib/services/drive_oauth_config.dart). Replace
|
||||
with your iOS OAuth client's REVERSED_CLIENT_ID from Google Cloud
|
||||
Console (reverse of the client ID, e.g.
|
||||
com.googleusercontent.apps.XXXXXXXXXXXX-yyyyyyyyyyyyyyyyyyyyyyyyyyyy). -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Dropbox & OneDrive OAuth redirects (flutter_web_auth_2). These
|
||||
scheme values must exactly match dropboxRedirectUri /
|
||||
oneDriveRedirectUri in lib/services/cloud_oauth_config.dart. -->
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-dropbox</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>mofueltaxback-onedrive</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<!-- Google's official *test* AdMob App ID (safe to publish/commit —
|
||||
see https://developers.google.com/admob/flutter/test-ads). Swap
|
||||
for your own real AdMob App ID before shipping — see
|
||||
lib/services/ad_service.dart for the matching ad unit ID. -->
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-3940256099942544~1458002511</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A stable handle on the app's navigator, for the few places that need to
|
||||
/// put something on screen from outside a widget's own build method:
|
||||
///
|
||||
/// - [AppState.onAdWatched]'s "remove ads for a year" upsell, which fires
|
||||
/// from deep inside a background sync rather than from a widget.
|
||||
/// - `showAdPlaceholder`, called by [AdService], which owns no
|
||||
/// [BuildContext] of its own (real ads are native overlays that never
|
||||
/// needed one).
|
||||
///
|
||||
/// Lives here rather than in `main.dart` so services and widgets can reach
|
||||
/// it without importing the app's entry point.
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'app_navigator.dart';
|
||||
import 'screens/main_shell.dart';
|
||||
import 'screens/user_agreement_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'services/app_state.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'widgets/ad_free_upsell_dialog.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const FuelTaxTrackerApp());
|
||||
|
|
@ -19,21 +15,15 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState()..init(),
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, _) {
|
||||
appState.onAdWatched ??= () {
|
||||
final dialogContext = navigatorKey.currentContext;
|
||||
if (dialogContext != null) showAdFreeUpsellDialog(dialogContext);
|
||||
};
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
title: 'Receipt Tracker',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: appState.themeMode,
|
||||
child: MaterialApp(
|
||||
title: 'Fuel Tax Tracker',
|
||||
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
|
||||
darkTheme: ThemeData(
|
||||
colorSchemeSeed: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const AppRoot(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -51,36 +41,7 @@ class AppRoot extends StatelessWidget {
|
|||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (appState.initError != null) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
size: 48, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not start the app:\n${appState.initError}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => appState.init(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!appState.hasAcceptedUserAgreement) {
|
||||
return const UserAgreementScreen();
|
||||
}
|
||||
return const MainShell();
|
||||
return const HomeScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
class FuelEntry {
|
||||
final String id;
|
||||
|
||||
/// References [Vehicle.id] (the hidden, immutable identifier) — not the
|
||||
/// VIN, which is user-editable and so unsuitable as a foreign key.
|
||||
final String vehicleId;
|
||||
|
||||
final DateTime date;
|
||||
final double gallons;
|
||||
final double pricePerGallon;
|
||||
final double totalCost;
|
||||
final String? receiptImagePath;
|
||||
final String? receiptDriveFileId;
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Soft-delete tombstone: null means active. See [Vehicle.deletedAt] for
|
||||
/// why this is a flag rather than an actual row deletion.
|
||||
final DateTime? deletedAt;
|
||||
|
||||
FuelEntry({
|
||||
required this.id,
|
||||
|
|
@ -24,75 +14,26 @@ class FuelEntry {
|
|||
required this.gallons,
|
||||
required this.pricePerGallon,
|
||||
required this.totalCost,
|
||||
required this.updatedAt,
|
||||
this.receiptImagePath,
|
||||
this.receiptDriveFileId,
|
||||
this.deletedAt,
|
||||
});
|
||||
|
||||
/// True once the receipt photo has been uploaded to Drive, regardless of
|
||||
/// whether a local copy is also being kept (see the "keep photos on this
|
||||
/// phone" setting). Viewing it locally is preferred when a local copy
|
||||
/// exists; otherwise it requires downloading from Drive on demand.
|
||||
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
|
||||
|
||||
/// True while a receipt photo still needs to be uploaded to Drive: a
|
||||
/// local file exists but hasn't made it there yet. Once
|
||||
/// [receiptDriveFileId] is set this is false — even if a local copy is
|
||||
/// also being kept — since that field alone is what marks "no longer
|
||||
/// needs uploading", independent of local retention. A row in this state
|
||||
/// is never pushed to Drive as data (a local file path is meaningless on
|
||||
/// another device) — sync uploads the image first, which sets
|
||||
/// [receiptDriveFileId] and clears this.
|
||||
bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null;
|
||||
|
||||
FuelEntry copyWith({
|
||||
String? receiptImagePath,
|
||||
String? receiptDriveFileId,
|
||||
DateTime? updatedAt,
|
||||
DateTime? deletedAt,
|
||||
bool clearReceiptImagePath = false,
|
||||
}) {
|
||||
return FuelEntry(
|
||||
id: id,
|
||||
vehicleId: vehicleId,
|
||||
date: date,
|
||||
gallons: gallons,
|
||||
pricePerGallon: pricePerGallon,
|
||||
totalCost: totalCost,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
receiptImagePath:
|
||||
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
|
||||
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() => {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'vehicle_id': vehicleId,
|
||||
'date': date.millisecondsSinceEpoch,
|
||||
'vehicleId': vehicleId,
|
||||
'date': date.toIso8601String(),
|
||||
'gallons': gallons,
|
||||
'price_per_gallon': pricePerGallon,
|
||||
'total_cost': totalCost,
|
||||
'receipt_image_path': receiptImagePath,
|
||||
'receipt_drive_file_id': receiptDriveFileId,
|
||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
||||
'pricePerGallon': pricePerGallon,
|
||||
'totalCost': totalCost,
|
||||
'receiptImagePath': receiptImagePath,
|
||||
};
|
||||
|
||||
factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
|
||||
id: map['id'] as String,
|
||||
vehicleId: map['vehicle_id'] as String,
|
||||
date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
|
||||
gallons: (map['gallons'] as num).toDouble(),
|
||||
pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
|
||||
totalCost: (map['total_cost'] as num).toDouble(),
|
||||
receiptImagePath: map['receipt_image_path'] as String?,
|
||||
receiptDriveFileId: map['receipt_drive_file_id'] as String?,
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
||||
deletedAt: map['deleted_at'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
||||
: null,
|
||||
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
|
||||
id: json['id'] as String,
|
||||
vehicleId: json['vehicleId'] as String,
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
gallons: (json['gallons'] as num).toDouble(),
|
||||
pricePerGallon: (json['pricePerGallon'] as num).toDouble(),
|
||||
totalCost: (json['totalCost'] as num).toDouble(),
|
||||
receiptImagePath: json['receiptImagePath'] as String?,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,48 @@
|
|||
class Vehicle {
|
||||
/// Hidden, immutable, generated primary key — never shown in the UI and
|
||||
/// never editable. This is what fuel entries actually reference and what
|
||||
/// sync merges on, so that [vin] itself is free to be edited without
|
||||
/// breaking those references or losing sync history.
|
||||
final String id;
|
||||
|
||||
/// The vehicle's identification number. Required and must be unique
|
||||
/// among active (non-deleted) vehicles, but — unlike [id] — the user can
|
||||
/// edit it later (e.g. to fix a typo from a misread VIN scan).
|
||||
final String vin;
|
||||
|
||||
/// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional —
|
||||
/// when absent, screens fall back to other identifying info instead.
|
||||
final String? nickname;
|
||||
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Soft-delete tombstone: null means active. Deleting sets this instead
|
||||
/// of removing the row, so the deletion itself can be merged/synced like
|
||||
/// any other change (newest `updatedAt` wins) instead of silently
|
||||
/// disappearing and later being resurrected by a device that hasn't seen
|
||||
/// the deletion yet.
|
||||
final DateTime? deletedAt;
|
||||
final String make;
|
||||
final String model;
|
||||
final String color;
|
||||
final String licensePlate;
|
||||
|
||||
Vehicle({
|
||||
required this.id,
|
||||
required this.vin,
|
||||
required this.updatedAt,
|
||||
this.nickname,
|
||||
this.deletedAt,
|
||||
required this.make,
|
||||
required this.model,
|
||||
required this.color,
|
||||
required this.licensePlate,
|
||||
});
|
||||
|
||||
/// What screens should show as the vehicle's primary label: the nickname
|
||||
/// if one was given, otherwise the VIN itself.
|
||||
String get displayLabel {
|
||||
final trimmedNickname = nickname?.trim();
|
||||
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
|
||||
return trimmedNickname;
|
||||
}
|
||||
return vin;
|
||||
}
|
||||
String get displayName => '$color $make $model ($licensePlate)';
|
||||
|
||||
/// Note: [id] is intentionally not overridable here — it's the hidden
|
||||
/// identifier, not an editable field.
|
||||
Vehicle copyWith({
|
||||
String? vin,
|
||||
String? nickname,
|
||||
bool clearNickname = false,
|
||||
DateTime? updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String? make,
|
||||
String? model,
|
||||
String? color,
|
||||
String? licensePlate,
|
||||
}) {
|
||||
return Vehicle(
|
||||
id: id,
|
||||
vin: vin ?? this.vin,
|
||||
nickname: clearNickname ? null : (nickname ?? this.nickname),
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
make: make ?? this.make,
|
||||
model: model ?? this.model,
|
||||
color: color ?? this.color,
|
||||
licensePlate: licensePlate ?? this.licensePlate,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() => {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'vin': vin,
|
||||
'nickname': nickname,
|
||||
'updated_at': updatedAt.millisecondsSinceEpoch,
|
||||
'deleted_at': deletedAt?.millisecondsSinceEpoch,
|
||||
'make': make,
|
||||
'model': model,
|
||||
'color': color,
|
||||
'licensePlate': licensePlate,
|
||||
};
|
||||
|
||||
factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
|
||||
id: map['id'] as String,
|
||||
vin: map['vin'] as String,
|
||||
nickname: map['nickname'] as String?,
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
|
||||
deletedAt: map['deleted_at'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
|
||||
: null,
|
||||
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle(
|
||||
id: json['id'] as String,
|
||||
make: json['make'] as String,
|
||||
model: json['model'] as String,
|
||||
color: json['color'] as String,
|
||||
licensePlate: json['licensePlate'] as String,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,15 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/ocr_service.dart';
|
||||
import '../services/vin_parser.dart';
|
||||
import '../widgets/image_source_sheet.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
|
||||
/// omit it to create a new one. [promptToCreateForReceipt] is set when this
|
||||
/// screen was reached because the user tried to log a receipt with no
|
||||
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
|
||||
/// so that's clear, rather than silently landing on the add-vehicle form.
|
||||
/// omit it to create a new one.
|
||||
class AddEditVehicleScreen extends StatefulWidget {
|
||||
final Vehicle? vehicle;
|
||||
final bool promptToCreateForReceipt;
|
||||
|
||||
const AddEditVehicleScreen({
|
||||
super.key,
|
||||
this.vehicle,
|
||||
this.promptToCreateForReceipt = false,
|
||||
});
|
||||
const AddEditVehicleScreen({super.key, this.vehicle});
|
||||
|
||||
@override
|
||||
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
|
||||
|
|
@ -32,249 +17,52 @@ class AddEditVehicleScreen extends StatefulWidget {
|
|||
|
||||
class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nicknameController;
|
||||
late final TextEditingController _vinController;
|
||||
|
||||
bool _saving = false;
|
||||
bool _scanningVin = false;
|
||||
bool _checkingDuplicateVin = false;
|
||||
String? _error;
|
||||
late final TextEditingController _makeController;
|
||||
late final TextEditingController _modelController;
|
||||
late final TextEditingController _colorController;
|
||||
late final TextEditingController _plateController;
|
||||
|
||||
bool get _isEditing => widget.vehicle != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
|
||||
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
|
||||
|
||||
if (widget.promptToCreateForReceipt) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: const Text('Create a vehicle to attach receipt'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
_makeController = TextEditingController(text: widget.vehicle?.make ?? '');
|
||||
_modelController = TextEditingController(text: widget.vehicle?.model ?? '');
|
||||
_colorController = TextEditingController(text: widget.vehicle?.color ?? '');
|
||||
_plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nicknameController.dispose();
|
||||
_vinController.dispose();
|
||||
_makeController.dispose();
|
||||
_modelController.dispose();
|
||||
_colorController.dispose();
|
||||
_plateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showVinLocationHelp() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Where to find the VIN'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"It's usually on a sticker in the driver's side door jamb, or on a "
|
||||
'small plate at the base of the windshield.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset('assets/vin_help/door_jamb.jpg'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset('assets/vin_help/windshield.jpg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scanVin() async {
|
||||
final source = await chooseImageSource(
|
||||
context,
|
||||
heading: 'VIN From',
|
||||
onInfoTap: _showVinLocationHelp,
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? photo;
|
||||
try {
|
||||
photo = await picker.pickImage(source: source, imageQuality: 85);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open $sourceLabel: $e')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (photo == null) return;
|
||||
|
||||
setState(() => _scanningVin = true);
|
||||
|
||||
final ocrService = OcrService();
|
||||
String recognizedText = '';
|
||||
try {
|
||||
recognizedText = await ocrService.recognizeText(File(photo.path));
|
||||
} catch (_) {
|
||||
recognizedText = '';
|
||||
} finally {
|
||||
ocrService.dispose();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final vin = VinParser.parse(recognizedText);
|
||||
setState(() => _scanningVin = false);
|
||||
|
||||
if (vin != null) {
|
||||
_vinController.text = vin;
|
||||
await _checkForExistingVin();
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text("Couldn't read a VIN from that photo. Please enter it manually."),
|
||||
duration: const Duration(seconds: 8),
|
||||
action: recognizedText.trim().isEmpty
|
||||
? null
|
||||
// Lets you see exactly what the on-device OCR read, rather
|
||||
// than only knowing "no VIN was found in it" — the
|
||||
// difference between "OCR misread a character" and "OCR
|
||||
// read it fine but the VIN pattern itself needs a fix"
|
||||
// isn't visible any other way.
|
||||
: SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showRecognizedText(String text) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Recognized Text'),
|
||||
content: SingleChildScrollView(child: SelectableText(text)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Field-level counterpart to [_checkForExistingVin]'s dialog: catches
|
||||
/// "Required" as before, plus flags the field itself as invalid when the
|
||||
/// VIN belongs to another active vehicle — excluding this vehicle's own
|
||||
/// id while editing, same as [AppState.updateVehicle]'s check, so editing
|
||||
/// a vehicle without touching its VIN doesn't flag itself.
|
||||
String? _validateVin(String? value) {
|
||||
final vin = value?.trim() ?? '';
|
||||
if (vin.isEmpty) return 'Required';
|
||||
|
||||
final existing = context.read<AppState>().vehicleByVin(vin);
|
||||
if (existing != null && existing.id != widget.vehicle?.id) {
|
||||
return 'A vehicle with VIN "$vin" already exists.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Only relevant when adding a new vehicle — editing one already starts
|
||||
/// with its own VIN pre-filled, which would trivially "match itself".
|
||||
/// Guarded against overlapping calls so rapid edits (or the scan-fill
|
||||
/// path firing right after `onChanged`) can't stack multiple dialogs.
|
||||
Future<void> _checkForExistingVin() async {
|
||||
if (_isEditing || _checkingDuplicateVin) return;
|
||||
|
||||
final vin = _vinController.text.trim();
|
||||
if (vin.isEmpty) return;
|
||||
final existing = context.read<AppState>().vehicleByVin(vin);
|
||||
if (existing == null) return;
|
||||
|
||||
_checkingDuplicateVin = true;
|
||||
final openExisting = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Vehicle already exists'),
|
||||
content: Text('Vehicle with the VIN $vin already exists, would you like to open that vehicle?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
|
||||
],
|
||||
),
|
||||
);
|
||||
_checkingDuplicateVin = false;
|
||||
if (!mounted) return;
|
||||
|
||||
if (openExisting != true) {
|
||||
// Declining to open the existing vehicle leaves them here with a
|
||||
// still-duplicate VIN — surface that on the field itself rather than
|
||||
// only catching it later at Save.
|
||||
_formKey.currentState?.validate();
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context)
|
||||
..pop()
|
||||
..push(MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: existing.id)));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final vin = _vinController.text.trim();
|
||||
final nickname = _nicknameController.text.trim();
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (_isEditing) {
|
||||
await appState.updateVehicle(widget.vehicle!.copyWith(
|
||||
vin: vin,
|
||||
nickname: nickname.isEmpty ? null : nickname,
|
||||
clearNickname: nickname.isEmpty,
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
));
|
||||
} else {
|
||||
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
|
||||
await appState.addVehicle(
|
||||
make: _makeController.text.trim(),
|
||||
model: _modelController.text.trim(),
|
||||
color: _colorController.text.trim(),
|
||||
licensePlate: _plateController.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Reached from "log a receipt with no vehicle yet" — once the
|
||||
// vehicle exists, continue straight into the receipt capture flow
|
||||
// the user was actually trying to do, rather than just landing
|
||||
// back on an (again) empty Receipts tab. The caller does the
|
||||
// continuing; this just hands back which vehicle to do it for.
|
||||
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
|
||||
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
|
||||
}
|
||||
} on DuplicateVinException catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
|
|
@ -284,7 +72,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
title: const Text('Delete vehicle?'),
|
||||
content: Text(
|
||||
'This will also delete all fuel entries and receipt photos logged for '
|
||||
'${widget.vehicle!.displayLabel}. This cannot be undone.',
|
||||
'${widget.vehicle!.displayName}. This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
|
||||
|
|
@ -326,49 +114,36 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nicknameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nickname (optional)',
|
||||
hintText: 'e.g. Mom\'s Car, Red Ford F-150',
|
||||
),
|
||||
controller: _makeController,
|
||||
decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _vinController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'VIN *',
|
||||
hintText: 'Vehicle Identification Number',
|
||||
helperText: 'Required — tap the camera to scan it from a photo',
|
||||
suffixIcon: _scanningVin
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
controller: _modelController,
|
||||
decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
tooltip: 'Scan VIN from a photo',
|
||||
onPressed: _scanVin,
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
validator: _validateVin,
|
||||
onChanged: _isEditing ? null : (_) => _checkForExistingVin(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _colorController,
|
||||
decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _plateController,
|
||||
decoration: const InputDecoration(labelText: 'License Plate'),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
onPressed: _save,
|
||||
child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
|
||||
enum _BrowseRoot { myFiles, sharedWithMe }
|
||||
|
||||
/// Lets the user navigate whichever cloud storage provider is connected —
|
||||
/// their own files, and (if the provider supports it) files others have
|
||||
/// shared with them — and pick a parent location. Confirming looks for (or
|
||||
/// creates) the `Show Me The Fuel Refund` folder under that location, so
|
||||
/// two people pointing at the same shared parent converge on the same app
|
||||
/// folder, regardless of which provider each of them is using. Picking a
|
||||
/// folder that's already named that directly (e.g. a shared folder set up
|
||||
/// by someone else) uses it as-is instead of nesting another one inside —
|
||||
/// see `CloudSyncService.selectAppFolder`.
|
||||
class CloudFolderBrowserScreen extends StatefulWidget {
|
||||
const CloudFolderBrowserScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
|
||||
}
|
||||
|
||||
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
|
||||
late final CloudStorageSession _session;
|
||||
|
||||
_BrowseRoot _root = _BrowseRoot.myFiles;
|
||||
final List<CloudFolder> _pathStack = [];
|
||||
|
||||
List<CloudFolder>? _folders;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _confirming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_session = context.read<AppState>().activeProvider!.beginSession();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_session.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me';
|
||||
|
||||
/// The folder ID that "Use This Folder" would act on, or null if the
|
||||
/// current view is a virtual listing (top-level "Shared with me") rather
|
||||
/// than an actual folder.
|
||||
String? get _currentFolderId {
|
||||
if (_pathStack.isNotEmpty) return _pathStack.last.id;
|
||||
if (_root == _BrowseRoot.myFiles) return 'root';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The currently browsed-into folder's own name, so [_useThisFolder] can
|
||||
/// tell whether the user picked a folder already named
|
||||
/// `Show Me The Fuel Refund` — null at a virtual root ("My Files"),
|
||||
/// which is never itself named that.
|
||||
String? get _currentFolderName => _pathStack.isNotEmpty ? _pathStack.last.name : null;
|
||||
|
||||
String get _breadcrumbPath =>
|
||||
([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / ');
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
List<CloudFolder> folders;
|
||||
if (_pathStack.isNotEmpty) {
|
||||
folders = await _session.listFolders(parentId: _pathStack.last.id);
|
||||
} else if (_root == _BrowseRoot.myFiles) {
|
||||
folders = await _session.listFolders(parentId: 'root');
|
||||
} else {
|
||||
folders = await _session.listFolders(sharedWithMe: true);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_folders = folders;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Could not load folders: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _switchRoot(_BrowseRoot root) {
|
||||
if (root == _root) return;
|
||||
setState(() {
|
||||
_root = root;
|
||||
_pathStack.clear();
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
void _openFolder(CloudFolder folder) {
|
||||
setState(() => _pathStack.add(folder));
|
||||
_load();
|
||||
}
|
||||
|
||||
void _goToBreadcrumb(int index) {
|
||||
// index == -1 means the root label itself.
|
||||
setState(() {
|
||||
if (index < 0) {
|
||||
_pathStack.clear();
|
||||
} else {
|
||||
_pathStack.removeRange(index + 1, _pathStack.length);
|
||||
}
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _useThisFolder() async {
|
||||
final parentId = _currentFolderId;
|
||||
if (parentId == null) return;
|
||||
|
||||
setState(() => _confirming = true);
|
||||
try {
|
||||
await context.read<AppState>().chooseCloudFolder(
|
||||
parentId: parentId,
|
||||
breadcrumbPath: _breadcrumbPath,
|
||||
currentFolderName: _currentFolderName,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not use this folder: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _confirming = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final providerName = context.read<AppState>().activeProvider!.displayName;
|
||||
final canUseCurrentFolder = _currentFolderId != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Choose $providerName Folder')),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_session.supportsSharedWithMe)
|
||||
SegmentedButton<_BrowseRoot>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')),
|
||||
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
|
||||
],
|
||||
selected: {_root},
|
||||
onSelectionChanged: (selection) => _switchRoot(selection.first),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _goToBreadcrumb(-1),
|
||||
child: Text(_rootLabel),
|
||||
),
|
||||
for (var i = 0; i < _pathStack.length; i++) ...[
|
||||
const Icon(Icons.chevron_right, size: 18),
|
||||
TextButton(
|
||||
onPressed: () => _goToBreadcrumb(i),
|
||||
child: Text(_pathStack[i].name),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder,
|
||||
icon: _confirming
|
||||
? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.check),
|
||||
label: Text('Use "$_breadcrumbPath"'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)));
|
||||
}
|
||||
final folders = _folders ?? [];
|
||||
if (folders.isEmpty) {
|
||||
return const Center(child: Text('No folders here.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: folders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final folder = folders[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(folder.name),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openFolder(folder),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -7,7 +6,6 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/receipt_parser.dart';
|
||||
import '../widgets/backup_reminder.dart';
|
||||
import 'receipt_image_screen.dart';
|
||||
|
||||
/// Shown right after a receipt photo is captured and OCR'd. Pre-fills
|
||||
|
|
@ -35,16 +33,12 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
late final TextEditingController _gallonsController;
|
||||
late final TextEditingController _priceController;
|
||||
late final TextEditingController _totalController;
|
||||
late DateTime _date;
|
||||
DateTime _date = DateTime.now();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Prefer the date/time printed on the receipt; fall back to now, same
|
||||
// as before, when it couldn't be parsed — the date picker below is
|
||||
// always available either way for manual entry/correction.
|
||||
_date = widget.parsed.date ?? DateTime.now();
|
||||
_gallonsController = TextEditingController(
|
||||
text: widget.parsed.gallons?.toStringAsFixed(3) ?? '',
|
||||
);
|
||||
|
|
@ -54,8 +48,6 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
_totalController = TextEditingController(
|
||||
text: widget.parsed.totalCost?.toStringAsFixed(2) ?? '',
|
||||
);
|
||||
// So an ad is ready by the time _save() finishes without delaying it.
|
||||
context.read<AppState>().preloadFuelSaveAd();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -77,20 +69,11 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
// initialDate must fall within [firstDate, lastDate] or the picker
|
||||
// throws — widen the bounds to cover _date in case a misread date from
|
||||
// the receipt landed outside the normal 5-years-back-to-today window.
|
||||
final today = DateTime.now();
|
||||
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
|
||||
? _date
|
||||
: today.subtract(const Duration(days: 365 * 5));
|
||||
final lastDate = _date.isAfter(today) ? _date : today;
|
||||
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365 * 5)),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (pickedDate == null || !mounted) return;
|
||||
|
||||
|
|
@ -116,8 +99,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final appState = context.read<AppState>();
|
||||
await appState.addFuelEntry(
|
||||
await context.read<AppState>().addFuelEntry(
|
||||
vehicleId: widget.vehicleId,
|
||||
date: _date,
|
||||
gallons: double.parse(_gallonsController.text),
|
||||
|
|
@ -125,31 +107,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
totalCost: double.parse(_totalController.text),
|
||||
receiptImage: widget.imageFile,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
// The entry is already committed by this point, so the ad isn't
|
||||
// gating the save — it just fills the moment between saving and
|
||||
// landing back on the list. Awaited rather than fired off, so the
|
||||
// two don't race and the ad can't end up drawn over the Receipts
|
||||
// tab after this screen has gone.
|
||||
final adShown = await appState.maybeShowFuelSaveAd();
|
||||
if (!mounted) return;
|
||||
|
||||
// Ask while this screen (and its context) is still fully alive,
|
||||
// before popping — simpler than trying to show a dialog against a
|
||||
// context whose widget is mid-removal. Held back when an ad just
|
||||
// ran: one full-screen surface per save, and the reminder comes
|
||||
// back around on the next save anyway.
|
||||
var wantsBackupSetup = false;
|
||||
if (!adShown && !appState.hasCloudBackupConfigured) {
|
||||
wantsBackupSetup = await showBackupReminderDialog(context);
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
if (wantsBackupSetup) {
|
||||
unawaited(openCloudBackupSetup(context));
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
|
|
@ -178,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
|
|||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
|
||||
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
|
|
|
|||
|
|
@ -1,571 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/cloud/cloud_storage_provider.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'cloud_folder_browser_screen.dart';
|
||||
|
||||
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
|
||||
/// connection, the two photo-handling switches, and an Advanced section
|
||||
/// (stale sync lock timeout, plus the destructive purge actions — grouped
|
||||
/// there rather than given their own top-level "Danger Zone" so they sit
|
||||
/// behind the same disclosure as other rarely-touched settings).
|
||||
class DataSettingsScreen extends StatefulWidget {
|
||||
const DataSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DataSettingsScreen> createState() => _DataSettingsScreenState();
|
||||
}
|
||||
|
||||
class _DataSettingsScreenState extends State<DataSettingsScreen> {
|
||||
static final _dateFormat = DateFormat.yMMMd();
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
DateTime? _purgeRangeStart;
|
||||
DateTime? _purgeRangeEnd;
|
||||
String? _purgeRangeError;
|
||||
|
||||
Future<void> _connect(CloudProviderId id) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProvider(id);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not sign in: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectManual(CloudProviderId id, String providerName) async {
|
||||
final credentials = await showDialog<_WebDavCredentials>(
|
||||
context: context,
|
||||
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
|
||||
);
|
||||
if (credentials == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await context.read<AppState>().connectProviderWithCredentials(
|
||||
id,
|
||||
serverUrl: credentials.serverUrl,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().disconnectCloud();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _chooseFolder() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await context.read<AppState>().syncNow();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The resting (non-syncing) Sync Now icon — badged with the same
|
||||
/// play-triangle [CloudBackupActionButton] uses, for anyone who isn't on
|
||||
/// an active ad-free purchase, since tapping this can trigger the
|
||||
/// rewarded ad gate (see [AppState.syncNow]/`AdService.showGateAd`). Not
|
||||
/// a live prediction of whether *this* tap specifically will show one —
|
||||
/// same general "this leads to an ad-supported feature" disclosure the
|
||||
/// icon-badge uses elsewhere, not an attempt to account for the open
|
||||
/// gate window or the free-first-sync case.
|
||||
Widget _syncNowIcon(BuildContext context, AppState appState) {
|
||||
const icon = Icon(Icons.sync);
|
||||
if (appState.adsCurrentlyDisabled) return icon;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Badge(
|
||||
backgroundColor: colors.tertiary,
|
||||
label: Icon(Icons.play_arrow, size: 8, color: colors.onTertiary),
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared warning-dialog shell for both purge actions — [message] is the
|
||||
/// caller's job to make specific and unambiguous, since this is the only
|
||||
/// thing standing between the user and an unrecoverable delete.
|
||||
Future<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))
|
||||
: _syncNowIcon(context, appState),
|
||||
label: const Text('Sync Now'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (appState.lastSyncedAt != null)
|
||||
Text(
|
||||
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (appState.lastSyncError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Last sync failed: ${appState.lastSyncError}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep photos on this phone after syncing'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, a receipt photo is removed from this device once '
|
||||
"it's safely uploaded to the cloud.",
|
||||
),
|
||||
value: appState.keepReceiptPhotosLocally,
|
||||
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
title: const Text('Keep max quality images'),
|
||||
subtitle: const Text(
|
||||
'Otherwise, receipt photos are downscaled to a reasonable size '
|
||||
'before storing — smaller cloud storage and faster syncs, with '
|
||||
'no loss of legibility for a printed receipt.',
|
||||
),
|
||||
value: appState.keepMaxQualityReceiptPhotos,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ExpansionTile(
|
||||
title: const Text('Advanced'),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"If another device disconnects mid-sync without releasing its lock, "
|
||||
"this is how long to wait before treating it as abandoned and clearing "
|
||||
"it so sync can continue.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Slider(
|
||||
value: appState.staleLockMinutes.toDouble(),
|
||||
min: minStaleLockMinutes.toDouble(),
|
||||
max: maxStaleLockMinutes.toDouble(),
|
||||
divisions: maxStaleLockMinutes - minStaleLockMinutes,
|
||||
label: '${appState.staleLockMinutes} min',
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setStaleLockMinutes(value.round()),
|
||||
),
|
||||
Text('${appState.staleLockMinutes} minute(s)'),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'Danger Zone',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Deletes every vehicle, fuel entry, and receipt photo on this device '
|
||||
'and starts completely fresh.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _purgeAllData,
|
||||
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
|
||||
label: Text(
|
||||
'Purge All Data',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Deletes fuel entries and receipt photos dated within a range, across '
|
||||
'all vehicles. Vehicles themselves are kept.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true),
|
||||
child: Text(
|
||||
_purgeRangeStart == null
|
||||
? 'Start date'
|
||||
: _dateFormat.format(_purgeRangeStart!),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false),
|
||||
child: Text(
|
||||
_purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_purgeRangeError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_purgeRangeError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null)
|
||||
? null
|
||||
: _purgeDataInRange,
|
||||
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
|
||||
label: Text(
|
||||
'Purge Range',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebDavCredentials {
|
||||
final String serverUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
|
||||
}
|
||||
|
||||
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
|
||||
/// needs, since (unlike the OAuth providers) there's no browser flow to
|
||||
/// gather these instead.
|
||||
class _WebDavCredentialsDialog extends StatefulWidget {
|
||||
final String providerName;
|
||||
const _WebDavCredentialsDialog({required this.providerName});
|
||||
|
||||
@override
|
||||
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
|
||||
}
|
||||
|
||||
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _serverController = TextEditingController();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Navigator.of(context).pop(_WebDavCredentials(
|
||||
serverUrl: _serverController.text.trim(),
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Connect ${widget.providerName}'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _serverController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
|
||||
/// Lets the user correct the *data* logged about a receipt (date, gallons,
|
||||
/// price/gal, total cost, and which vehicle it's attached to) — not the
|
||||
/// receipt photo itself, which is shown read-only up top (tap it to view
|
||||
/// full-screen, same as everywhere else it's shown) and can't be replaced
|
||||
/// from here.
|
||||
class EditFuelEntryScreen extends StatefulWidget {
|
||||
final FuelEntry entry;
|
||||
|
||||
const EditFuelEntryScreen({super.key, required this.entry});
|
||||
|
||||
@override
|
||||
State<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;
|
||||
late String _vehicleId;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_date = widget.entry.date;
|
||||
_vehicleId = widget.entry.vehicleId;
|
||||
_gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
|
||||
_priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
|
||||
_totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_gallonsController.dispose();
|
||||
_priceController.dispose();
|
||||
_totalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _calculateTotal() {
|
||||
final gallons = double.tryParse(_gallonsController.text);
|
||||
final price = double.tryParse(_priceController.text);
|
||||
if (gallons != null && price != null) {
|
||||
setState(() {
|
||||
_totalController.text = (gallons * price).toStringAsFixed(2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<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,
|
||||
vehicleId: _vehicleId,
|
||||
date: _date,
|
||||
gallons: double.parse(_gallonsController.text),
|
||||
pricePerGallon: double.parse(_priceController.text),
|
||||
totalCost: double.parse(_totalController.text),
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
String? _requiredDecimal(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return 'Required';
|
||||
if (double.tryParse(value) == null) return 'Enter a valid number';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat.yMMMd().add_jm();
|
||||
final vehicles = context.watch<AppState>().vehicles;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit Fuel Entry')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(
|
||||
child: ReceiptThumbnail(entry: widget.entry, size: 180),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap the photo to view it full-screen. The photo itself can\'t '
|
||||
"be changed here — only the data below can.",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _vehicleId,
|
||||
decoration: const InputDecoration(labelText: 'Vehicle'),
|
||||
items: [
|
||||
for (final vehicle in vehicles)
|
||||
DropdownMenuItem(value: vehicle.id, child: Text(vehicle.displayLabel)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _vehicleId = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Date & time'),
|
||||
subtitle: Text(dateFormat.format(_date)),
|
||||
trailing: const Icon(Icons.edit_calendar_outlined),
|
||||
onTap: _pickDate,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _gallonsController,
|
||||
decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _requiredDecimal,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _priceController,
|
||||
decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _requiredDecimal,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _totalController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Total cost',
|
||||
prefixText: '\$',
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.calculate_outlined),
|
||||
tooltip: 'Calculate from gallons × price',
|
||||
onPressed: _calculateTotal,
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _requiredDecimal,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class _FaqEntry {
|
||||
final String question;
|
||||
final String answer;
|
||||
const _FaqEntry(this.question, this.answer);
|
||||
}
|
||||
|
||||
class _FaqSection {
|
||||
final String title;
|
||||
final List<_FaqEntry> entries;
|
||||
const _FaqSection(this.title, this.entries);
|
||||
}
|
||||
|
||||
const _faqSections = <_FaqSection>[
|
||||
_FaqSection('Missouri Motor Fuel Tax Refund', [
|
||||
_FaqEntry(
|
||||
'What is the Missouri Motor Fuel Tax Refund?',
|
||||
'Missouri actually offers two separate fuel tax refunds — it\'s worth evaluating '
|
||||
'both to see how you could benefit:\n\n'
|
||||
'• Highway use — up to 12.5¢ per gallon.\n'
|
||||
'• Non-highway use — up to 29.5¢ per gallon.\n\n'
|
||||
"This app helps you collect and organize the receipts you'll need to claim "
|
||||
"either one. This isn't tax advice — for guidance on which refund(s) fit your "
|
||||
'situation, consult a tax professional. Rates and eligibility are set by '
|
||||
'Missouri law, which can change at any time, so always confirm current details '
|
||||
'with the Missouri Department of Revenue before filing.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'Is this app affiliated with the Missouri Department of Revenue?',
|
||||
"No. This is an independent tool for organizing your own receipts — it isn't run by, "
|
||||
"endorsed by, or connected to the State of Missouri. It doesn't file anything on "
|
||||
'your behalf.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I generate a report for my refund claim?',
|
||||
"On the Reports tab, pick a date range to see your totals, then use Share or Print. "
|
||||
"There's also a link to Missouri's official Motor Fuel Refund Claim form once "
|
||||
"you're ready to file.",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Usage', [
|
||||
_FaqEntry(
|
||||
'How do I log a fuel purchase?',
|
||||
"On the Receipts tab, tap the + button, snap a photo of the receipt, and the app "
|
||||
'reads the date, gallons, and price automatically. Review (and correct, if '
|
||||
'needed) the values before saving — every field stays editable both before and '
|
||||
'after saving.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'What if the app misreads my receipt?',
|
||||
'Automatic reading is usually accurate but not perfect — correct any field yourself '
|
||||
'before saving, or edit it afterward from the entry\'s detail page.',
|
||||
),
|
||||
_FaqEntry(
|
||||
"Found a receipt, VIN, or error the app couldn't handle?",
|
||||
'Send it to ohbrer+ShowMeTheFuelRefund@gmail.com — example receipts or VINs that '
|
||||
"didn't scan correctly, screenshots of error screens, or anything else that "
|
||||
"didn't work as expected all help improve the app.",
|
||||
),
|
||||
_FaqEntry(
|
||||
'Do I need to add a vehicle before logging a receipt?',
|
||||
'Yes — every fuel entry is linked to a vehicle. Add one on the Vehicles tab first '
|
||||
"(you'll need its VIN).",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Data Safety', [
|
||||
_FaqEntry(
|
||||
'Is my data backed up?',
|
||||
'Not by default — everything you log lives only on this device until you connect a '
|
||||
'cloud storage account (Settings > Data). Once connected, a copy is kept there '
|
||||
'too, in storage you control.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'What cloud storage options are supported?',
|
||||
'Google Drive, Dropbox, OneDrive, or your own WebDAV server (Nextcloud, ownCloud, or '
|
||||
'any self-hosted WebDAV server).',
|
||||
),
|
||||
_FaqEntry(
|
||||
"What happens if I lose my phone, or reinstall the app?",
|
||||
"If you never connected cloud storage, that data is gone — it only ever lived on "
|
||||
"that device. If you had connected one, reconnect the same account on the new "
|
||||
"device (or after reinstalling) and your vehicles, receipts, and photos sync back "
|
||||
'down automatically.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I delete my data?',
|
||||
'Settings > Data > Advanced has options to purge everything, or just fuel entries in '
|
||||
"a specific date range. Both are permanent and can't be undone.",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Advertisements', [
|
||||
_FaqEntry(
|
||||
'Why do I sometimes see an ad?',
|
||||
'Syncing your data to the cloud requires watching a short ad, to help support the '
|
||||
"app — at most once every 5 minutes, so it won't show again if you sync "
|
||||
'repeatedly in a short span.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I remove ads?',
|
||||
'"Remove Ads for a Year" in Settings is a one-time purchase that disables ads for a '
|
||||
"year from when you buy it. It doesn't auto-renew or charge you again "
|
||||
"automatically — once the year's up, ads come back until you buy again.",
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
/// A simple, static list of common questions grouped into sections —
|
||||
/// reached from [SettingsScreen] (and every tab's AppBar via
|
||||
/// [FaqActionButton]), the "back up your data" reminder, and the
|
||||
/// first-launch user agreement screen.
|
||||
class FaqScreen extends StatelessWidget {
|
||||
/// If given, that question's entry starts expanded (and the rest
|
||||
/// collapsed) instead of everything starting collapsed — used when a
|
||||
/// link elsewhere in the app points at one specific answer, e.g. the
|
||||
/// backup reminder dialog jumping straight to the backup question rather
|
||||
/// than making the user hunt for it.
|
||||
final String? initiallyExpandedQuestion;
|
||||
|
||||
const FaqScreen({super.key, this.initiallyExpandedQuestion});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('FAQ')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final section in _faqSections) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 8),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final entry in section.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Card(
|
||||
child: ExpansionTile(
|
||||
title: Text(entry.question, style: textTheme.titleSmall),
|
||||
initiallyExpanded: entry.question == initiallyExpandedQuestion,
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(entry.answer, style: textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A "?" AppBar action that jumps straight to [FaqScreen] — added to every
|
||||
/// tab's AppBar (Receipts, Vehicles, Reports, Settings; see [MainShell])
|
||||
/// so help is reachable the same way no matter which screen the user's on.
|
||||
class FaqActionButton extends StatelessWidget {
|
||||
const FaqActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
tooltip: 'FAQ',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,10 @@ import 'package:provider/provider.dart';
|
|||
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../widgets/cloud_backup_action_button.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// The "Vehicles" tab body — Settings and Reports are reached via the
|
||||
/// bottom nav now (see [MainShell]), not from icons here.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
|
|
@ -21,19 +17,15 @@ class HomeScreen extends StatelessWidget {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Vehicles'),
|
||||
actionsPadding: const EdgeInsets.only(right: 20),
|
||||
title: const Text('My Vehicles'),
|
||||
actions: [
|
||||
IconButton(
|
||||
key: OnboardingKeys.vehiclesAddButton,
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add Vehicle',
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
const CloudBackupActionButton(),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
body: vehicles.isEmpty
|
||||
|
|
@ -48,6 +40,13 @@ class HomeScreen extends StatelessWidget {
|
|||
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Vehicle'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,49 +59,22 @@ class _VehicleCard extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false;
|
||||
final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged';
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'),
|
||||
),
|
||||
title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'),
|
||||
subtitle: Text(
|
||||
'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Row cross-axis defaults to center, so the chevron sits
|
||||
// vertically centered against the title+subtitle block even when
|
||||
// the nickname line makes that block taller — ListTile's
|
||||
// isThreeLine forces leading/trailing to the top instead, per
|
||||
// Material spec, which is what pinned it to the top before.
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium),
|
||||
// Only repeat the VIN here when the title is already
|
||||
// showing the nickname instead — otherwise the title
|
||||
// (falling back to the VIN when there's no nickname)
|
||||
// would show it twice.
|
||||
Text(
|
||||
hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||