604 lines
22 KiB
Dart
604 lines
22 KiB
Dart
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';
|
||
}
|