A whole bunch of changes

This commit is contained in:
Courtney Arnold 2026-08-15 17:52:25 -05:00
parent 901ec678a3
commit 956231048e
122 changed files with 5442 additions and 914 deletions

View file

@ -7,77 +7,596 @@ 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();
/// Renders [report] as a single-page-friendly PDF: a per-vehicle table
/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total
/// row, suitable for attaching to a fuel tax refund submission or handing
/// to a printer.
Future<Uint8List> buildFuelReportPdf(FuelReport report) async {
// 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,
header: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
pw.Text(
'${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}',
style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700),
),
pw.SizedBox(height: 16),
],
margin: pw.EdgeInsets.zero,
header: (context) => _buildBanner(generatedAt),
footer: (context) => _buildFooter(report, context),
build: (context) => _buildBody(
report,
includeLineItems: includeLineItems,
includeReceiptImages: includeReceiptImages,
receiptImages: receiptImages,
),
build: (context) => [
if (report.rows.isEmpty)
pw.Text('No vehicles had a receipted fuel entry in this date range.')
else
pw.TableHelper.fromTextArray(
headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'],
data: [
for (final row in report.rows)
[
row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—',
row.vehicle.vin,
row.entryCount.toString(),
row.totalGallons.toStringAsFixed(3),
_currencyFormat.format(row.totalCost),
],
],
cellAlignments: const {
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerAlignments: const {
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
},
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
pw.SizedBox(height: 12),
pw.Divider(),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Text(
'${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}',
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
),
],
),
],
),
);
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');