A whole lot of stuff

This commit is contained in:
Courtney Arnold 2026-08-18 11:04:58 -05:00
parent faf319322a
commit ad850d2be6
50 changed files with 3013 additions and 213 deletions

View file

@ -5,11 +5,18 @@ import 'screens/main_shell.dart';
import 'screens/user_agreement_screen.dart';
import 'services/app_state.dart';
import 'theme/app_theme.dart';
import 'widgets/ad_free_upsell_dialog.dart';
void main() {
runApp(const FuelTaxTrackerApp());
}
/// A stable handle on the navigator so [AppState.onAdWatched] can show the
/// "remove ads" dialog from wherever the app happens to be — it fires from
/// deep inside a background sync, not from a widget's own build method, so
/// there's no local [BuildContext] to reach for at that point.
final navigatorKey = GlobalKey<NavigatorState>();
class FuelTaxTrackerApp extends StatelessWidget {
const FuelTaxTrackerApp({super.key});
@ -18,13 +25,20 @@ class FuelTaxTrackerApp extends StatelessWidget {
return ChangeNotifierProvider(
create: (_) => AppState()..init(),
child: Consumer<AppState>(
builder: (context, appState, _) => MaterialApp(
title: 'Fuel Tax Tracker',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: appState.themeMode,
home: const AppRoot(),
),
builder: (context, appState, _) {
appState.onAdWatched ??= () {
final dialogContext = navigatorKey.currentContext;
if (dialogContext != null) showAdFreeUpsellDialog(dialogContext);
};
return MaterialApp(
navigatorKey: navigatorKey,
title: 'Fuel Tax Tracker',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: appState.themeMode,
home: const AppRoot(),
);
},
),
);
}

View file

@ -7,9 +7,10 @@ import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
/// Lets the user correct the *data* logged about a receipt (date, gallons,
/// price/gal, total cost) — not the receipt photo itself, which is shown
/// read-only up top (tap it to view full-screen, same as everywhere else
/// it's shown) and can't be replaced from here.
/// price/gal, total cost, and which vehicle it's attached to) — not the
/// receipt photo itself, which is shown read-only up top (tap it to view
/// full-screen, same as everywhere else it's shown) and can't be replaced
/// from here.
class EditFuelEntryScreen extends StatefulWidget {
final FuelEntry entry;
@ -25,12 +26,14 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
late final TextEditingController _priceController;
late final TextEditingController _totalController;
late DateTime _date;
late String _vehicleId;
bool _saving = false;
@override
void initState() {
super.initState();
_date = widget.entry.date;
_vehicleId = widget.entry.vehicleId;
_gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
_priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
_totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2));
@ -96,6 +99,7 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
try {
await context.read<AppState>().updateFuelEntry(
entryId: widget.entry.id,
vehicleId: _vehicleId,
date: _date,
gallons: double.parse(_gallonsController.text),
pricePerGallon: double.parse(_priceController.text),
@ -116,6 +120,7 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMMMd().add_jm();
final vehicles = context.watch<AppState>().vehicles;
return Scaffold(
appBar: AppBar(title: const Text('Edit Fuel Entry')),
@ -135,6 +140,18 @@ class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _vehicleId,
decoration: const InputDecoration(labelText: 'Vehicle'),
items: [
for (final vehicle in vehicles)
DropdownMenuItem(value: vehicle.id, child: Text(vehicle.displayLabel)),
],
onChanged: (value) {
if (value != null) setState(() => _vehicleId = value);
},
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Date & time'),

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

@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
class _FaqEntry {
final String question;
final String answer;
const _FaqEntry(this.question, this.answer);
}
class _FaqSection {
final String title;
final List<_FaqEntry> entries;
const _FaqSection(this.title, this.entries);
}
const _faqSections = <_FaqSection>[
_FaqSection('Missouri Motor Fuel Tax Refund', [
_FaqEntry(
'What is the Missouri Motor Fuel Tax Refund?',
'Missouri actually offers two separate fuel tax refunds — it\'s worth evaluating '
'both to see how you could benefit:\n\n'
'• Highway use — up to 12.5¢ per gallon.\n'
'• Non-highway use — up to 29.5¢ per gallon.\n\n'
"This app helps you collect and organize the receipts you'll need to claim "
"either one. This isn't tax advice — for guidance on which refund(s) fit your "
'situation, consult a tax professional. Rates and eligibility are set by '
'Missouri law, which can change at any time, so always confirm current details '
'with the Missouri Department of Revenue before filing.',
),
_FaqEntry(
'Is this app affiliated with the Missouri Department of Revenue?',
"No. This is an independent tool for organizing your own receipts — it isn't run by, "
"endorsed by, or connected to the State of Missouri. It doesn't file anything on "
'your behalf.',
),
_FaqEntry(
'How do I generate a report for my refund claim?',
"On the Reports tab, pick a date range to see your totals, then use Share or Print. "
"There's also a link to Missouri's official Motor Fuel Refund Claim form once "
"you're ready to file.",
),
]),
_FaqSection('Usage', [
_FaqEntry(
'How do I log a fuel purchase?',
"On the Receipts tab, tap the + button, snap a photo of the receipt, and the app "
'reads the date, gallons, and price automatically. Review (and correct, if '
'needed) the values before saving — every field stays editable both before and '
'after saving.',
),
_FaqEntry(
'What if the app misreads my receipt?',
'Automatic reading is usually accurate but not perfect — correct any field yourself '
'before saving, or edit it afterward from the entry\'s detail page.',
),
_FaqEntry(
"Found a receipt, VIN, or error the app couldn't handle?",
'Send it to ohbrer+ShowMeTheFuelRefund@gmail.com — example receipts or VINs that '
"didn't scan correctly, screenshots of error screens, or anything else that "
"didn't work as expected all help improve the app.",
),
_FaqEntry(
'Do I need to add a vehicle before logging a receipt?',
'Yes — every fuel entry is linked to a vehicle. Add one on the Vehicles tab first '
"(you'll need its VIN).",
),
]),
_FaqSection('Data Safety', [
_FaqEntry(
'Is my data backed up?',
'Not by default — everything you log lives only on this device until you connect a '
'cloud storage account (Settings > Data). Once connected, a copy is kept there '
'too, in storage you control.',
),
_FaqEntry(
'What cloud storage options are supported?',
'Google Drive, Dropbox, OneDrive, or your own WebDAV server (Nextcloud, ownCloud, or '
'any self-hosted WebDAV server).',
),
_FaqEntry(
"What happens if I lose my phone, or reinstall the app?",
"If you never connected cloud storage, that data is gone — it only ever lived on "
"that device. If you had connected one, reconnect the same account on the new "
"device (or after reinstalling) and your vehicles, receipts, and photos sync back "
'down automatically.',
),
_FaqEntry(
'How do I delete my data?',
'Settings > Data > Advanced has options to purge everything, or just fuel entries in '
"a specific date range. Both are permanent and can't be undone.",
),
]),
_FaqSection('Advertisements', [
_FaqEntry(
'Why do I sometimes see an ad?',
'Syncing your data to the cloud requires watching a short ad, to help support the '
"app — at most once every 5 minutes, so it won't show again if you sync "
'repeatedly in a short span.',
),
_FaqEntry(
'How do I remove ads?',
'"Remove Ads for a Year" in Settings is a one-time purchase that disables ads for a '
"year from when you buy it. It doesn't auto-renew or charge you again "
"automatically — once the year's up, ads come back until you buy again.",
),
]),
];
/// A simple, static list of common questions grouped into sections —
/// reached from [SettingsScreen] (and every tab's AppBar via
/// [FaqActionButton]), the "back up your data" reminder, and the
/// first-launch user agreement screen.
class FaqScreen extends StatelessWidget {
/// If given, that question's entry starts expanded (and the rest
/// collapsed) instead of everything starting collapsed — used when a
/// link elsewhere in the app points at one specific answer, e.g. the
/// backup reminder dialog jumping straight to the backup question rather
/// than making the user hunt for it.
final String? initiallyExpandedQuestion;
const FaqScreen({super.key, this.initiallyExpandedQuestion});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('FAQ')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final section in _faqSections) ...[
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 8),
child: Text(
section.title,
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
for (final entry in section.entries)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
child: ExpansionTile(
title: Text(entry.question, style: textTheme.titleSmall),
initiallyExpanded: entry.question == initiallyExpandedQuestion,
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.answer, style: textTheme.bodyMedium),
],
),
),
),
const SizedBox(height: 8),
],
],
),
);
}
}
/// A "?" AppBar action that jumps straight to [FaqScreen] — added to every
/// tab's AppBar (Receipts, Vehicles, Reports, Settings; see [MainShell])
/// so help is reachable the same way no matter which screen the user's on.
class FaqActionButton extends StatelessWidget {
const FaqActionButton({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.help_outline),
tooltip: 'FAQ',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FaqScreen()),
),
);
}
}

