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 buildFuelReportPdf( FuelReport report, { bool includeLineItems = false, bool includeReceiptImages = false, Map 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 _buildBody( FuelReport report, { required bool includeLineItems, required bool includeReceiptImages, required Map 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.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 = ''' '''; 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 _buildLineItemsAndImagesSections( FuelReport report, { required bool includeLineItems, required bool includeReceiptImages, required Map receiptImages, }) { final showItemNumbers = includeLineItems && includeReceiptImages; final itemNumbers = {}; var nextNumber = 1; for (final row in report.rows) { for (final entry in row.entries) { itemNumbers[entry.id] = nextNumber++; } } final widgets = []; 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 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 receiptImages, { required bool showItemNumbers, required Map 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--.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'; }