import 'dart:isolate'; import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:pdf/pdf.dart' as pdf; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import '../../providers/reports_provider.dart'; import '../../utils/app_time.dart'; /// Captures all visible report charts via their [RepaintBoundary] keys /// and generates a PDF document for export / printing. class ReportPdfExport { ReportPdfExport._(); /// Capture a [RepaintBoundary] as a PNG [Uint8List]. /// /// Yields to the event loop after each capture so the UI stays responsive. static Future _captureWidget(GlobalKey key) async { final boundary = key.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null; final image = await boundary.toImage(pixelRatio: 1.5); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); // Yield to the event loop so the UI thread doesn't freeze await Future.delayed(Duration.zero); return byteData?.buffer.asUint8List(); } /// Generates the full report PDF from the given [captureKeys] map. /// /// [captureKeys] maps a human-readable chart title to the [GlobalKey] /// attached to that chart's [RepaintBoundary]. /// /// [dateRange] is for display in the header. /// [enabledWidgets] filters which charts to include. static Future generatePdf({ required Map captureKeys, required ReportDateRange dateRange, required Set enabledWidgets, }) async { // Load fonts final regularFontData = await rootBundle.load( 'assets/fonts/Roboto-Regular.ttf', ); final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf'); // Try to load logo (may not exist in all builds) Uint8List? logoBytes; try { final logoData = await rootBundle.load('assets/crmc_logo.png'); logoBytes = logoData.buffer.asUint8List(); } catch (_) { // Logo not available — skip } // ── Capture chart images on the main thread (needs render objects) ── final chartImages = {}; for (final entry in captureKeys.entries) { final bytes = await _captureWidget(entry.value); if (bytes != null) { chartImages[entry.key] = bytes; } } final dateRangeText = AppTime.formatDateRange(dateRange.dateTimeRange); final generated = AppTime.formatDate(AppTime.now()); final dateLabel = dateRange.label; // ── Build the PDF on a background isolate so it doesn't freeze the UI ── return Isolate.run(() { return _buildPdfBytes( chartImages: chartImages, dateRangeText: dateRangeText, dateLabel: dateLabel, generated: generated, regularFontData: regularFontData.buffer.asUint8List(), boldFontData: boldFontData.buffer.asUint8List(), logoBytes: logoBytes, ); }); } /// Pure function that assembles the PDF document from raw data. /// Designed to run inside [Isolate.run]. static Future _buildPdfBytes({ required Map chartImages, required String dateRangeText, required String dateLabel, required String generated, required Uint8List regularFontData, required Uint8List boldFontData, Uint8List? logoBytes, }) { final regularFont = pw.Font.ttf(regularFontData.buffer.asByteData()); final boldFont = pw.Font.ttf(boldFontData.buffer.asByteData()); pw.MemoryImage? logoImage; if (logoBytes != null) { logoImage = pw.MemoryImage(logoBytes); } final doc = pw.Document(); // One page per chart to avoid TooManyPagesException and keep layout simple // First chart gets the full header; subsequent ones get a compact header. var isFirst = true; for (final entry in chartImages.entries) { doc.addPage( pw.Page( pageFormat: pdf.PdfPageFormat.a4.landscape, margin: const pw.EdgeInsets.all(32), theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont), build: (pw.Context ctx) { final header = isFirst ? pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ if (logoImage != null) pw.Container( width: 50, height: 50, child: pw.Image(logoImage), ), if (logoImage != null) pw.SizedBox(width: 12), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( 'TasQ Report', style: pw.TextStyle( fontSize: 20, fontWeight: pw.FontWeight.bold, ), ), pw.Text( '$dateLabel — $dateRangeText', style: pw.TextStyle( fontSize: 11, color: pdf.PdfColors.grey700, ), ), ], ), pw.Spacer(), pw.Text( 'Generated: $generated', style: pw.TextStyle( fontSize: 9, color: pdf.PdfColors.grey500, ), ), ], ), pw.Divider(thickness: 0.5), pw.SizedBox(height: 8), ], ) : pw.Container( margin: const pw.EdgeInsets.only(bottom: 8), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'TasQ Report', style: pw.TextStyle( fontSize: 10, color: pdf.PdfColors.grey600, ), ), pw.Text( dateRangeText, style: pw.TextStyle( fontSize: 10, color: pdf.PdfColors.grey600, ), ), ], ), ); isFirst = false; return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ header, pw.Text( entry.key, style: pw.TextStyle( fontSize: 13, fontWeight: pw.FontWeight.bold, ), ), pw.SizedBox(height: 6), pw.Expanded( child: pw.Center( child: pw.Image( pw.MemoryImage(entry.value), fit: pw.BoxFit.contain, ), ), ), pw.Container( alignment: pw.Alignment.centerRight, child: pw.Text( 'Page ${ctx.pageNumber} • Generated by TasQ', style: pw.TextStyle(fontSize: 9, color: pdf.PdfColors.grey), ), ), ], ); }, ), ); } if (chartImages.isEmpty) { doc.addPage( pw.Page( pageFormat: pdf.PdfPageFormat.a4.landscape, margin: const pw.EdgeInsets.all(32), theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont), build: (pw.Context ctx) => pw.Center( child: pw.Text( 'No charts selected for export.', style: pw.TextStyle(fontSize: 14), ), ), ), ); } return doc.save(); } /// Show a print / share dialog for the generated PDF. static Future sharePdf(Uint8List pdfBytes) async { await Printing.layoutPdf( onLayout: (_) => pdfBytes, name: 'TasQ_Report_${AppTime.formatDate(AppTime.now()).replaceAll(' ', '_')}.pdf', ); } }