View file

@ -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

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
@ -51,7 +52,13 @@ class _OnboardingStep {
final String title;
final String description;
final List<GlobalKey> targetKeys;
final void Function() activate;
/// Gets the app into the right state to show this step (switch tabs,
/// push a route, ...). Returns the route it just pushed, if any — so
/// [_MainShellState._waitForTargets] can wait for that specific route's
/// transition to settle before spotlighting something on it — or null
/// for a step (like every tab switch) that didn't push one.
final ModalRoute<dynamic>? Function() activate;
const _OnboardingStep({
required this.title,
@ -80,8 +87,8 @@ class _MainShellState extends State<MainShell> {
_OnboardingStep(
title: 'What This App Does',
description: 'This app helps you collect and organize your Missouri fuel purchase '
"receipts so you can claim Missouri's Motor Fuel Tax Refund — currently 12.5¢ back "
'per gallon.\n\n'
"receipts so you can claim Missouri's Motor Fuel Tax Refund — up to 12.5¢/gal "
'for highway use, up to 29.5¢/gal non-highway.\n\n'
'Disclaimer: this refund is available for as long as Missouri lawmakers continue to '
'offer it, and state law could change or end the program at any time. Always confirm '
'current eligibility and rates with the Missouri Department of Revenue before filing.',
@ -90,7 +97,7 @@ class _MainShellState extends State<MainShell> {
// be when the tour starts (normally the Receipts tab, since that's
// MainShell's default).
targetKeys: const [],
activate: () {},
activate: () => null,
),
_OnboardingStep(
title: 'Add Receipts Here',
@ -98,7 +105,10 @@ class _MainShellState extends State<MainShell> {
'Snap a photo of it and the app reads the date, gallons, and price for you '
'automatically.',
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
activate: () => _jumpToTab(0),
activate: () {
_jumpToTab(0);
return null;
},
),
_OnboardingStep(
title: 'Add Vehicles Here',
@ -106,7 +116,10 @@ class _MainShellState extends State<MainShell> {
'receipt gets linked to one of your vehicles, so add one here first before logging '
'a receipt for it.',
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
activate: () => _jumpToTab(1),
activate: () {
_jumpToTab(1);
return null;
},
),
_OnboardingStep(
title: 'Choose a Report Date Range',
@ -126,14 +139,20 @@ class _MainShellState extends State<MainShell> {
OnboardingKeys.reportShareAndPrintRow,
OnboardingKeys.refundFormLink,
],
activate: () => _jumpToTab(2),
activate: () {
_jumpToTab(2);
return null;
},
),
_OnboardingStep(
title: 'Find Data Settings',
description: 'This is the Settings tab — tap Data here to manage where your vehicles, '
'receipts, and photos get backed up.',
targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry],
activate: () => _jumpToTab(3),
activate: () {
_jumpToTab(3);
return null;
},
),
_OnboardingStep(
title: 'Back Up to the Cloud',
@ -143,13 +162,14 @@ class _MainShellState extends State<MainShell> {
targetKeys: [OnboardingKeys.cloudStorageCard],
activate: () {
_tourPushedDataScreen = true;
final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen());
// Not awaited: Navigator.push's returned future only completes on
// pop, not on the pushed route finishing its build — the polling
// wait in _showTourStep (for the target key's RenderObject to
// exist) is what actually waits for this screen to be ready.
unawaited(Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
));
// exist, then for `route` itself — returned below — to finish its
// transition) is what actually waits for this screen to be ready.
unawaited(Navigator.of(context).push(route));
return route;
},
),
];
@ -207,7 +227,7 @@ class _MainShellState extends State<MainShell> {
/// page (like the Reports tab's refund-form link) could still be
/// off-screen despite having a real, laid-out RenderBox, and would get
/// "highlighted" somewhere the user can't actually see.
Future<void> _waitForTargets(List<GlobalKey> keys) async {
Future<void> _waitForTargets(List<GlobalKey> keys, ModalRoute<dynamic>? pushedRoute) async {
for (var attempt = 0; attempt < 30; attempt++) {
if (!mounted) return;
final allResolved = keys.every((key) {
@ -229,12 +249,40 @@ class _MainShellState extends State<MainShell> {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
// The "Back Up to the Cloud" step's activate() pushes a route and
// returns it as [pushedRoute] — wait for that push transition to fully
// settle before capturing its target's position, or the spotlight ends
// up shifted by however far the slide-in hadn't yet finished. A no-op
// for every other step, which only switches tabs and so has no route
// to pass here.
await waitForRouteTransition(pushedRoute?.animation);
if (!mounted) return;
// The route's own AnimationController reports AnimationStatus.completed
// at this point, but empirically the render tree's transforms (from
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
// Android default as of Flutter 3.44) still reflect a mid-transition
// position for a few more frames after that — confirmed by walking the
// RenderObject ancestor chain and finding an active
// RenderFractionalTranslation still present, with a position matching
// the transition's *starting* offset rather than its resting
// Offset.zero. These extra frames give it time to actually settle
// before the spotlight measures anything. SchedulerBinding.endOfFrame
// (rather than a bare Future.delayed) so this genuinely waits on real
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
// while something has an actual frame scheduled, which a bare delay
// timer doesn't count as once the route's own transition has already
// finished.
for (var i = 0; i < 10; i++) {
if (!mounted) return;
await SchedulerBinding.instance.endOfFrame;
}
}
Future<void> _showTourStep() async {
final step = _tourSteps[_tourStepIndex];
step.activate();
await _waitForTargets(step.targetKeys);
final pushedRoute = step.activate();
await _waitForTargets(step.targetKeys, pushedRoute);
if (!mounted) return;
_tourEntry?.remove();

View file

@ -5,11 +5,13 @@ import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../services/estimated_refund.dart';
import '../services/onboarding_keys.dart';
import '../theme/app_theme.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import 'add_edit_vehicle_screen.dart';
import 'faq_screen.dart';
import 'receipt_detail_screen.dart';
/// The three summary periods selectable below the hero totals — the
@ -142,6 +144,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),
),
const FaqActionButton(),
],
),
body: ListView(
@ -154,6 +157,9 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
gallonsFormat: heroGallonsFormat,
selectedRange: _range,
onRangeSelected: (range) => setState(() => _range = range),
estimatedRefundValue: appState.showEstimatedFuelRefund
? currencyFormat.format(estimatedFuelRefund(totalGallons))
: null,
),
Padding(
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
@ -176,6 +182,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
dateFormat: dateFormat,
currencyFormat: currencyFormat,
gallonsFormat: gallonsFormat,
showEstimatedRefund: appState.showEstimatedFuelRefund,
onTap: () => _openReceipt(context, entries[i]),
),
],
@ -229,6 +236,7 @@ class _HeroTotal extends StatelessWidget {
final NumberFormat gallonsFormat;
final _DateRange selectedRange;
final ValueChanged<_DateRange> onRangeSelected;
final String? estimatedRefundValue;
const _HeroTotal({
required this.totalCost,
@ -237,6 +245,7 @@ class _HeroTotal extends StatelessWidget {
required this.gallonsFormat,
required this.selectedRange,
required this.onRangeSelected,
this.estimatedRefundValue,
});
@override
@ -248,6 +257,7 @@ class _HeroTotal extends StatelessWidget {
HeroStatsRow(
gallonsValue: gallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
estimatedRefundValue: estimatedRefundValue,
),
const SizedBox(height: 7),
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
@ -263,6 +273,7 @@ class _ReceiptRow extends StatelessWidget {
final DateFormat dateFormat;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final bool showEstimatedRefund;
final VoidCallback onTap;
const _ReceiptRow({
@ -271,6 +282,7 @@ class _ReceiptRow extends StatelessWidget {
required this.dateFormat,
required this.currencyFormat,
required this.gallonsFormat,
required this.showEstimatedRefund,
required this.onTap,
});
@ -295,7 +307,10 @@ class _ReceiptRow extends StatelessWidget {
),
const SizedBox(height: 3),
Text(
'${gallonsFormat.format(entry.gallons)} gal',
showEstimatedRefund
? '${gallonsFormat.format(entry.gallons)} gal · Est. '
'${currencyFormat.format(estimatedFuelRefund(entry.gallons))}'
: '${gallonsFormat.format(entry.gallons)} gal',
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
),
],

View file

@ -7,10 +7,12 @@ import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/app_state.dart';
import '../services/estimated_refund.dart';
import '../services/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
import '../services/onboarding_keys.dart';
import 'faq_screen.dart';
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
/// file for the user — it's a state form, submitted to the state — so the
@ -51,13 +53,26 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
final (start, end) = defaultReportDateRange(DateTime.now());
_startDate = start;
_endDate = end;
}
Future<void> _pickDate({required bool isStart}) async {
final now = DateTime.now();
// The default range's end can fall in the future (it covers the
// fuel-tax-refund period currently underway, per defaultReportDateRange)
// — showDatePicker asserts initialDate <= lastDate, so lastDate has to
// stretch to cover it too, not just "today".
final lastDate = _endDate != null && _endDate!.isAfter(now) ? _endDate! : now;
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _startDate : _endDate) ?? now,
firstDate: DateTime(2000),
lastDate: now,
lastDate: lastDate,
);
if (picked == null) return;
setState(() {
@ -118,6 +133,22 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
}
}
Future<void> _preview(FuelReport report) async {
setState(() => _busy = true);
Uint8List bytes;
try {
bytes = await _buildPdfBytes(report);
} finally {
if (mounted) setState(() => _busy = false);
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ReportPreviewScreen(bytes: bytes, fileName: fuelReportFileName(report)),
),
);
}
Future<void> _openRefundFormLink() async {
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
@ -161,7 +192,10 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
);
return Scaffold(
appBar: AppBar(title: const Text('Fuel Report')),
appBar: AppBar(
title: const Text('Fuel Report'),
actions: const [FaqActionButton()],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -233,6 +267,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
_VehicleSelectionCard(
row: row,
selected: _selectedVehicleIds.contains(row.vehicle.id),
showEstimatedRefund: appState.showEstimatedFuelRefund,
onTap: () => setState(() {
if (_selectedVehicleIds.contains(row.vehicle.id)) {
_selectedVehicleIds.remove(row.vehicle.id);
@ -252,7 +287,9 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
'${report.totalGallons.toStringAsFixed(3)} gal · '
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
'${NumberFormat.simpleCurrency().format(report.totalCost)}'
'${appState.showEstimatedFuelRefund ? ' · Est. '
'${NumberFormat.simpleCurrency().format(estimatedFuelRefund(report.totalGallons))}' : ''}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@ -260,6 +297,14 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
Row(
key: OnboardingKeys.reportShareAndPrintRow,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _preview(report),
icon: const Icon(Icons.visibility_outlined),
label: const Text('Preview'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _share(report),
@ -334,9 +379,15 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
class _VehicleSelectionCard extends StatelessWidget {
final VehicleReportRow row;
final bool selected;
final bool showEstimatedRefund;
final VoidCallback onTap;
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
const _VehicleSelectionCard({
required this.row,
required this.selected,
required this.showEstimatedRefund,
required this.onTap,
});
@override
Widget build(BuildContext context) {
@ -379,6 +430,13 @@ class _VehicleSelectionCard extends StatelessWidget {
NumberFormat.simpleCurrency().format(row.totalCost),
style: TextStyle(color: foreground),
),
if (showEstimatedRefund) ...[
const SizedBox(height: 2),
Text(
'Est. ${NumberFormat.simpleCurrency().format(estimatedFuelRefund(row.totalGallons))}',
style: TextStyle(color: foregroundVariant, fontSize: 12),
),
],
],
),
],
@ -388,3 +446,31 @@ class _VehicleSelectionCard extends StatelessWidget {
);
}
}
/// Shows the already-built report PDF in-app, with [PdfPreview]'s own
/// built-in Share/Print actions — so Preview isn't a dead end, the same
/// two actions available back on [ReportScreen] are still reachable from
/// here without backing out first. The report's format is fixed
/// ([buildFuelReportPdf] always renders US Letter), so the page
/// format/orientation controls [PdfPreview] would otherwise offer are
/// turned off since they wouldn't actually change anything.
class _ReportPreviewScreen extends StatelessWidget {
final Uint8List bytes;
final String fileName;
const _ReportPreviewScreen({required this.bytes, required this.fileName});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Report Preview')),
body: PdfPreview(
build: (format) async => bytes,
pdfFileName: fileName,
canChangePageFormat: false,
canChangeOrientation: false,
canDebug: false,
),
);
}
}

View file

@ -5,7 +5,9 @@ import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/onboarding_keys.dart';
import 'data_settings_screen.dart';
import 'faq_screen.dart';
import 'ui_settings_screen.dart';
import 'user_agreement_screen.dart';
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
@ -18,7 +20,10 @@ class SettingsScreen extends StatelessWidget {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(
title: const Text('Settings'),
actions: const [FaqActionButton()],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -47,6 +52,30 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.help_outline),
title: const Text('FAQ'),
subtitle: const Text('Common questions about the app and the refund program'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FaqScreen()),
),
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.description_outlined),
title: const Text('User Agreement'),
subtitle: const Text('Data liability, privacy, and the tax-advice disclaimer'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)),
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -63,8 +92,8 @@ class SettingsScreen extends StatelessWidget {
),
] else ...[
Text(
'This app shows a single ad, at most once per app session, right '
'before syncing your data to the cloud.',
'Syncing your data to the cloud requires watching a short ad — '
'at most once every 5 minutes.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
@ -84,6 +113,19 @@ class SettingsScreen extends StatelessWidget {
: 'Remove Ads for a Year — ${appState.adFreeYearPriceLabel}',
),
),
TextButton(
onPressed: () {
context.read<AppState>().restoreAdFreeYear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Checking for a previous purchase on this Google account…",
),
),
);
},
child: const Text('Restore Purchase'),
),
],
],
),

View file

@ -40,6 +40,30 @@ class UiSettingsScreen extends StatelessWidget {
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Estimated Fuel Refund'),
subtitle: const Text(
'Shows an estimate of your Missouri Highway Fuel Tax Refund next to '
'your receipts and totals. Based on the current rate — contingent on '
"it, and not a substitute for confirming the actual amount with the "
'Missouri Department of Revenue.',
),
value: appState.showEstimatedFuelRefund,
onChanged: (value) =>
context.read<AppState>().setShowEstimatedFuelRefund(value),
),
],
),
),
),
],
),
);

