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