Compare commits

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

13 commits

189 changed files with 18405 additions and 799 deletions

7
.gitignore vendored
View file

@ -43,3 +43,10 @@ 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 Normal file

File diff suppressed because one or more lines are too long

92
PRIVACY_POLICY.md Normal file
View file

@ -0,0 +1,92 @@
# 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
View file

@ -3,37 +3,227 @@
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 to a data file in a folder you choose.
photo. Vehicles and fuel entries live in a local SQLite database that's kept
in sync with a shared folder on **Google Drive, Dropbox, OneDrive, or your
own WebDAV server** (your choice), so multiple people can log fuel against
the same pool of vehicles from their own phones, offline or online.
## Features
- 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).
- 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).
- Per-vehicle fuel log with running gallons total, tap-to-zoom receipt
photos, and delete.
- 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).
- 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.
## Project layout
```
lib/
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization
models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
(toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and
`deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's
`id` is a hidden, generated, immutable primary key — never shown in the
UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a
required, unique-among-active-vehicles, but user-editable field, and
`nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a
`receiptDriveFileId`, or both at once if "keep photos on this phone" is
on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the
distinction.
services/
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
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.
screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail,
confirm fuel entry, receipt viewer, settings)
confirm fuel entry, receipt viewer, settings, cloud folder browser).
```
Data is stored as a single `fuel_tax_data.json` file plus a `receipts/`
subfolder of photos, both inside whatever directory Settings points at.
## 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.
## Running it
@ -51,43 +241,145 @@ flutter build ios --release # iOS (requires a full Xcode install + signing
This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and
`flutter test` are clean, and `flutter build apk --debug` has been confirmed
to produce a working APK.
to produce a working APK. None of the three providers' sign-in/sync paths
can be exercised end-to-end until you've done that provider's manual OAuth
setup above.
## Permissions
- **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
set up for receipt capture.
- **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.
- **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.
## Known caveats / things to revisit
- **`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`).
- **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.
- **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 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.
- 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.

View file

@ -1,11 +1,24 @@
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.courtneyarnold.fuel_tax_tracker"
namespace = "com.ohbrer.show_me_the_fuel_refund"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@ -15,8 +28,7 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.courtneyarnold.fuel_tax_tracker"
applicationId = "com.ohbrer.show_me_the_fuel_refund"
// 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
@ -25,11 +37,31 @@ 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 {
// 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")
// 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"
)
}
}
}

35
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,35 @@
# 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.** { *; }

View file

@ -1,8 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<!-- Required for Drive sign-in/sync. Debug builds get this for free via
android/app/src/debug/AndroidManifest.xml, but release builds need
it declared here explicitly. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="fuel_tax_tracker"
android:label="Receipt Tracker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@ -27,6 +32,28 @@
<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
@ -43,5 +70,13 @@
<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>

View file

@ -1,4 +1,4 @@
package com.courtneyarnold.fuel_tax_tracker
package com.ohbrer.show_me_the_fuel_refund
import io.flutter.embedding.android.FlutterActivity

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#101110</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#101110</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
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

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#24603A</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#24603A</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
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
app-ads.txt Normal file
View file

@ -0,0 +1 @@
google.com, pub-9212406812117696, DIRECT, f08c47fec0942fa0

BIN
assets/icon/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

BIN
assets/icon/hero_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

425
docs/class-diagram.md Normal file
View file

@ -0,0 +1,425 @@
# 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 Normal file
View file

@ -0,0 +1,73 @@
# 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.

View file

@ -441,7 +441,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_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 = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 725 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 204 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 421 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View file

@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<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>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<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"/>
<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"/>
</constraints>
</view>
</viewController>
@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View file