View file

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import 'faq_screen.dart';
/// The very first thing shown on a fresh install — before the onboarding
/// tour, before anything else — gating [MainShell] entirely until the user
@ -11,16 +12,32 @@ import '../services/app_state.dart';
/// Its whole purpose is the liability section below: this app stores data
/// on the user's own device (and, optionally, their own cloud storage
/// account) with no server or backend of ours involved, so we have no
/// ability to recover anything for them if it's lost.
/// ability to recover anything for them if it's lost — plus the privacy
/// section, disclosing that we don't track/log anything ourselves, and
/// that AdMob (only active for users on the free, ad-supported tier) is
/// the sole source of any data collection, governed by Google's own
/// practices rather than anything this app adds.
class UserAgreementScreen extends StatelessWidget {
const UserAgreementScreen({super.key});
/// True when reached from Settings (see [SettingsScreen]) to re-read the
/// agreement/privacy policy at any time — required so this content stays
/// reachable in the app beyond the one-time first-launch gate, not just
/// something a user saw once and can never get back to. Hides the
/// Decline/I Agree actions (already answered, and "Decline" quitting the
/// app would be a bizarre side effect of just re-reading this) and shows
/// a normal back button instead of gating navigation away entirely.
final bool isReview;
const UserAgreementScreen({super.key, this.isReview = false});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('User Agreement'), automaticallyImplyLeading: false),
appBar: AppBar(
title: const Text('User Agreement'),
automaticallyImplyLeading: isReview,
),
body: SafeArea(
child: Column(
children: [
@ -29,8 +46,11 @@ class UserAgreementScreen extends StatelessWidget {
padding: const EdgeInsets.all(20),
children: [
Text(
'Please read and accept the following before using Show Me The Fuel '
'Refund.',
isReview
? 'This is the agreement you accepted when you first opened Show Me '
'The Fuel Refund.'
: 'Please read and accept the following before using Show Me The Fuel '
'Refund.',
style: textTheme.bodyLarge,
),
const SizedBox(height: 20),
@ -43,6 +63,17 @@ class UserAgreementScreen extends StatelessWidget {
'also kept there — in storage you control, not on any server we run. '
"We don't operate a backend, and we don't have a copy of your data.",
),
_Section(
title: 'Privacy',
body:
"We don't track or log your data in any way — there's no analytics, no "
"crash reporting, and no server of ours receiving anything you enter. If "
'you haven\'t purchased "Remove Ads for a Year" and are using the free, '
'ad-supported version, the only data collected is whatever Google AdMob '
'itself requires to serve ads (e.g. a device advertising identifier) — '
"that collection is Google's, governed by its own privacy practices, not "
'something this app adds on top of.',
),
_Section(
title: 'We Are Not Responsible for Your Data or Any Data Loss',
body:
@ -66,37 +97,55 @@ class UserAgreementScreen extends StatelessWidget {
_Section(
title: 'Not Tax or Legal Advice',
body:
"This app helps you organize receipts for Missouri's Motor Fuel Tax "
'Refund program; it does not provide tax or legal advice, and does not '
'guarantee your eligibility for any refund. The program itself is set '
'by Missouri law, which can change at any time — always confirm current '
'eligibility and rates with the Missouri Department of Revenue.',
'This app is purely informational. Missouri actually offers two '
'separate fuel tax refunds — up to 12.5¢ per gallon for highway use, '
"and up to 29.5¢ per gallon for non-highway use — and it's worth "
"evaluating both to see how you could benefit. This app helps you "
"organize the receipts you'd need for either one.\n\n"
"Nothing here is tax or legal advice, and using this app doesn't "
'guarantee your eligibility for either refund. For guidance on your '
'specific situation, consult a tax professional. Both refunds are set '
'by Missouri law, which can change at any time — always confirm '
'current eligibility and rates with the Missouri Department of '
'Revenue before filing.',
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FaqScreen()),
),
icon: const Icon(Icons.help_outline),
label: const Text('See the FAQ for more'),
),
),
const SizedBox(height: 8),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => SystemNavigator.pop(),
child: const Text('Decline'),
if (!isReview) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => SystemNavigator.pop(),
child: const Text('Decline'),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () => context.read<AppState>().acceptUserAgreement(),
child: const Text('I Agree'),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () => context.read<AppState>().acceptUserAgreement(),
child: const Text('I Agree'),
),
),
),
],
],
),
),
),
],
],
),
),

