enrollment-app/lib/presentation/claims_overview/claims_overview_pdf_export.dart

667 lines
19 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:google_fonts/google_fonts.dart';
import 'package:image/image.dart' as img;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:universal_html/html.dart' as html;
import 'claims_collection_kpi.dart';
import 'enrollment_collection_kpi.dart';
import 'claims_overview_scope.dart';
import 'claims_overview_tabs.dart';
import 'claims_overview_theme.dart';
const _tabNames = [
'Overview',
'Claims Overview',
'Claims Analysis',
'Demographics',
'Hospitals & Geography',
'Specialty Analysis',
'Insurred',
];
/// Fallback heights if layout measure fails (avoids second pass).
const _tabFallbackHeights = [
720.0,
680.0,
820.0,
760.0,
680.0,
580.0,
820.0,
];
const _captureWidth = 1100.0;
const _capturePadding = 16.0;
const _maxMeasureHeight = 1400.0;
const _enrollmentMaxMeasureHeight = 1020.0;
const _minCaptureHeight = 360.0;
const _captureTimeout = Duration(seconds: 30);
/// Target embed width (~200 DPI on A4 landscape content area) for sharp text.
const _pdfEmbedMaxWidth = 1800;
const _pdfJpegQuality = 92;
const _logoAsset = 'assets/Nhance-Logo-Final 1.png';
const _pdfLogoHeight = 28.0;
const _pdfPageMargin = pw.EdgeInsets.fromLTRB(10, 14, 10, 14);
typedef ClaimsPdfExportProgress = void Function(int current, int total, String label);
class ClaimsPdfDashboardInfo {
final String policyLabel;
final String? clientName;
final String? branchName;
final String? misCreationDate;
const ClaimsPdfDashboardInfo({
required this.policyLabel,
this.clientName,
this.branchName,
this.misCreationDate,
});
}
class _TabCapture {
final String tabName;
final Uint8List png;
const _TabCapture({required this.tabName, required this.png});
}
class _PdfBuildInput {
final List<_TabCapture> captures;
final Uint8List? logoBytes;
final Uint8List? clientLogoBytes;
final ClaimsPdfDashboardInfo dashboardInfo;
const _PdfBuildInput({
required this.captures,
required this.logoBytes,
required this.dashboardInfo,
this.clientLogoBytes,
});
}
Future<void> _preloadFonts() async {
try {
await GoogleFonts.pendingFonts([
GoogleFonts.poppins(),
GoogleFonts.poppins(fontWeight: FontWeight.w600),
]).timeout(const Duration(milliseconds: 600));
} catch (_) {
// Continue export even if web font CDN is slow.
}
}
/// Optionally downscales and JPEG-encodes tab screenshots for PDF embed.
/// Keeps high resolution so dashboard text/charts stay sharp when zoomed.
Uint8List _compressTabImageForPdf(Uint8List pngBytes) {
try {
final decoded = img.decodePng(pngBytes);
if (decoded == null) return pngBytes;
img.Image processed = decoded;
if (decoded.width > _pdfEmbedMaxWidth) {
processed = img.copyResize(
decoded,
width: _pdfEmbedMaxWidth,
interpolation: img.Interpolation.cubic,
);
}
return Uint8List.fromList(
img.encodeJpg(processed, quality: _pdfJpegQuality),
);
} catch (e) {
debugPrint('PDF export: image compress skipped ($e)');
return pngBytes;
}
}
pw.MemoryImage? _pdfMemoryImage(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return pw.MemoryImage(bytes);
}
Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
final logo = _pdfMemoryImage(input.logoBytes);
final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
final doc = pw.Document();
for (var i = 0; i < input.captures.length; i++) {
final capture = input.captures[i];
final embed = _compressTabImageForPdf(capture.png);
final isFirstPage = i == 0;
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4.landscape,
margin: _pdfPageMargin,
build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
if (isFirstPage) ...[
_pdfDashboardHeader(
clientLogo: clientLogo,
logo: logo,
info: input.dashboardInfo,
),
pw.SizedBox(height: 8),
],
pw.Expanded(
child: pw.Image(
pw.MemoryImage(embed),
fit: pw.BoxFit.fitWidth,
alignment: pw.Alignment.topCenter,
),
),
],
),
),
);
}
return doc.save();
}
/// Web: compress/build in chunks so the progress dialog can repaint.
Future<Uint8List> _buildPdfBytesWithYields(
_PdfBuildInput input,
ClaimsPdfExportProgress? onProgress,
int totalSteps,
) async {
final logo = _pdfMemoryImage(input.logoBytes);
final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
final doc = pw.Document();
final total = input.captures.length;
for (var i = 0; i < total; i++) {
final capture = input.captures[i];
_reportProgress(
onProgress,
_tabNames.length + 1,
totalSteps,
'Compressing ${capture.tabName} (${i + 1}/$total)…',
);
await Future<void>.delayed(Duration.zero);
final embed = _compressTabImageForPdf(capture.png);
final isFirstPage = i == 0;
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4.landscape,
margin: _pdfPageMargin,
build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
if (isFirstPage) ...[
_pdfDashboardHeader(
clientLogo: clientLogo,
logo: logo,
info: input.dashboardInfo,
),
pw.SizedBox(height: 8),
],
pw.Expanded(
child: pw.Image(
pw.MemoryImage(embed),
fit: pw.BoxFit.fitWidth,
alignment: pw.Alignment.topCenter,
),
),
],
),
),
);
}
_reportProgress(
onProgress,
_tabNames.length + 1,
totalSteps,
'Writing PDF file…',
);
await Future<void>.delayed(Duration.zero);
return doc.save();
}
Widget _buildTabCapture({
required ClaimsOverviewViewData data,
required EnrollmentOverviewViewData enrollmentData,
required int tabIndex,
required int replayToken,
required String tabName,
}) {
final laneWidth = _captureWidth - _capturePadding * 2;
return ClaimsOverviewScope(
data: data,
child: EnrollmentOverviewScope(
data: enrollmentData,
child: ClaimsPdfExportScope(
enabled: true,
laneWidth: laneWidth,
child: Container(
width: _captureWidth,
color: ClaimsOverviewTheme.pageBg,
padding: const EdgeInsets.all(_capturePadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: ClaimsOverviewTheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
tabName,
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.primary,
),
),
),
const SizedBox(height: 12),
ClaimsOverviewTab(
index: tabIndex,
replayToken: replayToken,
isActive: true,
forPdfExport: true,
),
],
),
),
),
),
);
}
Future<void> _waitForPaint() async {
for (var i = 0; i < 3; i++) {
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 48));
}
}
double _capturePixelRatio({required bool isEnrollmentTab}) {
// 2x+ capture is required for crisp text when screenshots are
// scaled into A4 landscape PDF pages (especially on Flutter web).
if (kIsWeb) {
return isEnrollmentTab ? 1.75 : 2.0;
}
return isEnrollmentTab ? 1.75 : 2.25;
}
Future<ui.Image?> _rasterizeBoundary(
RenderRepaintBoundary boundary,
double pixelRatio,
) async {
try {
return await boundary
.toImage(pixelRatio: pixelRatio)
.timeout(_captureTimeout);
} on TimeoutException {
debugPrint('PDF export: rasterize timed out at ratio $pixelRatio');
return null;
}
}
/// Single overlay pass: layout tab, measure height, rasterize to PNG.
Future<Uint8List?> _captureTabPng({
required OverlayState overlay,
required Widget child,
required double fallbackHeight,
double? maxMeasureHeight,
bool isEnrollmentTab = false,
ClaimsPdfExportProgress? onProgress,
String? progressLabel,
}) async {
final boundaryKey = GlobalKey();
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => Material(
type: MaterialType.transparency,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
top: 0,
width: _captureWidth,
child: IgnorePointer(
child: Opacity(
opacity: 0.01,
child: RepaintBoundary(
key: boundaryKey,
child: MediaQuery(
data: const MediaQueryData(),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: _captureWidth,
minHeight: fallbackHeight,
maxHeight: maxMeasureHeight ??
(isEnrollmentTab
? _enrollmentMaxMeasureHeight
: _maxMeasureHeight),
),
child: child,
),
),
),
),
),
),
),
],
),
),
);
overlay.insert(entry);
try {
await _waitForPaint();
final renderObject = boundaryKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
debugPrint('PDF export: missing RepaintBoundary');
return null;
}
final measuredHeight = renderObject.size.height;
if (measuredHeight < fallbackHeight * 0.85) {
debugPrint(
'PDF export: $progressLabel measured ${measuredHeight.toStringAsFixed(0)}px '
'(expected >= ${fallbackHeight.toStringAsFixed(0)}px)',
);
await _waitForPaint();
}
onProgress?.call(0, 0, '$progressLabel (rasterize…)');
var ratio = _capturePixelRatio(isEnrollmentTab: isEnrollmentTab);
ui.Image? image = await _rasterizeBoundary(renderObject, ratio);
if (image == null && ratio > 0.65) {
ratio *= 0.75;
image = await _rasterizeBoundary(renderObject, ratio);
}
if (image == null) return null;
onProgress?.call(0, 0, '$progressLabel (encode…)');
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData?.buffer.asUint8List();
} catch (e, stack) {
debugPrint('PDF export capture failed: $e\n$stack');
return null;
} finally {
entry.remove();
}
}
const _pdfTextPrimary = PdfColor.fromInt(0xFF1E293B);
const _pdfTextSecondary = PdfColor.fromInt(0xFF64748B);
const _pdfGreen = PdfColor.fromInt(0xFF22C55E);
const _pdfPrimary = PdfColor.fromInt(0xFF14A39A);
pw.Widget _pdfDashboardHeader({
pw.MemoryImage? clientLogo,
pw.MemoryImage? logo,
required ClaimsPdfDashboardInfo info,
}) {
final misDate = info.misCreationDate?.trim();
final hasMisDate = misDate != null && misDate.isNotEmpty;
final clientName = info.clientName?.trim() ?? '';
final branchName = info.branchName?.trim() ?? '';
final hasClientMeta = clientName.isNotEmpty || branchName.isNotEmpty;
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (clientLogo != null)
pw.Container(
height: _pdfLogoHeight,
alignment: pw.Alignment.centerLeft,
child: pw.Image(clientLogo, height: _pdfLogoHeight, fit: pw.BoxFit.contain),
)
else
pw.SizedBox(height: _pdfLogoHeight),
if (hasClientMeta) ...[
pw.SizedBox(width: 4),
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
if (clientName.isNotEmpty)
pw.Text(
clientName,
style: pw.TextStyle(
fontSize: 10,
fontWeight: pw.FontWeight.bold,
color: _pdfTextPrimary,
),
),
if (branchName.isNotEmpty)
pw.Text(
branchName,
style: const pw.TextStyle(
fontSize: 8,
color: _pdfTextSecondary,
),
),
],
),
),
] else
pw.Spacer(),
if (logo != null)
pw.Container(
height: _pdfLogoHeight,
width: _pdfLogoHeight * 3.2,
alignment: pw.Alignment.centerRight,
child: pw.Image(logo, fit: pw.BoxFit.contain),
),
],
),
pw.SizedBox(height: 10),
pw.Text(
'Claims Overview',
style: pw.TextStyle(
fontSize: 16,
fontWeight: pw.FontWeight.bold,
color: _pdfTextPrimary,
),
),
pw.SizedBox(height: 4),
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Expanded(
child: pw.Wrap(
spacing: 4,
runSpacing: 2,
crossAxisAlignment: pw.WrapCrossAlignment.center,
children: [
pw.Text(
'Policy & Claims Analytics',
style: const pw.TextStyle(
fontSize: 9,
color: _pdfTextSecondary,
),
),
if (hasMisDate) ...[
pw.Text(
'·',
style: const pw.TextStyle(
fontSize: 9,
color: _pdfTextSecondary,
),
),
pw.Text(
'Generated at',
style: const pw.TextStyle(
fontSize: 8,
color: _pdfGreen,
),
),
pw.Text(
'· $misDate',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: _pdfGreen,
),
),
],
],
),
),
pw.SizedBox(width: 12),
pw.Text(
'Policy No : ${info.policyLabel}',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: _pdfTextPrimary,
),
),
],
),
pw.SizedBox(height: 6),
pw.Divider(color: _pdfPrimary, thickness: 0.5),
],
);
}
Future<void> _savePdfBytes(Uint8List bytes, String fileName) async {
if (kIsWeb) {
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..setAttribute('download', fileName)
..style.display = 'none';
html.document.body?.append(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
} else {
await Printing.sharePdf(bytes: bytes, filename: fileName);
}
}
void _reportProgress(
ClaimsPdfExportProgress? onProgress,
int current,
int total,
String label,
) {
onProgress?.call(current, total, label);
if (kIsWeb) {
// Let the progress dialog repaint before heavy work on web.
SchedulerBinding.instance.scheduleFrame();
}
}
/// Captures each dashboard tab (same layout as on screen) into a PDF.
Future<void> exportClaimsOverviewChartsPdf({
required OverlayState overlay,
required int replayToken,
required ClaimsOverviewViewData viewData,
required EnrollmentOverviewViewData enrollmentViewData,
required String policyId,
required ClaimsPdfDashboardInfo dashboardInfo,
Uint8List? clientLogoBytes,
ClaimsPdfExportProgress? onProgress,
}) async {
await _preloadFonts();
Uint8List? logoBytes;
try {
final data = await rootBundle.load(_logoAsset);
logoBytes = data.buffer.asUint8List();
} catch (_) {
logoBytes = null;
}
final totalSteps = _tabNames.length + 2;
_reportProgress(onProgress, 0, totalSteps, 'Preparing captures…');
final captures = <_TabCapture>[];
for (var i = 0; i < _tabNames.length; i++) {
await Future<void>.delayed(Duration.zero);
final tabName = _tabNames[i];
_reportProgress(onProgress, i + 1, totalSteps, 'Capturing $tabName');
final tabWidget = _buildTabCapture(
data: viewData,
enrollmentData: enrollmentViewData,
tabIndex: i,
replayToken: replayToken,
tabName: tabName,
);
final captureLabel = 'Capturing $tabName';
final png = await _captureTabPng(
overlay: overlay,
child: tabWidget,
fallbackHeight: _tabFallbackHeights[i],
maxMeasureHeight: i == 2 ? 1000.0 : null,
isEnrollmentTab: i == 6,
onProgress: onProgress,
progressLabel: captureLabel,
);
if (png == null) continue;
captures.add(_TabCapture(tabName: tabName, png: png));
}
if (captures.isEmpty) {
throw Exception('Could not capture any tabs for PDF export');
}
_reportProgress(
onProgress,
_tabNames.length + 1,
totalSteps,
'Building PDF file…',
);
final buildInput = _PdfBuildInput(
captures: captures,
logoBytes: logoBytes,
clientLogoBytes: clientLogoBytes,
dashboardInfo: dashboardInfo,
);
final Uint8List bytes;
if (kIsWeb) {
bytes = await _buildPdfBytesWithYields(buildInput, onProgress, totalSteps);
} else {
bytes = await _buildPdfBytesSync(buildInput);
}
_reportProgress(onProgress, totalSteps, totalSteps, 'Downloading…');
await _savePdfBytes(bytes, 'Claims_Overview_$policyId.pdf');
}