@ -1,72 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>NSCameraUsageDescription</key>
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
<key>CFBundleDisplayName</key>
<string>Fuel Tax Tracker</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>fuel_tax_tracker</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>NSCameraUsageDescription</key>
<string>Fuel Tax Tracker uses the camera to take photos of fuel receipts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt.</string>
<key>CFBundleDisplayName</key>
<string>Receipt Tracker</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>fuel_tax_tracker</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<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>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

14
lib/app_navigator.dart Normal file
View file

@ -0,0 +1,14 @@
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>();

View file

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'app_navigator.dart';
import 'screens/main_shell.dart';
import 'screens/user_agreement_screen.dart';
import 'services/app_state.dart';
import 'theme/app_theme.dart';
import 'widgets/ad_free_upsell_dialog.dart';
void main() {
runApp(const FuelTaxTrackerApp());
@ -15,15 +19,21 @@ class FuelTaxTrackerApp extends StatelessWidget {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
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(),
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,
home: const AppRoot(),
);
},
),
);
}
@ -41,7 +51,36 @@ class AppRoot extends StatelessWidget {
body: Center(child: CircularProgressIndicator()),
);
}
return const HomeScreen();
if (appState.initError != null) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
'Could not start the app:\n${appState.initError}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => appState.init(),
child: const Text('Retry'),
),
],
),
),
),
);
}
if (!appState.hasAcceptedUserAgreement) {
return const UserAgreementScreen();
}
return const MainShell();
},
);
}

View file

@ -1,11 +1,21 @@
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,
@ -14,26 +24,75 @@ class FuelEntry {
required this.gallons,
required this.pricePerGallon,
required this.totalCost,
required this.updatedAt,
this.receiptImagePath,
this.receiptDriveFileId,
this.deletedAt,
});
Map<String, dynamic> toJson() => {
/// True once the receipt photo has been uploaded to Drive, regardless of
/// whether a local copy is also being kept (see the "keep photos on this
/// phone" setting). Viewing it locally is preferred when a local copy
/// exists; otherwise it requires downloading from Drive on demand.
bool get isReceiptUploadedToDrive => receiptDriveFileId != null;
/// True while a receipt photo still needs to be uploaded to Drive: a
/// local file exists but hasn't made it there yet. Once
/// [receiptDriveFileId] is set this is false — even if a local copy is
/// also being kept — since that field alone is what marks "no longer
/// needs uploading", independent of local retention. A row in this state
/// is never pushed to Drive as data (a local file path is meaningless on
/// another device) — sync uploads the image first, which sets
/// [receiptDriveFileId] and clears this.
bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null;
FuelEntry copyWith({
String? receiptImagePath,
String? receiptDriveFileId,
DateTime? updatedAt,
DateTime? deletedAt,
bool clearReceiptImagePath = false,
}) {
return FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
updatedAt: updatedAt ?? this.updatedAt,
receiptImagePath:
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
deletedAt: deletedAt ?? this.deletedAt,
);
}
Map<String, Object?> toMap() => {
'id': id,
'vehicleId': vehicleId,
'date': date.toIso8601String(),
'vehicle_id': vehicleId,
'date': date.millisecondsSinceEpoch,
'gallons': gallons,
'pricePerGallon': pricePerGallon,
'totalCost': totalCost,
'receiptImagePath': receiptImagePath,
'price_per_gallon': pricePerGallon,
'total_cost': totalCost,
'receipt_image_path': receiptImagePath,
'receipt_drive_file_id': receiptDriveFileId,
'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
};
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry(
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?,
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,
);
}

View file