View file

@ -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,
),

View file

@ -2,37 +2,53 @@ import 'dart:async';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// The single ad placement this app has: one interstitial, shown at most
/// once per app session, right before the *upload-to-cloud* phase of the
/// first sync that reaches it (see [CloudSyncService.syncNow]'s
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not before
/// pulling/merging remote changes down, and never anywhere else in the app.
/// The single ad placement this app has: one interstitial, gating the
/// *upload* phase of a cloud sync (see [CloudSyncService.syncNow]'s
/// `beforeUpload` hook and [AppState.syncNow]) — deliberately not pulling/
/// merging remote changes down, and never anywhere else in the app.
///
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) is
/// the real one from your AdMob console. [_interstitialAdUnitId] below is
/// TEMPORARILY back on Google's official test interstitial ID — your real
/// one (`ca-app-pub-9212406812117696/9482586380`) was returning no-fill
/// (error code 3), most likely just because it's brand new; this swap is
/// only to confirm the trigger/preload/display mechanism itself works
/// while that warms up. Swap the real one back in once it's serving.
/// ios/Runner/Info.plist's `GADApplicationIdentifier` is still Google's
/// test iOS App ID, though — an AdMob App ID is registered per-platform, so
/// it needs its own real iOS App ID (and a real iOS interstitial ad unit
/// ID here) from the AdMob console if this app ever ships on iOS.
/// Showing an ad opens a gate that stays open for [gateValidity]; a sync
/// attempted after that window closes has to show (and have the user sit
/// through the start of) another one before it's allowed to push data to
/// the cloud. Selecting/connecting a cloud provider is never gated — only
/// the upload side of a sync is, via [showGateAd].
///
/// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) and
/// [_interstitialAdUnitId] below are both the real ones from your AdMob
/// console. ios/Runner/Info.plist's `GADApplicationIdentifier` is still
/// Google's test iOS App ID, though — an AdMob App ID is registered
/// per-platform, so it needs its own real iOS App ID (and a real iOS
/// interstitial ad unit ID here) from the AdMob console if this app ever
/// ships on iOS.
/// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the
/// caller may proceed" — they're kept distinct only so [AppState] can tell
/// whether *this* call is the one that actually put a user in front of an
/// ad, which is the trigger for the "remove ads for a year" upsell dialog.
/// [AdGateResult.blocked] means the caller must not proceed.
enum AdGateResult { alreadyOpen, justShown, blocked }
class AdService {
static const _interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712';
static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380';
/// How long a successfully-shown ad keeps the upload gate open before the
/// next sync attempt has to show another one.
static const gateValidity = Duration(minutes: 5);
bool _sdkInitialized = false;
bool _shownThisSession = false;
DateTime? _lastShownAt;
InterstitialAd? _preloadedAd;
Completer<void>? _loadCompleter;
/// Starts the Mobile Ads SDK and begins preloading this session's one
/// interstitial. Idempotent (a no-op after the first call) and safe to
/// call speculatively — [AppState] calls this from [AppState.syncNow]
/// rather than unconditionally at app startup, so a user who never
/// connects cloud sync never triggers any ad-related network activity at
/// all.
bool get _gateOpen {
final lastShownAt = _lastShownAt;
return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity;
}
/// Starts the Mobile Ads SDK and begins preloading an interstitial.
/// Idempotent (a no-op after the first call) and safe to call
/// speculatively — [AppState] calls this from [AppState.syncNow] rather
/// than unconditionally at app startup, so a user who never connects
/// cloud sync never triggers any ad-related network activity at all.
Future<void> initialize() async {
if (_sdkInitialized) return;
_sdkInitialized = true;
@ -59,16 +75,22 @@ class AdService {
return completer.future;
}
/// Shows the preloaded interstitial if this is the first call this app
/// session; every call after that (or if no ad ever became available) is
/// a no-op. Waits for the ad to actually be dismissed before returning,
/// so the caller — the sync engine, right before it starts uploading —
/// genuinely happens *after* the ad, not just alongside it; bounded so a
/// slow ad load or a stuck ad SDK callback can never block sync
/// indefinitely.
Future<void> maybeShowBeforeSync() async {
if (_shownThisSession) return;
_shownThisSession = true;
/// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an
/// ad was already shown within [gateValidity]; otherwise shows the
/// preloaded interstitial and returns [AdGateResult.justShown] once it
/// actually starts displaying (the only "watched it" signal a plain
/// interstitial — as opposed to a rewarded ad — can give us), or
/// [AdGateResult.blocked] if none was available in time or it failed to
/// show. A [AdGateResult.blocked] result means the caller must not
/// proceed with uploading.
///
/// Bounded throughout so a slow ad load or a stuck ad SDK callback can
/// never hang a sync indefinitely. Always lines up the next interstitial
/// afterward, whether this attempt succeeded or not, so the next call —
/// whether that's because this one failed or because [gateValidity]
/// elapsed — has the best chance of a preloaded ad ready to go.
Future<AdGateResult> showGateAd() async {
if (_gateOpen) return AdGateResult.alreadyOpen;
if (_preloadedAd == null) {
await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {});
@ -76,21 +98,27 @@ class AdService {
final ad = _preloadedAd;
_preloadedAd = null;
if (ad == null) return;
if (ad == null) {
unawaited(_preload());
return AdGateResult.blocked;
}
final dismissed = Completer<void>();
final showed = Completer<bool>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
if (!dismissed.isCompleted) dismissed.complete();
onAdShowedFullScreenContent: (ad) {
_lastShownAt = DateTime.now();
if (!showed.isCompleted) showed.complete(true);
},
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
if (!dismissed.isCompleted) dismissed.complete();
if (!showed.isCompleted) showed.complete(false);
},
);
await ad.show();
await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {});
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
unawaited(_preload());
return opened ? AdGateResult.justShown : AdGateResult.blocked;
}
}

