diff --git a/HOMEPAGE.html b/HOMEPAGE.html new file mode 100644 index 0000000..fc52a76 --- /dev/null +++ b/HOMEPAGE.html @@ -0,0 +1,432 @@ + + +
+
+ +
+
+
+ Show Me The Fuel Refund app icon +
+
+ Missouri · Fuel Tax Refund Tracker + Show Me The Fuel Refund +
+
+
+ +
+

Every fill-up is money Missouri owes you back.

+

+ Snap a photo of the receipt. The app reads the gallons, the price, and the + date — and keeps a running, ready-to-file tally toward your Missouri + Motor Fuel Tax Refund. +

+
+ + + +
+
+

What's on the receipt

+ 4 items +
+ +
+ 01 +
+

Receipts, read automatically

+

Photograph a pump receipt and the app fills in the date, gallons, and + price for you. Every field stays editable, so a misread is a two-second fix.

+
+
+
+ 02 +
+

One tally per vehicle

+

Add each vehicle by VIN and its fuel purchases sort themselves out — + no spreadsheets, no shoebox of paper.

+
+
+
+ 03 +
+

A report built for filing

+

Pick a date range and get a print- or share-ready PDF, itemized and + totaled, with a direct link to the state's official claim form.

+
+
+
+ 04 +
+

Backed up your way

+

Connect Google Drive, Dropbox, OneDrive, or your own WebDAV server. + Your data goes to storage you own — never to us.

+
+
+
+ + + +
+
+

Two refunds, one app

+ current rates +
+ +
+
+ Highway use + Ordinary vehicle fill-ups + + + Up to 12.5¢/gal + Form 4923-H + +
+
+ Non-highway use + Off-road, farm, and other qualifying use + + + Up to 29.5¢/gal + Form 4923 + +
+
+ Most people qualify for one or the other — some for both. This is + informational, not tax advice; rates are set by Missouri law and subject + to change. Confirm current eligibility and rates with the Missouri + Department of Revenue before filing. +
+
+
+ + + +
+
+

Local-first

+

Every record lives on your device by default. Nothing reaches us, ever.

+
+
+

No tracking

+

No analytics, no crash reporting, no ad-tech beyond the ads themselves.

+
+
+

Your cloud, your rules

+

Optional backup goes to an account only you control — and only you can reach.