@ -1,48 +1,79 @@
class Vehicle {
/// Hidden, immutable, generated primary key — never shown in the UI and
/// never editable. This is what fuel entries actually reference and what
/// sync merges on, so that [vin] itself is free to be edited without
/// breaking those references or losing sync history.
final String id;
final String make;
final String model;
final String color;
final String licensePlate;
/// 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;
Vehicle({
required this.id,
required this.make,
required this.model,
required this.color,
required this.licensePlate,
required this.vin,
required this.updatedAt,
this.nickname,
this.deletedAt,
});
String get displayName => '$color $make $model ($licensePlate)';
/// What screens should show as the vehicle's primary label: the nickname
/// if one was given, otherwise the VIN itself.
String get displayLabel {
final trimmedNickname = nickname?.trim();
if (trimmedNickname != null && trimmedNickname.isNotEmpty) {
return trimmedNickname;
}
return vin;
}
/// Note: [id] is intentionally not overridable here — it's the hidden
/// identifier, not an editable field.
Vehicle copyWith({
String? make,
String? model,
String? color,
String? licensePlate,
String? vin,
String? nickname,
bool clearNickname = false,
DateTime? updatedAt,
DateTime? deletedAt,
}) {
return Vehicle(
id: id,
make: make ?? this.make,
model: model ?? this.model,
color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate,
vin: vin ?? this.vin,
nickname: clearNickname ? null : (nickname ?? this.nickname),
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
);
}
Map<String, dynamic> toJson() => {
Map<String, Object?> toMap() => {
'id': id,
'make': make,
'model': model,
'color': color,
'licensePlate': licensePlate,
'vin': vin,
'nickname': nickname,
'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
};
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,
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,
);
}

View file

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

View file