View file

@ -28,6 +28,7 @@ const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const _prefsKeyThemeMode = 'theme_mode';
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement';
const _prefsKeyShowEstimatedFuelRefund = 'show_estimated_fuel_refund';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
@ -91,6 +92,12 @@ class AppState extends ChangeNotifier {
DateTime? lastSyncedAt;
Object? lastSyncError;
/// Set by the widget tree at startup (see `main.dart`) to show the
/// "remove ads for a year" upsell — [syncNow] calls this the moment an ad
/// was actually just shown to gate an upload, never on a sync that finds
/// the gate already open from a recent prior view.
VoidCallback? onAdWatched;
bool keepReceiptPhotosLocally = false;
int staleLockMinutes = defaultStaleLockMinutes;
@ -104,6 +111,14 @@ class AppState extends ChangeNotifier {
ThemeMode themeMode = ThemeMode.system;
/// Whether to show an estimated Missouri Highway Fuel Tax Refund amount
/// alongside gallons/cost figures throughout the app (Receipts hero
/// banner, receipt line items, report totals — see
/// lib/services/estimated_refund.dart). Off by default: it's a rough,
/// rate-contingent estimate, not something every user necessarily wants
/// cluttering their totals.
bool showEstimatedFuelRefund = false;
/// Whether the first-launch guided tour ([OnboardingTour]) has already
/// been shown. Defaults to `true` here (not `false`) specifically so
/// that widget tests constructing `AppState()` directly and skipping
@ -183,6 +198,7 @@ class AppState extends ChangeNotifier {
);
hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false;
hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false;
showEstimatedFuelRefund = prefs.getBool(_prefsKeyShowEstimatedFuelRefund) ?? false;
} catch (e) {
initError = e;
isLoading = false;
@ -365,19 +381,24 @@ class AppState extends ChangeNotifier {
if (isSyncing || sync == null || !sync.isConfigured) return;
// A user with a currently-active "remove ads for a year" purchase
// skips the ad entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to do here", same as any other call site
// that never passed one) and the ad SDK isn't even touched this sync.
Future<void> Function()? beforeUpload;
// skips the gate entirely — beforeUpload stays null (CloudSyncService
// treats that as "nothing to check", same as any other call site that
// never passed one) and the ad SDK isn't even touched this sync.
Future<bool> Function()? beforeUpload;
if (!adsCurrentlyDisabled) {
// Idempotent, and only ever reached once sync is actually configured
// — a user who never connects cloud storage never triggers any
// ad-related activity at all. Not awaited: it's fine if this
// session's one ad is still loading by the time beforeUpload below
// is reached — maybeShowBeforeSync degrades gracefully (just skips
// showing it) if it isn't ready in time.
// ad-related activity at all.
unawaited(adService.initialize());
beforeUpload = adService.maybeShowBeforeSync;
beforeUpload = () async {
final result = await adService.showGateAd();
// Fired, not awaited: the upsell dialog is purely informational —
// this sync (specifically the upload [beforeUpload] is about to
// unblock) shouldn't wait on however long it takes the user to
// dismiss it.
if (result == AdGateResult.justShown) onAdWatched?.call();
return result != AdGateResult.blocked;
};
}
isSyncing = true;
@ -393,6 +414,12 @@ class AppState extends ChangeNotifier {
await _refreshFromDatabase();
lastSyncedAt = DateTime.now();
lastSyncError = null;
} else if (result.adGateBlocked) {
// Whatever the remote side had was still pulled/merged in — only the
// upload was withheld — so the on-screen data should reflect that
// even though this doesn't count as a completed sync.
await _refreshFromDatabase();
lastSyncError = 'Watch a short ad to finish syncing your data to the cloud.';
} else if (result.error != null) {
lastSyncError = result.error;
}
@ -422,19 +449,39 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> setShowEstimatedFuelRefund(bool value) async {
showEstimatedFuelRefund = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyShowEstimatedFuelRefund, value);
notifyListeners();
}
/// Kicks off the platform purchase UI for a year of no ads — see
/// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted
/// asynchronously, once the store confirms the purchase (see
/// [_grantAdFreeYear]), not immediately when this returns.
Future<void> buyAdFreeYear() => purchaseService.buyAdFreeYear();
/// [PurchaseService]'s `onPurchaseGranted` callback: records a fresh
/// year of ad-free time starting now, regardless of any time already
/// remaining on a previous purchase — buying again before the current
/// year lapses simply resets the clock rather than stacking.
Future<void> _grantAdFreeYear() async {
final now = DateTime.now().toUtc();
await database.setAdFreeUntil(now.add(const Duration(days: 365)), now);
/// Re-checks Play Billing for an active purchase and, if one is found,
/// re-grants the entitlement locally — see [PurchaseService.restorePurchase].
/// For a user who reinstalled or switched devices without cloud backup
/// connected, so there was nothing local to sync the entitlement back
/// down from. Safe to call any time; does nothing if there's no active
/// purchase to find.
Future<void> restoreAdFreeYear() => purchaseService.restorePurchase();
/// [PurchaseService]'s `onPurchaseGranted` callback: records a year of
/// ad-free time anchored to [purchaseTime] — Play Billing's own record of
/// when the purchase actually happened, not "now" — so restoring a
/// purchase made months ago correctly reflects however much of that year
/// is already gone, rather than handing out a fresh extra year. Buying
/// again before the current year lapses simply resets the clock to a
/// fresh year from that new purchase, rather than stacking.
Future<void> _grantAdFreeYear(DateTime purchaseTime) async {
await database.setAdFreeUntil(
purchaseTime.add(const Duration(days: 365)),
DateTime.now().toUtc(),
);
purchaseError = null;
await _persist();
}
@ -547,13 +594,19 @@ class AppState extends ChangeNotifier {
return entry;
}
/// Corrects the logged values (date, gallons, price/gal, total cost) for
/// an existing fuel entry — the receipt photo itself isn't editable here,
/// only the data recorded about it (e.g. fixing a misread OCR value).
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
/// carries over untouched.
/// Corrects the logged values (date, gallons, price/gal, total cost, and
/// which vehicle it's attached to) for an existing fuel entry — the
/// receipt photo itself isn't editable here, only the data recorded
/// about it (e.g. fixing a misread OCR value, or a receipt that got
/// logged under the wrong vehicle). [entryId]'s receipt photo reference
/// (local path and/or cloud file id) carries over untouched even when
/// [vehicleId] changes — the underlying photo file stays exactly where
/// it already is (including whichever vehicle's folder it was filed
/// under locally/in the cloud); only the database's own record of which
/// vehicle owns this entry moves.
Future<void> updateFuelEntry({
required String entryId,
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
@ -562,7 +615,7 @@ class AppState extends ChangeNotifier {
final existing = fuelEntries.firstWhere((e) => e.id == entryId);
final updated = FuelEntry(
id: existing.id,
vehicleId: existing.vehicleId,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,

View file

@ -12,15 +12,30 @@ class SyncResult {
final bool ranSync;
final Object? error;
/// True when pull/merge completed but [CloudSyncService.syncNow]'s
/// `beforeUpload` gate (the app's ad-watch requirement — see
/// [AdService.showGateAd]) came back closed, so nothing local was
/// uploaded. Remote changes, if any, were still merged in.
final bool adGateBlocked;
SyncResult.skipped()
: ranSync = false,
error = null;
error = null,
adGateBlocked = false;
SyncResult.success()
: ranSync = true,
error = null;
error = null,
adGateBlocked = false;
SyncResult.failure(this.error) : ranSync = false;
SyncResult.failure(this.error)
: ranSync = false,
adGateBlocked = false;
SyncResult.adGateBlocked()
: ranSync = false,
error = null,
adGateBlocked = true;
}
/// Orchestrates one round of sync against the shared cloud folder — same
@ -144,14 +159,16 @@ class CloudSyncService {
///
/// [beforeUpload], if given, is awaited once — after any pull/merge of
/// remote changes has finished, but before anything local gets uploaded
/// (pending receipt photos or the database snapshot itself). This is the
/// one hook [AppState] uses to show the app's single ad placement, since
/// it's meant to run before *pushing* local data up, not before *pulling*
/// remote data down.
/// (pending receipt photos or the database snapshot itself) — and its
/// result decides whether the upload happens at all. This is the hook
/// [AppState] uses to gate uploads behind the app's ad placement: a
/// `false` return means the gate is closed (see [AdService.showGateAd])
/// and this call returns [SyncResult.adGateBlocked] without uploading
/// anything, having still pulled/merged whatever the remote side had.
Future<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
Future<void> Function()? beforeUpload,
Future<bool> Function()? beforeUpload,
}) async {
final appFolderId = _appFolderId;
if (!provider.isSignedIn || appFolderId == null) {
@ -180,8 +197,8 @@ class CloudSyncService {
await _pullAndMerge(session, remoteInfo.id);
}
if (beforeUpload != null) {
await beforeUpload();
if (beforeUpload != null && !await beforeUpload()) {
return SyncResult.adGateBlocked();
}
final allReceiptsUploaded =

View file

@ -0,0 +1,26 @@
/// The Missouri Highway Fuel Tax Refund rate per gallon — the basis for
/// [estimatedFuelRefund]. Set by Missouri law and subject to change at any
/// time; the UI Settings toggle that turns these estimates on says as much,
/// and this is deliberately the highway rate only (not the separate,
/// higher non-highway-use refund) since that's the one that applies to
/// ordinary vehicle fill-ups this app is built around.
const moHighwayFuelTaxRefundRatePerGallon = 0.125;
/// A rough estimate of the Missouri Highway Fuel Tax Refund for [gallons]
/// of fuel, at the current rate. Purely informational — not tax advice,
/// and not a substitute for confirming the actual claimable amount with
/// the Missouri Department of Revenue.
///
/// [gallons] is rounded to the nearest whole gallon *before* multiplying —
/// matching how the actual refund calculation works, rather than
/// multiplying the precise (fractional) logged amount. The result is then
/// rounded down to the nearest cent, never up, so this never overstates
/// what's actually claimable.
double estimatedFuelRefund(double gallons) {
final roundedGallons = gallons.roundToDouble();
final rawRefund = roundedGallons * moHighwayFuelTaxRefundRatePerGallon;
// The tiny epsilon guards against binary floating-point representation
// error nudging an exact cent value (e.g. 2.50) just under its true
// value and floor()ing it down a cent it doesn't actually owe.
return (rawRefund * 100 + 1e-9).floorToDouble() / 100;
}

View file

@ -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

View file

@ -1,35 +1,52 @@
import 'dart:async';
import 'dart:io';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
/// Wraps the app's one purchasable product: a *consumable* one-time
/// purchase that grants a year of no ads (see
/// [AppState.adsCurrentlyDisabled]) — consumable specifically so it can be
/// bought again once that year lapses, unlike a plain non-consumable
/// (which Play Store would only ever let you own once, permanently) or an
/// auto-renewing subscription (which would charge the user again every
/// year without them actively choosing to).
/// Wraps the app's one purchasable product: a one-year, non-auto-renewing
/// *prepaid subscription* that grants a year of no ads (see
/// [AppState.adsCurrentlyDisabled]).
///
/// Prepaid subscription, specifically — not a consumable, a plain
/// non-consumable, or an auto-renewing subscription:
/// - A *consumable* has to be acknowledged (= consumed, for this product
/// type) within 3 days of purchase or Play Billing auto-refunds it —
/// there's no way to leave it unconsumed so it stays restorable for the
/// whole year. And once consumed, Play Billing forgets it existed, so a
/// user who loses local data (no cloud backup connected) has nothing
/// left to restore from. This is what this product used to be, before
/// that gap was found.
/// - A plain *non-consumable* would only ever let a Google account buy it
/// once, permanently — doesn't fit "buy another year once this one
/// lapses".
/// - An *auto-renewing subscription* would charge the user again every
/// year without them actively choosing to.
/// - A *prepaid* subscription base plan fits all three constraints: it's
/// acknowledged immediately (same 3-day rule, satisfied same as any
/// purchase), stays active — and restorable via [restorePurchase] — for
/// its whole prepaid year without needing to be consumed, then simply
/// expires with no charge and can be bought again.
///
/// [adFreeYearProductId] is a placeholder — nothing will actually load or
/// be purchasable until a real in-app product with this same ID exists in
/// your Google Play Console (Monetize > Products > In-app products),
/// configured as a *managed product*, with whatever price you choose
/// there (Play Billing doesn't take a price from the app itself). Rename
/// the constant below to match whatever product ID you actually create,
/// if you'd rather not use this one.
/// be purchasable until a real subscription with this same ID exists in
/// your Google Play Console (Monetize > Products > Subscriptions), with a
/// *prepaid* base plan named [_basePlanId] and whatever price/duration you
/// choose there (Play Billing doesn't take a price from the app itself).
/// Rename either constant to match whatever you actually create, if you'd
/// rather not use these.
class PurchaseService {
static const adFreeYearProductId = 'ad_free_year';
static const _basePlanId = 'ad-free-year-prepaid';
/// Called once for every successful (or restored) purchase of
/// [adFreeYearProductId] — [AppState] is what actually records the
/// resulting entitlement; this service only reports that a purchase
/// happened.
final void Function() onPurchaseGranted;
/// [adFreeYearProductId], with the time Play Billing recorded the
/// purchase actually happening — [AppState] anchors the year of ad-free
/// time to that, not to "now", so restoring an existing purchase doesn't
/// hand out a free extra year on top of time already elapsed.
final void Function(DateTime purchaseTime) onPurchaseGranted;
/// Called with a user-facing message when a purchase attempt fails —
/// [AppState] surfaces this via [AppState.purchaseError] for the
/// Called with a user-facing message when a purchase or restore attempt
/// fails — [AppState] surfaces this via [AppState.purchaseError] for the
/// Settings screen to display.
final void Function(String message)? onPurchaseError;
@ -40,8 +57,8 @@ class PurchaseService {
/// The store's own formatted, localized price string (e.g. `"$4.99"`)
/// once [initialize] has loaded the product — null before that, or if
/// the product ID above doesn't match anything configured in the store
/// yet.
/// neither the product ID nor base plan ID above matches anything
/// configured in the store yet.
String? get priceLabel => _product?.price;
Future<void> initialize() async {
@ -55,23 +72,55 @@ class PurchaseService {
);
final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId});
if (response.productDetails.isNotEmpty) {
_product = response.productDetails.first;
_product = _selectOffer(response.productDetails);
}
/// For a subscription product, [InAppPurchase.queryProductDetails]
/// returns one [ProductDetails] per offer/base-plan combination it has,
/// not one per product ID — picks the prepaid base plan set up for this
/// product specifically, falling back to whichever offer loaded first so
/// a Play Console setup with only the one base plan (the expected,
/// common case here) still works without its name needing to match
/// exactly.
ProductDetails? _selectOffer(List<ProductDetails> offers) {
for (final offer in offers) {
if (offer is! GooglePlayProductDetails) continue;
final index = offer.subscriptionIndex;
final basePlanId =
index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId;
if (basePlanId == _basePlanId) return offer;
}
return offers.isEmpty ? null : offers.first;
}
/// Kicks off the platform purchase UI. Does nothing (and reports an
/// error) if the product hasn't loaded — either the store isn't
/// available, or [adFreeYearProductId] doesn't match a real product yet.
/// available, or neither [adFreeYearProductId] nor [_basePlanId] matches
/// a real product yet.
Future<void> buyAdFreeYear() async {
final product = _product;
if (product == null) {
onPurchaseError?.call("This purchase isn't available right now.");
return;
}
await InAppPurchase.instance.buyConsumable(
purchaseParam: PurchaseParam(productDetails: product),
);
final purchaseParam = product is GooglePlayProductDetails
? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken)
: PurchaseParam(productDetails: product);
await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam);
}
/// Re-derives the local entitlement from Play Billing's own record of an
/// active (not yet expired) prepaid purchase — for a user who reinstalled
/// or switched devices without cloud backup connected, where nothing
/// local survived to sync the entitlement back down. A silent no-op if
/// there's nothing currently active to find, which is the normal outcome
/// for anyone who's never purchased, so that's not treated as an error.
Future<void> restorePurchase() async {
try {
await InAppPurchase.instance.restorePurchases();
} on InAppPurchaseException catch (e) {
onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later.");
}
}
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
@ -80,16 +129,7 @@ class PurchaseService {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
if (purchase.productID == adFreeYearProductId) {
onPurchaseGranted();
}
// Android specifically: a *consumable* purchase has to be
// explicitly "consumed" or Play Billing considers it still owned
// and refuses to sell it again next year. iOS has no equivalent
// step — StoreKit consumables are inherently one-shot already.
if (Platform.isAndroid) {
final androidAddition =
InAppPurchase.instance.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
await androidAddition.consumePurchase(purchase);
onPurchaseGranted(_purchaseTime(purchase));
}
if (purchase.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchase);
@ -106,6 +146,18 @@ class PurchaseService {
}
}
/// Play reports this as epoch milliseconds in a string (see
/// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the
/// current time in the shouldn't-happen case that it's missing or
/// malformed, rather than leaving the entitlement ungranted over a
/// parsing hiccup.
DateTime _purchaseTime(PurchaseDetails purchase) {
final millis = int.tryParse(purchase.transactionDate ?? '');
return millis == null
? DateTime.now().toUtc()
: DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
}
void dispose() {
_subscription?.cancel();
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
/// Shown right after a user watches the app's one ad placement (see
/// [AdService.showGateAd] / [AppState.syncNow]) — not on every sync, only
/// the moment an ad was actually just shown to them, since that's when the
/// "you could skip these" pitch is most relevant. Purely informational: it
/// doesn't block or delay the sync that triggered it, which is already
/// proceeding underneath it by the time this appears.
Future<void> showAdFreeUpsellDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tired of Ads?'),
content: Consumer<AppState>(
builder: (context, appState, _) => Text(
appState.adFreeYearPriceLabel == null
? "You can remove ads for a full year with a single one-time purchase."
: 'You can remove ads for a full year for '
'${appState.adFreeYearPriceLabel} — a single one-time purchase.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Not Now'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
context.read<AppState>().buyAdFreeYear();
},
child: const Text('Remove Ads'),
),
],
),
);
}

View file

@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../screens/data_settings_screen.dart';
import '../screens/faq_screen.dart';
import '../services/onboarding_keys.dart';
import 'onboarding_tour_overlay.dart';
const _backedUpFaqQuestion = 'Is my data backed up?';
/// Shown right after a fuel entry is saved (see
/// `ConfirmFuelEntryScreen._save`) when no cloud backup is configured yet —
/// nudges the user toward Settings > Data before they forget and eventually
@ -21,6 +25,19 @@ Future<bool> showBackupReminderDialog(BuildContext context) async {
'Would you like to set up a backup method now?',
),
actions: [
TextButton(
onPressed: () {
// Resolves as "not now" (they didn't choose to set backup up
// directly), but still takes them somewhere useful about it.
Navigator.of(context).pop(false);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const FaqScreen(initiallyExpandedQuestion: _backedUpFaqQuestion),
),
);
},
child: const Text('Learn More'),
),
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Not Now')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Set Up Backup')),
],
@ -80,6 +97,34 @@ class _SpotlightedDataSettingsScreenState extends State<_SpotlightedDataSettings
await Future.delayed(const Duration(milliseconds: 16));
}
if (!mounted) return;
// This screen was reached via Navigator.push (see openCloudBackupSetup
// below) — wait for that push transition to fully settle before
// capturing the card's position, or the spotlight ends up shifted by
// however far the slide-in hadn't yet finished. ModalRoute.of(context)
// correctly resolves to that pushed route here, since this State's
// context lives inside it.
await waitForRouteTransition(ModalRoute.of(context)?.animation);
if (!mounted) return;
// The route's own AnimationController reports AnimationStatus.completed
// at this point, but empirically the render tree's transforms (from
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
// Android default as of Flutter 3.44) still reflect a mid-transition
// position for a few more frames after that — confirmed by walking the
// RenderObject ancestor chain and finding an active
// RenderFractionalTranslation still present, with a position matching
// the transition's *starting* offset rather than its resting
// Offset.zero. These extra frames give it time to actually settle
// before the spotlight measures anything. SchedulerBinding.endOfFrame
// (rather than a bare Future.delayed) so this genuinely waits on real
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
// while something has an actual frame scheduled, which a bare delay
// timer doesn't count as once the route's own transition has already
// finished.
for (var i = 0; i < 10; i++) {
if (!mounted) return;
await SchedulerBinding.instance.endOfFrame;
}
if (!mounted) return;
_entry = OverlayEntry(
builder: (_) => OnboardingTourOverlay(

View file

@ -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,
),
),
],
);
}
}

