667 lines
19 KiB
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');
|
|
}
|