@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
enum _BrowseRoot { myFiles, sharedWithMe }
/// Lets the user navigate whichever cloud storage provider is connected —
/// their own files, and (if the provider supports it) files others have
/// shared with them — and pick a parent location. Confirming looks for (or
/// creates) the `Show Me The Fuel Refund` folder under that location, so
/// two people pointing at the same shared parent converge on the same app
/// folder, regardless of which provider each of them is using. Picking a
/// folder that's already named that directly (e.g. a shared folder set up
/// by someone else) uses it as-is instead of nesting another one inside —
/// see `CloudSyncService.selectAppFolder`.
class CloudFolderBrowserScreen extends StatefulWidget {
const CloudFolderBrowserScreen({super.key});
@override
State<CloudFolderBrowserScreen> createState() => _CloudFolderBrowserScreenState();
}
class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
late final CloudStorageSession _session;
_BrowseRoot _root = _BrowseRoot.myFiles;
final List<CloudFolder> _pathStack = [];
List<CloudFolder>? _folders;
bool _loading = true;
String? _error;
bool _confirming = false;
@override
void initState() {
super.initState();
_session = context.read<AppState>().activeProvider!.beginSession();
_load();
}
@override
void dispose() {
_session.close();
super.dispose();
}
String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me';
/// The folder ID that "Use This Folder" would act on, or null if the
/// current view is a virtual listing (top-level "Shared with me") rather
/// than an actual folder.
String? get _currentFolderId {
if (_pathStack.isNotEmpty) return _pathStack.last.id;
if (_root == _BrowseRoot.myFiles) return 'root';
return null;
}
/// The currently browsed-into folder's own name, so [_useThisFolder] can
/// tell whether the user picked a folder already named
/// `Show Me The Fuel Refund` — null at a virtual root ("My Files"),
/// which is never itself named that.
String? get _currentFolderName => _pathStack.isNotEmpty ? _pathStack.last.name : null;
String get _breadcrumbPath =>
([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / ');
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
List<CloudFolder> folders;
if (_pathStack.isNotEmpty) {
folders = await _session.listFolders(parentId: _pathStack.last.id);
} else if (_root == _BrowseRoot.myFiles) {
folders = await _session.listFolders(parentId: 'root');
} else {
folders = await _session.listFolders(sharedWithMe: true);
}
if (!mounted) return;
setState(() {
_folders = folders;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Could not load folders: $e';
_loading = false;
});
}
}
void _switchRoot(_BrowseRoot root) {
if (root == _root) return;
setState(() {
_root = root;
_pathStack.clear();
});
_load();
}
void _openFolder(CloudFolder folder) {
setState(() => _pathStack.add(folder));
_load();
}
void _goToBreadcrumb(int index) {
// index == -1 means the root label itself.
setState(() {
if (index < 0) {
_pathStack.clear();
} else {
_pathStack.removeRange(index + 1, _pathStack.length);
}
});
_load();
}
Future<void> _useThisFolder() async {
final parentId = _currentFolderId;
if (parentId == null) return;
setState(() => _confirming = true);
try {
await context.read<AppState>().chooseCloudFolder(
parentId: parentId,
breadcrumbPath: _breadcrumbPath,
currentFolderName: _currentFolderName,
);
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not use this folder: $e')),
);
}
} finally {
if (mounted) setState(() => _confirming = false);
}
}
@override
Widget build(BuildContext context) {
final providerName = context.read<AppState>().activeProvider!.displayName;
final canUseCurrentFolder = _currentFolderId != null;
return Scaffold(
appBar: AppBar(title: Text('Choose $providerName Folder')),
body: Column(
children: [
if (_session.supportsSharedWithMe)
SegmentedButton<_BrowseRoot>(
segments: const [
ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')),
ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')),
],
selected: {_root},
onSelectionChanged: (selection) => _switchRoot(selection.first),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
TextButton(
onPressed: () => _goToBreadcrumb(-1),
child: Text(_rootLabel),
),
for (var i = 0; i < _pathStack.length; i++) ...[
const Icon(Icons.chevron_right, size: 18),
TextButton(
onPressed: () => _goToBreadcrumb(i),
child: Text(_pathStack[i].name),
),
],
],
),
),
),
const Divider(height: 1),
Expanded(child: _buildBody()),
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder,
icon: _confirming
? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.check),
label: Text('Use "$_breadcrumbPath"'),
),
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!)));
}
final folders = _folders ?? [];
if (folders.isEmpty) {
return const Center(child: Text('No folders here.'));
}
return ListView.builder(
itemCount: folders.length,
itemBuilder: (context, index) {
final folder = folders[index];
return ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(folder.name),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openFolder(folder),
);
},
);
}
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -6,6 +7,7 @@ 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
@ -33,12 +35,16 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
DateTime _date = DateTime.now();
late DateTime _date;
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) ?? '',
);
@ -48,6 +54,8 @@ 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
@ -69,11 +77,20 @@ 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: DateTime.now().subtract(const Duration(days: 365 * 5)),
lastDate: DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
);
if (pickedDate == null || !mounted) return;
@ -99,7 +116,8 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
setState(() => _saving = true);
try {
await context.read<AppState>().addFuelEntry(
final appState = context.read<AppState>();
await appState.addFuelEntry(
vehicleId: widget.vehicleId,
date: _date,
gallons: double.parse(_gallonsController.text),
@ -107,7 +125,31 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
totalCost: double.parse(_totalController.text),
receiptImage: widget.imageFile,
);
if (mounted) Navigator.of(context).pop();
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));
}
} finally {
if (mounted) setState(() => _saving = false);
}
@ -136,7 +178,7 @@ class _ConfirmFuelEntryScreenState extends State<ConfirmFuelEntryScreen> {
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path),
builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path),
),
),
child: ClipRRect(

View file

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

View file

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

178
lib/screens/faq_screen.dart Normal file
View file

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

View file

@ -3,10 +3,14 @@ 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 'settings_screen.dart';
import 'faq_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});
@ -17,15 +21,19 @@ class HomeScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('My Vehicles'),
title: const Text('Vehicles'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
key: OnboardingKeys.vehiclesAddButton,
icon: const Icon(Icons.add),
tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
),
const CloudBackupActionButton(),
const FaqActionButton(),
],
),
body: vehicles.isEmpty
@ -40,13 +48,6 @@ 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'),
),
);
}
}
@ -59,22 +60,49 @@ 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: 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),
child: InkWell(
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),
],
),
),
),
);
}

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