MO-Fuel-Tax-Back/lib/screens/report_screen.dart

386 lines
14 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/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.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;
Future<void> _pickDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _startDate : _endDate) ?? now,
firstDate: DateTime(2000),
lastDate: now,
);
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> _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')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
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),
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)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
Row(
children: [
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(
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 VoidCallback onTap;
const _VehicleSelectionCard({required this.row, required this.selected, 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),
),
],
),
],
),
),
),
);
}
}