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