tasq/lib/screens/reports/report_pdf_export.dart
2026-03-03 07:38:40 +08:00

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',
);
}
}