253 lines
8.9 KiB
Dart
253 lines
8.9 KiB
Dart
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<Uint8List?> _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<void>.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<Uint8List> generatePdf({
|
|
required Map<String, GlobalKey> captureKeys,
|
|
required ReportDateRange dateRange,
|
|
required Set<ReportWidgetType> 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 = <String, Uint8List>{};
|
|
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<Uint8List> _buildPdfBytes({
|
|
required Map<String, Uint8List> 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<void> sharePdf(Uint8List pdfBytes) async {
|
|
await Printing.layoutPdf(
|
|
onLayout: (_) => pdfBytes,
|
|
name:
|
|
'TasQ_Report_${AppTime.formatDate(AppTime.now()).replaceAll(' ', '_')}.pdf',
|
|
);
|
|
}
|
|
}
|