View file

@ -1,6 +1,41 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Waits for a route's push/pop transition, if one is still in progress, to
/// finish. Needed before capturing a highlighted target's on-screen
/// position via `RenderBox.localToGlobal` (see `MainShell._waitForTargets`
/// / `_SpotlightedDataSettingsScreen`'s `_showSpotlight` in
/// backup_reminder.dart, both of which push a [MaterialPageRoute] and then
/// spotlight something on it): a [RenderBox] reports `hasSize` — and a
/// real, but not yet final, global position — as soon as its first layout
/// pass completes, which happens well before a [MaterialPageRoute]'s
/// ~300ms slide-in transition actually settles. Capturing the position too
/// early bakes in wherever the page was mid-slide, which is what produced
/// a spotlight rectangle visibly shifted from its target.
///
/// Takes the [Animation] directly — rather than a [BuildContext] to look
/// one up via `ModalRoute.of` — because the caller doesn't always have a
/// [BuildContext] that's actually inside the route in question: `MainShell`
/// pushes the route from its own (different, already-settled) route, so
/// `ModalRoute.of(mainShellContext)` would resolve to the wrong one.
/// Passing null (nothing to wait for) or an already-[AnimationStatus.completed]
/// animation both resolve immediately, so it's safe to call unconditionally.
Future<void> waitForRouteTransition(Animation<double>? animation) async {
if (animation == null || animation.status == AnimationStatus.completed) return;
final completer = Completer<void>();
void listener(AnimationStatus status) {
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
completer.complete();
}
}
animation.addStatusListener(listener);
await completer.future;
animation.removeStatusListener(listener);
}
/// The full-screen "spotlight" shown by one step of the first-launch
/// guided tour: a dimmed barrier with a cut-out hole around each of
/// [targetKeys]' current on-screen positions (or, for any key that isn't