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 createState() => _ReportScreenState(); } class _ReportScreenState extends State 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 _selectedVehicleIds = {}; final Set _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 _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 _buildPdfBytes(FuelReport report) async { var receiptImages = const {}; if (_includeReceiptImages) { final loaded = await loadReceiptImageBytes( report: report, activeProvider: context.read().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 _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 _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 _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(); 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), ), ], ), ], ), ), ), ); } }