+
+
+ + + +
+
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 0000000..e782391 --- /dev/null +++ b/PRIVACY_POLICY.md @@ -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** diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index d7cc735..43ee6d9 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app-ads.txt b/app-ads.txt new file mode 100644 index 0000000..9faca8e --- /dev/null +++ b/app-ads.txt @@ -0,0 +1 @@ +google.com, pub-9212406812117696, DIRECT, f08c47fec0942fa0 diff --git a/docs/class-diagram.md b/docs/class-diagram.md new file mode 100644 index 0000000..d70a5b4 --- /dev/null +++ b/docs/class-diagram.md @@ -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 { + <> + googleDrive + dropbox + oneDrive + webdav + } + + class CloudStorageProvider { + <> + +CloudProviderId id + +String displayName + +bool isSignedIn + +String? accountLabel + +attemptSilentSignIn() bool + +signIn() String + +signOut() + +beginSession() CloudStorageSession + } + + class CloudStorageSession { + <> + +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 { + <> + +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 { + <> + 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 { + <> + } + class AppRoot { + <> + } + class UserAgreementScreen { + <> + } + class MainShell { + <> + } + class ReceiptsScreen { + <> + } + class HomeScreen { + <> + } + class ReportScreen { + <> + } + class SettingsScreen { + <> + } + class DataSettingsScreen { + <> + } + class UiSettingsScreen { + <> + } + class FaqScreen { + <> + } + class AddEditVehicleScreen { + <> + } + class VehicleDetailScreen { + <> + } + class ConfirmFuelEntryScreen { + <> + } + class EditFuelEntryScreen { + <> + } + class ReceiptDetailScreen { + <> + } + class ReceiptImageScreen { + <> + } + class CloudFolderBrowserScreen { + <> + } + + 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 +``` diff --git a/docs/erd.md b/docs/erd.md new file mode 100644 index 0000000..f384bbb --- /dev/null +++ b/docs/erd.md @@ -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. diff --git a/lib/main.dart b/lib/main.dart index 384e789..dbdc7ac 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); + class FuelTaxTrackerApp extends StatelessWidget { const FuelTaxTrackerApp({super.key}); @@ -18,13 +25,20 @@ class FuelTaxTrackerApp extends StatelessWidget { return ChangeNotifierProvider( create: (_) => AppState()..init(), child: Consumer( - 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(), + ); + }, ), ); } diff --git a/lib/screens/edit_fuel_entry_screen.dart b/lib/screens/edit_fuel_entry_screen.dart index c331158..53d5af7 100644 --- a/lib/screens/edit_fuel_entry_screen.dart +++ b/lib/screens/edit_fuel_entry_screen.dart @@ -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 { 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 { try { await context.read().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 { @override Widget build(BuildContext context) { final dateFormat = DateFormat.yMMMd().add_jm(); + final vehicles = context.watch().vehicles; return Scaffold( appBar: AppBar(title: const Text('Edit Fuel Entry')), @@ -135,6 +140,18 @@ class _EditFuelEntryScreenState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 16), + DropdownButtonFormField( + 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'), diff --git a/lib/screens/faq_screen.dart b/lib/screens/faq_screen.dart new file mode 100644 index 0000000..f2ef532 --- /dev/null +++ b/lib/screens/faq_screen.dart @@ -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()), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 983a34e..0b25f44 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 18dc9e3..01a7c18 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -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 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? Function() activate; const _OnboardingStep({ required this.title, @@ -80,8 +87,8 @@ class _MainShellState extends State { _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 { // 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 { '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 { '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 { 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 { 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 { /// 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 _waitForTargets(List keys) async { + Future _waitForTargets(List keys, ModalRoute? 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 { 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 _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(); diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart index 48b5c7b..3560985 100644 --- a/lib/screens/receipts_screen.dart +++ b/lib/screens/receipts_screen.dart @@ -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 with AutomaticKeepAlive tooltip: 'Log Fuel Receipt', onPressed: () => _addReceipt(context), ), + const FaqActionButton(), ], ), body: ListView( @@ -154,6 +157,9 @@ class _ReceiptsScreenState extends State 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 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), ), ], diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart index 2cb4a32..2e3157b 100644 --- a/lib/screens/report_screen.dart +++ b/lib/screens/report_screen.dart @@ -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 with AutomaticKeepAliveClie @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + final (start, end) = defaultReportDateRange(DateTime.now()); + _startDate = start; + _endDate = end; + } + Future _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 with AutomaticKeepAliveClie } } + Future _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 _openRefundFormLink() async { final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication); if (!launched && mounted) { @@ -161,7 +192,10 @@ class _ReportScreenState extends State 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 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 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 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 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, + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0229d81..120945c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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(); 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().restoreAdFreeYear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Checking for a previous purchase on this Google account…", + ), + ), + ); + }, + child: const Text('Restore Purchase'), + ), ], ], ), diff --git a/lib/screens/ui_settings_screen.dart b/lib/screens/ui_settings_screen.dart index e183c25..23b8118 100644 --- a/lib/screens/ui_settings_screen.dart +++ b/lib/screens/ui_settings_screen.dart @@ -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().setShowEstimatedFuelRefund(value), + ), + ], + ), + ), + ), ], ), ); diff --git a/lib/screens/user_agreement_screen.dart b/lib/screens/user_agreement_screen.dart index 0266499..1f1fb2b 100644 --- a/lib/screens/user_agreement_screen.dart +++ b/lib/screens/user_agreement_screen.dart @@ -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().acceptUserAgreement(), - child: const Text('I Agree'), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => context.read().acceptUserAgreement(), + child: const Text('I Agree'), + ), ), - ), - ], + ], + ), ), - ), + ], ], ), ), diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 90ae960..0bfae22 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -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, ), diff --git a/lib/services/ad_service.dart b/lib/services/ad_service.dart index f2e7150..b5fe43a 100644 --- a/lib/services/ad_service.dart +++ b/lib/services/ad_service.dart @@ -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? _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 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 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 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(); + final showed = Completer(); 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; } } diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index de36d2a..154ed2a 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -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 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 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 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 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 _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 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 _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 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, diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart index c0d00a3..ca6f812 100644 --- a/lib/services/cloud_sync_service.dart +++ b/lib/services/cloud_sync_service.dart @@ -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 syncNow({ bool keepLocalReceiptCopies = false, Duration staleLockAge = const Duration(minutes: 10), - Future Function()? beforeUpload, + Future 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 = diff --git a/lib/services/estimated_refund.dart b/lib/services/estimated_refund.dart new file mode 100644 index 0000000..64e79af --- /dev/null +++ b/lib/services/estimated_refund.dart @@ -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; +} diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart index ea4342d..de88e3c 100644 --- a/lib/services/fuel_report.dart +++ b/lib/services/fuel_report.dart @@ -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 diff --git a/lib/services/purchase_service.dart b/lib/services/purchase_service.dart index f7dae5f..e0e0f87 100644 --- a/lib/services/purchase_service.dart +++ b/lib/services/purchase_service.dart @@ -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 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 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 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 restorePurchase() async { + try { + await InAppPurchase.instance.restorePurchases(); + } on InAppPurchaseException catch (e) { + onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later."); + } } Future _handlePurchaseUpdates(List 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(); - 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(); } diff --git a/lib/widgets/ad_free_upsell_dialog.dart b/lib/widgets/ad_free_upsell_dialog.dart new file mode 100644 index 0000000..92e460d --- /dev/null +++ b/lib/widgets/ad_free_upsell_dialog.dart @@ -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 showAdFreeUpsellDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Tired of Ads?'), + content: Consumer( + 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().buyAdFreeYear(); + }, + child: const Text('Remove Ads'), + ), + ], + ), + ); +} diff --git a/lib/widgets/backup_reminder.dart b/lib/widgets/backup_reminder.dart index 6a5d235..ed0017d 100644 --- a/lib/widgets/backup_reminder.dart +++ b/lib/widgets/backup_reminder.dart @@ -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 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( diff --git a/lib/widgets/hero_banner.dart b/lib/widgets/hero_banner.dart index 0a5d0c5..82ff2f6 100644 --- a/lib/widgets/hero_banner.dart +++ b/lib/widgets/hero_banner.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/onboarding_tour_overlay.dart b/lib/widgets/onboarding_tour_overlay.dart index 067824f..65fb024 100644 --- a/lib/widgets/onboarding_tour_overlay.dart +++ b/lib/widgets/onboarding_tour_overlay.dart @@ -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 waitForRouteTransition(Animation? animation) async { + if (animation == null || animation.status == AnimationStatus.completed) return; + final completer = Completer(); + 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 diff --git a/privacy-policy.html b/privacy-policy.html new file mode 100644 index 0000000..8786b66 --- /dev/null +++ b/privacy-policy.html @@ -0,0 +1,263 @@ + + + + + +Privacy Policy — Show Me The Fuel Refund + + + +
+
+ + Show Me The Fuel Refund +
+ +

Privacy Policy

+

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 + and + AdMob's data disclosure + — 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. +

+
+ + +
+ + + diff --git a/pubspec.yaml b/pubspec.yaml index 952b866..2587d56 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/store_assets/feature_graphic_1024x500.png b/store_assets/feature_graphic_1024x500.png new file mode 100644 index 0000000..55e65d1 Binary files /dev/null and b/store_assets/feature_graphic_1024x500.png differ diff --git a/store_assets/play_store_icon_256.png b/store_assets/play_store_icon_256.png new file mode 100644 index 0000000..b16d7ac Binary files /dev/null and b/store_assets/play_store_icon_256.png differ diff --git a/store_assets/play_store_icon_512.png b/store_assets/play_store_icon_512.png new file mode 100644 index 0000000..b16d7ac Binary files /dev/null and b/store_assets/play_store_icon_512.png differ diff --git a/store_assets/screenshots/1_receipts.png b/store_assets/screenshots/1_receipts.png new file mode 100644 index 0000000..bef8163 Binary files /dev/null and b/store_assets/screenshots/1_receipts.png differ diff --git a/store_assets/screenshots/2_report.png b/store_assets/screenshots/2_report.png new file mode 100644 index 0000000..113916f Binary files /dev/null and b/store_assets/screenshots/2_report.png differ diff --git a/store_assets/screenshots/3_vehicles.png b/store_assets/screenshots/3_vehicles.png new file mode 100644 index 0000000..009eb3e Binary files /dev/null and b/store_assets/screenshots/3_vehicles.png differ diff --git a/store_assets/screenshots/4_vehicle_detail.png b/store_assets/screenshots/4_vehicle_detail.png new file mode 100644 index 0000000..2d6a2d8 Binary files /dev/null and b/store_assets/screenshots/4_vehicle_detail.png differ diff --git a/store_assets/screenshots/5_report_preview.png b/store_assets/screenshots/5_report_preview.png new file mode 100644 index 0000000..d95dd75 Binary files /dev/null and b/store_assets/screenshots/5_report_preview.png differ diff --git a/test/ad_free_upsell_dialog_test.dart b/test/ad_free_upsell_dialog_test.dart new file mode 100644 index 0000000..309b993 --- /dev/null +++ b/test/ad_free_upsell_dialog_test.dart @@ -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 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); + }); +} diff --git a/test/backup_reminder_test.dart b/test/backup_reminder_test.dart index 420dd0c..6427e68 100644 --- a/test/backup_reminder_test.dart +++ b/test/backup_reminder_test.dart @@ -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( diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart index 6359c8a..adebf41 100644 --- a/test/cloud_sync_service_test.dart +++ b/test/cloud_sync_service_test.dart @@ -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 = []; + 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); diff --git a/test/edit_fuel_entry_test.dart b/test/edit_fuel_entry_test.dart index 322f08f..e974184 100644 --- a/test/edit_fuel_entry_test.dart +++ b/test/edit_fuel_entry_test.dart @@ -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, 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)); + 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, otherVehicle.displayLabel), + findsOneWidget); + expect(find.widgetWithText(DropdownButtonFormField, vehicle.displayLabel), + findsNothing); + }); }); } diff --git a/test/estimated_refund_test.dart b/test/estimated_refund_test.dart new file mode 100644 index 0000000..ebdbb0a --- /dev/null +++ b/test/estimated_refund_test.dart @@ -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); + }); +} diff --git a/test/faq_screen_test.dart b/test/faq_screen_test.dart new file mode 100644 index 0000000..596bbe9 --- /dev/null +++ b/test/faq_screen_test.dart @@ -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); + }); +} diff --git a/test/fuel_report_test.dart b/test/fuel_report_test.dart index d73df3d..19f6254 100644 --- a/test/fuel_report_test.dart +++ b/test/fuel_report_test.dart @@ -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)); + }); + }); } diff --git a/test/receipts_screen_test.dart b/test/receipts_screen_test.dart index cc650e4..2fb2b8f 100644 --- a/test/receipts_screen_test.dart +++ b/test/receipts_screen_test.dart @@ -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', diff --git a/test/report_vehicle_selection_test.dart b/test/report_vehicle_selection_test.dart index 372921e..dab17e4 100644 --- a/test/report_vehicle_selection_test.dart +++ b/test/report_vehicle_selection_test.dart @@ -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 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 pumpReport(WidgetTester tester) async { + Future 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. diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 7d63c96..d1876d0 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -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); + }); } diff --git a/test/theme_toggle_test.dart b/test/theme_toggle_test.dart index 331551c..3466d24 100644 --- a/test/theme_toggle_test.dart +++ b/test/theme_toggle_test.dart @@ -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(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); + }); + }); } diff --git a/test/user_agreement_screen_test.dart b/test/user_agreement_screen_test.dart index e828635..0dc3f20 100644 --- a/test/user_agreement_screen_test.dart +++ b/test/user_agreement_screen_test.dart @@ -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 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 pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async { final appState = AppState() diff --git a/test/vehicle_detail_screen_test.dart b/test/vehicle_detail_screen_test.dart index 779647f..a08c8db 100644 --- a/test/vehicle_detail_screen_test.dart +++ b/test/vehicle_detail_screen_test.dart @@ -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); + }); }