476 lines
17 KiB
Dart
476 lines
17 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:printing/printing.dart';
|
|
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
|
|
/// Reports tab just links out to where to get it rather than trying to
|
|
/// reproduce or embed it.
|
|
final _refundFormUrl = Uri.parse(
|
|
'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
|
|
);
|
|
|
|
class ReportScreen extends StatefulWidget {
|
|
const ReportScreen({super.key});
|
|
|
|
@override
|
|
State<ReportScreen> createState() => _ReportScreenState();
|
|
}
|
|
|
|
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
|
|
static final _dateFormat = DateFormat.yMMMd();
|
|
|
|
DateTime? _startDate;
|
|
DateTime? _endDate;
|
|
bool _includeReceiptImages = false;
|
|
bool _includeLineItems = false;
|
|
bool _busy = false;
|
|
|
|
/// Which vehicles (by id) to include in the report — checkboxes next to
|
|
/// each vehicle below the date range. [_seenVehicleIds] tracks every
|
|
/// vehicle a checkbox has ever been shown for, so a vehicle newly
|
|
/// appearing in range (first time it's seen) defaults to selected without
|
|
/// clobbering a selection the user already made for one seen before (e.g.
|
|
/// after picking a different range and back).
|
|
final Set<String> _selectedVehicleIds = {};
|
|
final Set<String> _seenVehicleIds = {};
|
|
|
|
// Keeps this tab's state (chosen date range, include-image/line-item
|
|
// toggles) alive when MainShell's PageView builds/tears down tabs
|
|
// outside its cache extent as the user swipes between them.
|
|
@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: lastDate,
|
|
);
|
|
if (picked == null) return;
|
|
setState(() {
|
|
if (isStart) {
|
|
_startDate = picked;
|
|
} else {
|
|
_endDate = picked;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
|
|
var receiptImages = const <String, Uint8List>{};
|
|
if (_includeReceiptImages) {
|
|
final loaded = await loadReceiptImageBytes(
|
|
report: report,
|
|
activeProvider: context.read<AppState>().activeProvider,
|
|
);
|
|
receiptImages = loaded.bytesByEntryId;
|
|
if (loaded.failedEntries.isNotEmpty && mounted) {
|
|
final count = loaded.failedEntries.length;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
"$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
|
|
'not signed in to the provider that has it, or the remote file is gone.',
|
|
),
|
|
duration: const Duration(seconds: 6),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return buildFuelReportPdf(
|
|
report,
|
|
includeLineItems: _includeLineItems,
|
|
includeReceiptImages: _includeReceiptImages,
|
|
receiptImages: receiptImages,
|
|
);
|
|
}
|
|
|
|
Future<void> _share(FuelReport report) async {
|
|
setState(() => _busy = true);
|
|
try {
|
|
final bytes = await _buildPdfBytes(report);
|
|
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _print(FuelReport report) async {
|
|
setState(() => _busy = true);
|
|
try {
|
|
final bytes = await _buildPdfBytes(report);
|
|
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("Couldn't open the link.")),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
final appState = context.watch<AppState>();
|
|
|
|
String? rangeError;
|
|
FuelReport? rawReport;
|
|
if (_startDate != null && _endDate != null) {
|
|
if (_endDate!.isBefore(_startDate!)) {
|
|
rangeError = 'End date must be on or after the start date.';
|
|
} else {
|
|
rawReport = buildFuelReport(
|
|
vehicles: appState.vehicles,
|
|
fuelEntries: appState.fuelEntries,
|
|
startDate: _startDate!,
|
|
endDate: _endDate!,
|
|
);
|
|
for (final row in rawReport.rows) {
|
|
if (_seenVehicleIds.add(row.vehicle.id)) {
|
|
_selectedVehicleIds.add(row.vehicle.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final report = rawReport == null
|
|
? null
|
|
: FuelReport(
|
|
startDate: rawReport.startDate,
|
|
endDate: rawReport.endDate,
|
|
rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
|
|
);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Fuel Report'),
|
|
actions: const [FaqActionButton()],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Row(
|
|
key: OnboardingKeys.reportDateRange,
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => _pickDate(isStart: true),
|
|
child: Text(_startDate == null ? 'Start date' : _dateFormat.format(_startDate!)),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => _pickDate(isStart: false),
|
|
child: Text(_endDate == null ? 'End date' : _dateFormat.format(_endDate!)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (rangeError != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
],
|
|
const SizedBox(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Include Receipt Images'),
|
|
subtitle: const Text(
|
|
'Every receipt photo in the date range, grouped by vehicle and '
|
|
'sorted by date — packed as many to a page as will fit.',
|
|
),
|
|
value: _includeReceiptImages,
|
|
onChanged: (value) => setState(() => _includeReceiptImages = value),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Include Receipt Line Items'),
|
|
subtitle: const Text(
|
|
'Every receipt as its own line (date, gallons, price/gal, '
|
|
'cost), grouped by vehicle and sorted by date.',
|
|
),
|
|
value: _includeLineItems,
|
|
onChanged: (value) => setState(() => _includeLineItems = value),
|
|
),
|
|
if (_includeReceiptImages && _includeLineItems)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
'Each line item and its receipt photo will be tagged with a '
|
|
'matching number.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (rawReport == null)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Text('Choose a start and end date to generate a report.'),
|
|
)
|
|
else if (rawReport.rows.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Text('No vehicles had a receipted fuel entry in that date range.'),
|
|
)
|
|
else ...[
|
|
for (final row in rawReport.rows)
|
|
_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);
|
|
} else {
|
|
_selectedVehicleIds.add(row.vehicle.id);
|
|
}
|
|
}),
|
|
),
|
|
const Divider(height: 32),
|
|
if (report!.rows.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Text('No vehicles selected — tap at least one above to build a report.'),
|
|
)
|
|
else ...[
|
|
ListTile(
|
|
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
trailing: Text(
|
|
'${report.totalGallons.toStringAsFixed(3)} gal · '
|
|
'${NumberFormat.simpleCurrency().format(report.totalCost)}'
|
|
'${appState.showEstimatedFuelRefund ? ' · Est. '
|
|
'${NumberFormat.simpleCurrency().format(estimatedFuelRefund(report.totalGallons))}' : ''}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
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),
|
|
icon: const Icon(Icons.share_outlined),
|
|
label: const Text('Share'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: _busy ? null : () => _print(report),
|
|
icon: const Icon(Icons.print_outlined),
|
|
label: const Text('Print'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
const SizedBox(height: 24),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
"This app tracks your fuel purchases, but the actual refund claim is "
|
|
"filed with the state. Get the official form from Missouri's "
|
|
'Department of Revenue website.',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
InkWell(
|
|
key: OnboardingKeys.refundFormLink,
|
|
onTap: _openRefundFormLink,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
'Get the Motor Fuel Refund Claim form',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A per-vehicle row in the vehicle-selection list — tapping the whole
|
|
/// card toggles [selected], indicated purely by color rather than a
|
|
/// checkbox: [selected] uses the same primary/on-primary theming as the
|
|
/// "Share" [FilledButton] below it, [!selected] looks like a plain
|
|
/// unselected [Card] (default surface color, normal text colors).
|
|
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.showEstimatedRefund,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
|
|
final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
|
|
|
|
return Card(
|
|
color: selected ? colorScheme.primary : null,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
row.vehicle.displayLabel,
|
|
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
|
|
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
|
|
style: TextStyle(color: foregroundVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
|
|
Text(
|
|
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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
);
|
|
}
|
|
}
|