A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
|
|
@ -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
178
lib/screens/faq_screen.dart
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class _FaqEntry {
|
||||
final String question;
|
||||
final String answer;
|
||||
const _FaqEntry(this.question, this.answer);
|
||||
}
|
||||
|
||||
class _FaqSection {
|
||||
final String title;
|
||||
final List<_FaqEntry> entries;
|
||||
const _FaqSection(this.title, this.entries);
|
||||
}
|
||||
|
||||
const _faqSections = <_FaqSection>[
|
||||
_FaqSection('Missouri Motor Fuel Tax Refund', [
|
||||
_FaqEntry(
|
||||
'What is the Missouri Motor Fuel Tax Refund?',
|
||||
'Missouri actually offers two separate fuel tax refunds — it\'s worth evaluating '
|
||||
'both to see how you could benefit:\n\n'
|
||||
'• Highway use — up to 12.5¢ per gallon.\n'
|
||||
'• Non-highway use — up to 29.5¢ per gallon.\n\n'
|
||||
"This app helps you collect and organize the receipts you'll need to claim "
|
||||
"either one. This isn't tax advice — for guidance on which refund(s) fit your "
|
||||
'situation, consult a tax professional. Rates and eligibility are set by '
|
||||
'Missouri law, which can change at any time, so always confirm current details '
|
||||
'with the Missouri Department of Revenue before filing.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'Is this app affiliated with the Missouri Department of Revenue?',
|
||||
"No. This is an independent tool for organizing your own receipts — it isn't run by, "
|
||||
"endorsed by, or connected to the State of Missouri. It doesn't file anything on "
|
||||
'your behalf.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I generate a report for my refund claim?',
|
||||
"On the Reports tab, pick a date range to see your totals, then use Share or Print. "
|
||||
"There's also a link to Missouri's official Motor Fuel Refund Claim form once "
|
||||
"you're ready to file.",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Usage', [
|
||||
_FaqEntry(
|
||||
'How do I log a fuel purchase?',
|
||||
"On the Receipts tab, tap the + button, snap a photo of the receipt, and the app "
|
||||
'reads the date, gallons, and price automatically. Review (and correct, if '
|
||||
'needed) the values before saving — every field stays editable both before and '
|
||||
'after saving.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'What if the app misreads my receipt?',
|
||||
'Automatic reading is usually accurate but not perfect — correct any field yourself '
|
||||
'before saving, or edit it afterward from the entry\'s detail page.',
|
||||
),
|
||||
_FaqEntry(
|
||||
"Found a receipt, VIN, or error the app couldn't handle?",
|
||||
'Send it to ohbrer+ShowMeTheFuelRefund@gmail.com — example receipts or VINs that '
|
||||
"didn't scan correctly, screenshots of error screens, or anything else that "
|
||||
"didn't work as expected all help improve the app.",
|
||||
),
|
||||
_FaqEntry(
|
||||
'Do I need to add a vehicle before logging a receipt?',
|
||||
'Yes — every fuel entry is linked to a vehicle. Add one on the Vehicles tab first '
|
||||
"(you'll need its VIN).",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Data Safety', [
|
||||
_FaqEntry(
|
||||
'Is my data backed up?',
|
||||
'Not by default — everything you log lives only on this device until you connect a '
|
||||
'cloud storage account (Settings > Data). Once connected, a copy is kept there '
|
||||
'too, in storage you control.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'What cloud storage options are supported?',
|
||||
'Google Drive, Dropbox, OneDrive, or your own WebDAV server (Nextcloud, ownCloud, or '
|
||||
'any self-hosted WebDAV server).',
|
||||
),
|
||||
_FaqEntry(
|
||||
"What happens if I lose my phone, or reinstall the app?",
|
||||
"If you never connected cloud storage, that data is gone — it only ever lived on "
|
||||
"that device. If you had connected one, reconnect the same account on the new "
|
||||
"device (or after reinstalling) and your vehicles, receipts, and photos sync back "
|
||||
'down automatically.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I delete my data?',
|
||||
'Settings > Data > Advanced has options to purge everything, or just fuel entries in '
|
||||
"a specific date range. Both are permanent and can't be undone.",
|
||||
),
|
||||
]),
|
||||
_FaqSection('Advertisements', [
|
||||
_FaqEntry(
|
||||
'Why do I sometimes see an ad?',
|
||||
'Syncing your data to the cloud requires watching a short ad, to help support the '
|
||||
"app — at most once every 5 minutes, so it won't show again if you sync "
|
||||
'repeatedly in a short span.',
|
||||
),
|
||||
_FaqEntry(
|
||||
'How do I remove ads?',
|
||||
'"Remove Ads for a Year" in Settings is a one-time purchase that disables ads for a '
|
||||
"year from when you buy it. It doesn't auto-renew or charge you again "
|
||||
"automatically — once the year's up, ads come back until you buy again.",
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
/// A simple, static list of common questions grouped into sections —
|
||||
/// reached from [SettingsScreen] (and every tab's AppBar via
|
||||
/// [FaqActionButton]), the "back up your data" reminder, and the
|
||||
/// first-launch user agreement screen.
|
||||
class FaqScreen extends StatelessWidget {
|
||||
/// If given, that question's entry starts expanded (and the rest
|
||||
/// collapsed) instead of everything starting collapsed — used when a
|
||||
/// link elsewhere in the app points at one specific answer, e.g. the
|
||||
/// backup reminder dialog jumping straight to the backup question rather
|
||||
/// than making the user hunt for it.
|
||||
final String? initiallyExpandedQuestion;
|
||||
|
||||
const FaqScreen({super.key, this.initiallyExpandedQuestion});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('FAQ')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final section in _faqSections) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 8),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final entry in section.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Card(
|
||||
child: ExpansionTile(
|
||||
title: Text(entry.question, style: textTheme.titleSmall),
|
||||
initiallyExpanded: entry.question == initiallyExpandedQuestion,
|
||||
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(entry.answer, style: textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A "?" AppBar action that jumps straight to [FaqScreen] — added to every
|
||||
/// tab's AppBar (Receipts, Vehicles, Reports, Settings; see [MainShell])
|
||||
/// so help is reachable the same way no matter which screen the user's on.
|
||||
class FaqActionButton extends StatelessWidget {
|
||||
const FaqActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
tooltip: 'FAQ',
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import '../models/vehicle.dart';
|
|||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'vehicle_detail_screen.dart';
|
||||
|
||||
/// The "Vehicles" tab body — Settings and Reports are reached via the
|
||||
|
|
@ -30,6 +31,7 @@ class HomeScreen extends StatelessWidget {
|
|||
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
|
||||
),
|
||||
),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
body: vehicles.isEmpty
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
|
|
@ -51,7 +52,13 @@ class _OnboardingStep {
|
|||
final String title;
|
||||
final String description;
|
||||
final List<GlobalKey> targetKeys;
|
||||
final void Function() activate;
|
||||
|
||||
/// Gets the app into the right state to show this step (switch tabs,
|
||||
/// push a route, ...). Returns the route it just pushed, if any — so
|
||||
/// [_MainShellState._waitForTargets] can wait for that specific route's
|
||||
/// transition to settle before spotlighting something on it — or null
|
||||
/// for a step (like every tab switch) that didn't push one.
|
||||
final ModalRoute<dynamic>? Function() activate;
|
||||
|
||||
const _OnboardingStep({
|
||||
required this.title,
|
||||
|
|
@ -80,8 +87,8 @@ class _MainShellState extends State<MainShell> {
|
|||
_OnboardingStep(
|
||||
title: 'What This App Does',
|
||||
description: 'This app helps you collect and organize your Missouri fuel purchase '
|
||||
"receipts so you can claim Missouri's Motor Fuel Tax Refund — currently 12.5¢ back "
|
||||
'per gallon.\n\n'
|
||||
"receipts so you can claim Missouri's Motor Fuel Tax Refund — up to 12.5¢/gal "
|
||||
'for highway use, up to 29.5¢/gal non-highway.\n\n'
|
||||
'Disclaimer: this refund is available for as long as Missouri lawmakers continue to '
|
||||
'offer it, and state law could change or end the program at any time. Always confirm '
|
||||
'current eligibility and rates with the Missouri Department of Revenue before filing.',
|
||||
|
|
@ -90,7 +97,7 @@ class _MainShellState extends State<MainShell> {
|
|||
// be when the tour starts (normally the Receipts tab, since that's
|
||||
// MainShell's default).
|
||||
targetKeys: const [],
|
||||
activate: () {},
|
||||
activate: () => null,
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Receipts Here',
|
||||
|
|
@ -98,7 +105,10 @@ class _MainShellState extends State<MainShell> {
|
|||
'Snap a photo of it and the app reads the date, gallons, and price for you '
|
||||
'automatically.',
|
||||
targetKeys: [OnboardingKeys.receiptsAddButton, OnboardingKeys.receiptsNavDestination],
|
||||
activate: () => _jumpToTab(0),
|
||||
activate: () {
|
||||
_jumpToTab(0);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Add Vehicles Here',
|
||||
|
|
@ -106,7 +116,10 @@ class _MainShellState extends State<MainShell> {
|
|||
'receipt gets linked to one of your vehicles, so add one here first before logging '
|
||||
'a receipt for it.',
|
||||
targetKeys: [OnboardingKeys.vehiclesAddButton, OnboardingKeys.vehiclesNavDestination],
|
||||
activate: () => _jumpToTab(1),
|
||||
activate: () {
|
||||
_jumpToTab(1);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Choose a Report Date Range',
|
||||
|
|
@ -126,14 +139,20 @@ class _MainShellState extends State<MainShell> {
|
|||
OnboardingKeys.reportShareAndPrintRow,
|
||||
OnboardingKeys.refundFormLink,
|
||||
],
|
||||
activate: () => _jumpToTab(2),
|
||||
activate: () {
|
||||
_jumpToTab(2);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Find Data Settings',
|
||||
description: 'This is the Settings tab — tap Data here to manage where your vehicles, '
|
||||
'receipts, and photos get backed up.',
|
||||
targetKeys: [OnboardingKeys.settingsNavDestination, OnboardingKeys.settingsDataMenuEntry],
|
||||
activate: () => _jumpToTab(3),
|
||||
activate: () {
|
||||
_jumpToTab(3);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_OnboardingStep(
|
||||
title: 'Back Up to the Cloud',
|
||||
|
|
@ -143,13 +162,14 @@ class _MainShellState extends State<MainShell> {
|
|||
targetKeys: [OnboardingKeys.cloudStorageCard],
|
||||
activate: () {
|
||||
_tourPushedDataScreen = true;
|
||||
final route = MaterialPageRoute(builder: (_) => const DataSettingsScreen());
|
||||
// Not awaited: Navigator.push's returned future only completes on
|
||||
// pop, not on the pushed route finishing its build — the polling
|
||||
// wait in _showTourStep (for the target key's RenderObject to
|
||||
// exist) is what actually waits for this screen to be ready.
|
||||
unawaited(Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
|
||||
));
|
||||
// exist, then for `route` itself — returned below — to finish its
|
||||
// transition) is what actually waits for this screen to be ready.
|
||||
unawaited(Navigator.of(context).push(route));
|
||||
return route;
|
||||
},
|
||||
),
|
||||
];
|
||||
|
|
@ -207,7 +227,7 @@ class _MainShellState extends State<MainShell> {
|
|||
/// page (like the Reports tab's refund-form link) could still be
|
||||
/// off-screen despite having a real, laid-out RenderBox, and would get
|
||||
/// "highlighted" somewhere the user can't actually see.
|
||||
Future<void> _waitForTargets(List<GlobalKey> keys) async {
|
||||
Future<void> _waitForTargets(List<GlobalKey> keys, ModalRoute<dynamic>? pushedRoute) async {
|
||||
for (var attempt = 0; attempt < 30; attempt++) {
|
||||
if (!mounted) return;
|
||||
final allResolved = keys.every((key) {
|
||||
|
|
@ -229,12 +249,40 @@ class _MainShellState extends State<MainShell> {
|
|||
if (!mounted) return;
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
if (!mounted) return;
|
||||
// The "Back Up to the Cloud" step's activate() pushes a route and
|
||||
// returns it as [pushedRoute] — wait for that push transition to fully
|
||||
// settle before capturing its target's position, or the spotlight ends
|
||||
// up shifted by however far the slide-in hadn't yet finished. A no-op
|
||||
// for every other step, which only switches tabs and so has no route
|
||||
// to pass here.
|
||||
await waitForRouteTransition(pushedRoute?.animation);
|
||||
if (!mounted) return;
|
||||
// The route's own AnimationController reports AnimationStatus.completed
|
||||
// at this point, but empirically the render tree's transforms (from
|
||||
// FadeForwardsPageTransitionsBuilder's SlideTransition, the actual
|
||||
// Android default as of Flutter 3.44) still reflect a mid-transition
|
||||
// position for a few more frames after that — confirmed by walking the
|
||||
// RenderObject ancestor chain and finding an active
|
||||
// RenderFractionalTranslation still present, with a position matching
|
||||
// the transition's *starting* offset rather than its resting
|
||||
// Offset.zero. These extra frames give it time to actually settle
|
||||
// before the spotlight measures anything. SchedulerBinding.endOfFrame
|
||||
// (rather than a bare Future.delayed) so this genuinely waits on real
|
||||
// frames — in a widget test, tester.pumpAndSettle() only keeps pumping
|
||||
// while something has an actual frame scheduled, which a bare delay
|
||||
// timer doesn't count as once the route's own transition has already
|
||||
// finished.
|
||||
for (var i = 0; i < 10; i++) {
|
||||
if (!mounted) return;
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showTourStep() async {
|
||||
final step = _tourSteps[_tourStepIndex];
|
||||
step.activate();
|
||||
await _waitForTargets(step.targetKeys);
|
||||
final pushedRoute = step.activate();
|
||||
await _waitForTargets(step.targetKeys, pushedRoute);
|
||||
if (!mounted) return;
|
||||
|
||||
_tourEntry?.remove();
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import 'package:provider/provider.dart';
|
|||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import 'add_edit_vehicle_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'receipt_detail_screen.dart';
|
||||
|
||||
/// The three summary periods selectable below the hero totals — the
|
||||
|
|
@ -142,6 +144,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
tooltip: 'Log Fuel Receipt',
|
||||
onPressed: () => _addReceipt(context),
|
||||
),
|
||||
const FaqActionButton(),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
|
|
@ -154,6 +157,9 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
gallonsFormat: heroGallonsFormat,
|
||||
selectedRange: _range,
|
||||
onRangeSelected: (range) => setState(() => _range = range),
|
||||
estimatedRefundValue: appState.showEstimatedFuelRefund
|
||||
? currencyFormat.format(estimatedFuelRefund(totalGallons))
|
||||
: null,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
|
||||
|
|
@ -176,6 +182,7 @@ class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAlive
|
|||
dateFormat: dateFormat,
|
||||
currencyFormat: currencyFormat,
|
||||
gallonsFormat: gallonsFormat,
|
||||
showEstimatedRefund: appState.showEstimatedFuelRefund,
|
||||
onTap: () => _openReceipt(context, entries[i]),
|
||||
),
|
||||
],
|
||||
|
|
@ -229,6 +236,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
final NumberFormat gallonsFormat;
|
||||
final _DateRange selectedRange;
|
||||
final ValueChanged<_DateRange> onRangeSelected;
|
||||
final String? estimatedRefundValue;
|
||||
|
||||
const _HeroTotal({
|
||||
required this.totalCost,
|
||||
|
|
@ -237,6 +245,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
required this.gallonsFormat,
|
||||
required this.selectedRange,
|
||||
required this.onRangeSelected,
|
||||
this.estimatedRefundValue,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -248,6 +257,7 @@ class _HeroTotal extends StatelessWidget {
|
|||
HeroStatsRow(
|
||||
gallonsValue: gallonsFormat.format(totalGallons),
|
||||
costValue: currencyFormat.format(totalCost),
|
||||
estimatedRefundValue: estimatedRefundValue,
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
|
||||
|
|
@ -263,6 +273,7 @@ class _ReceiptRow extends StatelessWidget {
|
|||
final DateFormat dateFormat;
|
||||
final NumberFormat currencyFormat;
|
||||
final NumberFormat gallonsFormat;
|
||||
final bool showEstimatedRefund;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReceiptRow({
|
||||
|
|
@ -271,6 +282,7 @@ class _ReceiptRow extends StatelessWidget {
|
|||
required this.dateFormat,
|
||||
required this.currencyFormat,
|
||||
required this.gallonsFormat,
|
||||
required this.showEstimatedRefund,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
|
|
@ -295,7 +307,10 @@ class _ReceiptRow extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${gallonsFormat.format(entry.gallons)} gal',
|
||||
showEstimatedRefund
|
||||
? '${gallonsFormat.format(entry.gallons)} gal · Est. '
|
||||
'${currencyFormat.format(estimatedFuelRefund(entry.gallons))}'
|
||||
: '${gallonsFormat.format(entry.gallons)} gal',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import 'package:provider/provider.dart';
|
|||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../services/fuel_report.dart';
|
||||
import '../services/fuel_report_images.dart';
|
||||
import '../services/fuel_report_pdf.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'faq_screen.dart';
|
||||
|
||||
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
|
||||
/// file for the user — it's a state form, submitted to the state — so the
|
||||
|
|
@ -51,13 +53,26 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final (start, end) = defaultReportDateRange(DateTime.now());
|
||||
_startDate = start;
|
||||
_endDate = end;
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
// The default range's end can fall in the future (it covers the
|
||||
// fuel-tax-refund period currently underway, per defaultReportDateRange)
|
||||
// — showDatePicker asserts initialDate <= lastDate, so lastDate has to
|
||||
// stretch to cover it too, not just "today".
|
||||
final lastDate = _endDate != null && _endDate!.isAfter(now) ? _endDate! : now;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _startDate : _endDate) ?? now,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: now,
|
||||
lastDate: lastDate,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
|
|
@ -118,6 +133,22 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _preview(FuelReport report) async {
|
||||
setState(() => _busy = true);
|
||||
Uint8List bytes;
|
||||
try {
|
||||
bytes = await _buildPdfBytes(report);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _ReportPreviewScreen(bytes: bytes, fileName: fuelReportFileName(report)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openRefundFormLink() async {
|
||||
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
|
|
@ -161,7 +192,10 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Fuel Report')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Fuel Report'),
|
||||
actions: const [FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -233,6 +267,7 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
_VehicleSelectionCard(
|
||||
row: row,
|
||||
selected: _selectedVehicleIds.contains(row.vehicle.id),
|
||||
showEstimatedRefund: appState.showEstimatedFuelRefund,
|
||||
onTap: () => setState(() {
|
||||
if (_selectedVehicleIds.contains(row.vehicle.id)) {
|
||||
_selectedVehicleIds.remove(row.vehicle.id);
|
||||
|
|
@ -252,7 +287,9 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
'${report.totalGallons.toStringAsFixed(3)} gal · '
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
|
||||
'${NumberFormat.simpleCurrency().format(report.totalCost)}'
|
||||
'${appState.showEstimatedFuelRefund ? ' · Est. '
|
||||
'${NumberFormat.simpleCurrency().format(estimatedFuelRefund(report.totalGallons))}' : ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
|
@ -260,6 +297,14 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
Row(
|
||||
key: OnboardingKeys.reportShareAndPrintRow,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _busy ? null : () => _preview(report),
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
label: const Text('Preview'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : () => _share(report),
|
||||
|
|
@ -334,9 +379,15 @@ class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClie
|
|||
class _VehicleSelectionCard extends StatelessWidget {
|
||||
final VehicleReportRow row;
|
||||
final bool selected;
|
||||
final bool showEstimatedRefund;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
|
||||
const _VehicleSelectionCard({
|
||||
required this.row,
|
||||
required this.selected,
|
||||
required this.showEstimatedRefund,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -379,6 +430,13 @@ class _VehicleSelectionCard extends StatelessWidget {
|
|||
NumberFormat.simpleCurrency().format(row.totalCost),
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
if (showEstimatedRefund) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Est. ${NumberFormat.simpleCurrency().format(estimatedFuelRefund(row.totalGallons))}',
|
||||
style: TextStyle(color: foregroundVariant, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -388,3 +446,31 @@ class _VehicleSelectionCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the already-built report PDF in-app, with [PdfPreview]'s own
|
||||
/// built-in Share/Print actions — so Preview isn't a dead end, the same
|
||||
/// two actions available back on [ReportScreen] are still reachable from
|
||||
/// here without backing out first. The report's format is fixed
|
||||
/// ([buildFuelReportPdf] always renders US Letter), so the page
|
||||
/// format/orientation controls [PdfPreview] would otherwise offer are
|
||||
/// turned off since they wouldn't actually change anything.
|
||||
class _ReportPreviewScreen extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
|
||||
const _ReportPreviewScreen({required this.bytes, required this.fileName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Report Preview')),
|
||||
body: PdfPreview(
|
||||
build: (format) async => bytes,
|
||||
pdfFileName: fileName,
|
||||
canChangePageFormat: false,
|
||||
canChangeOrientation: false,
|
||||
canDebug: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:provider/provider.dart';
|
|||
import '../services/app_state.dart';
|
||||
import '../services/onboarding_keys.dart';
|
||||
import 'data_settings_screen.dart';
|
||||
import 'faq_screen.dart';
|
||||
import 'ui_settings_screen.dart';
|
||||
import 'user_agreement_screen.dart';
|
||||
|
||||
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
|
||||
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
|
||||
|
|
@ -18,7 +20,10 @@ class SettingsScreen extends StatelessWidget {
|
|||
final appState = context.watch<AppState>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: const [FaqActionButton()],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -47,6 +52,30 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: const Text('FAQ'),
|
||||
subtitle: const Text('Common questions about the app and the refund program'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: const Text('User Agreement'),
|
||||
subtitle: const Text('Data liability, privacy, and the tax-advice disclaimer'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
@ -63,8 +92,8 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
] else ...[
|
||||
Text(
|
||||
'This app shows a single ad, at most once per app session, right '
|
||||
'before syncing your data to the cloud.',
|
||||
'Syncing your data to the cloud requires watching a short ad — '
|
||||
'at most once every 5 minutes.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -84,6 +113,19 @@ class SettingsScreen extends StatelessWidget {
|
|||
: 'Remove Ads for a Year — ${appState.adFreeYearPriceLabel}',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().restoreAdFreeYear();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"Checking for a previous purchase on this Google account…",
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Restore Purchase'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ class UiSettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Estimated Fuel Refund'),
|
||||
subtitle: const Text(
|
||||
'Shows an estimate of your Missouri Highway Fuel Tax Refund next to '
|
||||
'your receipts and totals. Based on the current rate — contingent on '
|
||||
"it, and not a substitute for confirming the actual amount with the "
|
||||
'Missouri Department of Revenue.',
|
||||
),
|
||||
value: appState.showEstimatedFuelRefund,
|
||||
onChanged: (value) =>
|
||||
context.read<AppState>().setShowEstimatedFuelRefund(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import 'faq_screen.dart';
|
||||
|
||||
/// The very first thing shown on a fresh install — before the onboarding
|
||||
/// tour, before anything else — gating [MainShell] entirely until the user
|
||||
|
|
@ -11,16 +12,32 @@ import '../services/app_state.dart';
|
|||
/// Its whole purpose is the liability section below: this app stores data
|
||||
/// on the user's own device (and, optionally, their own cloud storage
|
||||
/// account) with no server or backend of ours involved, so we have no
|
||||
/// ability to recover anything for them if it's lost.
|
||||
/// ability to recover anything for them if it's lost — plus the privacy
|
||||
/// section, disclosing that we don't track/log anything ourselves, and
|
||||
/// that AdMob (only active for users on the free, ad-supported tier) is
|
||||
/// the sole source of any data collection, governed by Google's own
|
||||
/// practices rather than anything this app adds.
|
||||
class UserAgreementScreen extends StatelessWidget {
|
||||
const UserAgreementScreen({super.key});
|
||||
/// True when reached from Settings (see [SettingsScreen]) to re-read the
|
||||
/// agreement/privacy policy at any time — required so this content stays
|
||||
/// reachable in the app beyond the one-time first-launch gate, not just
|
||||
/// something a user saw once and can never get back to. Hides the
|
||||
/// Decline/I Agree actions (already answered, and "Decline" quitting the
|
||||
/// app would be a bizarre side effect of just re-reading this) and shows
|
||||
/// a normal back button instead of gating navigation away entirely.
|
||||
final bool isReview;
|
||||
|
||||
const UserAgreementScreen({super.key, this.isReview = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('User Agreement'), automaticallyImplyLeading: false),
|
||||
appBar: AppBar(
|
||||
title: const Text('User Agreement'),
|
||||
automaticallyImplyLeading: isReview,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -29,8 +46,11 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Text(
|
||||
'Please read and accept the following before using Show Me The Fuel '
|
||||
'Refund.',
|
||||
isReview
|
||||
? 'This is the agreement you accepted when you first opened Show Me '
|
||||
'The Fuel Refund.'
|
||||
: 'Please read and accept the following before using Show Me The Fuel '
|
||||
'Refund.',
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
|
@ -43,6 +63,17 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
'also kept there — in storage you control, not on any server we run. '
|
||||
"We don't operate a backend, and we don't have a copy of your data.",
|
||||
),
|
||||
_Section(
|
||||
title: 'Privacy',
|
||||
body:
|
||||
"We don't track or log your data in any way — there's no analytics, no "
|
||||
"crash reporting, and no server of ours receiving anything you enter. If "
|
||||
'you haven\'t purchased "Remove Ads for a Year" and are using the free, '
|
||||
'ad-supported version, the only data collected is whatever Google AdMob '
|
||||
'itself requires to serve ads (e.g. a device advertising identifier) — '
|
||||
"that collection is Google's, governed by its own privacy practices, not "
|
||||
'something this app adds on top of.',
|
||||
),
|
||||
_Section(
|
||||
title: 'We Are Not Responsible for Your Data or Any Data Loss',
|
||||
body:
|
||||
|
|
@ -66,37 +97,55 @@ class UserAgreementScreen extends StatelessWidget {
|
|||
_Section(
|
||||
title: 'Not Tax or Legal Advice',
|
||||
body:
|
||||
"This app helps you organize receipts for Missouri's Motor Fuel Tax "
|
||||
'Refund program; it does not provide tax or legal advice, and does not '
|
||||
'guarantee your eligibility for any refund. The program itself is set '
|
||||
'by Missouri law, which can change at any time — always confirm current '
|
||||
'eligibility and rates with the Missouri Department of Revenue.',
|
||||
'This app is purely informational. Missouri actually offers two '
|
||||
'separate fuel tax refunds — up to 12.5¢ per gallon for highway use, '
|
||||
"and up to 29.5¢ per gallon for non-highway use — and it's worth "
|
||||
"evaluating both to see how you could benefit. This app helps you "
|
||||
"organize the receipts you'd need for either one.\n\n"
|
||||
"Nothing here is tax or legal advice, and using this app doesn't "
|
||||
'guarantee your eligibility for either refund. For guidance on your '
|
||||
'specific situation, consult a tax professional. Both refunds are set '
|
||||
'by Missouri law, which can change at any time — always confirm '
|
||||
'current eligibility and rates with the Missouri Department of '
|
||||
'Revenue before filing.',
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const FaqScreen()),
|
||||
),
|
||||
icon: const Icon(Icons.help_outline),
|
||||
label: const Text('See the FAQ for more'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => SystemNavigator.pop(),
|
||||
child: const Text('Decline'),
|
||||
if (!isReview) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => SystemNavigator.pop(),
|
||||
child: const Text('Decline'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => context.read<AppState>().acceptUserAgreement(),
|
||||
child: const Text('I Agree'),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => context.read<AppState>().acceptUserAgreement(),
|
||||
child: const Text('I Agree'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_state.dart';
|
||||
import '../services/estimated_refund.dart';
|
||||
import '../widgets/hero_banner.dart';
|
||||
import '../widgets/receipt_capture.dart';
|
||||
import '../widgets/receipt_thumbnail.dart';
|
||||
|
|
@ -151,8 +152,10 @@ class _VehicleDetailPage extends StatelessWidget {
|
|||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
|
||||
'${dateFormat.format(entry.date)}',
|
||||
'${currencyFormat.format(entry.pricePerGallon)}/gal'
|
||||
'${appState.showEstimatedFuelRefund ? ' · Est. '
|
||||
'${currencyFormat.format(estimatedFuelRefund(entry.gallons))}' : ''}'
|
||||
'\n${dateFormat.format(entry.date)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue