A whole lot of stuff
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**
|
||||
21
android/app/proguard-rules.pro
vendored
|
|
@ -12,3 +12,24 @@
|
|||
-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
app-ads.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
google.com, pub-9212406812117696, DIRECT, f08c47fec0942fa0
|
||||
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.
|
||||
|
|
@ -5,11 +5,18 @@ import 'screens/main_shell.dart';
|
|||
import 'screens/user_agreement_screen.dart';
|
||||
import 'services/app_state.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'widgets/ad_free_upsell_dialog.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const FuelTaxTrackerApp());
|
||||
}
|
||||
|
||||
/// A stable handle on the navigator so [AppState.onAdWatched] can show the
|
||||
/// "remove ads" dialog from wherever the app happens to be — it fires from
|
||||
/// deep inside a background sync, not from a widget's own build method, so
|
||||
/// there's no local [BuildContext] to reach for at that point.
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
class FuelTaxTrackerApp extends StatelessWidget {
|
||||
const FuelTaxTrackerApp({super.key});
|
||||
|
||||
|
|
@ -18,13 +25,20 @@ class FuelTaxTrackerApp extends StatelessWidget {
|
|||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState()..init(),
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, _) => MaterialApp(
|
||||
title: 'Fuel Tax Tracker',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: appState.themeMode,
|
||||
home: const AppRoot(),
|
||||
),
|
||||
builder: (context, appState, _) {
|
||||
appState.onAdWatched ??= () {
|
||||
final dialogContext = navigatorKey.currentContext;
|
||||
if (dialogContext != null) showAdFreeUpsellDialog(dialogContext);
|
||||
};
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
title: 'Fuel Tax Tracker',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: appState.themeMode,
|
||||
home: const AppRoot(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import '../services/app_state.dart';
|
|||
import '../widgets/receipt_thumbnail.dart';
|
||||
|
||||
/// Lets the user correct the *data* logged about a receipt (date, gallons,
|
||||
/// price/gal, total cost) — not the receipt photo itself, which is shown
|
||||
/// read-only up top (tap it to view full-screen, same as everywhere else
|
||||
/// it's shown) and can't be replaced from here.
|
||||
/// 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;
|
||||
|
||||
|
|
@ -25,12 +26,14 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
|||
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));
|
||||
|
|
@ -96,6 +99,7 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
|||
try {
|
||||
await context.read<AppState>().updateFuelEntry(
|
||||
entryId: widget.entry.id,
|
||||
vehicleId: _vehicleId,
|
||||
date: _date,
|
||||
gallons: double.parse(_gallonsController.text),
|
||||
pricePerGallon: double.parse(_priceController.text),
|
||||
|
|
@ -116,6 +120,7 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
|||
@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')),
|
||||
|
|
@ -135,6 +140,18 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
|
|||
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'),
|
||||
|
|
|
|||
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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import '../models/vehicle.dart';
|
|||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// The "Vehicles" tab body — Settings and Reports are reached via the
|
||||
|
|
@ -30,6 +31,7 @@ class HomeScreen extends StatelessWidget {
|
|||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
),
|
||||
),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
body: vehicles.isEmpty
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
|
|
@ -51,7 +52,13 @@ class _OnboardingStep {
|
|||
final String title;
|
||||
final String description;
|
||||
final List<GlobalKey> targetKeys;
|
||||
final void Function() activate;
|
||||
|
||||
/// Gets the app into the right state to show this step (switch tabs,
|
||||
/// push a route, ...). Returns the route it just pushed, if any — so
|
||||
/// [_MainShellState._waitForTargets] can wait for that specific route's
|
||||
/// transition to settle before spotlighting something on it — or null
|
||||
/// for a step (like every tab switch) that didn't push one.
|
||||
final ModalRoute<dynamic>? Function() activate;
|
||||
|
||||
const _OnboardingStep({
|
||||
required this.title,
|
||||
|
|
@ -80,8 +87,8 @@ class _MainShellState extends State<MainShell> {
|
|||
_OnboardingStep(
|
||||
title: 'What This App Does',
|
||||
description: 'This app helps you collect and organize your Missouri fuel purchase '
|
||||
"receipts so you can claim Missouri's Motor Fuel Tax Refund — currently 12.5¢ back "
|
||||
'per gallon.\n\n'
|
||||
"receipts so you can claim Missouri's Motor Fuel Tax Refund — up to 12.5¢/gal "
|
||||
'for highway use, up to 29.5¢/gal non-highway.\n\n'
|
||||
'Disclaimer: this refund is available for as long as Missouri lawmakers continue to '
|
||||
'offer it, and state law could change or end the program at any time. Always confirm '
|
||||
'current eligibility and rates with the Missouri Department of Revenue before filing.',
|
||||
|
|
@ -90,7 +97,7 @@ class _MainShellState extends State<MainShell> {
|
|||
// be when the tour starts (normally the Receipts tab, since that's
|
||||
// MainShell's default).
|
||||
targetKeys: const [],
|
||||
activate: () {},
|
||||
activate: () => null,
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Receipts Here',
|
||||
|
|
@ -98,7 +105,10 @@ class _MainShellState extends State<MainShell> {
|
|||
'Snap a photo of it and the app reads the date, gallons, and price for you '
|
||||
'automatically.',
|
||||
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
|
||||
activate: () => _jumpToTab(0),
|
||||
activate: () {
|
||||
_jumpToTab(0);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Vehicles Here',
|
||||
|
|
@ -106,7 +116,10 @@ class _MainShellState extends State<MainShell> {
|
|||
'receipt gets linked to one of your vehicles, so add one here first before logging '
|
||||
'a receipt for it.',
|
||||
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
|
||||
activate: () => _jumpToTab(1),
|
||||
activate: () {
|
||||
_jumpToTab(1);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Choose a Report Date Range',
|
||||
|
|
@ -126,14 +139,20 @@ class _MainShellState extends State<MainShell> {
|
|||
OnboardingKeys.reportShareAndPrintRow,
|
||||
OnboardingKeys.refundFormLink,
|
||||
],
|
||||
activate: () => _jumpToTab(2),
|
||||
activate: () {
|
||||
_jumpToTab(2);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Find Data Settings',
|
||||
description: 'This is the Settings tab — tap Data here to manage where your vehicles, '
|
||||
'receipts, and photos get backed up.',
|
||||
targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry],
|
||||
activate: () => _jumpToTab(3),
|
||||
activate: () {
|
||||
_jumpToTab(3);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Back Up to the Cloud',
|
||||
|
|
@ -143,13 +162,14 @@ class _MainShellState extends State<MainShell> {
|
|||
targetKeys: [OnboardingKeys.cloudStorageCard],
|
||||
activate: () {
|
||||
_tourPushedDataScreen = true;
|
||||
final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen());
|
||||
// Not awaited: Navigator.push's returned future only completes on
|
||||
// pop, not on the pushed route finishing its build — the polling
|
||||
// wait in _showTourStep (for the target key's RenderObject to
|
||||
// exist) is what actually waits for this screen to be ready.
|
||||
unawaited(Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
|
||||
));
|
||||
// exist, then for `route` itself — returned below — to finish its
|
||||
// transition) is what actually waits for this screen to be ready.
|
||||
unawaited(Navigator.of(context).push(route));
|
||||
return route;
|
||||
},
|
||||
),
|
||||
];
|
||||
|
|
@ -207,7 +227,7 @@ class _MainShellState extends State<MainShell> {
|
|||
/// page (like the Reports tab's refund-form link) could still be
|
||||
/// off-screen despite having a real, laid-out RenderBox, and would get
|
||||
/// "highlighted" somewhere the user can't actually see.
|
||||
Future<void> _waitForTargets(List<GlobalKey> keys) async {
|
||||
Future<void> _waitForTargets(List<GlobalKey> keys, ModalRoute<dynamic>? pushedRoute) async {
|
||||
for (var attempt = 0; attempt < 30; attempt++) {
|
||||
if (!mounted) return;
|
||||
final allResolved = keys.every((key) {
|
||||
|
|
@ -229,12 +249,40 @@ class _MainShellState extends State<MainShell> {
|
|||
if (!mounted) return;
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
if (!mounted) return;
|
||||
// The "Back Up to the Cloud" step's activate() pushes a route and
|
||||
// returns it as [pushedRoute] — wait for that push transition to fully
|
||||
// settle before capturing its target's position, or the spotlight ends
|
||||
// up shifted by however far the slide-in hadn't yet finished. A no-op
|
||||
// for every other step, which only switches tabs and so has no route
|
||||
// to pass here.
|
||||
await waitForRouteTransition(pushedRoute?.animation);
|
||||
if (!mounted) return;
|
||||
// The route's own AnimationController reports AnimationStatus.completed
|
||||
// at this point, but empirically the render tree's transforms (from
|
||||
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
|
||||
// Android default as of Flutter 3.44) still reflect a mid-transition
|
||||
// position for a few more frames after that — confirmed by walking the
|
||||
// RenderObject ancestor chain and finding an active
|
||||
// RenderFractionalTranslation still present, with a position matching
|
||||
// the transition's *starting* offset rather than its resting
|
||||
// Offset.zero. These extra frames give it time to actually settle
|
||||
// before the spotlight measures anything. SchedulerBinding.endOfFrame
|
||||
// (rather than a bare Future.delayed) so this genuinely waits on real
|
||||
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
|
||||
// while something has an actual frame scheduled, which a bare delay
|
||||
// timer doesn't count as once the route's own transition has already
|
||||
// finished.
|
||||
for (var i = 0; i < 10; i++) {
|
||||
if (!mounted) return;
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showTourStep() async {
|
||||
final step = _tourSteps[_tourStepIndex];
|
||||
step.activate();
|
||||
await _waitForTargets(step.targetKeys);
|
||||
final pushedRoute = step.activate();
|
||||
await _waitForTargets(step.targetKeys, pushedRoute);
|
||||
if (!mounted) return;
|
||||
|
||||
_tourEntry?.remove();
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import 'package:provider/provider.dart';
|
|||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'receipt_detail_screen.dart';
|
||||
|
||||
/// The three summary periods selectable below the hero totals — the
|
||||
|
|
@ -142,6 +144,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
tooltip: 'Log Fuel Receipt',
|
||||
onPressed: () => _addReceipt(context),
|
||||
),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
|
|
@ -154,6 +157,9 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
gallonsFormat: heroGallonsFormat,
|
||||
selectedRange: _range,
|
||||
onRangeSelected: (range) => setState(() => _range = range),
|
||||
estimatedRefundValue: appState.showEstimatedFuelRefund
|
||||
? currencyFormat.format(estimatedFuelRefund(totalGallons))
|
||||
: null,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
|
||||
|
|
@ -176,6 +182,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
dateFormat: dateFormat,
|
||||
currencyFormat: currencyFormat,
|
||||
gallonsFormat: gallonsFormat,
|
||||
showEstimatedRefund: appState.showEstimatedFuelRefund,
|
||||
onTap: () => _openReceipt(context, entries[i]),
|
||||
),
|
||||
],
|
||||
|
|
@ -229,6 +236,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
final NumberFormat gallonsFormat;
|
||||
final _DateRange selectedRange;
|
||||
final ValueChanged<_DateRange> onRangeSelected;
|
||||
final String? estimatedRefundValue;
|
||||
|
||||
const _HeroTotal({
|
||||
required this.totalCost,
|
||||
|
|
@ -237,6 +245,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
required this.gallonsFormat,
|
||||
required this.selectedRange,
|
||||
required this.onRangeSelected,
|
||||
this.estimatedRefundValue,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -248,6 +257,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
HeroStatsRow(
|
||||
gallonsValue: gallonsFormat.format(totalGallons),
|
||||
costValue: currencyFormat.format(totalCost),
|
||||
estimatedRefundValue: estimatedRefundValue,
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
|
||||
|
|
@ -263,6 +273,7 @@ class _ReceiptRow extends StatelessWidget {
|
|||
final DateFormat dateFormat;
|
||||
final NumberFormat currencyFormat;
|
||||
final NumberFormat gallonsFormat;
|
||||
final bool showEstimatedRefund;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReceiptRow({
|
||||
|
|
@ -271,6 +282,7 @@ class _ReceiptRow extends StatelessWidget {
|
|||
required this.dateFormat,
|
||||
required this.currencyFormat,
|
||||
required this.gallonsFormat,
|
||||
required this.showEstimatedRefund,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
|
|
@ -295,7 +307,10 @@ class _ReceiptRow extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${gallonsFormat.format(entry.gallons)} gal',
|
||||
showEstimatedRefund
|
||||
? '${gallonsFormat.format(entry.gallons)} gal · Est. '
|
||||
'${currencyFormat.format(estimatedFuelRefund(entry.gallons))}'
|
||||
: '${gallonsFormat.format(entry.gallons)} gal',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import 'package:provider/provider.dart';
|
|||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../services/fuel_report.dart';
|
||||
import '../services/fuel_report_images.dart';
|
||||
import '../services/fuel_report_pdf.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'faq_screen.dart';
|
||||
|
||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
||||
/// file for the user — it's a state form, submitted to the state — so the
|
||||
|
|
@ -51,13 +53,26 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final (start, end) = defaultReportDateRange(DateTime.now());
|
||||
_startDate = start;
|
||||
_endDate = end;
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
// The default range's end can fall in the future (it covers the
|
||||
// fuel-tax-refund period currently underway, per defaultReportDateRange)
|
||||
// — showDatePicker asserts initialDate <= lastDate, so lastDate has to
|
||||
// stretch to cover it too, not just "today".
|
||||
final lastDate = _endDate != null && _endDate!.isAfter(now) ? _endDate! : now;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _startDate : _endDate) ?? now,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: now,
|
||||
lastDate: lastDate,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
|
|
@ -118,6 +133,22 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _preview(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = await _buildPdfBytes(report);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _ReportPreviewScreen(bytes: bytes, fileName: fuelReportFileName(report)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openRefundFormLink() async {
|
||||
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
|
|
@ -161,7 +192,10 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Fuel Report')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Fuel Report'),
|
||||
actions: const [FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -233,6 +267,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
_VehicleSelectionCard(
|
||||
row: row,
|
||||
selected: _selectedVehicleIds.contains(row.vehicle.id),
|
||||
showEstimatedRefund: appState.showEstimatedFuelRefund,
|
||||
onTap: () => setState(() {
|
||||
if (_selectedVehicleIds.contains(row.vehicle.id)) {
|
||||
_selectedVehicleIds.remove(row.vehicle.id);
|
||||
|
|
@ -252,7 +287,9 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}'
|
||||
'${appState.showEstimatedFuelRefund ? ' · Est. '
|
||||
'${NumberFormat.simpleCurrency().format(estimatedFuelRefund(report.totalGallons))}' : ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
|
@ -260,6 +297,14 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
Row(
|
||||
key: OnboardingKeys.reportShareAndPrintRow,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _preview(report),
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
label: const Text('Preview'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : () => _share(report),
|
||||
|
|
@ -334,9 +379,15 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
class _VehicleSelectionCard extends StatelessWidget {
|
||||
final VehicleReportRow row;
|
||||
final bool selected;
|
||||
final bool showEstimatedRefund;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
|
||||
const _VehicleSelectionCard({
|
||||
required this.row,
|
||||
required this.selected,
|
||||
required this.showEstimatedRefund,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -379,6 +430,13 @@ class _VehicleSelectionCard extends StatelessWidget {
|
|||
NumberFormat.simpleCurrency().format(row.totalCost),
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
if (showEstimatedRefund) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Est. ${NumberFormat.simpleCurrency().format(estimatedFuelRefund(row.totalGallons))}',
|
||||
style: TextStyle(color: foregroundVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -388,3 +446,31 @@ class _VehicleSelectionCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the already-built report PDF in-app, with [PdfPreview]'s own
|
||||
/// built-in Share/Print actions — so Preview isn't a dead end, the same
|
||||
/// two actions available back on [ReportScreen] are still reachable from
|
||||
/// here without backing out first. The report's format is fixed
|
||||
/// ([buildFuelReportPdf] always renders US Letter), so the page
|
||||
/// format/orientation controls [PdfPreview] would otherwise offer are
|
||||
/// turned off since they wouldn't actually change anything.
|
||||
class _ReportPreviewScreen extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
|
||||
const _ReportPreviewScreen({required this.bytes, required this.fileName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Report Preview')),
|
||||
body: PdfPreview(
|
||||
build: (format) async => bytes,
|
||||
pdfFileName: fileName,
|
||||
canChangePageFormat: false,
|
||||
canChangeOrientation: false,
|
||||
canDebug: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:provider/provider.dart';
|
|||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'data_settings_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'ui_settings_screen.dart';
|
||||
import 'user_agreement_screen.dart';
|
||||
|
||||
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
|
||||
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
|
||||
|
|
@ -18,7 +20,10 @@ class SettingsScreen extends StatelessWidget {
|
|||
final appState = context.watch<AppState>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: const [FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -47,6 +52,30 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: const Text('FAQ'),
|
||||
subtitle: const Text('Common questions about the app and the refund program'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: const Text('User Agreement'),
|
||||
subtitle: const Text('Data liability, privacy, and the tax-advice disclaimer'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -63,8 +92,8 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
] else ...[
|
||||
Text(
|
||||
'This app shows a single ad, at most once per app session, right '
|
||||
'before syncing your data to the cloud.',
|
||||
'Syncing your data to the cloud requires watching a short ad — '
|
||||
'at most once every 5 minutes.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -84,6 +113,19 @@ class SettingsScreen extends StatelessWidget {
|
|||
: 'Remove Ads for a Year — ${appState.adFreeYearPriceLabel}',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().restoreAdFreeYear();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"Checking for a previous purchase on this Google account…",
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Restore Purchase'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ class UiSettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Estimated Fuel Refund'),
|
||||
subtitle: const Text(
|
||||
'Shows an estimate of your Missouri Highway Fuel Tax Refund next to '
|
||||
'your receipts and totals. Based on the current rate — contingent on '
|
||||
"it, and not a substitute for confirming the actual amount with the "
|
||||
'Missouri Department of Revenue.',
|
||||
),
|
||||
value: appState.showEstimatedFuelRefund,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setShowEstimatedFuelRefund(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import 'faq_screen.dart';
|
||||
|
||||
/// The very first thing shown on a fresh install — before the onboarding
|
||||
/// tour, before anything else — gating [MainShell] entirely until the user
|
||||
|
|
@ -11,16 +12,32 @@ import '../services/app_state.dart';
|
|||
/// Its whole purpose is the liability section below: this app stores data
|
||||
/// on the user's own device (and, optionally, their own cloud storage
|
||||
/// account) with no server or backend of ours involved, so we have no
|
||||
/// ability to recover anything for them if it's lost.
|
||||
/// ability to recover anything for them if it's lost — plus the privacy
|
||||
/// section, disclosing that we don't track/log anything ourselves, and
|
||||
/// that AdMob (only active for users on the free, ad-supported tier) is
|
||||
/// the sole source of any data collection, governed by Google's own
|
||||
/// practices rather than anything this app adds.
|
||||
class UserAgreementScreen extends StatelessWidget {
|
||||
const UserAgreementScreen({super.key});
|
||||
/// True when reached from Settings (see [SettingsScreen]) to re-read the
|
||||
/// agreement/privacy policy at any time — required so this content stays
|
||||
/// reachable in the app beyond the one-time first-launch gate, not just
|
||||
/// something a user saw once and can never get back to. Hides the
|
||||
/// Decline/I Agree actions (already answered, and "Decline" quitting the
|
||||
/// app would be a bizarre side effect of just re-reading this) and shows
|
||||
/// a normal back button instead of gating navigation away entirely.
|
||||
final bool isReview;
|
||||
|
||||
const UserAgreementScreen({super.key, this.isReview = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('User Agreement'), automaticallyImplyLeading: false),
|
||||
appBar: AppBar(
|
||||
title: const Text('User Agreement'),
|
||||
automaticallyImplyLeading: isReview,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -29,8 +46,11 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Text(
|
||||
'Please read and accept the following before using Show Me The Fuel '
|
||||
'Refund.',
|
||||
isReview
|
||||
? 'This is the agreement you accepted when you first opened Show Me '
|
||||
'The Fuel Refund.'
|
||||
: 'Please read and accept the following before using Show Me The Fuel '
|
||||
'Refund.',
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
|
@ -43,6 +63,17 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
'also kept there — in storage you control, not on any server we run. '
|
||||
"We don't operate a backend, and we don't have a copy of your data.",
|
||||
),
|
||||
_Section(
|
||||
title: 'Privacy',
|
||||
body:
|
||||
"We don't track or log your data in any way — there's no analytics, no "
|
||||
"crash reporting, and no server of ours receiving anything you enter. If "
|
||||
'you haven\'t purchased "Remove Ads for a Year" and are using the free, '
|
||||
'ad-supported version, the only data collected is whatever Google AdMob '
|
||||
'itself requires to serve ads (e.g. a device advertising identifier) — '
|
||||
"that collection is Google's, governed by its own privacy practices, not "
|
||||
'something this app adds on top of.',
|
||||
),
|
||||
_Section(
|
||||
title: 'We Are Not Responsible for Your Data or Any Data Loss',
|
||||
body:
|
||||
|
|
@ -66,37 +97,55 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
_Section(
|
||||
title: 'Not Tax or Legal Advice',
|
||||
body:
|
||||
"This app helps you organize receipts for Missouri's Motor Fuel Tax "
|
||||
'Refund program; it does not provide tax or legal advice, and does not '
|
||||
'guarantee your eligibility for any refund. The program itself is set '
|
||||
'by Missouri law, which can change at any time — always confirm current '
|
||||
'eligibility and rates with the Missouri Department of Revenue.',
|
||||
'This app is purely informational. Missouri actually offers two '
|
||||
'separate fuel tax refunds — up to 12.5¢ per gallon for highway use, '
|
||||
"and up to 29.5¢ per gallon for non-highway use — and it's worth "
|
||||
"evaluating both to see how you could benefit. This app helps you "
|
||||
"organize the receipts you'd need for either one.\n\n"
|
||||
"Nothing here is tax or legal advice, and using this app doesn't "
|
||||
'guarantee your eligibility for either refund. For guidance on your '
|
||||
'specific situation, consult a tax professional. Both refunds are set '
|
||||
'by Missouri law, which can change at any time — always confirm '
|
||||
'current eligibility and rates with the Missouri Department of '
|
||||
'Revenue before filing.',
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
icon: const Icon(Icons.help_outline),
|
||||
label: const Text('See the FAQ for more'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => SystemNavigator.pop(),
|
||||
child: const Text('Decline'),
|
||||
if (!isReview) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => SystemNavigator.pop(),
|
||||
child: const Text('Decline'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => context.read<AppState>().acceptUserAgreement(),
|
||||
child: const Text('I Agree'),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => context.read<AppState>().acceptUserAgreement(),
|
||||
child: const Text('I Agree'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
|
|
@ -151,8 +152,10 @@ class _VehicleDetailPage extends StatelessWidget {
|
|||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
|
||||
'${dateFormat.format(entry.date)}',
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal'
|
||||
'${appState.showEstimatedFuelRefund ? ' · Est. '
|
||||
'${currencyFormat.format(estimatedFuelRefund(entry.gallons))}' : ''}'
|
||||
'\n${dateFormat.format(entry.date)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,37 +2,53 @@ import 'dart:async';
|
|||
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
/// The single ad placement this app has: one interstitial, shown at most
|
||||
/// once per app session, right before the *upload-to-cloud* phase of the
|
||||
/// first sync that reaches it (see [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not before
|
||||
/// pulling/merging remote changes down, and never anywhere else in the app.
|
||||
/// The single ad placement this app has: one interstitial, gating the
|
||||
/// *upload* phase of a cloud sync (see [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not pulling/
|
||||
/// merging remote changes down, and never anywhere else in the app.
|
||||
///
|
||||
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) is
|
||||
/// the real one from your AdMob console. [_interstitialAdUnitId] below is
|
||||
/// TEMPORARILY back on Google's official test interstitial ID — your real
|
||||
/// one (`ca-app-pub-9212406812117696/9482586380`) was returning no-fill
|
||||
/// (error code 3), most likely just because it's brand new; this swap is
|
||||
/// only to confirm the trigger/preload/display mechanism itself works
|
||||
/// while that warms up. Swap the real one back in once it's serving.
|
||||
/// ios/Runner/Info.plist's `GADApplicationIdentifier` is still Google's
|
||||
/// test iOS App ID, though — an AdMob App ID is registered per-platform, so
|
||||
/// it needs its own real iOS App ID (and a real iOS interstitial ad unit
|
||||
/// ID here) from the AdMob console if this app ever ships on iOS.
|
||||
/// Showing an ad opens a gate that stays open for [gateValidity]; a sync
|
||||
/// attempted after that window closes has to show (and have the user sit
|
||||
/// through the start of) another one before it's allowed to push data to
|
||||
/// the cloud. Selecting/connecting a cloud provider is never gated — only
|
||||
/// the upload side of a sync is, via [showGateAd].
|
||||
///
|
||||
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) and
|
||||
/// [_interstitialAdUnitId] below are both the real ones from your AdMob
|
||||
/// console. ios/Runner/Info.plist's `GADApplicationIdentifier` is still
|
||||
/// Google's test iOS App ID, though — an AdMob App ID is registered
|
||||
/// per-platform, so it needs its own real iOS App ID (and a real iOS
|
||||
/// interstitial ad unit ID here) from the AdMob console if this app ever
|
||||
/// ships on iOS.
|
||||
/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the
|
||||
/// caller may proceed" — they're kept distinct only so [AppState] can tell
|
||||
/// whether *this* call is the one that actually put a user in front of an
|
||||
/// ad, which is the trigger for the "remove ads for a year" upsell dialog.
|
||||
/// [AdGateResult.blocked] means the caller must not proceed.
|
||||
enum AdGateResult { alreadyOpen, justShown, blocked }
|
||||
|
||||
class AdService {
|
||||
static const _interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
|
||||
static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
|
||||
|
||||
/// How long a successfully-shown ad keeps the upload gate open before the
|
||||
/// next sync attempt has to show another one.
|
||||
static const gateValidity = Duration(minutes: 5);
|
||||
|
||||
bool _sdkInitialized = false;
|
||||
bool _shownThisSession = false;
|
||||
DateTime? _lastShownAt;
|
||||
InterstitialAd? _preloadedAd;
|
||||
Completer<void>? _loadCompleter;
|
||||
|
||||
/// Starts the Mobile Ads SDK and begins preloading this session's one
|
||||
/// interstitial. Idempotent (a no-op after the first call) and safe to
|
||||
/// call speculatively — [AppState] calls this from [AppState.syncNow]
|
||||
/// rather than unconditionally at app startup, so a user who never
|
||||
/// connects cloud sync never triggers any ad-related network activity at
|
||||
/// all.
|
||||
bool get _gateOpen {
|
||||
final lastShownAt = _lastShownAt;
|
||||
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
|
||||
}
|
||||
|
||||
/// Starts the Mobile Ads SDK and begins preloading an interstitial.
|
||||
/// Idempotent (a no-op after the first call) and safe to call
|
||||
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
|
||||
/// than unconditionally at app startup, so a user who never connects
|
||||
/// cloud sync never triggers any ad-related network activity at all.
|
||||
Future<void> initialize() async {
|
||||
if (_sdkInitialized) return;
|
||||
_sdkInitialized = true;
|
||||
|
|
@ -59,16 +75,22 @@ class AdService {
|
|||
return completer.future;
|
||||
}
|
||||
|
||||
/// Shows the preloaded interstitial if this is the first call this app
|
||||
/// session; every call after that (or if no ad ever became available) is
|
||||
/// a no-op. Waits for the ad to actually be dismissed before returning,
|
||||
/// so the caller — the sync engine, right before it starts uploading —
|
||||
/// genuinely happens *after* the ad, not just alongside it; bounded so a
|
||||
/// slow ad load or a stuck ad SDK callback can never block sync
|
||||
/// indefinitely.
|
||||
Future<void> maybeShowBeforeSync() async {
|
||||
if (_shownThisSession) return;
|
||||
_shownThisSession = true;
|
||||
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
|
||||
/// ad was already shown within [gateValidity]; otherwise shows the
|
||||
/// preloaded interstitial and returns [AdGateResult.justShown] once it
|
||||
/// actually starts displaying (the only "watched it" signal a plain
|
||||
/// interstitial — as opposed to a rewarded ad — can give us), or
|
||||
/// [AdGateResult.blocked] if none was available in time or it failed to
|
||||
/// show. A [AdGateResult.blocked] result means the caller must not
|
||||
/// proceed with uploading.
|
||||
///
|
||||
/// Bounded throughout so a slow ad load or a stuck ad SDK callback can
|
||||
/// never hang a sync indefinitely. Always lines up the next interstitial
|
||||
/// afterward, whether this attempt succeeded or not, so the next call —
|
||||
/// whether that's because this one failed or because [gateValidity]
|
||||
/// elapsed — has the best chance of a preloaded ad ready to go.
|
||||
Future<AdGateResult> showGateAd() async {
|
||||
if (_gateOpen) return AdGateResult.alreadyOpen;
|
||||
|
||||
if (_preloadedAd == null) {
|
||||
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
|
||||
|
|
@ -76,21 +98,27 @@ class AdService {
|
|||
|
||||
final ad = _preloadedAd;
|
||||
_preloadedAd = null;
|
||||
if (ad == null) return;
|
||||
if (ad == null) {
|
||||
unawaited(_preload());
|
||||
return AdGateResult.blocked;
|
||||
}
|
||||
|
||||
final dismissed = Completer<void>();
|
||||
final showed = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
onAdShowedFullScreenContent: (ad) {
|
||||
_lastShownAt = DateTime.now();
|
||||
if (!showed.isCompleted) showed.complete(true);
|
||||
},
|
||||
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
if (!showed.isCompleted) showed.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show();
|
||||
await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {});
|
||||
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||
unawaited(_preload());
|
||||
return opened ? AdGateResult.justShown : AdGateResult.blocked;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
|||
const _prefsKeyThemeMode = 'theme_mode';
|
||||
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
|
||||
const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement';
|
||||
const _prefsKeyShowEstimatedFuelRefund = 'show_estimated_fuel_refund';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
|
|
@ -91,6 +92,12 @@ class AppState extends ChangeNotifier {
|
|||
DateTime? lastSyncedAt;
|
||||
Object? lastSyncError;
|
||||
|
||||
/// Set by the widget tree at startup (see `main.dart`) to show the
|
||||
/// "remove ads for a year" upsell — [syncNow] calls this the moment an ad
|
||||
/// was actually just shown to gate an upload, never on a sync that finds
|
||||
/// the gate already open from a recent prior view.
|
||||
VoidCallback? onAdWatched;
|
||||
|
||||
bool keepReceiptPhotosLocally = false;
|
||||
int staleLockMinutes = defaultStaleLockMinutes;
|
||||
|
||||
|
|
@ -104,6 +111,14 @@ class AppState extends ChangeNotifier {
|
|||
|
||||
ThemeMode themeMode = ThemeMode.system;
|
||||
|
||||
/// Whether to show an estimated Missouri Highway Fuel Tax Refund amount
|
||||
/// alongside gallons/cost figures throughout the app (Receipts hero
|
||||
/// banner, receipt line items, report totals — see
|
||||
/// lib/services/estimated_refund.dart). Off by default: it's a rough,
|
||||
/// rate-contingent estimate, not something every user necessarily wants
|
||||
/// cluttering their totals.
|
||||
bool showEstimatedFuelRefund = false;
|
||||
|
||||
/// Whether the first-launch guided tour ([OnboardingTour]) has already
|
||||
/// been shown. Defaults to `true` here (not `false`) specifically so
|
||||
/// that widget tests constructing `AppState()` directly and skipping
|
||||
|
|
@ -183,6 +198,7 @@ class AppState extends ChangeNotifier {
|
|||
);
|
||||
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
|
||||
hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false;
|
||||
showEstimatedFuelRefund = prefs.getBool(_prefsKeyShowEstimatedFuelRefund) ?? false;
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
|
|
@ -365,19 +381,24 @@ class AppState extends ChangeNotifier {
|
|||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
||||
|
||||
// A user with a currently-active "remove ads for a year" purchase
|
||||
// skips the ad entirely — beforeUpload stays null (CloudSyncService
|
||||
// treats that as "nothing to do here", same as any other call site
|
||||
// that never passed one) and the ad SDK isn't even touched this sync.
|
||||
Future<void> Function()? beforeUpload;
|
||||
// skips the gate entirely — beforeUpload stays null (CloudSyncService
|
||||
// treats that as "nothing to check", same as any other call site that
|
||||
// never passed one) and the ad SDK isn't even touched this sync.
|
||||
Future<bool> Function()? beforeUpload;
|
||||
if (!adsCurrentlyDisabled) {
|
||||
// Idempotent, and only ever reached once sync is actually configured
|
||||
// — a user who never connects cloud storage never triggers any
|
||||
// ad-related activity at all. Not awaited: it's fine if this
|
||||
// session's one ad is still loading by the time beforeUpload below
|
||||
// is reached — maybeShowBeforeSync degrades gracefully (just skips
|
||||
// showing it) if it isn't ready in time.
|
||||
// ad-related activity at all.
|
||||
unawaited(adService.initialize());
|
||||
beforeUpload = adService.maybeShowBeforeSync;
|
||||
beforeUpload = () async {
|
||||
final result = await adService.showGateAd();
|
||||
// Fired, not awaited: the upsell dialog is purely informational —
|
||||
// this sync (specifically the upload [beforeUpload] is about to
|
||||
// unblock) shouldn't wait on however long it takes the user to
|
||||
// dismiss it.
|
||||
if (result == AdGateResult.justShown) onAdWatched?.call();
|
||||
return result != AdGateResult.blocked;
|
||||
};
|
||||
}
|
||||
|
||||
isSyncing = true;
|
||||
|
|
@ -393,6 +414,12 @@ class AppState extends ChangeNotifier {
|
|||
await _refreshFromDatabase();
|
||||
lastSyncedAt = DateTime.now();
|
||||
lastSyncError = null;
|
||||
} else if (result.adGateBlocked) {
|
||||
// Whatever the remote side had was still pulled/merged in — only the
|
||||
// upload was withheld — so the on-screen data should reflect that
|
||||
// even though this doesn't count as a completed sync.
|
||||
await _refreshFromDatabase();
|
||||
lastSyncError = 'Watch a short ad to finish syncing your data to the cloud.';
|
||||
} else if (result.error != null) {
|
||||
lastSyncError = result.error;
|
||||
}
|
||||
|
|
@ -422,19 +449,39 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowEstimatedFuelRefund(bool value) async {
|
||||
showEstimatedFuelRefund = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyShowEstimatedFuelRefund, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Kicks off the platform purchase UI for a year of no ads — see
|
||||
/// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted
|
||||
/// asynchronously, once the store confirms the purchase (see
|
||||
/// [_grantAdFreeYear]), not immediately when this returns.
|
||||
Future<void> buyAdFreeYear() => purchaseService.buyAdFreeYear();
|
||||
|
||||
/// [PurchaseService]'s `onPurchaseGranted` callback: records a fresh
|
||||
/// year of ad-free time starting now, regardless of any time already
|
||||
/// remaining on a previous purchase — buying again before the current
|
||||
/// year lapses simply resets the clock rather than stacking.
|
||||
Future<void> _grantAdFreeYear() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
await database.setAdFreeUntil(now.add(const Duration(days: 365)), now);
|
||||
/// Re-checks Play Billing for an active purchase and, if one is found,
|
||||
/// re-grants the entitlement locally — see [PurchaseService.restorePurchase].
|
||||
/// For a user who reinstalled or switched devices without cloud backup
|
||||
/// connected, so there was nothing local to sync the entitlement back
|
||||
/// down from. Safe to call any time; does nothing if there's no active
|
||||
/// purchase to find.
|
||||
Future<void> restoreAdFreeYear() => purchaseService.restorePurchase();
|
||||
|
||||
/// [PurchaseService]'s `onPurchaseGranted` callback: records a year of
|
||||
/// ad-free time anchored to [purchaseTime] — Play Billing's own record of
|
||||
/// when the purchase actually happened, not "now" — so restoring a
|
||||
/// purchase made months ago correctly reflects however much of that year
|
||||
/// is already gone, rather than handing out a fresh extra year. Buying
|
||||
/// again before the current year lapses simply resets the clock to a
|
||||
/// fresh year from that new purchase, rather than stacking.
|
||||
Future<void> _grantAdFreeYear(DateTime purchaseTime) async {
|
||||
await database.setAdFreeUntil(
|
||||
purchaseTime.add(const Duration(days: 365)),
|
||||
DateTime.now().toUtc(),
|
||||
);
|
||||
purchaseError = null;
|
||||
await _persist();
|
||||
}
|
||||
|
|
@ -547,13 +594,19 @@ class AppState extends ChangeNotifier {
|
|||
return entry;
|
||||
}
|
||||
|
||||
/// Corrects the logged values (date, gallons, price/gal, total cost) for
|
||||
/// an existing fuel entry — the receipt photo itself isn't editable here,
|
||||
/// only the data recorded about it (e.g. fixing a misread OCR value).
|
||||
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
|
||||
/// carries over untouched.
|
||||
/// Corrects the logged values (date, gallons, price/gal, total cost, and
|
||||
/// which vehicle it's attached to) for an existing fuel entry — the
|
||||
/// receipt photo itself isn't editable here, only the data recorded
|
||||
/// about it (e.g. fixing a misread OCR value, or a receipt that got
|
||||
/// logged under the wrong vehicle). [entryId]'s receipt photo reference
|
||||
/// (local path and/or cloud file id) carries over untouched even when
|
||||
/// [vehicleId] changes — the underlying photo file stays exactly where
|
||||
/// it already is (including whichever vehicle's folder it was filed
|
||||
/// under locally/in the cloud); only the database's own record of which
|
||||
/// vehicle owns this entry moves.
|
||||
Future<void> updateFuelEntry({
|
||||
required String entryId,
|
||||
required String vehicleId,
|
||||
required DateTime date,
|
||||
required double gallons,
|
||||
required double pricePerGallon,
|
||||
|
|
@ -562,7 +615,7 @@ class AppState extends ChangeNotifier {
|
|||
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
final updated = FuelEntry(
|
||||
id: existing.id,
|
||||
vehicleId: existing.vehicleId,
|
||||
vehicleId: vehicleId,
|
||||
date: date,
|
||||
gallons: gallons,
|
||||
pricePerGallon: pricePerGallon,
|
||||
|
|
|
|||
|
|
@ -12,15 +12,30 @@ class SyncResult {
|
|||
final bool ranSync;
|
||||
final Object? error;
|
||||
|
||||
/// True when pull/merge completed but [CloudSyncService.syncNow]'s
|
||||
/// `beforeUpload` gate (the app's ad-watch requirement — see
|
||||
/// [AdService.showGateAd]) came back closed, so nothing local was
|
||||
/// uploaded. Remote changes, if any, were still merged in.
|
||||
final bool adGateBlocked;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
error = null;
|
||||
error = null,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.success()
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
error = null,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.failure(this.error) : ranSync = false;
|
||||
SyncResult.failure(this.error)
|
||||
: ranSync = false,
|
||||
adGateBlocked = false;
|
||||
|
||||
SyncResult.adGateBlocked()
|
||||
: ranSync = false,
|
||||
error = null,
|
||||
adGateBlocked = true;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of sync against the shared cloud folder — same
|
||||
|
|
@ -144,14 +159,16 @@ class CloudSyncService {
|
|||
///
|
||||
/// [beforeUpload], if given, is awaited once — after any pull/merge of
|
||||
/// remote changes has finished, but before anything local gets uploaded
|
||||
/// (pending receipt photos or the database snapshot itself). This is the
|
||||
/// one hook [AppState] uses to show the app's single ad placement, since
|
||||
/// it's meant to run before *pushing* local data up, not before *pulling*
|
||||
/// remote data down.
|
||||
/// (pending receipt photos or the database snapshot itself) — and its
|
||||
/// result decides whether the upload happens at all. This is the hook
|
||||
/// [AppState] uses to gate uploads behind the app's ad placement: a
|
||||
/// `false` return means the gate is closed (see [AdService.showGateAd])
|
||||
/// and this call returns [SyncResult.adGateBlocked] without uploading
|
||||
/// anything, having still pulled/merged whatever the remote side had.
|
||||
Future<SyncResult> syncNow({
|
||||
bool keepLocalReceiptCopies = false,
|
||||
Duration staleLockAge = const Duration(minutes: 10),
|
||||
Future<void> Function()? beforeUpload,
|
||||
Future<bool> Function()? beforeUpload,
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!provider.isSignedIn || appFolderId == null) {
|
||||
|
|
@ -180,8 +197,8 @@ class CloudSyncService {
|
|||
await _pullAndMerge(session, remoteInfo.id);
|
||||
}
|
||||
|
||||
if (beforeUpload != null) {
|
||||
await beforeUpload();
|
||||
if (beforeUpload != null && !await beforeUpload()) {
|
||||
return SyncResult.adGateBlocked();
|
||||
}
|
||||
|
||||
final allReceiptsUploaded =
|
||||
|
|
|
|||
26
lib/services/estimated_refund.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/// The Missouri Highway Fuel Tax Refund rate per gallon — the basis for
|
||||
/// [estimatedFuelRefund]. Set by Missouri law and subject to change at any
|
||||
/// time; the UI Settings toggle that turns these estimates on says as much,
|
||||
/// and this is deliberately the highway rate only (not the separate,
|
||||
/// higher non-highway-use refund) since that's the one that applies to
|
||||
/// ordinary vehicle fill-ups this app is built around.
|
||||
const moHighwayFuelTaxRefundRatePerGallon = 0.125;
|
||||
|
||||
/// A rough estimate of the Missouri Highway Fuel Tax Refund for [gallons]
|
||||
/// of fuel, at the current rate. Purely informational — not tax advice,
|
||||
/// and not a substitute for confirming the actual claimable amount with
|
||||
/// the Missouri Department of Revenue.
|
||||
///
|
||||
/// [gallons] is rounded to the nearest whole gallon *before* multiplying —
|
||||
/// matching how the actual refund calculation works, rather than
|
||||
/// multiplying the precise (fractional) logged amount. The result is then
|
||||
/// rounded down to the nearest cent, never up, so this never overstates
|
||||
/// what's actually claimable.
|
||||
double estimatedFuelRefund(double gallons) {
|
||||
final roundedGallons = gallons.roundToDouble();
|
||||
final rawRefund = roundedGallons * moHighwayFuelTaxRefundRatePerGallon;
|
||||
// The tiny epsilon guards against binary floating-point representation
|
||||
// error nudging an exact cent value (e.g. 2.50) just under its true
|
||||
// value and floor()ing it down a cent it doesn't actually owe.
|
||||
return (rawRefund * 100 + 1e-9).floorToDouble() / 100;
|
||||
}
|
||||
|
|
@ -106,6 +106,18 @@ class FuelReport {
|
|||
}
|
||||
}
|
||||
|
||||
/// The date range the Reports tab defaults to on open: a year-long window
|
||||
/// from July 1 through the following June 30, matching how Missouri's
|
||||
/// fuel tax refund periods run. Which specific year that window falls in
|
||||
/// rolls over on August 1: from August of year Y through July of year
|
||||
/// Y+1, this stays July Y–June (Y+1) throughout — the most recently
|
||||
/// completed (or currently running) period — rather than jumping to a
|
||||
/// brand new, still-empty one the moment August 1 hits.
|
||||
(DateTime start, DateTime end) defaultReportDateRange(DateTime now) {
|
||||
final fiscalStartYear = now.month >= 8 ? now.year : now.year - 1;
|
||||
return (DateTime(fiscalStartYear, 7, 1), DateTime(fiscalStartYear + 1, 6, 30));
|
||||
}
|
||||
|
||||
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
|
||||
/// judged by each entry's purchase date, in local time), restricted to
|
||||
/// entries that actually have a receipt attached — a report meant to
|
||||
|
|
|
|||
|
|
@ -1,35 +1,52 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
/// Wraps the app's one purchasable product: a *consumable* one-time
|
||||
/// purchase that grants a year of no ads (see
|
||||
/// [AppState.adsCurrentlyDisabled]) — consumable specifically so it can be
|
||||
/// bought again once that year lapses, unlike a plain non-consumable
|
||||
/// (which Play Store would only ever let you own once, permanently) or an
|
||||
/// auto-renewing subscription (which would charge the user again every
|
||||
/// year without them actively choosing to).
|
||||
/// Wraps the app's one purchasable product: a one-year, non-auto-renewing
|
||||
/// *prepaid subscription* that grants a year of no ads (see
|
||||
/// [AppState.adsCurrentlyDisabled]).
|
||||
///
|
||||
/// Prepaid subscription, specifically — not a consumable, a plain
|
||||
/// non-consumable, or an auto-renewing subscription:
|
||||
/// - A *consumable* has to be acknowledged (= consumed, for this product
|
||||
/// type) within 3 days of purchase or Play Billing auto-refunds it —
|
||||
/// there's no way to leave it unconsumed so it stays restorable for the
|
||||
/// whole year. And once consumed, Play Billing forgets it existed, so a
|
||||
/// user who loses local data (no cloud backup connected) has nothing
|
||||
/// left to restore from. This is what this product used to be, before
|
||||
/// that gap was found.
|
||||
/// - A plain *non-consumable* would only ever let a Google account buy it
|
||||
/// once, permanently — doesn't fit "buy another year once this one
|
||||
/// lapses".
|
||||
/// - An *auto-renewing subscription* would charge the user again every
|
||||
/// year without them actively choosing to.
|
||||
/// - A *prepaid* subscription base plan fits all three constraints: it's
|
||||
/// acknowledged immediately (same 3-day rule, satisfied same as any
|
||||
/// purchase), stays active — and restorable via [restorePurchase] — for
|
||||
/// its whole prepaid year without needing to be consumed, then simply
|
||||
/// expires with no charge and can be bought again.
|
||||
///
|
||||
/// [adFreeYearProductId] is a placeholder — nothing will actually load or
|
||||
/// be purchasable until a real in-app product with this same ID exists in
|
||||
/// your Google Play Console (Monetize > Products > In-app products),
|
||||
/// configured as a *managed product*, with whatever price you choose
|
||||
/// there (Play Billing doesn't take a price from the app itself). Rename
|
||||
/// the constant below to match whatever product ID you actually create,
|
||||
/// if you'd rather not use this one.
|
||||
/// be purchasable until a real subscription with this same ID exists in
|
||||
/// your Google Play Console (Monetize > Products > Subscriptions), with a
|
||||
/// *prepaid* base plan named [_basePlanId] and whatever price/duration you
|
||||
/// choose there (Play Billing doesn't take a price from the app itself).
|
||||
/// Rename either constant to match whatever you actually create, if you'd
|
||||
/// rather not use these.
|
||||
class PurchaseService {
|
||||
static const adFreeYearProductId = 'ad_free_year';
|
||||
static const _basePlanId = 'ad-free-year-prepaid';
|
||||
|
||||
/// Called once for every successful (or restored) purchase of
|
||||
/// [adFreeYearProductId] — [AppState] is what actually records the
|
||||
/// resulting entitlement; this service only reports that a purchase
|
||||
/// happened.
|
||||
final void Function() onPurchaseGranted;
|
||||
/// [adFreeYearProductId], with the time Play Billing recorded the
|
||||
/// purchase actually happening — [AppState] anchors the year of ad-free
|
||||
/// time to that, not to "now", so restoring an existing purchase doesn't
|
||||
/// hand out a free extra year on top of time already elapsed.
|
||||
final void Function(DateTime purchaseTime) onPurchaseGranted;
|
||||
|
||||
/// Called with a user-facing message when a purchase attempt fails —
|
||||
/// [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Called with a user-facing message when a purchase or restore attempt
|
||||
/// fails — [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Settings screen to display.
|
||||
final void Function(String message)? onPurchaseError;
|
||||
|
||||
|
|
@ -40,8 +57,8 @@ class PurchaseService {
|
|||
|
||||
/// The store's own formatted, localized price string (e.g. `"$4.99"`)
|
||||
/// once [initialize] has loaded the product — null before that, or if
|
||||
/// the product ID above doesn't match anything configured in the store
|
||||
/// yet.
|
||||
/// neither the product ID nor base plan ID above matches anything
|
||||
/// configured in the store yet.
|
||||
String? get priceLabel => _product?.price;
|
||||
|
||||
Future<void> initialize() async {
|
||||
|
|
@ -55,23 +72,55 @@ class PurchaseService {
|
|||
);
|
||||
|
||||
final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId});
|
||||
if (response.productDetails.isNotEmpty) {
|
||||
_product = response.productDetails.first;
|
||||
_product = _selectOffer(response.productDetails);
|
||||
}
|
||||
|
||||
/// For a subscription product, [InAppPurchase.queryProductDetails]
|
||||
/// returns one [ProductDetails] per offer/base-plan combination it has,
|
||||
/// not one per product ID — picks the prepaid base plan set up for this
|
||||
/// product specifically, falling back to whichever offer loaded first so
|
||||
/// a Play Console setup with only the one base plan (the expected,
|
||||
/// common case here) still works without its name needing to match
|
||||
/// exactly.
|
||||
ProductDetails? _selectOffer(List<ProductDetails> offers) {
|
||||
for (final offer in offers) {
|
||||
if (offer is! GooglePlayProductDetails) continue;
|
||||
final index = offer.subscriptionIndex;
|
||||
final basePlanId =
|
||||
index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId;
|
||||
if (basePlanId == _basePlanId) return offer;
|
||||
}
|
||||
return offers.isEmpty ? null : offers.first;
|
||||
}
|
||||
|
||||
/// Kicks off the platform purchase UI. Does nothing (and reports an
|
||||
/// error) if the product hasn't loaded — either the store isn't
|
||||
/// available, or [adFreeYearProductId] doesn't match a real product yet.
|
||||
/// available, or neither [adFreeYearProductId] nor [_basePlanId] matches
|
||||
/// a real product yet.
|
||||
Future<void> buyAdFreeYear() async {
|
||||
final product = _product;
|
||||
if (product == null) {
|
||||
onPurchaseError?.call("This purchase isn't available right now.");
|
||||
return;
|
||||
}
|
||||
await InAppPurchase.instance.buyConsumable(
|
||||
purchaseParam: PurchaseParam(productDetails: product),
|
||||
);
|
||||
final purchaseParam = product is GooglePlayProductDetails
|
||||
? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken)
|
||||
: PurchaseParam(productDetails: product);
|
||||
await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
}
|
||||
|
||||
/// Re-derives the local entitlement from Play Billing's own record of an
|
||||
/// active (not yet expired) prepaid purchase — for a user who reinstalled
|
||||
/// or switched devices without cloud backup connected, where nothing
|
||||
/// local survived to sync the entitlement back down. A silent no-op if
|
||||
/// there's nothing currently active to find, which is the normal outcome
|
||||
/// for anyone who's never purchased, so that's not treated as an error.
|
||||
Future<void> restorePurchase() async {
|
||||
try {
|
||||
await InAppPurchase.instance.restorePurchases();
|
||||
} on InAppPurchaseException catch (e) {
|
||||
onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
|
||||
|
|
@ -80,16 +129,7 @@ class PurchaseService {
|
|||
case PurchaseStatus.purchased:
|
||||
case PurchaseStatus.restored:
|
||||
if (purchase.productID == adFreeYearProductId) {
|
||||
onPurchaseGranted();
|
||||
}
|
||||
// Android specifically: a *consumable* purchase has to be
|
||||
// explicitly "consumed" or Play Billing considers it still owned
|
||||
// and refuses to sell it again next year. iOS has no equivalent
|
||||
// step — StoreKit consumables are inherently one-shot already.
|
||||
if (Platform.isAndroid) {
|
||||
final androidAddition =
|
||||
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
|
||||
await androidAddition.consumePurchase(purchase);
|
||||
onPurchaseGranted(_purchaseTime(purchase));
|
||||
}
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
await InAppPurchase.instance.completePurchase(purchase);
|
||||
|
|
@ -106,6 +146,18 @@ class PurchaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Play reports this as epoch milliseconds in a string (see
|
||||
/// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the
|
||||
/// current time in the shouldn't-happen case that it's missing or
|
||||
/// malformed, rather than leaving the entitlement ungranted over a
|
||||
/// parsing hiccup.
|
||||
DateTime _purchaseTime(PurchaseDetails purchase) {
|
||||
final millis = int.tryParse(purchase.transactionDate ?? '');
|
||||
return millis == null
|
||||
? DateTime.now().toUtc()
|
||||
: DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
}
|
||||
|
|
|
|||
40
lib/widgets/ad_free_upsell_dialog.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
|
||||
/// Shown right after a user watches the app's one ad placement (see
|
||||
/// [AdService.showGateAd] / [AppState.syncNow]) — not on every sync, only
|
||||
/// the moment an ad was actually just shown to them, since that's when the
|
||||
/// "you could skip these" pitch is most relevant. Purely informational: it
|
||||
/// doesn't block or delay the sync that triggered it, which is already
|
||||
/// proceeding underneath it by the time this appears.
|
||||
Future<void> showAdFreeUpsellDialog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Tired of Ads?'),
|
||||
content: Consumer<AppState>(
|
||||
builder: (context, appState, _) => Text(
|
||||
appState.adFreeYearPriceLabel == null
|
||||
? "You can remove ads for a full year with a single one-time purchase."
|
||||
: 'You can remove ads for a full year for '
|
||||
'${appState.adFreeYearPriceLabel} — a single one-time purchase.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Not Now'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.read<AppState>().buyAdFreeYear();
|
||||
},
|
||||
child: const Text('Remove Ads'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../screens/data_settings_screen.dart';
|
||||
import '../screens/faq_screen.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'onboarding_tour_overlay.dart';
|
||||
|
||||
const _backedUpFaqQuestion = 'Is my data backed up?';
|
||||
|
||||
/// Shown right after a fuel entry is saved (see
|
||||
/// `ConfirmFuelEntryScreen._save`) when no cloud backup is configured yet —
|
||||
/// nudges the user toward Settings > Data before they forget and eventually
|
||||
|
|
@ -21,6 +25,19 @@ Future<bool> showBackupReminderDialog(BuildContext context) async {
|
|||
'Would you like to set up a backup method now?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Resolves as "not now" (they didn't choose to set backup up
|
||||
// directly), but still takes them somewhere useful about it.
|
||||
Navigator.of(context).pop(false);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const FaqScreen(initiallyExpandedQuestion: _backedUpFaqQuestion),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Learn More'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Not Now')),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Set Up Backup')),
|
||||
],
|
||||
|
|
@ -80,6 +97,34 @@ class _SpotlightedDataSettingsScreenState extends State<_SpotlightedDataSettings
|
|||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
if (!mounted) return;
|
||||
// This screen was reached via Navigator.push (see openCloudBackupSetup
|
||||
// below) — wait for that push transition to fully settle before
|
||||
// capturing the card's position, or the spotlight ends up shifted by
|
||||
// however far the slide-in hadn't yet finished. ModalRoute.of(context)
|
||||
// correctly resolves to that pushed route here, since this State's
|
||||
// context lives inside it.
|
||||
await waitForRouteTransition(ModalRoute.of(context)?.animation);
|
||||
if (!mounted) return;
|
||||
// The route's own AnimationController reports AnimationStatus.completed
|
||||
// at this point, but empirically the render tree's transforms (from
|
||||
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
|
||||
// Android default as of Flutter 3.44) still reflect a mid-transition
|
||||
// position for a few more frames after that — confirmed by walking the
|
||||
// RenderObject ancestor chain and finding an active
|
||||
// RenderFractionalTranslation still present, with a position matching
|
||||
// the transition's *starting* offset rather than its resting
|
||||
// Offset.zero. These extra frames give it time to actually settle
|
||||
// before the spotlight measures anything. SchedulerBinding.endOfFrame
|
||||
// (rather than a bare Future.delayed) so this genuinely waits on real
|
||||
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
|
||||
// while something has an actual frame scheduled, which a bare delay
|
||||
// timer doesn't count as once the route's own transition has already
|
||||
// finished.
|
||||
for (var i = 0; i < 10; i++) {
|
||||
if (!mounted) return;
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
_entry = OverlayEntry(
|
||||
builder: (_) => OnboardingTourOverlay(
|
||||
|
|
|
|||
|
|
@ -68,12 +68,15 @@ class HeroStat extends StatelessWidget {
|
|||
}
|
||||
|
||||
/// The icon + two-stat row shared by every hero banner: gallons on the
|
||||
/// left, the hero icon in the middle, cost on the right.
|
||||
/// left, the hero icon (or, if [estimatedRefundValue] is given, the
|
||||
/// estimated fuel refund in its place — see the Receipts tab, the only
|
||||
/// caller that ever passes it) in the middle, cost on the right.
|
||||
class HeroStatsRow extends StatelessWidget {
|
||||
final String gallonsValue;
|
||||
final String costValue;
|
||||
final String gallonsLabel;
|
||||
final String costLabel;
|
||||
final String? estimatedRefundValue;
|
||||
|
||||
const HeroStatsRow({
|
||||
super.key,
|
||||
|
|
@ -81,6 +84,7 @@ class HeroStatsRow extends StatelessWidget {
|
|||
required this.costValue,
|
||||
this.gallonsLabel = 'Total Gallons',
|
||||
this.costLabel = 'Total Fuel Cost',
|
||||
this.estimatedRefundValue,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -91,18 +95,58 @@ class HeroStatsRow extends StatelessWidget {
|
|||
Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
// Tall enough to extend past the bottom of the label+value text
|
||||
// next to it, not just match the label's height — width scales
|
||||
// automatically from the source's own aspect ratio since only
|
||||
// height is given.
|
||||
child: Image.asset(
|
||||
'assets/icon/hero_icon.png',
|
||||
height: 58,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: estimatedRefundValue == null
|
||||
// Tall enough to extend past the bottom of the label+value
|
||||
// text next to it, not just match the label's height — width
|
||||
// scales automatically from the source's own aspect ratio
|
||||
// since only height is given.
|
||||
? Image.asset(
|
||||
'assets/icon/hero_icon.png',
|
||||
height: 58,
|
||||
fit: BoxFit.contain,
|
||||
)
|
||||
: _EstimatedRefundStat(value: estimatedRefundValue!),
|
||||
),
|
||||
Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The estimated-fuel-refund stat that takes the hero icon's place in the
|
||||
/// middle of [HeroStatsRow] when that's turned on — smaller/more compact
|
||||
/// than [HeroStat] since it sits in the narrower middle slot rather than
|
||||
/// an [Expanded] one.
|
||||
class _EstimatedRefundStat extends StatelessWidget {
|
||||
final String value;
|
||||
|
||||
const _EstimatedRefundStat({required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Est. Refund',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppTheme.heroTextSecondary(context),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.heroText,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,41 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Waits for a route's push/pop transition, if one is still in progress, to
|
||||
/// finish. Needed before capturing a highlighted target's on-screen
|
||||
/// position via `RenderBox.localToGlobal` (see `MainShell._waitForTargets`
|
||||
/// / `_SpotlightedDataSettingsScreen`'s `_showSpotlight` in
|
||||
/// backup_reminder.dart, both of which push a [MaterialPageRoute] and then
|
||||
/// spotlight something on it): a [RenderBox] reports `hasSize` — and a
|
||||
/// real, but not yet final, global position — as soon as its first layout
|
||||
/// pass completes, which happens well before a [MaterialPageRoute]'s
|
||||
/// ~300ms slide-in transition actually settles. Capturing the position too
|
||||
/// early bakes in wherever the page was mid-slide, which is what produced
|
||||
/// a spotlight rectangle visibly shifted from its target.
|
||||
///
|
||||
/// Takes the [Animation] directly — rather than a [BuildContext] to look
|
||||
/// one up via `ModalRoute.of` — because the caller doesn't always have a
|
||||
/// [BuildContext] that's actually inside the route in question: `MainShell`
|
||||
/// pushes the route from its own (different, already-settled) route, so
|
||||
/// `ModalRoute.of(mainShellContext)` would resolve to the wrong one.
|
||||
/// Passing null (nothing to wait for) or an already-[AnimationStatus.completed]
|
||||
/// animation both resolve immediately, so it's safe to call unconditionally.
|
||||
Future<void> waitForRouteTransition(Animation<double>? animation) async {
|
||||
if (animation == null || animation.status == AnimationStatus.completed) return;
|
||||
final completer = Completer<void>();
|
||||
void listener(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
animation.addStatusListener(listener);
|
||||
await completer.future;
|
||||
animation.removeStatusListener(listener);
|
||||
}
|
||||
|
||||
/// The full-screen "spotlight" shown by one step of the first-launch
|
||||
/// guided tour: a dimmed barrier with a cut-out hole around each of
|
||||
/// [targetKeys]' current on-screen positions (or, for any key that isn't
|
||||
|
|
|
|||
263
privacy-policy.html
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Privacy Policy — Show Me The Fuel Refund</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #24603a;
|
||||
--brand-ink: #163d26;
|
||||
--page-bg: #eeece3;
|
||||
--paper: #fdfcfa;
|
||||
--ink: #1c201d;
|
||||
--ink-2: #5b645d;
|
||||
--line: #e4e1d6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--page-bg); }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--page-bg);
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
}
|
||||
|
||||
.policy {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
margin: 0;
|
||||
height: fit-content;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(28, 32, 29, 0.04), 0 18px 40px -24px rgba(28, 32, 29, 0.22);
|
||||
padding: 48px 44px 60px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { padding: 0; }
|
||||
.policy { border-radius: 0; border: none; padding: 40px 24px 60px; }
|
||||
}
|
||||
|
||||
.policy header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 22px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.policy header .mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: var(--brand);
|
||||
flex: none;
|
||||
}
|
||||
.policy header .app-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.policy h1 {
|
||||
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-weight: 400;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
color: var(--brand-ink);
|
||||
margin: 0 0 6px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.policy .effective {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink-2);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.policy .intro {
|
||||
font-size: 16.5px;
|
||||
color: var(--ink-2);
|
||||
margin: 0 0 40px;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.policy h2 {
|
||||
font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-weight: 400;
|
||||
font-size: 20px;
|
||||
color: var(--ink);
|
||||
margin: 44px 0 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.policy section:first-of-type h2 { border-top: none; padding-top: 0; margin-top: 0; }
|
||||
|
||||
.policy p { margin: 0 0 14px; max-width: 62ch; }
|
||||
.policy ul { margin: 0 0 14px; padding-left: 22px; max-width: 60ch; }
|
||||
.policy li { margin: 0 0 6px; }
|
||||
|
||||
.policy a { color: var(--brand-ink); }
|
||||
.policy a:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
||||
|
||||
.policy strong { font-weight: 600; }
|
||||
|
||||
.policy footer {
|
||||
margin-top: 56px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13.5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div class="policy">
|
||||
<header>
|
||||
<div class="mark" aria-hidden="true"></div>
|
||||
<span class="app-name">Show Me The Fuel Refund</span>
|
||||
</header>
|
||||
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="effective">Effective date: August 17, 2026</p>
|
||||
<p class="intro">
|
||||
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
|
||||
<a href="mailto:ohbrer+ShowMeTheFuelRefund@gmail.com">ohbrer+ShowMeTheFuelRefund@gmail.com</a>.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Overview</h2>
|
||||
<p>
|
||||
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 <strong>you</strong> choose and control. We (the
|
||||
developer) never receive, see, or have access to a copy of your data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Stored Locally</h2>
|
||||
<p>
|
||||
The app stores the following on your device only, unless you connect a
|
||||
cloud storage account (see below):
|
||||
</p>
|
||||
<ul>
|
||||
<li>Vehicle information you enter (VIN, nickname)</li>
|
||||
<li>Fuel purchase records (date, gallons, price, total cost)</li>
|
||||
<li>Receipt photos you capture or select</li>
|
||||
</ul>
|
||||
<p>
|
||||
This data is never transmitted to us. Uninstalling the app, or never
|
||||
connecting a cloud backup, means this data exists only on that device.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Optional Cloud Backup</h2>
|
||||
<p>
|
||||
If you connect a cloud storage account in Settings, a copy of the data
|
||||
above is also stored there — in storage <strong>you</strong> control:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Google Drive</strong> — via Google Sign-In and the Drive API</li>
|
||||
<li><strong>Dropbox</strong> — via the Dropbox API</li>
|
||||
<li><strong>Microsoft OneDrive</strong> — via the Microsoft Graph API</li>
|
||||
<li><strong>WebDAV</strong> — your own self-hosted server (Nextcloud, ownCloud, or similar)</li>
|
||||
</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Advertising (Google AdMob)</h2>
|
||||
<p>
|
||||
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
|
||||
<a href="https://policies.google.com/privacy">Google's own privacy policy</a>
|
||||
and
|
||||
<a href="https://support.google.com/admob/answer/6128543">AdMob's data disclosure</a>
|
||||
— it is not something this app adds on top of, and we have no
|
||||
access to it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>In-App Purchases</h2>
|
||||
<p>
|
||||
"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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What We Don't Do</h2>
|
||||
<ul>
|
||||
<li>No analytics SDKs</li>
|
||||
<li>No crash-reporting SDKs</li>
|
||||
<li>No tracking of your usage or behavior</li>
|
||||
<li>No server of ours that receives, stores, or processes your data</li>
|
||||
<li>No selling or sharing of data with anyone, for any reason</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Deletion</h2>
|
||||
<p>
|
||||
You can delete your data at any time from <strong>Settings > Data >
|
||||
Advanced</strong> (purge all data, or a specific date range), and
|
||||
disconnect any cloud storage account from <strong>Settings >
|
||||
Data</strong>. 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Children's Privacy</h2>
|
||||
<p>
|
||||
This app is not directed at children under 13 and does not knowingly
|
||||
collect data from them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes to This Policy</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Questions, problem receipts/VINs, or error reports:
|
||||
<a href="mailto:ohbrer+ShowMeTheFuelRefund@gmail.com">ohbrer+ShowMeTheFuelRefund@gmail.com</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.0+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
|
|
|||
BIN
store_assets/feature_graphic_1024x500.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
store_assets/play_store_icon_256.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
store_assets/play_store_icon_512.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
store_assets/screenshots/1_receipts.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
store_assets/screenshots/2_report.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
store_assets/screenshots/3_vehicles.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
store_assets/screenshots/4_vehicle_detail.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
store_assets/screenshots/5_report_preview.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
69
test/ad_free_upsell_dialog_test.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
import 'package:fuel_tax_tracker/widgets/ad_free_upsell_dialog.dart';
|
||||
|
||||
/// Covers the "remove ads for a year" upsell shown right after a user
|
||||
/// watches the app's one ad placement — see `AppState.syncNow`'s
|
||||
/// `onAdWatched` hook, wired up in main.dart. Tested here as a standalone
|
||||
/// dialog function, matching backup_reminder_test.dart's convention.
|
||||
void main() {
|
||||
Future<void> openDialog(WidgetTester tester, AppState appState) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => showAdFreeUpsellDialog(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows the pitch with both actions', (tester) async {
|
||||
await openDialog(tester, AppState());
|
||||
|
||||
expect(find.text('Tired of Ads?'), findsOneWidget);
|
||||
expect(find.text('Not Now'), findsOneWidget);
|
||||
expect(find.text('Remove Ads'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to generic copy when no price is loaded yet', (tester) async {
|
||||
await openDialog(tester, AppState());
|
||||
|
||||
expect(find.textContaining('a single one-time purchase'), findsOneWidget);
|
||||
expect(find.textContaining('\$'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Not Now closes the dialog without navigating anywhere', (tester) async {
|
||||
await openDialog(tester, AppState());
|
||||
|
||||
await tester.tap(find.text('Not Now'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tired of Ads?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Remove Ads closes the dialog and starts the purchase flow', (tester) async {
|
||||
final appState = AppState();
|
||||
await openDialog(tester, appState);
|
||||
|
||||
await tester.tap(find.text('Remove Ads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tired of Ads?'), findsNothing, reason: 'dialog closed');
|
||||
// buyAdFreeYear() delegates to PurchaseService, which — with no real
|
||||
// store connection in tests — surfaces its failure through
|
||||
// AppState.purchaseError rather than throwing, so tapping through is
|
||||
// enough to confirm the button actually invoked it without crashing.
|
||||
expect(appState.purchaseError, isNotNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
|
||||
|
|
@ -44,10 +45,40 @@ void main() {
|
|||
await openDialog(tester);
|
||||
|
||||
expect(find.text("Your Data Isn't Backed Up"), findsOneWidget);
|
||||
expect(find.text('Learn More'), findsOneWidget);
|
||||
expect(find.text('Not Now'), findsOneWidget);
|
||||
expect(find.text('Set Up Backup'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Learn More resolves false, closes the dialog, and opens the FAQ to the '
|
||||
'backup question', (tester) async {
|
||||
bool? result;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () async => result = await showBackupReminderDialog(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Learn More'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, isFalse);
|
||||
expect(find.text("Your Data Isn't Backed Up"), findsNothing, reason: 'dialog closed');
|
||||
expect(find.byType(FaqScreen), findsOneWidget);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
expect(find.textContaining('Not by default'), findsOneWidget,
|
||||
reason: 'backup question starts expanded');
|
||||
});
|
||||
|
||||
testWidgets('Not Now resolves false and closes the dialog', (tester) async {
|
||||
bool? result;
|
||||
await tester.pumpWidget(
|
||||
|
|
|
|||
|
|
@ -180,10 +180,14 @@ void main() {
|
|||
syncService.configure('app-folder-id');
|
||||
|
||||
final result = await syncService.syncNow(
|
||||
beforeUpload: () async => calls.add('beforeUpload'),
|
||||
beforeUpload: () async {
|
||||
calls.add('beforeUpload');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ranSync, isTrue);
|
||||
expect(result.adGateBlocked, isFalse);
|
||||
expect(calls.first, 'download', reason: 'pull/merge happens first');
|
||||
expect(calls[1], 'beforeUpload');
|
||||
// Everything after beforeUpload is an upload (the data-file push,
|
||||
|
|
@ -200,12 +204,39 @@ void main() {
|
|||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
await syncService.syncNow(beforeUpload: () async => calls.add('beforeUpload'));
|
||||
await syncService.syncNow(
|
||||
beforeUpload: () async {
|
||||
calls.add('beforeUpload');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls.first, 'beforeUpload');
|
||||
expect(calls, contains('upload:$dataFileName'));
|
||||
});
|
||||
|
||||
test('a false return blocks the upload but keeps whatever was already pulled/merged',
|
||||
() async {
|
||||
final calls = <String>[];
|
||||
final remoteDbBytes = await File(databaseService.databasePath).readAsBytes();
|
||||
final session = _FakeSession();
|
||||
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
|
||||
session.remoteDbBytes = remoteDbBytes;
|
||||
session.onDownload = () => calls.add('download');
|
||||
session.onUpload = (name) => calls.add('upload:$name');
|
||||
final provider = _FakeProvider(session);
|
||||
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
|
||||
syncService.configure('app-folder-id');
|
||||
|
||||
final result = await syncService.syncNow(beforeUpload: () async => false);
|
||||
|
||||
expect(result.ranSync, isFalse);
|
||||
expect(result.adGateBlocked, isTrue);
|
||||
expect(calls, ['download'], reason: 'pull/merge still ran; nothing was uploaded');
|
||||
expect(session.deletedFileIds, ['lock-id'],
|
||||
reason: 'the lock must still be released when the gate blocks the upload');
|
||||
});
|
||||
|
||||
test('omitting it changes nothing — every other syncNow call site keeps working', () async {
|
||||
final session = _FakeSession();
|
||||
final provider = _FakeProvider(session);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import 'package:fuel_tax_tracker/services/database_service.dart';
|
|||
/// other upsert in this app (see [DatabaseService.saveVehicle]).
|
||||
void main() {
|
||||
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final otherVehicle =
|
||||
Vehicle(id: 'v2', vin: '1HGCM82633A004352', nickname: 'Second Car', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
|
|
@ -83,6 +85,25 @@ void main() {
|
|||
final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single;
|
||||
expect(rawRow['dirty'], 1);
|
||||
});
|
||||
|
||||
test('saving again with a different vehicleId moves the entry to that vehicle', () async {
|
||||
await database.saveVehicle(vehicle);
|
||||
await database.saveVehicle(otherVehicle);
|
||||
await database.saveFuelEntry(entry);
|
||||
|
||||
await database.saveFuelEntry(FuelEntry(
|
||||
id: entry.id,
|
||||
vehicleId: otherVehicle.id,
|
||||
date: entry.date,
|
||||
gallons: entry.gallons,
|
||||
pricePerGallon: entry.pricePerGallon,
|
||||
totalCost: entry.totalCost,
|
||||
updatedAt: DateTime.utc(2026, 3, 6),
|
||||
));
|
||||
|
||||
final saved = (await database.getFuelEntries()).single;
|
||||
expect(saved.vehicleId, otherVehicle.id);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditFuelEntryScreen', () {
|
||||
|
|
@ -142,6 +163,50 @@ void main() {
|
|||
expect(find.text('Required'), findsOneWidget);
|
||||
expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away');
|
||||
});
|
||||
|
||||
testWidgets('shows a vehicle picker pre-selected to the entry\'s current vehicle',
|
||||
(tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle, otherVehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.widgetWithText(DropdownButtonFormField<String>, vehicle.displayLabel),
|
||||
findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('picking a different vehicle updates the selection shown', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle, otherVehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<String>));
|
||||
await tester.pumpAndSettle();
|
||||
// Two matches once the menu is open: the closed field's current
|
||||
// selection, and the same label repeated as a menu item.
|
||||
await tester.tap(find.text(otherVehicle.displayLabel).last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(DropdownButtonFormField<String>, otherVehicle.displayLabel),
|
||||
findsOneWidget);
|
||||
expect(find.widgetWithText(DropdownButtonFormField<String>, vehicle.displayLabel),
|
||||
findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
29
test/estimated_refund_test.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/services/estimated_refund.dart';
|
||||
|
||||
void main() {
|
||||
test('the rate itself is 12.5 cents per gallon', () {
|
||||
expect(moHighwayFuelTaxRefundRatePerGallon, 0.125);
|
||||
});
|
||||
|
||||
test('multiplies whole gallons by the rate', () {
|
||||
expect(estimatedFuelRefund(10), 1.25);
|
||||
expect(estimatedFuelRefund(20), 2.50);
|
||||
expect(estimatedFuelRefund(0), 0);
|
||||
});
|
||||
|
||||
test('rounds fractional gallons to the nearest whole gallon before multiplying', () {
|
||||
// 10.523 rounds up to 11 gallons: 11 * $0.125 = $1.375, floored to $1.37.
|
||||
expect(estimatedFuelRefund(10.523), 1.37);
|
||||
// 10.4 rounds down to 10 gallons: 10 * $0.125 = $1.25 exactly.
|
||||
expect(estimatedFuelRefund(10.4), 1.25);
|
||||
});
|
||||
|
||||
test('rounds the resulting cents down, never up', () {
|
||||
// 1 gallon * $0.125 = $0.125 — rounding to the nearest cent would give
|
||||
// $0.13, but this must floor to $0.12 instead.
|
||||
expect(estimatedFuelRefund(1), 0.12);
|
||||
// 3 gallons * $0.125 = $0.375 — floors to $0.37, not $0.38.
|
||||
expect(estimatedFuelRefund(3), 0.37);
|
||||
});
|
||||
}
|
||||
104
test/faq_screen_test.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
|
||||
|
||||
/// Covers [FaqScreen] itself (question list, collapsed-by-default,
|
||||
/// deep-linking a specific entry open) and [FaqActionButton], the shared
|
||||
/// "?" AppBar action reused across every tab — see settings_screen_test.dart
|
||||
/// for the Settings landing page's own FAQ entry, and
|
||||
/// backup_reminder_test.dart for the "Learn More" link into a specific
|
||||
/// entry.
|
||||
void main() {
|
||||
testWidgets('every question starts collapsed with no initiallyExpandedQuestion given',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
|
||||
|
||||
expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget);
|
||||
expect(find.textContaining('12.5¢ per gallon'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('groups questions under section headers', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
|
||||
|
||||
// The first section header is visible without scrolling; the last
|
||||
// one only reachable by scrolling all the way down — checking both
|
||||
// confirms headers span the whole list, not just its start.
|
||||
expect(find.text('Missouri Motor Fuel Tax Refund'), findsOneWidget);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('Advertisements'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
expect(find.text('Advertisements'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping a question expands its answer', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
|
||||
|
||||
await tester.scrollUntilVisible(find.text('How do I remove ads?'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
// scrollUntilVisible stops as soon as any part is on-screen, which can
|
||||
// leave it right at the bottom edge — nudge further so its center
|
||||
// (what tap() targets) is comfortably inside the viewport.
|
||||
await tester.drag(find.byType(Scrollable), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('How do I remove ads?'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining("doesn't auto-renew"), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('includes a contact email for problem receipts/VINs/errors', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text("Found a receipt, VIN, or error the app couldn't handle?"), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
await tester.tap(find.text("Found a receipt, VIN, or error the app couldn't handle?"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('ohbrer+ShowMeTheFuelRefund@gmail.com'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the refund program question covers both highway and non-highway refunds',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
|
||||
|
||||
await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Highway use — up to 12.5¢ per gallon'), findsOneWidget);
|
||||
expect(find.textContaining('Non-highway use — up to 29.5¢ per gallon'), findsOneWidget);
|
||||
expect(find.textContaining("isn't tax advice"), findsOneWidget);
|
||||
expect(find.textContaining('consult a tax professional'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('initiallyExpandedQuestion starts that one entry open, others collapsed',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: FaqScreen(initiallyExpandedQuestion: 'Is my data backed up?')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Its section (Data Safety) is further down the page now that
|
||||
// questions are grouped — scroll to it before checking its content.
|
||||
await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
|
||||
expect(find.textContaining('Not by default'), findsOneWidget);
|
||||
expect(find.textContaining('12.5¢ per gallon'), findsNothing,
|
||||
reason: 'only the targeted question starts expanded');
|
||||
});
|
||||
|
||||
testWidgets('FaqActionButton pushes FaqScreen', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(appBar: AppBar(actions: const [FaqActionButton()])),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.help_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FaqScreen), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -278,4 +278,35 @@ void main() {
|
|||
expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']);
|
||||
expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']);
|
||||
});
|
||||
|
||||
group('defaultReportDateRange', () {
|
||||
test('from August through year-end, defaults to July of that year through the following June',
|
||||
() {
|
||||
final (start, end) = defaultReportDateRange(DateTime(2026, 8, 17));
|
||||
expect(start, DateTime(2026, 7, 1));
|
||||
expect(end, DateTime(2027, 6, 30));
|
||||
});
|
||||
|
||||
test('from January through July, still defaults to the July before through this June', () {
|
||||
final (start, end) = defaultReportDateRange(DateTime(2027, 7, 15));
|
||||
expect(start, DateTime(2026, 7, 1));
|
||||
expect(end, DateTime(2027, 6, 30));
|
||||
});
|
||||
|
||||
test('the next cycle (August the following year) rolls the whole range forward a year', () {
|
||||
final (start, end) = defaultReportDateRange(DateTime(2027, 8, 1));
|
||||
expect(start, DateTime(2027, 7, 1));
|
||||
expect(end, DateTime(2028, 6, 30));
|
||||
});
|
||||
|
||||
test('July 31 is still the old cycle; August 1 is the new one', () {
|
||||
final stillOld = defaultReportDateRange(DateTime(2026, 7, 31));
|
||||
expect(stillOld.$1, DateTime(2025, 7, 1));
|
||||
expect(stillOld.$2, DateTime(2026, 6, 30));
|
||||
|
||||
final nowNew = defaultReportDateRange(DateTime(2026, 8, 1));
|
||||
expect(nowNew.$1, DateTime(2026, 7, 1));
|
||||
expect(nowNew.$2, DateTime(2027, 6, 30));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,69 @@ void main() {
|
|||
expect(find.text('Red Truck'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows an estimated fuel refund in the hero banner and next to each row\'s gallons '
|
||||
'when the setting is on', (tester) async {
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 5, 14),
|
||||
gallons: 10.523,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 32.48,
|
||||
updatedAt: DateTime.utc(2026, 5, 14),
|
||||
receiptDriveFileId: 'remote-1',
|
||||
);
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry]
|
||||
..showEstimatedFuelRefund = true;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: ReceiptsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Est. Refund'), findsOneWidget, reason: 'replaces the hero icon');
|
||||
// 10.523 gal rounds up to 11, * $0.125/gal = $1.375, floored to $1.37 —
|
||||
// the hero's own standalone stat value...
|
||||
expect(find.text('\$1.37'), findsOneWidget);
|
||||
// ...and the same figure again, folded into the row's combined text.
|
||||
expect(find.textContaining('10.523 gal · Est. \$1.37'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("doesn't show an estimated fuel refund anywhere when the setting is off (default)",
|
||||
(tester) async {
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 5, 14),
|
||||
gallons: 10.523,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 32.48,
|
||||
updatedAt: DateTime.utc(2026, 5, 14),
|
||||
receiptDriveFileId: 'remote-1',
|
||||
);
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: ReceiptsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(appState.showEstimatedFuelRefund, isFalse, reason: 'off by default');
|
||||
expect(find.text('Est. Refund'), findsNothing);
|
||||
expect(find.textContaining('Est. \$'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping a receipted row opens the receipt detail page', (tester) async {
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import 'package:fuel_tax_tracker/models/vehicle.dart';
|
|||
import 'package:fuel_tax_tracker/screens/report_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers the per-vehicle selection cards on the Reports tab: once a date
|
||||
/// range is picked, every vehicle with a receipted entry in range starts
|
||||
/// selected (colored like the "Share" button), tapping one to deselect it
|
||||
/// Covers the per-vehicle selection cards on the Reports tab: with a date
|
||||
/// range in place (now defaulted automatically — see
|
||||
/// defaultReportDateRange/ReportScreen.initState), every vehicle with a
|
||||
/// receipted entry in range starts selected (colored like the "Share"
|
||||
/// button), tapping one to deselect it
|
||||
/// drops it from the total (and, by extension, the generated report) and
|
||||
/// reverts its coloring to a plain unselected card, and deselecting every
|
||||
/// vehicle shows a distinct "nothing selected" message rather than "no
|
||||
|
|
@ -19,10 +21,10 @@ void main() {
|
|||
final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
|
||||
// showDatePicker's initial date (when none is set yet) defaults to
|
||||
// "now" — dating these entries to "now" too, rather than a fixed date,
|
||||
// means just accepting that default on both pickers (below) already
|
||||
// covers them, without needing to drive the calendar UI to a specific day.
|
||||
// ReportScreen's date range now defaults to the current fuel-tax-refund
|
||||
// period (see defaultReportDateRange) as soon as it opens — dating these
|
||||
// entries to "now" means they always fall inside that default range,
|
||||
// without these tests needing to drive the date pickers themselves.
|
||||
final now = DateTime.now();
|
||||
|
||||
FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry(
|
||||
|
|
@ -36,26 +38,15 @@ void main() {
|
|||
receiptDriveFileId: 'remote-$id',
|
||||
);
|
||||
|
||||
Future<void> pickRange(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Start date'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('End date'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<AppState> pumpReport(WidgetTester tester) async {
|
||||
Future<AppState> pumpReport(WidgetTester tester, {bool showEstimatedFuelRefund = false}) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB]
|
||||
..fuelEntries = [
|
||||
entryFor('a', 'e1', 10, 35),
|
||||
entryFor('b', 'e2', 20, 70),
|
||||
];
|
||||
]
|
||||
..showEstimatedFuelRefund = showEstimatedFuelRefund;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
|
|
@ -73,7 +64,6 @@ void main() {
|
|||
testWidgets('both vehicles start colored as selected once a range with entries for both is picked',
|
||||
(tester) async {
|
||||
await pumpReport(tester);
|
||||
await pickRange(tester);
|
||||
|
||||
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
|
||||
|
||||
|
|
@ -82,10 +72,28 @@ void main() {
|
|||
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows an estimated fuel refund below each vehicle\'s total cost and next to the '
|
||||
'combined total, when the setting is on', (tester) async {
|
||||
await pumpReport(tester, showEstimatedFuelRefund: true);
|
||||
|
||||
// Truck: 10 gal * $0.125/gal = $1.25. Van: 20 gal * $0.125/gal = $2.50.
|
||||
expect(find.text('Est. \$1.25'), findsOneWidget);
|
||||
expect(find.text('Est. \$2.50'), findsOneWidget);
|
||||
// Combined: 30 gal * $0.125/gal = $3.75, folded into the Total row.
|
||||
expect(find.text('30.000 gal · \$105.00 · Est. \$3.75'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows no estimated fuel refund anywhere when the setting is off (default)',
|
||||
(tester) async {
|
||||
await pumpReport(tester);
|
||||
|
||||
expect(find.textContaining('Est. \$'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total',
|
||||
(tester) async {
|
||||
await pumpReport(tester);
|
||||
await pickRange(tester);
|
||||
|
||||
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
|
||||
|
||||
|
|
@ -100,7 +108,6 @@ void main() {
|
|||
testWidgets('tapping a deselected vehicle again reselects it (restores its coloring)',
|
||||
(tester) async {
|
||||
await pumpReport(tester);
|
||||
await pickRange(tester);
|
||||
|
||||
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
|
||||
|
||||
|
|
@ -113,10 +120,24 @@ void main() {
|
|||
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again');
|
||||
});
|
||||
|
||||
testWidgets('tapping Preview opens the report preview screen', (tester) async {
|
||||
await pumpReport(tester);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('Preview'), 300, scrollable: find.byType(Scrollable));
|
||||
await tester.tap(find.text('Preview'));
|
||||
// Not pumpAndSettle: PdfPreview's own internal rendering isn't under
|
||||
// test here (no platform channel mocked for it) — just that Preview
|
||||
// actually navigates. A couple of bounded pumps are enough for that
|
||||
// navigation (a synchronous PDF build, no plugin calls) to land.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('Report Preview'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deselecting every vehicle shows a distinct "nothing selected" message',
|
||||
(tester) async {
|
||||
await pumpReport(tester);
|
||||
await pickRange(tester);
|
||||
|
||||
await tester.tap(find.text('Truck'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -127,6 +148,7 @@ void main() {
|
|||
find.text('No vehicles selected — tap at least one above to build a report.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Preview'), findsNothing);
|
||||
expect(find.text('Share'), findsNothing);
|
||||
expect(find.text('Print'), findsNothing);
|
||||
// The cards themselves stay visible so the user can tap one again.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers the Settings landing page's menu wiring: it groups settings into
|
||||
|
|
@ -24,11 +26,15 @@ void main() {
|
|||
);
|
||||
}
|
||||
|
||||
testWidgets('shows UI and Data menu entries, plus the summary', (tester) async {
|
||||
testWidgets('shows UI, Data, FAQ, and User Agreement menu entries, plus the summary',
|
||||
(tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
expect(find.text('UI'), findsOneWidget);
|
||||
expect(find.text('Data'), findsOneWidget);
|
||||
expect(find.text('FAQ'), findsOneWidget);
|
||||
expect(find.text('User Agreement'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('Summary'), 300, scrollable: find.byType(Scrollable));
|
||||
expect(find.text('Summary'), findsOneWidget);
|
||||
// None of Data's contents leaked onto the landing page.
|
||||
expect(find.text('Cloud Storage'), findsNothing);
|
||||
|
|
@ -56,4 +62,45 @@ void main() {
|
|||
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
|
||||
expect(find.text('Advanced'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping FAQ opens FaqScreen with its questions collapsed by default',
|
||||
(tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('FAQ'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FaqScreen), findsOneWidget);
|
||||
expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget);
|
||||
// Answers are inside collapsed ExpansionTiles, not visible until tapped.
|
||||
expect(find.textContaining('12.5¢ per gallon'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('12.5¢ per gallon'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping User Agreement opens it in review mode (no Decline/I Agree)',
|
||||
(tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('User Agreement'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(UserAgreementScreen), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing);
|
||||
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Ads card offers both buying and restoring when ads are not disabled',
|
||||
(tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
// Not tapped — buyAdFreeYear/restoreAdFreeYear reach a real platform
|
||||
// channel via PurchaseService, which isn't set up in this test; only
|
||||
// confirming both entry points render.
|
||||
expect(find.widgetWithText(FilledButton, 'Remove Ads for a Year'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextButton, 'Restore Purchase'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,4 +61,43 @@ void main() {
|
|||
// already-persisted user settings by renaming it there.
|
||||
expect(prefs.getString('theme_mode'), 'dark');
|
||||
});
|
||||
|
||||
group('Estimated Fuel Refund toggle', () {
|
||||
testWidgets('off by default, flips on and persists when tapped', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: UiSettingsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(appState.showEstimatedFuelRefund, isFalse);
|
||||
expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isFalse);
|
||||
|
||||
await tester.tap(find.text('Estimated Fuel Refund'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(appState.showEstimatedFuelRefund, isTrue);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool('show_estimated_fuel_refund'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('explanation mentions the Missouri Highway Fuel Tax Refund and the current rate',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: UiSettingsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Missouri Highway Fuel Tax Refund'), findsOneWidget);
|
||||
expect(find.textContaining('current rate'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/main.dart';
|
||||
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/main_shell.dart';
|
||||
import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
|
@ -38,12 +39,49 @@ void main() {
|
|||
testWidgets('shows the data-loss liability section and both actions', (tester) async {
|
||||
await pumpScreen(tester);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget);
|
||||
expect(find.textContaining('developer is not responsible for your data'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'I Agree'), findsOneWidget);
|
||||
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the privacy section disclosing no tracking and AdMob-only data collection',
|
||||
(tester) async {
|
||||
await pumpScreen(tester);
|
||||
|
||||
expect(find.text('Privacy'), findsOneWidget);
|
||||
expect(find.textContaining("don't track or log your data"), findsOneWidget);
|
||||
expect(find.textContaining('Google AdMob'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows both refund types and a not-tax-advice / consult-a-professional disclaimer',
|
||||
(tester) async {
|
||||
await pumpScreen(tester);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('Not Tax or Legal Advice'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
expect(find.text('Not Tax or Legal Advice'), findsOneWidget);
|
||||
expect(find.textContaining('12.5¢ per gallon for highway use'), findsOneWidget);
|
||||
expect(find.textContaining('29.5¢ per gallon for non-highway use'), findsOneWidget);
|
||||
expect(find.textContaining('Nothing here is tax or legal advice'), findsOneWidget);
|
||||
expect(find.textContaining('consult a tax professional'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('See the FAQ for more opens FaqScreen', (tester) async {
|
||||
await pumpScreen(tester);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200,
|
||||
scrollable: find.byType(Scrollable));
|
||||
await tester.tap(find.text('See the FAQ for more'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FaqScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('I Agree marks the agreement accepted and persists it', (tester) async {
|
||||
final appState = await pumpScreen(tester);
|
||||
|
||||
|
|
@ -78,6 +116,56 @@ void main() {
|
|||
expect(appState.hasAcceptedUserAgreement, isFalse);
|
||||
});
|
||||
|
||||
group('isReview mode (reached from Settings, not the first-launch gate)', () {
|
||||
// Pushed on top of a placeholder route (rather than set as `home`
|
||||
// directly) so there's actually something to pop back to — matching
|
||||
// how it's really reached (pushed from Settings) and letting the
|
||||
// automaticallyImplyLeading back button behave realistically.
|
||||
Future<void> pumpReview(WidgetTester tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('hides Decline/I Agree and shows the content with a back button instead',
|
||||
(tester) async {
|
||||
await pumpReview(tester);
|
||||
|
||||
expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing);
|
||||
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing);
|
||||
expect(find.byType(BackButton), findsOneWidget);
|
||||
expect(
|
||||
find.text('This is the agreement you accepted when you first opened Show Me '
|
||||
'The Fuel Refund.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// Same legal content still shown, just not gating anything.
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300,
|
||||
scrollable: find.byType(Scrollable));
|
||||
expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('AppRoot gating', () {
|
||||
Future<AppState> pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async {
|
||||
final appState = AppState()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
||||
import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
|
@ -85,4 +86,47 @@ void main() {
|
|||
|
||||
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows an estimated fuel refund next to price/gal when the setting is on',
|
||||
(tester) async {
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'a',
|
||||
date: DateTime(2026, 5, 14, 9, 30),
|
||||
gallons: 10,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 35,
|
||||
updatedAt: DateTime.utc(2026, 5, 14),
|
||||
);
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA]
|
||||
..fuelEntries = [entry]
|
||||
..showEstimatedFuelRefund = true;
|
||||
|
||||
await pumpDetail(tester, appState, 'a');
|
||||
|
||||
// 10 gal * $0.125/gal = $1.25.
|
||||
expect(find.textContaining('\$3.50/gal · Est. \$1.25'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows no estimated fuel refund when the setting is off (default)', (tester) async {
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'a',
|
||||
date: DateTime(2026, 5, 14, 9, 30),
|
||||
gallons: 10,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 35,
|
||||
updatedAt: DateTime.utc(2026, 5, 14),
|
||||
);
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpDetail(tester, appState, 'a');
|
||||
|
||||
expect(find.textContaining('Est. \$'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
|
|||