MO-Fuel-Tax-Back/lib/services/fuel_report_pdf.dart

604 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:typed_data';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'fuel_report.dart';
final _dateFormat = DateFormat.yMMMd();
final _dateTimeFormat = DateFormat.yMMMd().add_jm();
final _monthFormat = DateFormat('MMM');
final _monthHeadingFormat = DateFormat.yMMMM();
final _dayMonthFormat = DateFormat('d MMM');
final _generatedFormat = DateFormat('d MMM yyyy');
final _currencyFormat = NumberFormat.simpleCurrency();
// Palette lifted from the report design mockup — kept in one place so
// every section (banner, cards, tables, watermark) draws from the same
// set of colors rather than each picking its own.
final _bannerBackground = PdfColor.fromHex('#EAF3DE');
final _bannerText = PdfColor.fromHex('#27500A');
final _textPrimary = PdfColor.fromHex('#1F1E1C');
final _textMuted = PdfColor.fromHex('#6B6A64');
final _textFaint = PdfColor.fromHex('#94938C');
final _borderLight = PdfColor.fromHex('#E5E3DB');
final _borderStrong = PdfColor.fromHex('#CFCDC4');
final _accentGreen = PdfColor.fromHex('#639922');
final _accentGreenDark = PdfColor.fromHex('#3B6D11');
final _cardBackground = PdfColor.fromHex('#F7F6F1');
final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5);
final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7'));
final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white);
final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground);
final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary);
/// Renders [report] as a PDF, styled after the app's report design mockup:
/// a branded letterhead banner, a headline total, three at-a-glance stat
/// cards, a "gallons by month" bar chart + table, and a by-vehicle
/// breakdown. When requested, a "Line Items" section (a table per vehicle)
/// and/or a "Receipt Images" section (photos per vehicle, sized to
/// actually be legible — not a small inline thumbnail) follow, each
/// grouped by vehicle and sorted oldest-first within it. When both are on,
/// each line item and its receipt photo are tagged with the same number
/// (assigned once, continuing across vehicles) so the two can be matched
/// up by eye despite living in separate sections.
///
/// [receiptImages] supplies the actual photo bytes, keyed by
/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in
/// that map (couldn't be read locally or downloaded) is left out of the
/// images section rather than shown as a broken image.
Future<Uint8List> buildFuelReportPdf(
FuelReport report, {
bool includeLineItems = false,
bool includeReceiptImages = false,
Map<String, Uint8List> receiptImages = const {},
}) async {
final doc = pw.Document();
final generatedAt = DateTime.now();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.letter,
margin: pw.EdgeInsets.zero,
header: (context) => _buildBanner(generatedAt),
footer: (context) => _buildFooter(report, context),
build: (context) => _buildBody(
report,
includeLineItems: includeLineItems,
includeReceiptImages: includeReceiptImages,
receiptImages: receiptImages,
),
),
);
return doc.save();
}
/// A *flat* list of top-level widgets — deliberately not one big [pw.Column]
/// wrapping everything. [pw.MultiPage] paginates by walking its `build:`
/// list one top-level item at a time, resetting its "stuck" bookkeeping
/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which
/// span pages on their own, e.g. a long line-items table or a big receipt
/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead
/// of being its own top-level entry defeats that bookkeeping and throws
/// `TooManyPagesException` on anything long enough to need several page
/// breaks — even though the content itself is perfectly valid.
List<pw.Widget> _buildBody(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> receiptImages,
}) {
pw.Widget pad(pw.Widget child) =>
pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child);
if (report.rows.isEmpty) {
return [
pw.Padding(
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
child: _buildTitleBlock(report),
),
pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')),
];
}
final widgets = <pw.Widget>[
pw.Padding(
padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0),
child: _buildTitleBlock(report),
),
pad(_buildHeroStat(report)),
pw.SizedBox(height: 22),
pad(_buildStatCardsRow(report)),
pw.SizedBox(height: 26),
pad(_buildMonthlySection(report)),
pw.SizedBox(height: 26),
pad(_buildVehicleSection(report)),
];
if (includeLineItems || includeReceiptImages) {
widgets.add(pw.SizedBox(height: 26));
widgets.addAll(
_buildLineItemsAndImagesSections(
report,
includeLineItems: includeLineItems,
includeReceiptImages: includeReceiptImages,
receiptImages: receiptImages,
).map(pad),
);
}
widgets.add(pw.SizedBox(height: 8));
return widgets;
}
pw.Widget _buildBanner(DateTime generatedAt) {
// The watermark lives here, inside the banner's own bounded height,
// rather than overlaid on the flowing body content below — a Stack
// wrapping unbounded/multi-page content doesn't paginate (MultiPage
// needs a SpanningWidget or a fixed-size child for that), so scoping it
// to this fixed-height strip is what keeps it safe across page breaks.
return pw.Container(
color: _bannerBackground,
child: pw.Stack(
children: [
pw.Positioned(
right: 12,
top: -14,
child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)),
),
pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Show Me The Fuel Refund',
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText),
),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: pw.BoxDecoration(
color: PdfColors.white,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
),
child: pw.Text(
'Generated ${_generatedFormat.format(generatedAt)}',
style: pw.TextStyle(fontSize: 10, color: _bannerText),
),
),
],
),
),
],
),
);
}
pw.Widget _buildFooter(FuelReport report, pw.Context context) {
return pw.Container(
padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20),
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))),
margin: const pw.EdgeInsets.only(top: 6),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · '
'${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · '
'${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}',
style: pw.TextStyle(fontSize: 9, color: _textFaint),
),
pw.Text(
'Page ${context.pageNumber} of ${context.pagesCount}',
style: pw.TextStyle(fontSize: 9, color: _textFaint),
),
],
),
);
}
/// A faint brand-colored fuel-can silhouette — purely decorative,
/// matching the report mockup's watermark shape.
pw.Widget _buildWatermark({required double alpha}) {
const svg = '''
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="M12,8 L76,8 L79,22 L73,33 L83,47 L78,58 L84,67 L86,89 L77,89 L77,70 L20,70 L20,30 Z" fill="#639922"/>
</svg>
''';
return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha));
}
pw.Widget _buildTitleBlock(FuelReport report) {
final months = report.monthlyTotals.length;
final vehicles = report.rows.length;
final fills = report.fillCount;
final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}');
if (report.rows.isNotEmpty) {
subtitle.write(
' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · '
'$fills fill-up${fills == 1 ? '' : 's'}',
);
}
return pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 20),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
pw.SizedBox(height: 3),
pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)),
],
),
);
}
pw.Widget _buildHeroStat(FuelReport report) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
pw.SizedBox(height: 4),
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
report.totalGallons.toStringAsFixed(3),
style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
),
pw.SizedBox(width: 6),
pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 5),
child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)),
),
],
),
],
);
}
pw.Widget _buildStatCardsRow(FuelReport report) {
pw.Widget statCard(String label, String value) => pw.Expanded(
child: pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: _cardBackground,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)),
pw.SizedBox(height: 3),
pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)),
],
),
),
);
return pw.Row(
children: [
statCard('Spent at the pump', _currencyFormat.format(report.totalCost)),
pw.SizedBox(width: 10),
statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)),
pw.SizedBox(width: 10),
statCard('Fill-ups logged', report.fillCount.toString()),
],
);
}
pw.Widget _buildMonthlySection(FuelReport report) {
final months = report.monthlyTotals;
if (months.isEmpty) return pw.SizedBox();
const chartHeight = 64.0;
final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max);
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
pw.SizedBox(height: 8),
if (months.length > 1) ...[
pw.SizedBox(
height: chartHeight,
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
for (final month in months)
pw.Expanded(
child: pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3),
child: pw.Container(
height: maxGallons == 0
? 2
: (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight),
decoration: pw.BoxDecoration(
color: _accentGreen,
borderRadius: const pw.BorderRadius.only(
topLeft: pw.Radius.circular(2),
topRight: pw.Radius.circular(2),
),
),
),
),
),
],
),
),
pw.SizedBox(height: 4),
pw.Row(
children: [
for (final month in months)
pw.Expanded(
child: pw.Center(
child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)),
),
),
],
),
pw.SizedBox(height: 10),
],
pw.TableHelper.fromTextArray(
headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'],
data: [
for (final month in months)
[
_monthHeadingFormat.format(month.month),
month.fillCount.toString(),
month.totalGallons.toStringAsFixed(3),
_currencyFormat.format(month.totalCost),
_currencyFormat.format(month.avgPricePerGallon),
],
],
cellAlignments: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerAlignments: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
cellStyle: const pw.TextStyle(fontSize: 10),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
border: _tableBorder,
headerDecoration: _tableHeaderDecoration,
rowDecoration: _tableEvenRowDecoration,
oddRowDecoration: _tableOddRowDecoration,
),
if (months.length > 1) ...[
pw.SizedBox(height: 6),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)),
pw.Text(
'${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · '
'${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}',
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
),
],
),
],
],
);
}
pw.Widget _buildVehicleSection(FuelReport report) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)),
for (final row in report.rows)
pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 10),
decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
flex: 2,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)),
],
),
),
pw.Expanded(
child: pw.Text(
'${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}',
textAlign: pw.TextAlign.right,
style: pw.TextStyle(fontSize: 11, color: _textMuted),
),
),
pw.Expanded(
child: pw.Text(
_currencyFormat.format(row.totalCost),
textAlign: pw.TextAlign.right,
style: const pw.TextStyle(fontSize: 11),
),
),
pw.Expanded(
child: pw.Text(
'${row.totalGallons.toStringAsFixed(3)} gal',
textAlign: pw.TextAlign.right,
style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark),
),
),
],
),
),
],
);
}
/// Item numbers (when both [includeLineItems] and [includeReceiptImages]
/// are on) are assigned once, in report order — vehicle by vehicle,
/// oldest-entry-first within each — and reused by both the "Line Items"
/// table and the "Receipt Images" section below it, so a photo and its
/// line item carry the same number despite living in separate sections.
List<pw.Widget> _buildLineItemsAndImagesSections(
FuelReport report, {
required bool includeLineItems,
required bool includeReceiptImages,
required Map<String, Uint8List> receiptImages,
}) {
final showItemNumbers = includeLineItems && includeReceiptImages;
final itemNumbers = <String, int>{};
var nextNumber = 1;
for (final row in report.rows) {
for (final entry in row.entries) {
itemNumbers[entry.id] = nextNumber++;
}
}
final widgets = <pw.Widget>[];
if (includeLineItems) {
widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle));
for (final row in report.rows) {
widgets.add(pw.SizedBox(height: 14));
widgets.add(_buildVehicleSubheading(row));
widgets.add(pw.SizedBox(height: 6));
widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers));
}
}
if (includeReceiptImages) {
final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id)));
if (rowsWithImages.isNotEmpty) {
if (includeLineItems) widgets.add(pw.SizedBox(height: 26));
widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle));
for (final row in rowsWithImages) {
widgets.add(pw.SizedBox(height: 14));
widgets.add(_buildVehicleSubheading(row));
widgets.add(pw.SizedBox(height: 8));
widgets.add(_buildReceiptImagesWrap(
row,
receiptImages,
showItemNumbers: showItemNumbers,
itemNumbers: itemNumbers,
));
}
}
}
return widgets;
}
pw.Widget _buildVehicleSubheading(VehicleReportRow row) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)),
],
);
}
pw.Widget _buildLineItemsTable(
VehicleReportRow row, {
required bool showItemNumbers,
required Map<String, int> itemNumbers,
}) {
return pw.TableHelper.fromTextArray(
headers: [
if (showItemNumbers) '#',
'Date',
'Gallons',
'Price/Gal',
'Total Cost',
],
data: [
for (final entry in row.entries)
[
if (showItemNumbers) itemNumbers[entry.id].toString(),
_dateTimeFormat.format(entry.date),
entry.gallons.toStringAsFixed(3),
_currencyFormat.format(entry.pricePerGallon),
_currencyFormat.format(entry.totalCost),
],
],
cellAlignments: showItemNumbers
? const {
0: pw.Alignment.centerLeft,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
}
: const {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted),
cellStyle: const pw.TextStyle(fontSize: 10),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
border: _tableBorder,
headerDecoration: _tableHeaderDecoration,
rowDecoration: _tableEvenRowDecoration,
oddRowDecoration: _tableOddRowDecoration,
);
}
/// 140×140 — comfortably legible (the previous design's inline thumbnails,
/// at ~34×44, were too small to make out) while still fitting several per
/// row on a letter-width page.
const _receiptImageSize = 140.0;
pw.Widget _buildReceiptImagesWrap(
VehicleReportRow row,
Map<String, Uint8List> receiptImages, {
required bool showItemNumbers,
required Map<String, int> itemNumbers,
}) {
return pw.Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (final entry in row.entries)
if (receiptImages[entry.id] case final bytes?)
pw.Column(
children: [
pw.Container(
width: _receiptImageSize,
height: _receiptImageSize,
decoration: pw.BoxDecoration(
color: _cardBackground,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
border: pw.Border.all(color: _borderLight, width: 0.5),
),
child: pw.ClipRRect(
horizontalRadius: 6,
verticalRadius: 6,
child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover),
),
),
pw.SizedBox(height: 4),
pw.Text(
showItemNumbers
? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}'
: _dayMonthFormat.format(entry.date),
style: pw.TextStyle(fontSize: 9, color: _textMuted),
),
],
),
],
);
}
/// `fuel-report-<start>-<end>.pdf`, e.g. `fuel-report-20260101-20260331.pdf`.
String fuelReportFileName(FuelReport report) {
final fmt = DateFormat('yyyyMMdd');
return 'fuel-report-${fmt.format(report.startDate)}-${fmt.format(report.endDate)}.pdf';
}