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 _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 _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 _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.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.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 _waitForPaint() async { for (var i = 0; i < 3; i++) { await WidgetsBinding.instance.endOfFrame; await Future.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 _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 _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 _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 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.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'); }