A whole bunch of changes
This commit is contained in:
parent
901ec678a3
commit
956231048e
122 changed files with 5442 additions and 914 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue