From c66c39e57c46bc4076ccf7e291fc1431c15e599f Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Thu, 4 Jun 2026 14:47:40 +0530 Subject: [PATCH] Dashboard charts --- lib/config/environment.dart | 2 + lib/customAppBar/side_bar.dart | 12 + lib/main.dart | 2 + .../claims_collection_kpi.dart | 1306 ++++++++++ .../claims_overview_animations.dart | 530 ++++ .../claims_overview_charts.dart | 2232 +++++++++++++++++ .../claims_overview_dashboard.dart | 737 ++++++ .../claims_overview_pdf_export.dart | 509 ++++ .../claims_overview_pdf_layout.dart | 123 + .../claims_overview_scope.dart | 80 + .../claims_overview/claims_overview_tabs.dart | 1349 ++++++++++ .../claims_overview_theme.dart | 33 + .../claims_overview_widgets.dart | 373 +++ .../enrollment_collection_kpi.dart | 360 +++ lib/service/api_service.dart | 82 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.yaml | 3 + web/index.html | 6 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 22 files changed, 7748 insertions(+), 2 deletions(-) create mode 100644 lib/presentation/claims_overview/claims_collection_kpi.dart create mode 100644 lib/presentation/claims_overview/claims_overview_animations.dart create mode 100644 lib/presentation/claims_overview/claims_overview_charts.dart create mode 100644 lib/presentation/claims_overview/claims_overview_dashboard.dart create mode 100644 lib/presentation/claims_overview/claims_overview_pdf_export.dart create mode 100644 lib/presentation/claims_overview/claims_overview_pdf_layout.dart create mode 100644 lib/presentation/claims_overview/claims_overview_scope.dart create mode 100644 lib/presentation/claims_overview/claims_overview_tabs.dart create mode 100644 lib/presentation/claims_overview/claims_overview_theme.dart create mode 100644 lib/presentation/claims_overview/claims_overview_widgets.dart create mode 100644 lib/presentation/claims_overview/enrollment_collection_kpi.dart diff --git a/lib/config/environment.dart b/lib/config/environment.dart index 576671b..08629ea 100755 --- a/lib/config/environment.dart +++ b/lib/config/environment.dart @@ -52,6 +52,8 @@ class Environment { switch (routeName) { case 'hrDashboard': return "$base - Insights"; + case 'claimsOverviewDashboard': + return "$base - Dashboard"; case 'policies': return "$base - Policies"; case 'CdPoliciesList': diff --git a/lib/customAppBar/side_bar.dart b/lib/customAppBar/side_bar.dart index fee9455..96394c6 100644 --- a/lib/customAppBar/side_bar.dart +++ b/lib/customAppBar/side_bar.dart @@ -323,6 +323,18 @@ class _NhanceSideBarState extends State { ); }), + if (postModules.isNotEmpty) + _SideItem( + icon: const Icon( + Icons.space_dashboard_outlined, + color: Colors.white, + size: 30, + ), + label: "Dashboard", + isActive: activeRoute == 'claimsOverviewDashboard', + onTap: () => _navigate('claimsOverviewDashboard'), + ), + if (postModules.isNotEmpty && postModules.contains(5)) _SideItem( // icon: Icons.dashboard, diff --git a/lib/main.dart b/lib/main.dart index 40fde1d..12c2aef 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:nhancepolicy/service/session/session_service.dart'; import 'package:nhancepolicy/service/token_storage_service.dart'; import 'package:nhancepolicy/home.dart'; import 'package:nhancepolicy/presentation/hrDashboard.dart'; +import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:nhancepolicy/hrLogin.dart'; import 'package:nhancepolicy/presentation/hrPolicyDetails.dart'; @@ -267,6 +268,7 @@ final Map appRoutes = { 'addOnsDetails': (context) => addOnsDetails(), 'empReviewDetails': (context) => empReviewDetails(), 'hrDashboard': (context) => hrDashboard(), + 'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(), 'hrPolicyDetails': (context) => hrPolicyDetails( ClientId: '', policyTypeId: '', diff --git a/lib/presentation/claims_overview/claims_collection_kpi.dart b/lib/presentation/claims_overview/claims_collection_kpi.dart new file mode 100644 index 0000000..c6140c3 --- /dev/null +++ b/lib/presentation/claims_overview/claims_collection_kpi.dart @@ -0,0 +1,1306 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:intl/intl.dart'; + +/// Metabase KPI slugs for `employeeRest/claims-collection-v2`. +abstract final class ClaimsKpiSlug { + static const policyExposureSummary = 'policy_exposure_summary'; + static const premiumAsOnDate = 'premium_as_on_date'; + static const claimsExperienceSummary = 'claims_experience_summary'; + static const claimAmountByGender = 'claim_amount_by_gender'; + static const ageBand = 'age_band'; + static const top5Hospitals = 'top_5_hospitals_by_incurred_amount'; + static const claimsIncidenceRate = 'claims_incidence_rate'; + static const policyStartDate = 'policy_start_date'; + static const policyEndDate = 'policy_end_date'; + static const insurer = 'insurer'; + static const tpa = 'tpa'; + static const earnedPremium = 'earned_premium'; + static const totalClaims = 'total_claims'; + static const incurredAmount = 'incurred_amount'; + static const incurredRatio = 'incurred_ratio'; + static const projectedClaims = 'projected_claims'; + static const projectedRatio = 'projected_ratio'; + static const totalReimbursementAmount = 'total_reimbursement_amount'; + static const totalReimbursementPct = 'total_reimbursement_amt_pct'; + static const cashlessClaimAmt = 'cashless_claim_amt'; + static const cashlessClaimPct = 'cashless_claim_amt_pct'; + static const totalIncurredByCity = 'total_incurred_by_city'; + static const claimAmountByClaimStatus = 'claim_amount_by_claim_status'; + static const hospitalsInDetail = 'hospitals_in_detail'; + static const hospitalCitySiPregnancy = 'hospital_city_wise_si_limit_pregnancy'; + static const pregnancyNormalExceeded = + 's_pregnancy_normal_delivery_exceeded_amt'; + static const pregnancyCSecAvgExceeded = 's_pregnancy_c_sec_avg_exceeded_amt'; + static const cataractExceededClaim = 'cataract_exceeded_claim_amount'; + static const cataractAvgExceeded = 'cataract_avg_exceeded_amount'; + static const hospitalCitySiCataract = 'hospital_city_wise_si_limit_cataract'; + static const totalIncurredByClaimStatus = 'total_incurred_by_cliam_status'; + static const inceptionEmpLives = 'inception_emp_lives'; + static const currentEmpLives = 'current_emp_lives'; + static const claimValueByMonth = 'claim_value_by_month'; + static const topAilments = 'top_ailments'; +} + +class ClaimsChartSeries { + final List labels; + final List values; + final double? maxY; + + const ClaimsChartSeries({ + required this.labels, + required this.values, + this.maxY, + }); + + bool get isEmpty => labels.isEmpty || values.isEmpty; +} + +class ClaimsListItem { + final String title; + final String value; + + const ClaimsListItem({required this.title, required this.value}); +} + +class ClaimsCityItem { + final String city; + final String amount; + final double fraction; + + const ClaimsCityItem({ + required this.city, + required this.amount, + required this.fraction, + }); +} + +class ClaimsScatterPoint { + final String label; + final double y; + + const ClaimsScatterPoint({required this.label, required this.y}); +} + +class ClaimsRelationshipItem { + final String relation; + final int claims; + final String amount; + + const ClaimsRelationshipItem({ + required this.relation, + required this.claims, + required this.amount, + }); +} + +class ClaimsAilmentItem { + final String title; + final String value; + + const ClaimsAilmentItem({required this.title, required this.value}); +} + +/// Parsed view-model for the Claims Overview dashboard. +class ClaimsOverviewViewData { + final Map kpiBySlug; + final String? loadError; + + const ClaimsOverviewViewData({ + required this.kpiBySlug, + this.loadError, + }); + + factory ClaimsOverviewViewData.empty({String? error}) => + ClaimsOverviewViewData(kpiBySlug: const {}, loadError: error); + + factory ClaimsOverviewViewData.fromApiResponse(Map response) { + return ClaimsOverviewViewData( + kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response), + ); + } + + /// Merges extra KPI blocks (e.g. from `/kpi/{slug}`) into this view-model. + ClaimsOverviewViewData withMergedKpis(Map extraKpis) { + if (extraKpis.isEmpty) return this; + final merged = Map.from(kpiBySlug)..addAll(extraKpis); + return ClaimsOverviewViewData(kpiBySlug: merged, loadError: loadError); + } + + /// Fills overview basics from `getPolicyLevelEmployeeSummaryData` when KPI rows are empty. + ClaimsOverviewViewData withPolicyFallback(Map policy) { + final merged = Map.from(kpiBySlug); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.policyStartDate, + 'policy_start_date', + policy['policy_start_date'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.policyEndDate, + 'policy_end_date', + policy['policy_expiry_date'] ?? policy['policy_end_date'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.policyExposureSummary, + 'policy_start_date', + policy['policy_start_date'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.policyExposureSummary, + 'policy_end_date', + policy['policy_expiry_date'] ?? policy['policy_end_date'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.insurer, + 'insurer_name', + policy['insurer_name'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.policyExposureSummary, + 'insurer_name', + policy['insurer_name'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.premiumAsOnDate, + 'premium_as_on_date', + policy['total_premium'], + ); + ClaimsKpiParser.ensureKpiField( + merged, + ClaimsKpiSlug.currentEmpLives, + 'current_lives', + policy['membersCountOfActive'] ?? policy['totalMembersCount'], + ); + return ClaimsOverviewViewData(kpiBySlug: merged, loadError: loadError); + } + + /// Loads optional KPIs not always present in `/all` (e.g. top ailments). + static Future enrichFromApiResponse( + Map allResponse, { + required Future> Function(String slug) fetchKpi, + }) async { + var data = ClaimsOverviewViewData.fromApiResponse(allResponse); + if (data.topAilments.isNotEmpty) return data; + + const optionalSlugs = [ + ClaimsKpiSlug.topAilments, + 'top_5_ailments', + 'top_ailments_by_amount', + 'top_ailments_by_incurred_amount', + 'claims_by_ailment', + ]; + + for (final slug in optionalSlugs) { + if (data.kpiBySlug.containsKey(slug)) continue; + try { + final response = await fetchKpi(slug); + final status = response['status']; + final ok = status == true || + status == 'success' || + status == 'Success' || + response['success'] == true; + if (!ok) continue; + + final extra = ClaimsKpiParser.normalizeAllKpis(response); + if (extra.isEmpty) continue; + data = data.withMergedKpis(extra); + if (data.topAilments.isNotEmpty) break; + } catch (_) { + // Optional KPI — ignore fetch errors. + } + } + return data; + } + + dynamic _kpi(String slug) => kpiBySlug[slug] ?? kpiBySlug[_idKey(slug)]; + + dynamic get _experience => _kpi(ClaimsKpiSlug.claimsExperienceSummary); + + String _expField(String field, {String fallback = '—'}) => + ClaimsKpiParser.rowField(_experience, field, fallback: fallback); + + ClaimsChartSeries chartSeries( + String slug, { + String? labelKey, + String? valueKey, + bool valuesInMillions = false, + }) { + return ClaimsKpiParser.labelValueSeries( + _kpi(slug), + labelKey: labelKey, + valueKey: valueKey, + valuesInMillions: valuesInMillions, + ); + } + + // --- Overview tab --- + String get policyStartDate => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyStartDate), + 'policy_start_date', + fallback: ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyExposureSummary), + 'policy_start_date', + ), + ); + + String get policyEndDate => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyEndDate), + 'policy_end_date', + fallback: ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyExposureSummary), + 'policy_end_date', + ), + ); + + String get premiumAsOnDate => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.premiumAsOnDate), + 'premium_as_on_date', + ), + ); + + String get earnedPremium => + ClaimsKpiParser.formatDisplay(_expField('earned_premium')); + + String get incurredClaims => + ClaimsKpiParser.formatDisplay(_expField('incurred_amount')); + + String get incurredRatio => _expField('incurred_ratio'); + + String get projectedClaims => + ClaimsKpiParser.formatDisplay(_expField('projected_claims')); + + String get projectedRatio => _expField('projected_ratio'); + + String get tpaName => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.tpa), + 'tpa_name', + fallback: ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyExposureSummary), + 'tpa_name', + ), + ); + + String get insurerName => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.insurer), + 'insurer_name', + fallback: ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.policyExposureSummary), + 'insurer_name', + ), + ); + + String get policyRunDays => _expField('run_days'); + + String get currentEmployee => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.currentEmpLives), + 'current_emp', + ), + ); + + String get currentLives => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.currentEmpLives), + 'current_lives', + ), + ); + + String get inceptionEmployee => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.inceptionEmpLives), + 'inception_emp', + ), + ); + + String get inceptionLives => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.inceptionEmpLives), + 'inception_lives', + ), + ); + + String get avgFamilySize { + final raw = ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.currentEmpLives), + 'avg_family_size', + fallback: '', + ); + if (raw.isEmpty || raw == '—') return '—'; + final n = ClaimsKpiParser.toDouble(raw); + if (n == null) return raw; + return NumberFormat('#,##0.##').format(n); + } + + // --- Policy experience --- + ClaimsChartSeries get incurredByClaimStatus => chartSeries( + ClaimsKpiSlug.totalIncurredByClaimStatus, + labelKey: 'Claim Status', + valueKey: 'Total Incurred Amount', + valuesInMillions: true, + ); + + ClaimsChartSeries get claimsCountByStatus => chartSeries( + ClaimsKpiSlug.claimAmountByClaimStatus, + labelKey: 'claim_status', + valueKey: 'claim_count', + ); + + String _kpiScalar(String slug, String field) => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.kpiOnlyField(_kpi(slug), field), + ); + + /// Metabase KPI 204 — `claims_reported` only (never experience-summary fallback). + String get sidebarTotalClaims => + _kpiScalar(ClaimsKpiSlug.totalClaims, 'claims_reported'); + + /// Metabase KPI 206 — `incurred_amount` only. + String get sidebarIncurredAmount => + _kpiScalar(ClaimsKpiSlug.incurredAmount, 'incurred_amount'); + + /// Metabase KPI 213 — Total Reimbursement Amount. + String get sidebarReimbursement => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.totalReimbursementAmount), + 'claim_value', + ), + ); + + /// Metabase KPI 214 — Total Reimbursement Amt %. + String get sidebarReimbursementPct => ClaimsKpiParser.formatPercent( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.totalReimbursementPct), + 'value_pct', + ), + ); + + /// Metabase KPI 215 — Cashless Claim Amt. + String get sidebarCashless => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.cashlessClaimAmt), + 'claim_value', + ), + ); + + /// Metabase KPI 216 — Cashless Claim Amt %. + String get sidebarCashlessPct => ClaimsKpiParser.formatPercent( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.cashlessClaimPct), + 'value_pct', + ), + ); + + List get relationshipClaims { + const knownSlugs = [ + 'claim_amount_by_relationship', + 'claims_by_relationship', + 'relationship_wise_claims', + 'relationship_wise_claim_details', + 'claim_amount_by_relation', + ]; + for (final slug in knownSlugs) { + final items = _relationshipFromKpi(_kpi(slug)); + if (items.isNotEmpty) return items; + } + for (final key in kpiBySlug.keys) { + final k = key.toString().toLowerCase(); + if (!k.contains('relation') || k.contains('gender')) continue; + final items = _relationshipFromKpi(kpiBySlug[key]); + if (items.isNotEmpty) return items; + } + return const []; + } + + List get topAilments { + const knownSlugs = [ + ClaimsKpiSlug.topAilments, + 'top_5_ailments', + 'top_ailments_by_amount', + 'top_ailments_by_incurred_amount', + 'claims_by_ailment', + 'claim_by_ailment', + ]; + for (final slug in knownSlugs) { + final items = _ailmentsFromKpi(_kpi(slug)); + if (items.isNotEmpty) return items; + } + for (final entry in kpiBySlug.entries) { + final kpi = entry.value; + if (kpi is! Map) continue; + final slug = entry.key.toString().toLowerCase(); + final label = kpi['label']?.toString().toLowerCase() ?? ''; + final matchesSlug = slug.contains('ailment') || slug.contains('diagnosis'); + final matchesLabel = label.contains('ailment') || label.contains('diagnosis'); + if (!matchesSlug && !matchesLabel) continue; + final items = _ailmentsFromKpi(kpi); + if (items.isNotEmpty) return items; + } + return const []; + } + + List _relationshipFromKpi(dynamic kpi) { + final rows = ClaimsKpiParser.extractRows(kpi); + if (rows.isEmpty) return const []; + + final items = []; + for (final row in rows) { + if (row is! Map) continue; + final relation = ClaimsKpiParser.rowValue(row, 'relationship') + ?.toString() ?? + ClaimsKpiParser.rowValue(row, 'relation')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'relationship_name')?.toString(); + if (relation == null || relation.isEmpty) continue; + + final count = ClaimsKpiParser.toDouble( + ClaimsKpiParser.rowValue(row, 'claim_count'), + ) ?? + ClaimsKpiParser.toDouble( + ClaimsKpiParser.rowValue(row, 'claims'), + ) ?? + ClaimsKpiParser.toDouble(ClaimsKpiParser.rowValue(row, 'count')) ?? + 0; + + final amountRaw = ClaimsKpiParser.rowValue(row, 'claim_value') ?? + ClaimsKpiParser.rowValue(row, 'claim_amount') ?? + ClaimsKpiParser.rowValue(row, 'amount') ?? + ClaimsKpiParser.rowValue(row, 'incurred_amount'); + + items.add( + ClaimsRelationshipItem( + relation: relation, + claims: count.round(), + amount: ClaimsKpiParser.formatDisplay(amountRaw), + ), + ); + } + return items; + } + + List _ailmentsFromKpi(dynamic kpi) { + final rows = ClaimsKpiParser.extractRows(kpi); + if (rows.isEmpty) return const []; + + final items = []; + for (final row in rows) { + if (row is! Map) continue; + var title = ClaimsKpiParser.rowValue(row, 'ailment')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'ailment_name')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'diagnosis')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'diagnosis_description')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'icd_description')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'primary_diagnosis')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'disease')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'title')?.toString() ?? + ClaimsKpiParser.rowValue(row, 'name')?.toString(); + + var valueRaw = ClaimsKpiParser.rowValue(row, 'claim_value') ?? + ClaimsKpiParser.rowValue(row, 'claim_amount') ?? + ClaimsKpiParser.rowValue(row, 'amount') ?? + ClaimsKpiParser.rowValue(row, 'incurred_amount') ?? + ClaimsKpiParser.rowValue(row, 'total_incurred_amount') ?? + ClaimsKpiParser.rowValue(row, 'SUM OF CLAIMED AMOUNT'); + + final fallback = ClaimsKpiParser.rowLabelAndValue(row); + title ??= fallback?.$1; + valueRaw ??= fallback?.$2; + + if (title == null || title.isEmpty || valueRaw == null) continue; + + items.add( + ClaimsAilmentItem( + title: title, + value: ClaimsKpiParser.formatDisplay(valueRaw), + ), + ); + } + return items; + } + + // --- Claims analysis --- + ClaimsChartSeries get claimAmountByStatusDonut => chartSeries( + ClaimsKpiSlug.claimAmountByClaimStatus, + labelKey: 'claim_status', + valueKey: 'value_pct', + ); + + /// Reimbursement vs cashless amounts (API has no per-type claim counts). + ClaimsChartSeries get claimTypeCountSeries { + final reimb = ClaimsKpiParser.rowDouble( + _kpi(ClaimsKpiSlug.totalReimbursementAmount), + 'claim_value', + ); + final cashless = ClaimsKpiParser.rowDouble( + _kpi(ClaimsKpiSlug.cashlessClaimAmt), + 'claim_value', + ); + if (reimb == null && cashless == null) { + return const ClaimsChartSeries(labels: [], values: []); + } + final series = ClaimsKpiParser.labelValueSeriesFromPairs([ + ('Reimbursement', reimb ?? 0), + ('Cashless', cashless ?? 0), + ]); + if (series.values.isEmpty) return series; + final inMillions = series.values.map((v) => v / 1000000).toList(); + final max = inMillions.reduce(math.max); + return ClaimsChartSeries( + labels: series.labels, + values: inMillions, + maxY: ClaimsKpiParser._niceMaxY(max), + ); + } + + ClaimsChartSeries get claimTypeIncurredDonut { + final reimb = ClaimsKpiParser.rowDouble( + _kpi(ClaimsKpiSlug.totalReimbursementPct), + 'value_pct', + ); + final cashless = ClaimsKpiParser.rowDouble( + _kpi(ClaimsKpiSlug.cashlessClaimPct), + 'value_pct', + ); + if (reimb == null && cashless == null) { + return const ClaimsChartSeries(labels: [], values: []); + } + return ClaimsKpiParser.labelValueSeriesFromPairs([ + ('Reimbursement', reimb ?? 0), + ('Cashless', cashless ?? 0), + ]); + } + + ClaimsChartSeries get amountByMonth => ClaimsKpiParser.monthValueSeries( + _kpi(ClaimsKpiSlug.claimValueByMonth), + valuesInMillions: true, + ); + + // --- Demographics --- + ClaimsChartSeries get ageBandSeries => chartSeries( + ClaimsKpiSlug.ageBand, + labelKey: 'age_band', + valueKey: 'claim_value', + ); + + ClaimsChartSeries get genderDonut { + final series = chartSeries( + ClaimsKpiSlug.claimAmountByGender, + labelKey: 'gender', + valueKey: 'value_pct', + ); + if (series.isEmpty) return series; + final labels = series.labels + .map((g) => switch (g.toUpperCase()) { + 'M' => 'Male', + 'F' => 'Female', + _ => g, + }) + .toList(); + return ClaimsChartSeries( + labels: labels, + values: series.values, + maxY: series.maxY, + ); + } + + // --- Hospitals --- + List get topHospitals { + final series = chartSeries( + ClaimsKpiSlug.top5Hospitals, + labelKey: 'hospital_name', + valueKey: 'claim_value', + ); + if (series.isEmpty) return const []; + return List.generate(series.labels.length, (i) { + return ClaimsListItem( + title: series.labels[i], + value: ClaimsKpiParser.formatDisplay(series.values[i]), + ); + }); + } + + List get cities { + final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.totalIncurredByCity)); + final totals = {}; + for (final row in rows) { + if (row is! Map) continue; + final city = ClaimsKpiParser.rowValue(row, 'hospital_city')?.toString(); + if (city == null || city.isEmpty || city == '-') continue; + final amount = ClaimsKpiParser.toDouble( + ClaimsKpiParser.rowValue(row, 'claim_value'), + ) ?? + 0; + totals[city] = (totals[city] ?? 0) + amount; + } + if (totals.isEmpty) return const []; + + final sorted = totals.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = sorted.take(5).toList(); + final max = top.first.value; + + return top + .map( + (e) => ClaimsCityItem( + city: _titleCaseCity(e.key), + amount: ClaimsKpiParser.formatDisplay(e.value), + fraction: max > 0 ? e.value / max : 0, + ), + ) + .toList(); + } + + ClaimsChartSeries get claimsByMonthSeries => ClaimsKpiParser.monthValueSeries( + _kpi(ClaimsKpiSlug.claimValueByMonth), + valuesInMillions: true, + ); + + /// Incidence rate (%) when monthly series is unavailable. + ClaimsScatterPoint? get claimsIncidencePoint { + final rate = ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.claimsIncidenceRate), + 'claims_incidence_rate', + fallback: '', + ); + if (rate.isEmpty || rate == '—') return null; + final y = ClaimsKpiParser.toDouble(rate) ?? 0; + return ClaimsScatterPoint(label: 'Claims incidence', y: y); + } + + // --- Specialty --- + String get pregnancyNormalExceeded => ClaimsKpiParser.formatDisplay( + ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.pregnancyNormalExceeded), + 'exceeded_claim_amount', + fallback: '0', + ), + ); + + String get pregnancyCSecExceeded => '0'; + + String get pregnancyCSecAvg => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.pregnancyCSecAvgExceeded), + 'avg_exceeded_amount', + fallback: '0', + ); + + String get pregnancyNormalDeliveryExceeded => pregnancyNormalExceeded; + + String get cataractExceededClaim => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.cataractExceededClaim), + 'exceeded_claim_amount', + fallback: '0', + ); + + String get cataractAvgExceeded => ClaimsKpiParser.rowField( + _kpi(ClaimsKpiSlug.cataractAvgExceeded), + 'avg_exceeded_claim_amount', + fallback: '0', + ); + + String get cataractWithinLimit { + final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.hospitalCitySiCataract)); + var sum = 0.0; + for (final row in rows) { + if (row is! Map) continue; + final limit = ClaimsKpiParser.rowValue(row, 'CLAIM STATUS LIMIT') + ?.toString() + .toLowerCase(); + if (limit != null && !limit.contains('within')) continue; + sum += ClaimsKpiParser.toDouble( + ClaimsKpiParser.rowValue(row, 'SUM OF CLAIMED AMOUNT'), + ) ?? + 0; + } + return sum > 0 ? ClaimsKpiParser.formatDisplay(sum) : '0'; + } + + String get totalCataractClaims { + final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.hospitalCitySiCataract)); + var sum = 0.0; + for (final row in rows) { + if (row is! Map) continue; + sum += ClaimsKpiParser.toDouble( + ClaimsKpiParser.rowValue(row, 'claim_count'), + ) ?? + 0; + } + return sum > 0 ? NumberFormat('#,##0').format(sum) : '0'; + } + + static String _titleCaseCity(String city) { + if (city.length <= 3) return city.toUpperCase(); + return city[0].toUpperCase() + city.substring(1).toLowerCase(); + } + + static String? _idKey(String slug) { + const ids = { + ClaimsKpiSlug.policyExposureSummary: '181', + ClaimsKpiSlug.premiumAsOnDate: '185', + ClaimsKpiSlug.claimsExperienceSummary: '186', + ClaimsKpiSlug.claimAmountByGender: '190', + ClaimsKpiSlug.ageBand: '191', + ClaimsKpiSlug.top5Hospitals: '194', + ClaimsKpiSlug.claimsIncidenceRate: '197', + ClaimsKpiSlug.policyStartDate: '198', + ClaimsKpiSlug.policyEndDate: '199', + ClaimsKpiSlug.insurer: '200', + ClaimsKpiSlug.tpa: '201', + ClaimsKpiSlug.earnedPremium: '203', + ClaimsKpiSlug.totalClaims: '204', + ClaimsKpiSlug.incurredAmount: '206', + ClaimsKpiSlug.incurredRatio: '207', + ClaimsKpiSlug.projectedClaims: '208', + ClaimsKpiSlug.projectedRatio: '209', + ClaimsKpiSlug.totalReimbursementAmount: '213', + ClaimsKpiSlug.totalReimbursementPct: '214', + ClaimsKpiSlug.cashlessClaimAmt: '215', + ClaimsKpiSlug.cashlessClaimPct: '216', + ClaimsKpiSlug.totalIncurredByCity: '217', + ClaimsKpiSlug.claimAmountByClaimStatus: '219', + ClaimsKpiSlug.hospitalsInDetail: '220', + ClaimsKpiSlug.hospitalCitySiPregnancy: '221', + ClaimsKpiSlug.pregnancyNormalExceeded: '224', + ClaimsKpiSlug.pregnancyCSecAvgExceeded: '225', + ClaimsKpiSlug.cataractExceededClaim: '228', + ClaimsKpiSlug.cataractAvgExceeded: '229', + ClaimsKpiSlug.hospitalCitySiCataract: '230', + ClaimsKpiSlug.totalIncurredByClaimStatus: '231', + ClaimsKpiSlug.inceptionEmpLives: '232', + ClaimsKpiSlug.currentEmpLives: '233', + ClaimsKpiSlug.claimValueByMonth: '234', + }; + return ids[slug]; + } +} + +abstract final class ClaimsKpiParser { + static bool mapsEqual(Map a, Map b) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !identical(a[key], b[key])) return false; + } + return true; + } + + static bool isSuccessResponse(Map response) { + final data = response['data'] ?? response['result']; + if (data is Map && data.isNotEmpty) return true; + + final status = response['status']; + return status == true || + status == 1 || + status == 'true' || + status == 'True' || + status == 'success' || + status == 'Success' || + response['success'] == true || + response['code'] == 200; + } + + static Map normalizeAllKpis(Map response) { + final map = {}; + if (!isSuccessResponse(response) && response.containsKey('status')) { + return map; + } + + var data = response['data'] ?? response['result']; + if (data is String) { + try { + data = jsonDecode(data); + } catch (_) { + data = null; + } + } + + if (data is Map) { + _ingestKpiMap(Map.from(data), map); + } else if (data is List) { + _ingest(data, map); + } else if (!response.containsKey('status') && + !response.containsKey('code')) { + _ingest(response, map); + } + + for (final key in map.keys.toList()) { + final kpi = map[key]; + if (kpi is Map) { + map[key] = normalizeKpiShape(Map.from(kpi)); + } + } + return map; + } + + static bool _isMetadataKey(String key) { + final n = key.toLowerCase(); + return n == 'policy_id' || + n == 'client_policy' || + n == 'client_policy_id' || + n == 'policyid'; + } + + static void _ingestKpiMap( + Map data, + Map map, + ) { + for (final entry in data.entries) { + final key = entry.key.toString(); + if (_isMetadataKey(key)) continue; + final value = entry.value; + if (value is! Map) continue; + + final kpi = normalizeKpiShape(Map.from(value)); + map[key] = kpi; + + final slug = _slugOf(kpi) ?? key; + map[slug] = kpi; + + final id = kpi['id']?.toString(); + if (id != null) map[id] = kpi; + } + } + + static Map normalizeKpiShape(Map kpi) { + var normalized = Map.from(kpi); + for (final key in ['result', 'response', 'payload']) { + final nested = normalized[key]; + if (nested is Map && + (nested.containsKey('rows') || + nested.containsKey('cols') || + nested.containsKey('value'))) { + normalized = { + ...normalized, + ...Map.from(nested), + }; + break; + } + } + + normalized = _normalizeMatrixContainer(normalized); + final data = normalized['data']; + if (data is Map) { + normalized['data'] = + _normalizeMatrixContainer(Map.from(data)); + } + return normalized; + } + + static Map _normalizeMatrixContainer( + Map container, + ) { + final normalized = Map.from(container); + final rows = normalized['rows']; + if (rows is! List || rows.isEmpty || rows.first is! List) { + return normalized; + } + + final cols = normalized['cols'] ?? + (normalized['data'] is Map + ? (normalized['data'] as Map)['cols'] + : null); + if (cols is List) { + normalized['rows'] = matrixRowsToMaps(cols, rows); + } + return normalized; + } + + static List> matrixRowsToMaps( + List cols, + List rows, + ) { + final names = cols.map((col) { + if (col is Map) { + return (col['name'] ?? col['display_name'] ?? col['field_ref']) + ?.toString(); + } + return col.toString(); + }).toList(); + + return rows.map((row) { + if (row is! List) return {}; + final map = {}; + for (var i = 0; i < row.length && i < names.length; i++) { + final name = names[i]; + if (name != null && name.isNotEmpty) { + map[name] = row[i]; + } + } + return map; + }).toList(); + } + + static void ensureKpiField( + Map kpiBySlug, + String slug, + String field, + dynamic value, + ) { + if (value == null || value.toString().trim().isEmpty) return; + + final existing = kpiBySlug[slug]; + if (existing != null) { + final current = kpiOnlyField(existing, field); + if (current != '—') return; + } + + if (existing is Map) { + final existingMap = Map.from(existing); + final rows = extractRows(existingMap); + if (rows.isNotEmpty && rows.first is Map) { + final first = Map.from(rows.first as Map); + first[field] = value; + existingMap['rows'] = [first, ...rows.skip(1)]; + kpiBySlug[slug] = existingMap; + return; + } + } + + kpiBySlug[slug] = { + 'slug': slug, + 'rows': [ + {field: value}, + ], + }; + } + + static void _ingest(dynamic node, Map map) { + if (node == null) return; + + if (node is List) { + for (final item in node) { + if (item is! Map) continue; + final slug = _slugOf(item); + if (slug != null) { + map[slug] = item; + final id = item['id']?.toString(); + if (id != null) map[id] = item; + } + } + return; + } + + if (node is Map) { + final selfSlug = _slugOf(node); + if (selfSlug != null && + (node.containsKey('rows') || + node.containsKey('value') || + node.containsKey('data'))) { + map[selfSlug] = node; + final id = node['id']?.toString(); + if (id != null) map[id] = node; + return; + } + + for (final entry in node.entries) { + final key = entry.key.toString(); + final value = entry.value; + + if (value is Map && + (value.containsKey('rows') || + value.containsKey('value') || + value.containsKey('data') || + value.containsKey('result'))) { + map[key] = value; + final slug = value['slug']?.toString(); + if (slug != null) map[slug] = value; + final id = value['id']?.toString(); + if (id != null) map[id] = value; + continue; + } + + if (value is Map) { + _ingest(value, map); + } else if (value is List) { + _ingest(value, map); + } + } + } + } + + static String? _slugOf(Map item) { + return (item['slug'] ?? + item['method_slug'] ?? + item['methodSlug'] ?? + item['kpi_slug']) + ?.toString(); + } + + static String _normalizeKey(String key) => + key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + + static dynamic rowValue(Map row, String field) { + final target = _normalizeKey(field); + for (final entry in row.entries) { + if (_normalizeKey(entry.key.toString()) == target) { + return entry.value; + } + } + return null; + } + + static Map? _asRowMap(dynamic row) { + if (row is Map) return row; + return null; + } + + static String rowField( + dynamic kpi, + String field, { + String fallback = '—', + }) { + if (kpi == null) return fallback; + if (kpi is Map) { + final direct = rowValue(kpi, field); + if (direct != null) return cleanDisplay(direct); + if (extractRows(kpi).isEmpty && kpi['value'] != null) { + return formatDisplay(kpi['value']); + } + } + final rows = extractRows(kpi); + if (rows.isEmpty) return fallback; + final first = _asRowMap(rows.first); + if (first == null) return fallback; + final value = rowValue(first, field); + if (value == null) return fallback; + return cleanDisplay(value); + } + + /// Read a scalar from one KPI block only — no cross-KPI fallback. + static String kpiOnlyField(dynamic kpi, String field) { + if (kpi == null) return '—'; + if (kpi is Map) { + final direct = rowValue(kpi, field); + if (direct != null) return cleanDisplay(direct); + if (kpi['value'] != null && _normalizeKey(field) == 'value') { + return formatDisplay(kpi['value']); + } + } + final rows = extractRows(kpi); + if (rows.isEmpty) return '—'; + final first = _asRowMap(rows.first); + if (first == null) return '—'; + final value = rowValue(first, field); + if (value == null) return '—'; + return cleanDisplay(value); + } + + static double? rowDouble(dynamic kpi, String field) { + final rows = extractRows(kpi); + if (rows.isEmpty) return null; + final first = rows.first; + if (first is! Map) return null; + return toDouble(rowValue(first, field)); + } + + static String cleanDisplay(dynamic value) { + return value + .toString() + .replaceAll(RegExp(r'[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]', unicode: true), '') + .trim(); + } + + static String formatPercent(String value, {String fallback = '—'}) { + if (value.isEmpty || value == fallback) return fallback; + final text = cleanDisplay(value); + if (text.isEmpty) return fallback; + return text.contains('%') ? text : '$text%'; + } + + static String formatDisplay(dynamic value) { + if (value == null) return '—'; + if (value is num) { + if (value == value.roundToDouble()) { + return NumberFormat('#,##0').format(value); + } + return NumberFormat('#,##0.##').format(value); + } + final text = cleanDisplay(value); + if (text.isEmpty) return '—'; + final n = toDouble(text); + if (n != null && !text.contains('%') && !text.contains(',')) { + return formatDisplay(n); + } + return text; + } + + static List extractRows(dynamic kpi) { + if (kpi == null) return const []; + if (kpi is List) { + if (kpi.isEmpty) return const []; + if (kpi.first is Map) return kpi; + if (kpi.first is List) return _matrixRowsFromLists(kpi, null); + return const []; + } + if (kpi is! Map) return const []; + + for (final key in ['rows', 'data', 'result', 'values']) { + final candidate = kpi[key]; + if (candidate is List) { + if (candidate.isEmpty) continue; + if (candidate.first is Map) return candidate; + if (candidate.first is List) { + final cols = kpi['cols'] ?? + (kpi['data'] is Map ? (kpi['data'] as Map)['cols'] : null); + return _matrixRowsFromLists(candidate, cols is List ? cols : null); + } + } + if (candidate is Map) { + final nested = extractRows(candidate); + if (nested.isNotEmpty) return nested; + } + } + return const []; + } + + static List _matrixRowsFromLists(List rows, List? cols) { + if (cols == null) return const []; + return matrixRowsToMaps(cols, rows); + } + + static ClaimsChartSeries labelValueSeries( + dynamic kpi, { + String? labelKey, + String? valueKey, + bool valuesInMillions = false, + }) { + final rows = extractRows(kpi); + if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); + + final labels = []; + final values = []; + + for (final row in rows) { + if (row is! Map) continue; + final label = labelKey != null + ? rowValue(row, labelKey)?.toString() + : _firstLabel(row); + final rawValue = valueKey != null + ? rowValue(row, valueKey) + : _firstNumeric(row); + final value = toDouble(rawValue); + if (label == null || value == null) continue; + labels.add(label); + values.add(valuesInMillions ? value / 1000000 : value); + } + + double? maxY; + if (values.isNotEmpty) { + final max = values.reduce((a, b) => a > b ? a : b); + maxY = _niceMaxY(max); + } + + return ClaimsChartSeries(labels: labels, values: values, maxY: maxY); + } + + /// Sorted monthly claim values (`month_sort`, `claim_month`, `claim_value`). + static ClaimsChartSeries monthValueSeries( + dynamic kpi, { + bool valuesInMillions = false, + }) { + final rows = extractRows(kpi); + if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); + + final items = <({String sort, String label, double value})>[]; + for (final row in rows) { + if (row is! Map) continue; + final sort = rowValue(row, 'month_sort')?.toString() ?? ''; + final label = rowValue(row, 'claim_month')?.toString(); + final value = toDouble(rowValue(row, 'claim_value')); + if (label == null || label.isEmpty || value == null) continue; + items.add((sort: sort, label: label, value: value)); + } + if (items.isEmpty) return const ClaimsChartSeries(labels: [], values: []); + + items.sort((a, b) => a.sort.compareTo(b.sort)); + final labels = items.map((e) => e.label).toList(); + final values = items + .map((e) => valuesInMillions ? e.value / 1000000 : e.value) + .toList(); + final max = values.reduce(math.max); + return ClaimsChartSeries( + labels: labels, + values: values, + maxY: _niceMaxY(max), + ); + } + + static ClaimsChartSeries labelValueSeriesFromPairs( + List<(String, double)> pairs, + ) { + final labels = pairs.map((p) => p.$1).toList(); + final values = pairs.map((p) => p.$2).toList(); + double? maxY; + if (values.isNotEmpty) { + maxY = _niceMaxY(values.reduce(math.max)); + } + return ClaimsChartSeries(labels: labels, values: values, maxY: maxY); + } + + /// First text-like column + first numeric column (generic KPI rows). + static (String, dynamic)? rowLabelAndValue(Map row) { + final label = _firstLabel(row); + final value = _firstNumeric(row); + if (label == null || value == null) return null; + return (label, value); + } + + static String? _firstLabel(Map row) { + for (final entry in row.entries) { + if (!_isNumericKey(entry.key.toString())) { + return entry.value?.toString(); + } + } + return null; + } + + static dynamic _firstNumeric(Map row) { + for (final entry in row.entries) { + if (_isNumericKey(entry.key.toString())) { + return entry.value; + } + } + return null; + } + + static bool _isNumericKey(String key) { + final n = _normalizeKey(key); + if (n.contains('name') || n.contains('status') || n.contains('gender')) { + return false; + } + return RegExp( + r'amount|count|value|total|incurred|pct|percent|claims|amt|premium|ratio|rate|days', + caseSensitive: false, + ).hasMatch(key); + } + + static double? toDouble(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned); + } + + static double sumRowField(dynamic kpi, String field) { + final rows = extractRows(kpi); + var sum = 0.0; + for (final row in rows) { + if (row is! Map) continue; + sum += toDouble(rowValue(row, field)) ?? 0; + } + return sum; + } + + static double _niceMaxY(double max) { + if (max <= 0) return 10; + final exp = (math.log(max) / math.ln10).floor(); + final magnitude = math.pow(10, exp).toDouble(); + final normalized = (max / magnitude).ceil() * magnitude; + return normalized * 1.1; + } +} diff --git a/lib/presentation/claims_overview/claims_overview_animations.dart b/lib/presentation/claims_overview/claims_overview_animations.dart new file mode 100644 index 0000000..901796b --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_animations.dart @@ -0,0 +1,530 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'claims_overview_scope.dart'; +import 'claims_overview_theme.dart'; + +/// Thin indeterminate bar shown while dashboard data reloads. +class ClaimsTopLoadingBar extends StatelessWidget { + final bool visible; + + const ClaimsTopLoadingBar({super.key, required this.visible}); + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: visible + ? const LinearProgressIndicator( + minHeight: 3, + backgroundColor: Color(0xFFE2E8F0), + color: ClaimsOverviewTheme.primary, + ) + : const SizedBox.shrink(), + ); + } +} + +/// Pulsing green dot + "Live" label for real-time analytics feel. +class ClaimsLiveIndicator extends StatefulWidget { + const ClaimsLiveIndicator({super.key}); + + @override + State createState() => _ClaimsLiveIndicatorState(); +} + +class _ClaimsLiveIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _controller, + builder: (_, __) { + final scale = 0.85 + (_controller.value * 0.3); + final opacity = 0.45 + (_controller.value * 0.55); + return Container( + width: 8 * scale, + height: 8 * scale, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ClaimsOverviewTheme.green.withValues(alpha: opacity), + boxShadow: [ + BoxShadow( + color: ClaimsOverviewTheme.green.withValues(alpha: 0.35), + blurRadius: 6 * scale, + spreadRadius: 1, + ), + ], + ), + ); + }, + ), + const SizedBox(width: 6), + Text( + 'Live', + style: GoogleFonts.poppins( + fontSize: 11, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.green, + ), + ), + ], + ); + } +} + +/// Fade + slide entrance; replays when [replayToken] changes. +class ClaimsStaggeredEntrance extends StatefulWidget { + final Widget child; + final int index; + final int replayToken; + final Duration baseDelay; + const ClaimsStaggeredEntrance({ + super.key, + required this.child, + this.index = 0, + this.replayToken = 0, + this.baseDelay = const Duration(milliseconds: 55), + }); + + @override + State createState() => _ClaimsStaggeredEntranceState(); +} + +class _ClaimsStaggeredEntranceState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fade; + late Animation _slide; + + bool _animationStarted = false; + + @override + void initState() { + super.initState(); + _initController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (ClaimsPdfExportScope.of(context)) { + _controller.value = 1.0; + return; + } + if (!_animationStarted) { + _animationStarted = true; + _runAnimation(); + } + } + + @override + void didUpdateWidget(ClaimsStaggeredEntrance oldWidget) { + super.didUpdateWidget(oldWidget); + if (ClaimsPdfExportScope.of(context)) { + _controller.value = 1.0; + return; + } + if (oldWidget.replayToken != widget.replayToken) { + _controller.reset(); + _animationStarted = true; + _runAnimation(); + } + } + + void _initController() { + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 520), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _slide = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(_fade); + } + + Future _runAnimation() async { + if (!mounted || ClaimsPdfExportScope.of(context)) { + if (mounted) _controller.value = 1.0; + return; + } + await Future.delayed(widget.baseDelay * widget.index); + if (!mounted) return; + await _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (ClaimsPdfExportScope.of(context)) return widget.child; + return FadeTransition( + opacity: _fade, + child: SlideTransition(position: _slide, child: widget.child), + ); + } +} + +/// Wraps tab body: skeleton while loading, then staggered content. +class ClaimsTabAnimatedShell extends StatelessWidget { + final bool isLoading; + final Widget child; + final int skeletonBlocks; + + const ClaimsTabAnimatedShell({ + super.key, + required this.isLoading, + required this.child, + this.skeletonBlocks = 4, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return ClaimsDashboardSkeleton(blockCount: skeletonBlocks); + } + return child; + } +} + +/// Shimmer placeholder grid while dummy data "loads". +class ClaimsDashboardSkeleton extends StatefulWidget { + final int blockCount; + + const ClaimsDashboardSkeleton({super.key, this.blockCount = 4}); + + @override + State createState() => _ClaimsDashboardSkeletonState(); +} + +class _ClaimsDashboardSkeletonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _shimmer; + + @override + void initState() { + super.initState(); + _shimmer = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _shimmer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: List.generate(widget.blockCount, (i) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _ShimmerBlock(animation: _shimmer, height: i == 0 ? 72 : 120), + ); + }), + ), + ); + } +} + +class _ShimmerBlock extends StatelessWidget { + final Animation animation; + final double height; + + const _ShimmerBlock({required this.animation, required this.height}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (_, __) { + return Container( + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment(-1 + animation.value * 2, 0), + end: Alignment(animation.value * 2, 0), + colors: const [ + Color(0xFFE8ECF0), + Color(0xFFF8FAFC), + Color(0xFFE8ECF0), + ], + ), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + ); + }, + ); + } +} + +/// Counts up numeric portions of [value] (e.g. "29,293,880" or "51.16%"). +class ClaimsAnimatedValueText extends StatelessWidget { + final String value; + final TextStyle style; + final int replayToken; + final Duration duration; + + const ClaimsAnimatedValueText({ + super.key, + required this.value, + required this.style, + required this.replayToken, + this.duration = const Duration(milliseconds: 1100), + }); + + static double? _parseNumeric(String raw) { + final cleaned = raw.replaceAll(',', '').replaceAll('%', '').trim(); + return double.tryParse(cleaned); + } + + static String _formatLikeOriginal(double n, String original) { + final hasPercent = original.contains('%'); + final hasComma = original.contains(','); + final decimals = original.contains('.') + ? original.split('.').last.replaceAll(RegExp(r'[^0-9]'), '').length + : 0; + + String formatted; + if (decimals > 0) { + formatted = n.toStringAsFixed(decimals); + } else { + formatted = n.round().toString(); + } + + if (hasComma) { + final parts = formatted.split('.'); + final intPart = parts[0]; + final buf = StringBuffer(); + for (var i = 0; i < intPart.length; i++) { + if (i > 0 && (intPart.length - i) % 3 == 0) buf.write(','); + buf.write(intPart[i]); + } + formatted = parts.length > 1 ? '${buf}.${parts[1]}' : buf.toString(); + } + + return hasPercent ? '$formatted%' : formatted; + } + + @override + Widget build(BuildContext context) { + final target = _parseNumeric(value); + if (target == null) { + return Text(value, style: style); + } + + return TweenAnimationBuilder( + key: ValueKey('$replayToken-$value'), + tween: Tween(begin: 0, end: target), + duration: duration, + curve: Curves.easeOutCubic, + builder: (_, v, __) { + return Text( + _formatLikeOriginal(v, value), + style: style, + ); + }, + ); + } +} + +/// Progress bar that animates from 0 → [value]. +class ClaimsAnimatedProgressBar extends StatelessWidget { + final double value; + final Color color; + final double minHeight; + final int replayToken; + + const ClaimsAnimatedProgressBar({ + super.key, + required this.value, + required this.color, + this.minHeight = 8, + required this.replayToken, + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + key: ValueKey('$replayToken-$value'), + tween: Tween(begin: 0, end: value.clamp(0.0, 1.0)), + duration: const Duration(milliseconds: 900), + curve: Curves.easeOutCubic, + builder: (_, v, __) { + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: v, + minHeight: minHeight, + backgroundColor: ClaimsOverviewTheme.border, + color: color, + ), + ); + }, + ); + } +} + +/// Center loading indicator for full tab refresh. +class ClaimsTabLoadingIndicator extends StatelessWidget { + final String? message; + + const ClaimsTabLoadingIndicator({super.key, this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: ClaimsOverviewTheme.primary, + ), + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: GoogleFonts.poppins( + fontSize: 13, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + ], + ], + ), + ); + } +} + +/// Rotating refresh icon while [isRefreshing]. +class ClaimsRefreshIcon extends StatelessWidget { + final bool isRefreshing; + final VoidCallback onPressed; + + const ClaimsRefreshIcon({ + super.key, + required this.isRefreshing, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: isRefreshing ? null : onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + icon: isRefreshing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: ClaimsOverviewTheme.primary, + ), + ) + : _SpinOnHover( + child: Icon( + Icons.refresh, + size: 18, + color: ClaimsOverviewTheme.textSecondary.withValues(alpha: 0.9), + ), + ), + ); + } +} + +class _SpinOnHover extends StatefulWidget { + final Widget child; + + const _SpinOnHover({required this.child}); + + @override + State<_SpinOnHover> createState() => _SpinOnHoverState(); +} + +class _SpinOnHoverState extends State<_SpinOnHover> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: AnimatedRotation( + turns: _hovering ? 0.25 : 0, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOut, + child: widget.child, + ), + ); + } +} + +/// Chart fade-in wrapper keyed to [replayToken]. +class ClaimsChartEntrance extends StatelessWidget { + final int replayToken; + final int index; + final Widget child; + + const ClaimsChartEntrance({ + super.key, + required this.replayToken, + this.index = 0, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return ClaimsStaggeredEntrance( + replayToken: replayToken, + index: index, + baseDelay: const Duration(milliseconds: 80), + child: child, + ); + } +} + +int claimsStaggerIndex({required int row, required int col, int cols = 4}) { + return row * cols + col; +} + +double claimsClampFraction(double v) => math.min(1.0, math.max(0.0, v)); diff --git a/lib/presentation/claims_overview/claims_overview_charts.dart b/lib/presentation/claims_overview/claims_overview_charts.dart new file mode 100644 index 0000000..751c7e1 --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_charts.dart @@ -0,0 +1,2232 @@ +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart' show NumberFormat; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'claims_overview_scope.dart'; +import 'claims_overview_theme.dart'; + +const _chartAnimDuration = Duration(milliseconds: 900); +const _chartRiseDuration = Duration(milliseconds: 1200); + +Duration _chartRiseDurationFor(BuildContext context) => + ClaimsPdfExportScope.of(context) ? Duration.zero : _chartRiseDuration; + +Duration _chartAnimDurationFor(BuildContext context) => + ClaimsPdfExportScope.of(context) ? Duration.zero : _chartAnimDuration; +const _chartAnimCurve = Curves.easeOutCubic; + +const _chartAxisColor = Color(0xFF9CA3AF); +const _axisLabelGap = 6.0; +const _axisTickLength = 8.0; +const _verticalLeftAxisWidth = 44.0; + +/// Five Y-axis ticks from 0 → [maxY] (for dynamic-scale vertical charts). +List _yTicksForMaxY(double maxY) { + if (maxY <= 0) return const [0.0, 5.0, 10.0, 15.0, 20.0]; + return List.generate(5, (i) => i == 4 ? maxY : (maxY / 4) * i); +} + +String _formatMillionsAxisTick(double v) { + if (v == 0) return '0'; + if (v >= 10) return '${v.round()}M'; + return '${v.toStringAsFixed(1)}M'; +} + +/// Colors aligned with the Nhance reference design (top → bottom). +final incurredByStatusColors = [ + ClaimsOverviewTheme.teal, + const Color(0xFFFFB74D), + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.red, + ClaimsOverviewTheme.cyan, +]; + +/// Horizontal incurred-by-status chart with hover tooltip and track highlight. +class ClaimsHorizontalIncurredBarChart extends StatefulWidget { + final List labels; + final List valuesMillions; + final int animationKey; + final double maxMillions; + + ClaimsHorizontalIncurredBarChart({ + super.key, + required this.labels, + required this.valuesMillions, + required this.animationKey, + this.maxMillions = 18, + }); + + @override + State createState() => + _ClaimsHorizontalIncurredBarChartState(); +} + +class _ClaimsHorizontalIncurredBarChartState + extends State { + static const Color _hoverTrackColor = Color(0xFFE9EDF2); + static const double _labelColumnWidth = 112; + static const double _bottomAxisHeight = 36; + static const double _xAxisRightInset = 32; + static const double _barThickness = 26; + + int _hoveredIndex = -1; + Offset? _tooltipAnchor; + + double get _maxAmount => widget.maxMillions * 1000000; + + List get _xTicks { + final max = _maxAmount; + if (max <= 0) { + return const [0, 4500000, 9000000, 13500000, 18000000]; + } + final step = max / 4; + return [0, step, step * 2, step * 3, max]; + } + + double _toAmount(double millions) => millions * 1000000; + + void _onPlotHover(Offset local, Size plotSize, int rowCount, List barValues) { + if (rowCount == 0) return; + final rowH = plotSize.height / rowCount; + final index = (local.dy / rowH).floor().clamp(0, rowCount - 1); + if (index == _hoveredIndex) return; + + final barW = _maxAmount > 0 + ? (barValues[index] / _maxAmount) * plotSize.width + : 0.0; + setState(() { + _hoveredIndex = index; + _tooltipAnchor = Offset( + barW.clamp(48.0, plotSize.width - 120), + (index + 0.5) * rowH, + ); + }); + } + + @override + Widget build(BuildContext context) { + final barValues = widget.valuesMillions.map(_toAmount).toList(); + final rowCount = widget.labels.length; + + return LayoutBuilder( + builder: (context, constraints) { + return TweenAnimationBuilder( + key: ValueKey(widget.animationKey), + tween: Tween(begin: 0, end: 1), + duration: _chartRiseDurationFor(context), + curve: Curves.easeOutCubic, + builder: (context, animProgress, _) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: _labelColumnWidth, + child: Column( + children: [ + Expanded( + child: Column( + children: List.generate(rowCount, (i) { + return Expanded( + child: Align( + alignment: Alignment.centerRight, + child: _IncurredStatusLabel( + label: widget.labels[i], + axisColor: _chartAxisColor, + ), + ), + ); + }), + ), + ), + const SizedBox(height: _bottomAxisHeight), + ], + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, plotConstraints) { + final chartWidth = + plotConstraints.maxWidth - _xAxisRightInset; + final plotHeight = + plotConstraints.maxHeight - _bottomAxisHeight; + final plotSize = Size(chartWidth, plotHeight); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: plotHeight, + child: Padding( + padding: const EdgeInsets.only( + right: _xAxisRightInset, + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + MouseRegion( + onHover: (e) => _onPlotHover( + e.localPosition, + plotSize, + rowCount, + barValues, + ), + onExit: (_) => setState(() { + _hoveredIndex = -1; + _tooltipAnchor = null; + }), + cursor: SystemMouseCursors.click, + child: CustomPaint( + size: plotSize, + painter: + _IncurredHorizontalPlotPainter( + values: barValues, + maxValue: _maxAmount, + colors: incurredByStatusColors, + animProgress: animProgress, + hoveredIndex: _hoveredIndex, + barThickness: _barThickness, + hoverTrackColor: _hoverTrackColor, + axisColor: _chartAxisColor, + gridColor: + ClaimsOverviewTheme.border, + ), + ), + ), + if (_hoveredIndex >= 0 && + _tooltipAnchor != null) + Positioned( + left: (_tooltipAnchor!.dx + 8) + .clamp(8.0, plotSize.width - 160), + top: (_tooltipAnchor!.dy - 52).clamp( + 4.0, + plotSize.height - 72, + ), + child: IgnorePointer( + child: _IncurredTooltip( + title: widget.labels[_hoveredIndex], + value: barValues[_hoveredIndex] + .round(), + ), + ), + ), + ], + ), + ), + ), + _IncurredBottomAxis( + ticks: _xTicks, + maxValue: _maxAmount, + axisColor: _chartAxisColor, + rightInset: _xAxisRightInset, + ), + ], + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } +} + +/// Status label with tick mark pointing at the Y-axis line. +class _IncurredStatusLabel extends StatelessWidget { + final String label; + final Color axisColor; + + const _IncurredStatusLabel({ + required this.label, + required this.axisColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + label, + style: GoogleFonts.poppins( + fontSize: 10, + color: ClaimsOverviewTheme.textPrimary, + height: 1.2, + ), + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: _axisLabelGap), + Container(width: _axisTickLength, height: 1, color: axisColor), + ], + ); + } +} + +class _IncurredBottomAxis extends StatelessWidget { + final List ticks; + final double maxValue; + final Color axisColor; + final double rightInset; + + const _IncurredBottomAxis({ + required this.ticks, + required this.maxValue, + required this.axisColor, + required this.rightInset, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _ClaimsHorizontalIncurredBarChartState._bottomAxisHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final chartW = constraints.maxWidth - rightInset; + return Column( + children: [ + Padding( + padding: EdgeInsets.only(right: rightInset), + child: Container(height: 1, color: axisColor), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(right: rightInset), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (var i = 0; i < ticks.length; i++) + _tickLabel( + ticks[i], + chartW, + isFirst: i == 0, + isLast: i == ticks.length - 1, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _tickLabel( + double t, + double chartW, { + required bool isFirst, + required bool isLast, + }) { + final label = Text( + t.toInt().toString(), + style: GoogleFonts.poppins( + fontSize: 9, + color: ClaimsOverviewTheme.textSecondary, + ), + ); + final tick = Container(width: 1, height: 6, color: axisColor); + final column = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: isLast + ? CrossAxisAlignment.end + : isFirst + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + tick, + const SizedBox(height: 4), + label, + ], + ); + + if (isFirst) { + return Positioned(left: 0, top: 0, child: column); + } + if (isLast) { + return Positioned(right: 0, top: 0, child: column); + } + + final x = (t / maxValue) * chartW; + return Positioned( + left: x, + top: 0, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: column, + ), + ); + } +} + +class _IncurredTooltip extends StatelessWidget { + final String title; + final int value; + + const _IncurredTooltip({required this.title, required this.value}); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 4, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + Text( + 'value : $value', + style: GoogleFonts.poppins( + fontSize: 12, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } +} + +class _IncurredHorizontalPlotPainter extends CustomPainter { + final List values; + final double maxValue; + final List colors; + final double animProgress; + final int hoveredIndex; + final double barThickness; + final Color hoverTrackColor; + final Color axisColor; + final Color gridColor; + + _IncurredHorizontalPlotPainter({ + required this.values, + required this.maxValue, + required this.colors, + required this.animProgress, + required this.hoveredIndex, + required this.barThickness, + required this.hoverTrackColor, + required this.axisColor, + required this.gridColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final n = values.length; + if (n == 0) return; + + final rowH = size.height / n; + final axisPaint = Paint() + ..color = axisColor + ..strokeWidth = 1; + + // Dashed grid — vertical (value steps). + final xSteps = [0.0, 0.25, 0.5, 0.75, 1.0]; + for (final f in xSteps) { + final x = size.width * f; + _drawDashedLine( + canvas, + Offset(x, 0), + Offset(x, size.height), + gridColor, + ); + } + + // Dashed grid — horizontal (between rows). + for (var i = 0; i <= n; i++) { + final y = i * rowH; + _drawDashedLine( + canvas, + Offset(0, y), + Offset(size.width, y), + gridColor, + ); + } + + // Solid L-shaped axes meeting at bottom-left origin. + canvas.drawLine(Offset.zero, Offset(0, size.height), axisPaint); + canvas.drawLine( + Offset(0, size.height), + Offset(size.width, size.height), + axisPaint, + ); + + for (var i = 0; i < n; i++) { + final rowTop = i * rowH; + final centerY = rowTop + rowH / 2; + final barTop = centerY - barThickness / 2; + final barBottom = centerY + barThickness / 2; + + if (i == hoveredIndex) { + final track = RRect.fromRectAndRadius( + Rect.fromLTWH(0, rowTop, size.width, rowH), + const Radius.circular(2), + ); + canvas.drawRRect(track, Paint()..color = hoverTrackColor); + } + + final barW = (values[i] / maxValue) * size.width * animProgress; + if (barW <= 0) continue; + + final barRect = RRect.fromRectAndCorners( + Rect.fromLTRB(0, barTop, barW, barBottom), + topRight: const Radius.circular(8), + bottomRight: const Radius.circular(8), + ); + canvas.drawRRect( + barRect, + Paint()..color = colors[i % colors.length], + ); + } + } + + @override + bool shouldRepaint(covariant _IncurredHorizontalPlotPainter old) { + return old.animProgress != animProgress || + old.hoveredIndex != hoveredIndex || + old.values != values; + } +} + +void _drawDashedLine(Canvas canvas, Offset a, Offset b, Color color) { + const dash = 4.0; + const gap = 4.0; + final paint = Paint() + ..color = color + ..strokeWidth = 1; + final total = (b - a).distance; + if (total == 0) return; + final dir = (b - a) / total; + var dist = 0.0; + while (dist < total) { + final end = dist + dash; + canvas.drawLine( + a + dir * dist, + a + dir * end.clamp(0.0, total), + paint, + ); + dist += dash + gap; + } +} + +/// Shared vertical bar chart: aligned axes, labels inside card, rise animation. +class ClaimsCustomVerticalBarChart extends StatefulWidget { + final List labels; + final List values; + final double maxY; + final List colors; + final int animationKey; + final double barWidth; + final double barSlotFraction; + final List yTicks; + final double leftAxisWidth; + final double bottomAxisHeight; + final double plotRightInset; + final String Function(double value)? formatYTick; + final String tooltipValueLabel; + + const ClaimsCustomVerticalBarChart({ + super.key, + required this.labels, + required this.values, + required this.maxY, + required this.colors, + required this.animationKey, + required this.yTicks, + this.barWidth = 56, + this.barSlotFraction = 0.72, + this.leftAxisWidth = _verticalLeftAxisWidth, + this.bottomAxisHeight = 52, + this.plotRightInset = 0, + this.formatYTick, + this.tooltipValueLabel = 'count', + }); + + @override + State createState() => + _ClaimsCustomVerticalBarChartState(); +} + +class _ClaimsCustomVerticalBarChartState + extends State { + static const Color _hoverTrackColor = Color(0xFFE9EDF2); + + int _hoveredIndex = -1; + Offset? _tooltipAnchor; + + String _formatTick(double v) => + widget.formatYTick?.call(v) ?? v.toInt().toString(); + + String _formatTooltipValue(double v) { + if (widget.formatYTick != null) { + return widget.formatYTick!(v); + } + return v.round().toString(); + } + + void _onPlotHover(Offset local, Size plotSize, int count) { + if (count == 0) return; + final chartW = plotSize.width - widget.plotRightInset; + final slotW = chartW / count; + final index = (local.dx / slotW).floor().clamp(0, count - 1); + if (index == _hoveredIndex) return; + + final value = widget.values[index]; + final barTop = widget.maxY > 0 + ? plotSize.height * (1 - value / widget.maxY) + : plotSize.height; + setState(() { + _hoveredIndex = index; + _tooltipAnchor = Offset((index + 0.5) * slotW, barTop); + }); + } + + Widget _buildChartBody(double animProgress) { + final count = widget.labels.length; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _VerticalLeftAxis( + yTicks: widget.yTicks, + maxY: widget.maxY, + axisWidth: widget.leftAxisWidth, + bottomSpacer: widget.bottomAxisHeight, + axisColor: _chartAxisColor, + formatTick: _formatTick, + ), + Expanded( + child: LayoutBuilder( + builder: (context, plotConstraints) { + final plotHeight = + plotConstraints.maxHeight - widget.bottomAxisHeight; + final plotSize = Size( + plotConstraints.maxWidth, + plotHeight, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: plotHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + MouseRegion( + onHover: (e) => _onPlotHover( + e.localPosition, + plotSize, + count, + ), + onExit: (_) => setState(() { + _hoveredIndex = -1; + _tooltipAnchor = null; + }), + cursor: SystemMouseCursors.click, + child: CustomPaint( + size: plotSize, + painter: _VerticalBarPlotPainter( + values: widget.values, + maxY: widget.maxY, + yTicks: widget.yTicks, + colors: widget.colors, + animProgress: animProgress, + hoveredIndex: _hoveredIndex, + barWidth: widget.barWidth, + barSlotFraction: widget.barSlotFraction, + plotRightInset: widget.plotRightInset, + hoverTrackColor: _hoverTrackColor, + axisColor: _chartAxisColor, + gridColor: ClaimsOverviewTheme.border, + ), + ), + ), + if (_hoveredIndex >= 0 && + _tooltipAnchor != null) + Positioned( + left: (_tooltipAnchor!.dx - 70) + .clamp(4.0, plotSize.width - 140), + top: (_tooltipAnchor!.dy - 56) + .clamp(4.0, plotSize.height - 72), + child: IgnorePointer( + child: _VerticalBarTooltip( + title: widget.labels[_hoveredIndex], + valueDisplay: _formatTooltipValue( + widget.values[_hoveredIndex], + ), + valueLabel: widget.tooltipValueLabel, + ), + ), + ), + ], + ), + ), + _VerticalCategoryAxis( + labels: widget.labels, + height: widget.bottomAxisHeight, + axisColor: _chartAxisColor, + rightInset: widget.plotRightInset, + ), + ], + ); + }, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (ClaimsPdfExportScope.of(context)) { + return LayoutBuilder( + builder: (context, constraints) => _buildChartBody(1.0), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + return TweenAnimationBuilder( + key: ValueKey(widget.animationKey), + tween: Tween(begin: 0, end: 1), + duration: _chartRiseDurationFor(context), + curve: Curves.easeOutCubic, + builder: (context, animProgress, _) => _buildChartBody(animProgress), + ); + }, + ); + } +} + +/// Y-axis value with gap + tick (matches horizontal status labels). +class _VerticalYTickLabel extends StatelessWidget { + final String text; + final Color axisColor; + + const _VerticalYTickLabel({ + required this.text, + required this.axisColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + text, + style: GoogleFonts.poppins( + fontSize: 10, + color: ClaimsOverviewTheme.textSecondary, + height: 1.2, + ), + textAlign: TextAlign.right, + ), + const SizedBox(width: _axisLabelGap), + Container(width: _axisTickLength, height: 1, color: axisColor), + ], + ); + } +} + +class _VerticalLeftAxis extends StatelessWidget { + final List yTicks; + final double maxY; + final double axisWidth; + final double bottomSpacer; + final Color axisColor; + final String Function(double) formatTick; + + const _VerticalLeftAxis({ + required this.yTicks, + required this.maxY, + required this.axisWidth, + required this.bottomSpacer, + required this.axisColor, + required this.formatTick, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: axisWidth, + child: Column( + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight; + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (final t in yTicks) + Positioned( + right: 0, + bottom: (t / maxY) * h, + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: _VerticalYTickLabel( + text: formatTick(t), + axisColor: axisColor, + ), + ), + ), + ], + ); + }, + ), + ), + SizedBox(height: bottomSpacer), + ], + ), + ); + } +} + +class _VerticalCategoryAxis extends StatelessWidget { + final List labels; + final double height; + final Color axisColor; + final double rightInset; + + const _VerticalCategoryAxis({ + required this.labels, + required this.height, + required this.axisColor, + this.rightInset = 0, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.only(right: rightInset), + child: Container(height: 1, color: axisColor), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(right: rightInset), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: labels.map((label) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 1, height: 6, color: axisColor), + const SizedBox(height: 4), + Text( + label, + style: GoogleFonts.poppins( + fontSize: 10, + color: ClaimsOverviewTheme.textPrimary, + height: 1.2, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } +} + +class _VerticalBarTooltip extends StatelessWidget { + final String title; + final String valueDisplay; + final String valueLabel; + + const _VerticalBarTooltip({ + required this.title, + required this.valueDisplay, + this.valueLabel = 'count', + }); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 4, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + Text( + '$valueLabel : $valueDisplay', + style: GoogleFonts.poppins( + fontSize: 12, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } +} + +class _VerticalBarPlotPainter extends CustomPainter { + final List values; + final double maxY; + final List yTicks; + final List colors; + final double animProgress; + final int hoveredIndex; + final double barWidth; + final double barSlotFraction; + final double plotRightInset; + final Color hoverTrackColor; + final Color axisColor; + final Color gridColor; + + _VerticalBarPlotPainter({ + required this.values, + required this.maxY, + required this.yTicks, + required this.colors, + required this.animProgress, + required this.hoveredIndex, + required this.barWidth, + required this.barSlotFraction, + required this.plotRightInset, + required this.hoverTrackColor, + required this.axisColor, + required this.gridColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final n = values.length; + if (n == 0) return; + + const chartLeft = 0.0; + final chartW = size.width - plotRightInset; + final slotW = chartW / n; + final axisPaint = Paint() + ..color = axisColor + ..strokeWidth = 1; + + for (final t in yTicks) { + final y = size.height - (t / maxY) * size.height; + _drawDashedLine( + canvas, + Offset(chartLeft, y), + Offset(chartLeft + chartW, y), + gridColor, + ); + } + + for (var i = 0; i <= n; i++) { + final x = chartLeft + i * slotW; + _drawDashedLine( + canvas, + Offset(x, 0), + Offset(x, size.height), + gridColor, + ); + } + + canvas.drawLine( + Offset(chartLeft, 0), + Offset(chartLeft, size.height), + axisPaint, + ); + canvas.drawLine( + Offset(chartLeft, size.height), + Offset(chartLeft + chartW, size.height), + axisPaint, + ); + + final effectiveBarW = barWidth.clamp(8.0, slotW * barSlotFraction); + + for (var i = 0; i < n; i++) { + final centerX = chartLeft + (i + 0.5) * slotW; + final left = centerX - effectiveBarW / 2; + + if (i == hoveredIndex) { + final track = RRect.fromRectAndRadius( + Rect.fromLTWH(left, 0, effectiveBarW, size.height), + const Radius.circular(2), + ); + canvas.drawRRect(track, Paint()..color = hoverTrackColor); + } + + final barH = (values[i] / maxY) * size.height * animProgress; + if (barH <= 0) continue; + + final barTop = size.height - barH; + final barRect = RRect.fromRectAndCorners( + Rect.fromLTRB(left, barTop, left + effectiveBarW, size.height), + topLeft: const Radius.circular(8), + topRight: const Radius.circular(8), + ); + canvas.drawRRect( + barRect, + Paint()..color = colors[i % colors.length], + ); + } + } + + @override + bool shouldRepaint(covariant _VerticalBarPlotPainter old) { + return old.animProgress != animProgress || + old.hoveredIndex != hoveredIndex || + old.values != values; + } +} + +/// Colors for Policy Experience — Total Claims Count by Status. +final claimsCountByStatusColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + const Color(0xFF4169E1), + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ClaimsOverviewTheme.red, + ClaimsOverviewTheme.cyan, +]; + +/// Vertical claims-count chart: wide bars, hover tooltip, slow rise animation. +class ClaimsCountByStatusBarChart extends StatefulWidget { + final List labels; + final List values; + final int animationKey; + final double maxY; + + ClaimsCountByStatusBarChart({ + super.key, + required this.labels, + required this.values, + required this.animationKey, + this.maxY = 220, + }); + + @override + State createState() => + _ClaimsCountByStatusBarChartState(); +} + +class _ClaimsCountByStatusBarChartState extends State { + static const _yTicks = [0.0, 55.0, 110.0, 165.0, 220.0]; + + @override + Widget build(BuildContext context) { + return ClaimsCustomVerticalBarChart( + labels: widget.labels, + values: widget.values, + maxY: widget.maxY, + colors: claimsCountByStatusColors, + animationKey: widget.animationKey, + yTicks: _yTicks, + barWidth: 56, + bottomAxisHeight: 52, + ); + } +} + +/// Colors for Claims Analysis — Total Counts by Claim Type. +final claimTypeCountColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, +]; + +/// Vertical claim-type count chart with L-border axes and wide rounded bars. +class ClaimsCountByClaimTypeChart extends StatefulWidget { + final List labels; + final List values; + final int animationKey; + final double maxY; + + ClaimsCountByClaimTypeChart({ + super.key, + required this.labels, + required this.values, + required this.animationKey, + this.maxY = 260, + }); + + @override + State createState() => + _ClaimsCountByClaimTypeChartState(); +} + +class _ClaimsCountByClaimTypeChartState + extends State { + @override + Widget build(BuildContext context) { + final maxY = widget.maxY > 0 ? widget.maxY.toDouble() : 25.0; + return ClaimsCustomVerticalBarChart( + labels: widget.labels, + values: widget.values, + maxY: maxY, + colors: claimTypeCountColors, + animationKey: widget.animationKey, + yTicks: _yTicksForMaxY(maxY), + formatYTick: _formatMillionsAxisTick, + tooltipValueLabel: 'amount', + leftAxisWidth: 48, + barWidth: 56, + bottomAxisHeight: 52, + ); + } +} + +class ClaimsVerticalBarChart extends StatelessWidget { + final List labels; + final List values; + final double maxY; + final List? colors; + final int replayToken; + + const ClaimsVerticalBarChart({ + super.key, + required this.labels, + required this.values, + required this.maxY, + this.colors, + this.replayToken = 0, + }); + + static const _ageBandYTicks = [ + 0.0, + 4000000.0, + 8000000.0, + 12000000.0, + 16000000.0, + ]; + + @override + Widget build(BuildContext context) { + final palette = colors ?? ClaimsOverviewTheme.chartPalette; + return ClaimsCustomVerticalBarChart( + labels: labels, + values: values, + maxY: maxY, + colors: palette, + animationKey: replayToken, + yTicks: _ageBandYTicks, + barWidth: 48, + barSlotFraction: 0.88, + leftAxisWidth: 56, + bottomAxisHeight: 48, + plotRightInset: 8, + formatYTick: (v) => v.toInt().toString(), + tooltipValueLabel: 'value', + ); + } +} + +/// Donut chart with external indicator lines, segment dots, sweep animation, legend. +class ClaimsDonutChart extends StatefulWidget { + final List values; + final List? labels; + final List? colors; + final bool showLegend; + final double size; + final int replayToken; + final int? animationKey; + + const ClaimsDonutChart({ + super.key, + required this.values, + this.labels, + this.colors, + this.showLegend = false, + this.size = 200, + this.replayToken = 0, + this.animationKey, + }); + + @override + State createState() => _ClaimsDonutChartState(); +} + +class _ClaimsDonutChartState extends State { + int _hoveredIndex = -1; + + int get _animKey => widget.animationKey ?? widget.replayToken; + + List get _palette { + if (widget.colors != null && widget.colors!.length >= widget.values.length) { + return widget.colors!; + } + return List.generate( + widget.values.length, + (i) => ClaimsOverviewTheme.chartPalette[i % ClaimsOverviewTheme.chartPalette.length], + ); + } + + int? _hitSegment(Offset local, Size size) { + if (widget.values.isEmpty) return null; + final center = Offset(size.width / 2, size.height / 2); + final outerR = math.min(size.width, size.height) / 2 * 0.78; + final innerR = outerR * 0.58; + final dx = local.dx - center.dx; + final dy = local.dy - center.dy; + final dist = math.sqrt(dx * dx + dy * dy); + if (dist < innerR || dist > outerR) return null; + + var angle = math.atan2(dy, dx) - _ClaimsDonutPainter._startAngle; + while (angle < 0) { + angle += 2 * math.pi; + } + while (angle >= 2 * math.pi) { + angle -= 2 * math.pi; + } + + final total = widget.values.fold(0, (a, b) => a + b); + if (total <= 0) return null; + + var cumulative = 0.0; + for (var i = 0; i < widget.values.length; i++) { + final sweep = (widget.values[i] / total) * 2 * math.pi; + if (angle >= cumulative && angle < cumulative + sweep) return i; + cumulative += sweep; + } + return widget.values.length - 1; + } + + String _formatDonutValue(double v) { + final total = widget.values.fold(0, (a, b) => a + b); + if (total > 0 && total <= 100.5) { + return '${v.toStringAsFixed(v == v.roundToDouble() ? 0 : 2)}%'; + } + if (v == v.roundToDouble()) { + return NumberFormat('#,##0').format(v.round()); + } + return NumberFormat('#,##0.##').format(v); + } + + Widget _buildDonutColumn(BuildContext context, double animProgress) { + final showLegend = + widget.showLegend && widget.labels != null && widget.labels!.isNotEmpty; + final isPdf = ClaimsPdfExportScope.of(context); + + return Column( + children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 1, + child: LayoutBuilder( + builder: (context, constraints) { + final side = math.min( + constraints.maxWidth, + constraints.maxHeight, + ); + final chartSize = Size(side, side); + final chart = CustomPaint( + size: chartSize, + painter: _ClaimsDonutPainter( + values: widget.values, + colors: _palette, + animProgress: animProgress, + hoveredIndex: isPdf ? -1 : _hoveredIndex, + compactIndicators: isPdf, + ), + ); + + if (isPdf) { + return ClipRect(child: chart); + } + + return MouseRegion( + onHover: (e) { + final hit = _hitSegment(e.localPosition, chartSize); + final next = hit ?? -1; + if (next != _hoveredIndex) { + setState(() => _hoveredIndex = next); + } + }, + onExit: (_) { + if (_hoveredIndex != -1) { + setState(() => _hoveredIndex = -1); + } + }, + cursor: SystemMouseCursors.click, + child: Stack( + clipBehavior: Clip.none, + children: [ + chart, + if (_hoveredIndex >= 0) + Positioned.fill( + child: IgnorePointer( + child: Center( + child: _DonutHoverTooltip( + title: widget.labels != null && + _hoveredIndex < + widget.labels!.length + ? widget.labels![_hoveredIndex] + : 'Segment ${_hoveredIndex + 1}', + value: _formatDonutValue( + widget.values[_hoveredIndex], + ), + color: _palette[_hoveredIndex], + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ), + if (showLegend) ...[ + const SizedBox(height: 8), + _DonutLegend( + labels: widget.labels!, + colors: _palette, + ), + ], + ], + ); + } + + @override + Widget build(BuildContext context) { + if (ClaimsPdfExportScope.of(context)) { + return _buildDonutColumn(context, 1.0); + } + + return TweenAnimationBuilder( + key: ValueKey('donut-$_animKey-${widget.values.join()}'), + tween: Tween(begin: 0, end: 1), + duration: _chartRiseDurationFor(context), + curve: Curves.easeOutCubic, + builder: (context, animProgress, _) => + _buildDonutColumn(context, animProgress), + ); + } +} + +class _DonutHoverTooltip extends StatelessWidget { + final String title; + final String value; + final Color color; + + const _DonutHoverTooltip({ + required this.title, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + constraints: const BoxConstraints(minWidth: 88), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: GoogleFonts.poppins( + fontSize: 22, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + height: 1.1, + ), + ), + ], + ), + ), + ); + } +} + +class _DonutLegend extends StatelessWidget { + final List labels; + final List colors; + + const _DonutLegend({required this.labels, required this.colors}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 20, + runSpacing: 8, + alignment: WrapAlignment.center, + children: List.generate(labels.length, (i) { + final color = colors[i % colors.length]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + labels[i], + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ); + }), + ); + } +} + +class _ClaimsDonutPainter extends CustomPainter { + final List values; + final List colors; + final double animProgress; + final int hoveredIndex; + final bool compactIndicators; + + _ClaimsDonutPainter({ + required this.values, + required this.colors, + required this.animProgress, + this.hoveredIndex = -1, + this.compactIndicators = false, + }); + + static const double _sectionGapRad = 0.04; + static const double _startAngle = -math.pi / 2; + + @override + void paint(Canvas canvas, Size size) { + if (values.isEmpty) return; + + final center = Offset(size.width / 2, size.height / 2); + final outerR = math.min(size.width, size.height) / 2 * 0.78; + final innerR = outerR * 0.58; + final ringWidth = outerR - innerR; + final midR = (outerR + innerR) / 2; + + final total = values.fold(0, (a, b) => a + b); + if (total <= 0) return; + + var angle = _startAngle; + + for (var i = 0; i < values.length; i++) { + final fraction = values[i] / total; + final fullSweep = fraction * 2 * math.pi; + final gap = values.length > 1 ? _sectionGapRad : 0.0; + final drawableSweep = math.max(fullSweep - gap, 0.0); + final animatedSweep = drawableSweep * animProgress; + final sectionStart = angle + gap / 2; + + if (animatedSweep > 0) { + final baseColor = colors[i % colors.length]; + final isHovered = i == hoveredIndex; + final ringPaint = Paint() + ..color = isHovered + ? baseColor + : baseColor.withValues(alpha: hoveredIndex >= 0 ? 0.45 : 1.0) + ..style = PaintingStyle.stroke + ..strokeWidth = isHovered ? ringWidth + 4 : ringWidth + ..strokeCap = StrokeCap.butt; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: midR), + sectionStart, + animatedSweep, + false, + ringPaint, + ); + } + + // Indicators + labels use full segment mid-angle (stable once drawn). + if (animProgress > 0.8) { + final midAngle = sectionStart + drawableSweep / 2; + _drawIndicator( + canvas, + center: center, + midAngle: midAngle, + outerR: outerR, + value: values[i], + color: colors[i % colors.length], + segmentCount: values.length, + opacity: ((animProgress - 0.8) / 0.2).clamp(0.0, 1.0), + ); + } + + angle += fullSweep; + } + + // Center hole (white donut core). + canvas.drawCircle( + center, + innerR - 1, + Paint()..color = Colors.white, + ); + } + + void _drawIndicator( + Canvas canvas, { + required Offset center, + required double midAngle, + required double outerR, + required double value, + required Color color, + required int segmentCount, + required double opacity, + }) { + if (opacity <= 0) return; + + final c = color.withValues(alpha: opacity); + final fontSize = compactIndicators + ? (segmentCount <= 2 ? 12.0 : 10.0) + : segmentCount <= 2 + ? 18.0 + : segmentCount <= 4 + ? 15.0 + : 12.0; + final leaderLen = compactIndicators + ? 6.0 + : segmentCount <= 4 + ? 14.0 + : 10.0; + final unitX = math.cos(midAngle); + final unitY = math.sin(midAngle); + + final arcEdge = Offset( + center.dx + outerR * unitX, + center.dy + outerR * unitY, + ); + + final label = _formatValue(value); + final tp = TextPainter( + text: TextSpan( + text: label, + style: GoogleFonts.poppins( + fontSize: fontSize, + fontWeight: FontWeight.w700, + color: c, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + // Place value right after the tick (minimal 2px padding). + const labelPadding = 2.0; + final textCenterDist = + outerR + leaderLen + labelPadding + _textExtentAlongRay(tp, midAngle); + final lineEnd = Offset( + center.dx + (outerR + leaderLen) * unitX, + center.dy + (outerR + leaderLen) * unitY, + ); + final labelCenter = Offset( + center.dx + textCenterDist * unitX, + center.dy + textCenterDist * unitY, + ); + + canvas.drawLine( + arcEdge, + lineEnd, + Paint() + ..color = c + ..strokeWidth = 1.2, + ); + + tp.paint( + canvas, + labelCenter - Offset(tp.width / 2, tp.height / 2), + ); + } + + String _formatValue(double v) { + if (v == v.roundToDouble()) { + return NumberFormat('#,##0').format(v.round()); + } + return NumberFormat('#,##0.#').format(v); + } + + /// Half of the text bbox projected along the indicator ray. + double _textExtentAlongRay(TextPainter tp, double angle) { + final c = math.cos(angle).abs(); + final s = math.sin(angle).abs(); + return (tp.width * c + tp.height * s) / 2; + } + + @override + bool shouldRepaint(covariant _ClaimsDonutPainter old) { + return old.animProgress != animProgress || + old.values != values || + old.hoveredIndex != hoveredIndex || + old.colors != colors || + old.compactIndicators != compactIndicators; + } +} + +/// Area / line chart — L-axes, ticks, dashed grid, hover crosshair, draw animation. +class ClaimsAreaChart extends StatefulWidget { + final List labels; + final List values; + final double maxY; + final List? yTicks; + final int replayToken; + + const ClaimsAreaChart({ + super.key, + required this.labels, + required this.values, + required this.maxY, + this.yTicks, + this.replayToken = 0, + }); + + @override + State createState() => _ClaimsAreaChartState(); +} + +class _ClaimsAreaChartState extends State { + static const double _bottomAxisHeight = 48; + static const double _plotRightInset = 8; + + int _hoveredIndex = -1; + Offset? _tooltipAnchor; + + List get _yTicks => + widget.yTicks ?? [0, widget.maxY * 0.25, widget.maxY * 0.5, widget.maxY * 0.75, widget.maxY]; + + void _onPlotHover(Offset local, Size plotSize, int count) { + if (count == 0) return; + final chartW = plotSize.width; + final slotW = chartW / count; + final index = (local.dx / slotW).floor().clamp(0, count - 1); + if (index == _hoveredIndex) return; + + final value = widget.values[index]; + final pointY = widget.maxY > 0 + ? plotSize.height * (1 - value / widget.maxY) + : plotSize.height; + setState(() { + _hoveredIndex = index; + _tooltipAnchor = Offset((index + 0.5) * slotW, pointY); + }); + } + + @override + Widget build(BuildContext context) { + final count = widget.labels.length; + + return TweenAnimationBuilder( + key: ValueKey('area-${widget.replayToken}-${widget.values.join()}'), + tween: Tween(begin: 0, end: 1), + duration: _chartRiseDurationFor(context), + curve: Curves.easeOutCubic, + builder: (context, animProgress, _) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _VerticalLeftAxis( + yTicks: _yTicks, + maxY: widget.maxY, + axisWidth: _verticalLeftAxisWidth, + bottomSpacer: _bottomAxisHeight, + axisColor: _chartAxisColor, + formatTick: (v) => v == v.roundToDouble() + ? v.round().toString() + : v.toStringAsFixed(1), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final plotHeight = + constraints.maxHeight - _bottomAxisHeight; + final plotSize = Size( + constraints.maxWidth - _plotRightInset, + plotHeight, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: plotHeight, + child: Padding( + padding: const EdgeInsets.only(right: _plotRightInset), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + MouseRegion( + onHover: (e) => _onPlotHover( + e.localPosition, + plotSize, + count, + ), + onExit: (_) => setState(() { + _hoveredIndex = -1; + _tooltipAnchor = null; + }), + cursor: SystemMouseCursors.click, + child: CustomPaint( + size: plotSize, + painter: _AreaLinePlotPainter( + values: widget.values, + maxY: widget.maxY, + yTicks: _yTicks, + animProgress: animProgress, + hoveredIndex: _hoveredIndex, + lineColor: ClaimsOverviewTheme.teal, + axisColor: _chartAxisColor, + gridColor: ClaimsOverviewTheme.border, + plotBorderColor: ClaimsOverviewTheme.border, + ), + ), + ), + if (_hoveredIndex >= 0 && + _tooltipAnchor != null) + Positioned( + left: (_tooltipAnchor!.dx + 8) + .clamp(8.0, plotSize.width - 150), + top: (_tooltipAnchor!.dy - 56) + .clamp(4.0, plotSize.height - 72), + child: IgnorePointer( + child: _AreaChartTooltip( + title: widget.labels[_hoveredIndex], + value: widget.values[_hoveredIndex], + ), + ), + ), + ], + ), + ), + ), + _VerticalCategoryAxis( + labels: widget.labels, + height: _bottomAxisHeight, + axisColor: _chartAxisColor, + rightInset: _plotRightInset, + ), + ], + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _AreaChartTooltip extends StatelessWidget { + final String title; + final double value; + + const _AreaChartTooltip({required this.title, required this.value}); + + String get _valueText { + if (value == value.roundToDouble()) return value.round().toString(); + return value.toStringAsFixed(1); + } + + @override + Widget build(BuildContext context) { + return Material( + elevation: 4, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + Text( + 'value : $_valueText', + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w500, + color: ClaimsOverviewTheme.teal, + ), + ), + ], + ), + ), + ); + } +} + +class _AreaLinePlotPainter extends CustomPainter { + final List values; + final double maxY; + final List yTicks; + final double animProgress; + final int hoveredIndex; + final Color lineColor; + final Color axisColor; + final Color gridColor; + final Color plotBorderColor; + + _AreaLinePlotPainter({ + required this.values, + required this.maxY, + required this.yTicks, + required this.animProgress, + required this.hoveredIndex, + required this.lineColor, + required this.axisColor, + required this.gridColor, + required this.plotBorderColor, + }); + + List _chartPoints(Size size) { + final n = values.length; + if (n == 0) return []; + final chartW = size.width; + final slotW = chartW / n; + return List.generate(n, (i) { + final x = (i + 0.5) * slotW; + final y = size.height - (values[i] / maxY) * size.height; + return Offset(x, y); + }); + } + + Path _smoothLinePath(List points) { + final path = Path(); + if (points.isEmpty) return path; + if (points.length == 1) { + path.moveTo(points[0].dx, points[0].dy); + return path; + } + path.moveTo(points[0].dx, points[0].dy); + for (var i = 0; i < points.length - 1; i++) { + final current = points[i]; + final next = points[i + 1]; + final controlX = (current.dx + next.dx) / 2; + path.cubicTo( + controlX, + current.dy, + controlX, + next.dy, + next.dx, + next.dy, + ); + } + return path; + } + + Path _animatedPath(Path fullPath, double progress) { + if (progress >= 1) return fullPath; + final metrics = fullPath.computeMetrics(); + final out = Path(); + for (final metric in metrics) { + out.addPath(metric.extractPath(0, metric.length * progress), Offset.zero); + } + return out; + } + + Path _fillPath(Path linePath, Size size) { + final fill = Path.from(linePath); + final bounds = linePath.getBounds(); + fill.lineTo(bounds.right, size.height); + fill.lineTo(bounds.left, size.height); + fill.close(); + return fill; + } + + @override + void paint(Canvas canvas, Size size) { + if (values.isEmpty) return; + + final plotRect = Rect.fromLTWH(0, 0, size.width, size.height); + final axisPaint = Paint() + ..color = axisColor + ..strokeWidth = 1; + + // Light inner plot border. + canvas.drawRect( + plotRect, + Paint() + ..color = plotBorderColor.withValues(alpha: 0.65) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + + final n = values.length; + final slotW = size.width / n; + + for (final t in yTicks) { + final y = size.height - (t / maxY) * size.height; + _drawDashedLine(canvas, Offset(0, y), Offset(size.width, y), gridColor); + } + for (var i = 0; i <= n; i++) { + final x = i * slotW; + _drawDashedLine(canvas, Offset(x, 0), Offset(x, size.height), gridColor); + } + + canvas.drawLine(Offset.zero, Offset(0, size.height), axisPaint); + canvas.drawLine( + Offset(0, size.height), + Offset(size.width, size.height), + axisPaint, + ); + + final points = _chartPoints(size); + final linePath = _smoothLinePath(points); + final animatedLine = _animatedPath(linePath, animProgress); + final fillPath = _fillPath(animatedLine, size); + + final fillPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + lineColor.withValues(alpha: 0.35), + lineColor.withValues(alpha: 0.02), + ], + ).createShader(plotRect); + canvas.drawPath(fillPath, fillPaint); + + canvas.drawPath( + animatedLine, + Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + + if (hoveredIndex >= 0 && hoveredIndex < points.length) { + final pt = points[hoveredIndex]; + + canvas.drawLine( + Offset(pt.dx, size.height), + Offset(pt.dx, pt.dy), + Paint() + ..color = const Color(0xFFD1D5DB) + ..strokeWidth = 1, + ); + + canvas.drawCircle( + pt, + 5, + Paint()..color = Colors.white, + ); + canvas.drawCircle( + pt, + 5, + Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + } + } + + @override + bool shouldRepaint(covariant _AreaLinePlotPainter old) { + return old.animProgress != animProgress || + old.hoveredIndex != hoveredIndex || + old.values != values; + } +} + +class ClaimsScatterChart extends StatelessWidget { + final List? values; + final List? labels; + final double? maxY; + final double? pointY; + final String? label; + final int replayToken; + + const ClaimsScatterChart({ + super.key, + this.values, + this.labels, + this.maxY, + this.pointY, + this.label, + this.replayToken = 0, + }); + + bool get _isMulti => + values != null && labels != null && values!.isNotEmpty && labels!.isNotEmpty; + + @override + Widget build(BuildContext context) { + if (_isMulti) { + return _buildMulti(context); + } + return _buildSingle(context); + } + + Widget _buildMulti(BuildContext context) { + final vals = values!; + final labs = labels!; + final chartMaxY = maxY ?? (vals.reduce((a, b) => a > b ? a : b) * 1.1); + final yInterval = chartMaxY > 0 ? chartMaxY / 4 : 1.0; + final maxX = math.max(1.0, (vals.length - 1).toDouble()); + + return LineChart( + duration: _chartAnimDurationFor(context), + curve: _chartAnimCurve, + key: ValueKey('scatter-multi-$replayToken-${vals.join(",")}'), + LineChartData( + maxY: chartMaxY, + minY: 0, + minX: 0, + maxX: maxX, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (v) => FlLine( + color: ClaimsOverviewTheme.border, + strokeWidth: 1, + dashArray: [4, 4], + ), + ), + borderData: FlBorderData( + show: true, + border: const Border( + left: BorderSide(color: _chartAxisColor, width: 1), + bottom: BorderSide(color: _chartAxisColor, width: 1), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: yInterval, + getTitlesWidget: (v, _) => Text( + v >= 1 ? v.toStringAsFixed(1) : v.toStringAsFixed(2), + style: GoogleFonts.poppins(fontSize: 10, color: Colors.grey), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 1, + getTitlesWidget: (v, _) { + final i = v.round(); + if (i < 0 || i >= labs.length) return const SizedBox.shrink(); + final text = labs[i]; + final short = text.length > 8 ? text.substring(0, 7) : text; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + short, + style: GoogleFonts.poppins(fontSize: 9, color: Colors.black54), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: [ + for (var i = 0; i < vals.length; i++) FlSpot(i.toDouble(), vals[i]), + ], + color: ClaimsOverviewTheme.orange, + barWidth: 0, + dotData: FlDotData( + show: true, + getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( + radius: 6, + color: ClaimsOverviewTheme.orange, + strokeWidth: 2, + strokeColor: Colors.white, + ), + ), + belowBarData: BarAreaData(show: false), + ), + ], + ), + ); + } + + Widget _buildSingle(BuildContext context) { + final y = pointY ?? 0; + final bottomLabel = label ?? ''; + return LineChart( + duration: _chartAnimDurationFor(context), + curve: _chartAnimCurve, + key: ValueKey('scatter-$replayToken-$y'), + LineChartData( + maxY: 600, + minY: 0, + minX: 0, + maxX: 2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (v) => FlLine( + color: ClaimsOverviewTheme.border, + strokeWidth: 1, + dashArray: [4, 4], + ), + ), + borderData: FlBorderData( + show: true, + border: const Border( + left: BorderSide(color: _chartAxisColor, width: 1), + bottom: BorderSide(color: _chartAxisColor, width: 1), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: 150, + getTitlesWidget: (v, _) => Text( + v.toInt().toString(), + style: GoogleFonts.poppins(fontSize: 10, color: Colors.grey), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + reservedSize: 36, + getTitlesWidget: (v, __) { + if (v.round() != 1) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + bottomLabel, + style: GoogleFonts.poppins(fontSize: 10, color: Colors.black54), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: [FlSpot(1, y)], + color: ClaimsOverviewTheme.orange, + barWidth: 0, + dotData: FlDotData( + show: true, + getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( + radius: 8, + color: ClaimsOverviewTheme.orange, + strokeWidth: 2, + strokeColor: Colors.white, + ), + ), + belowBarData: BarAreaData(show: false), + ), + ], + extraLinesData: ExtraLinesData( + verticalLines: [ + VerticalLine( + x: 1, + color: ClaimsOverviewTheme.orange.withValues(alpha: 0.4), + dashArray: [4, 4], + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/claims_overview/claims_overview_dashboard.dart b/lib/presentation/claims_overview/claims_overview_dashboard.dart new file mode 100644 index 0000000..8e7f67a --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_dashboard.dart @@ -0,0 +1,737 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../customAppBar/base_layout.dart'; +import '../../customAppBar/toastHelper.dart'; +import '../../service/api_service.dart'; +import '../../service/secure_pop_scope.dart'; +import '../../service/token_storage_service.dart'; +import 'claims_collection_kpi.dart'; +import 'enrollment_collection_kpi.dart'; +import 'claims_overview_animations.dart'; +import 'claims_overview_pdf_export.dart'; +import 'claims_overview_scope.dart'; +import 'claims_overview_tabs.dart'; +import 'claims_overview_theme.dart'; + +class ClaimsOverviewDashboard extends StatefulWidget { + const ClaimsOverviewDashboard({super.key}); + + @override + State createState() => + _ClaimsOverviewDashboardState(); +} + +class _ClaimsOverviewDashboardState extends State + with TickerProviderStateMixin { + late TabController _tabController; + late AnimationController _headerFadeController; + late Animation _headerFade; + + late final ApiService _apiService; + final _tokenService = TokenStorageService(); + + bool _isLoading = true; + bool _isRefreshing = false; + bool _isExportingPdf = false; + int _replayToken = 0; + int _currentTab = 0; + + ClaimsOverviewViewData _viewData = ClaimsOverviewViewData.empty(); + EnrollmentOverviewViewData _enrollmentViewData = + EnrollmentOverviewViewData.empty(); + String? _clientId; + String? _clientBranchId; + String? _selectedPolicyId; + List> _activePolicies = []; + + static const _tabs = [ + (Icons.dashboard_outlined, 'Overview'), + (Icons.timeline, 'Policy Experience'), + (Icons.analytics_outlined, 'Claims Analysis'), + (Icons.people_outline, 'Demographics'), + (Icons.location_city_outlined, 'Hospitals & Geography'), + (Icons.medical_services_outlined, 'Specialty Analysis'), + (Icons.group_add_outlined, 'Enrollment'), + ]; + + @override + void initState() { + super.initState(); + _apiService = ApiService(context); + _tabController = TabController(length: _tabs.length, vsync: this); + _tabController.addListener(_onTabChanged); + + _headerFadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _headerFade = CurvedAnimation( + parent: _headerFadeController, + curve: Curves.easeOut, + ); + _headerFadeController.forward(); + + _loadDashboard(); + } + + void _onTabChanged() { + if (_tabController.indexIsChanging) return; + final index = _tabController.index; + if (_currentTab != index) { + setState(() => _currentTab = index); + } + } + + Future _loadDashboard({bool reloadPolicies = false}) async { + final isInitialLoad = _replayToken == 0; + setState(() { + if (isInitialLoad) { + _isLoading = true; + } else { + _isRefreshing = true; + } + }); + + try { + _clientId ??= await _tokenService.readValue('empClientId'); + _clientBranchId ??= await _tokenService.readValue('empClientBranchId'); + final hrId = await _tokenService.readValue('empHrId'); + final token = await _tokenService.getCurrentToken(); + if (token != null && token.isNotEmpty) { + await _apiService.getTokenLoadAPI(token); + } + + if (isInitialLoad || reloadPolicies || _activePolicies.isEmpty) { + await _loadPolicyList(token ?? '', hrId ?? ''); + if (isInitialLoad && _activePolicies.isNotEmpty) { + _selectedPolicyId = + _activePolicies.first['client_policy_id'].toString(); + } + if (!mounted) return; + setState(() {}); + } + + if (_selectedPolicyId == null) { + if (!mounted) return; + setState(() { + _viewData = ClaimsOverviewViewData.empty( + error: 'No active policy found', + ); + _enrollmentViewData = EnrollmentOverviewViewData.empty(); + _isLoading = false; + _isRefreshing = false; + }); + return; + } + + final policyId = _selectedPolicyId!; + final response = await _apiService.getClaimsCollectionV2All( + clientPolicyId: policyId, + ); + final enrollmentResponse = + await _apiService.getEnrollmentCollectionV1All( + clientPolicyId: policyId, + ); + + if (!mounted) return; + + final claimsOk = ClaimsKpiParser.isSuccessResponse(response); + var claimsData = await ClaimsOverviewViewData.enrichFromApiResponse( + response, + fetchKpi: (slug) => _apiService.getClaimsCollectionV2Kpi( + slug, + clientPolicyId: policyId, + ), + ); + var enrollmentData = _parseEnrollmentResponse(enrollmentResponse); + + final selectedPolicy = _selectedPolicy(); + if (selectedPolicy != null) { + claimsData = claimsData.withPolicyFallback(selectedPolicy); + enrollmentData = enrollmentData.withPolicyFallback(selectedPolicy); + } + + if (!claimsOk) { + final message = + response['message']?.toString() ?? 'Failed to load claims data'; + claimsData = ClaimsOverviewViewData( + kpiBySlug: claimsData.kpiBySlug, + loadError: message, + ); + if (claimsData.kpiBySlug.isEmpty) { + if (!mounted) return; + setState(() { + _viewData = claimsData; + _enrollmentViewData = enrollmentData; + _isLoading = false; + _isRefreshing = false; + _replayToken++; + }); + ToastHelper.showErrorToast(context, message); + return; + } + } + + if (!mounted) return; + setState(() { + _viewData = claimsData; + _enrollmentViewData = enrollmentData; + _isLoading = false; + _isRefreshing = false; + _replayToken++; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _viewData = ClaimsOverviewViewData.empty(error: e.toString()); + _enrollmentViewData = + EnrollmentOverviewViewData.empty(error: e.toString()); + _isLoading = false; + _isRefreshing = false; + }); + ToastHelper.showErrorToast(context, 'Failed to load claims overview'); + } + } + + Future _loadPolicyList(String token, String hrId) async { + _clientId ??= await _tokenService.readValue('empClientId'); + _clientBranchId ??= await _tokenService.readValue('empClientBranchId'); + + if (_clientId == null || _clientBranchId == null) return; + + final policyRes = await _apiService.getActiveCashDepositDetailsToApi( + _clientId!, + _clientBranchId!, + hrId, + token, + 1, + ); + + if (policyRes['status'] != 'success' || policyRes['data'] is! List) { + _activePolicies = []; + _selectedPolicyId = null; + return; + } + + _activePolicies = List>.from(policyRes['data']) + ..sort((a, b) { + final aLabel = '${a['type']} - ${a['policy_no']}'; + final bLabel = '${b['type']} - ${b['policy_no']}'; + return aLabel.compareTo(bLabel); + }); + + if (_activePolicies.isNotEmpty) { + final firstId = + _activePolicies.first['client_policy_id'].toString(); + final stillValid = _selectedPolicyId != null && + _activePolicies.any( + (p) => p['client_policy_id'].toString() == _selectedPolicyId, + ); + if (!stillValid) { + _selectedPolicyId = firstId; + } + } else { + _selectedPolicyId = null; + } + } + + EnrollmentOverviewViewData _parseEnrollmentResponse( + Map response, + ) { + if (!ClaimsKpiParser.isSuccessResponse(response)) { + return EnrollmentOverviewViewData.empty( + error: response['message']?.toString() ?? + 'Failed to load enrollment data', + ); + } + return EnrollmentOverviewViewData.fromApiResponse(response); + } + + Map? _selectedPolicy() { + if (_selectedPolicyId == null) return null; + for (final policy in _activePolicies) { + if (policy['client_policy_id'].toString() == _selectedPolicyId) { + return policy; + } + } + return null; + } + + void _onPolicySelected(String policyId) { + if (policyId == _selectedPolicyId) return; + setState(() => _selectedPolicyId = policyId); + _loadDashboard(); + } + + String _selectedPolicyLabel() { + if (_selectedPolicyId == null) return 'Select Policy'; + for (final policy in _activePolicies) { + if (policy['client_policy_id'].toString() == _selectedPolicyId) { + return '${policy['type']} - ${policy['policy_no']}'; + } + } + return 'Policy $_selectedPolicyId'; + } + + Future _downloadChartsPdf() async { + if (_isExportingPdf || _isLoading) return; + + setState(() => _isExportingPdf = true); + + if (!mounted) return; + + final navigator = Navigator.of(context, rootNavigator: true); + final overlay = navigator.overlay; + if (overlay == null) { + setState(() => _isExportingPdf = false); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Could not generate PDF. Overlay unavailable.', + style: GoogleFonts.poppins(), + ), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + var progressLabel = 'Preparing PDF…'; + void Function(void Function())? updateProgressDialog; + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (ctx) => PopScope( + canPop: false, + child: StatefulBuilder( + builder: (context, setDialogState) { + updateProgressDialog = setDialogState; + return AlertDialog( + content: Row( + children: [ + const CircularProgressIndicator(), + const SizedBox(width: 20), + Expanded( + child: Text( + 'Generating PDF…\n$progressLabel', + style: GoogleFonts.poppins(fontSize: 14), + ), + ), + ], + ), + ); + }, + ), + ), + ); + + try { + await exportClaimsOverviewChartsPdf( + overlay: overlay, + replayToken: _replayToken, + viewData: _viewData, + enrollmentViewData: _enrollmentViewData, + policyId: _selectedPolicyId ?? '', + onProgress: (_, __, label) { + updateProgressDialog?.call(() { + progressLabel = label; + }); + }, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Claims Overview PDF downloaded', + style: GoogleFonts.poppins(), + ), + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Could not generate PDF. Please try again. ($e)', + style: GoogleFonts.poppins(), + ), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + ), + ); + } finally { + if (mounted && navigator.canPop()) { + navigator.pop(); + } + if (mounted) setState(() => _isExportingPdf = false); + } + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + _headerFadeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BaseLayout( + child: SecurePopScope( + child: Container( + color: ClaimsOverviewTheme.pageBg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FadeTransition( + opacity: _headerFade, + child: _buildHeader(), + ), + ClaimsTopLoadingBar(visible: _isRefreshing), + _buildTabBar(), + Expanded( + child: ClaimsOverviewScope( + key: ValueKey('claims-$_selectedPolicyId-$_replayToken'), + data: _viewData, + child: EnrollmentOverviewScope( + key: ValueKey('enrollment-$_selectedPolicyId-$_replayToken'), + data: _enrollmentViewData, + child: TabBarView( + controller: _tabController, + physics: _isRefreshing + ? const NeverScrollableScrollPhysics() + : null, + children: List.generate( + _tabs.length, + (i) => Padding( + padding: const EdgeInsets.only(top: 16), + child: ClaimsTabAnimatedShell( + isLoading: _isLoading, + skeletonBlocks: i == 0 ? 5 : 4, + child: ClaimsOverviewTab( + index: i, + replayToken: _replayToken, + isActive: _currentTab == i, + ), + ), + ), + ), + ), + ), + ), + ), + _buildFooter(), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + decoration: BoxDecoration( + color: ClaimsOverviewTheme.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TweenAnimationBuilder( + tween: Tween(begin: 0.85, end: 1), + duration: const Duration(milliseconds: 500), + curve: Curves.elasticOut, + builder: (_, scale, child) => + Transform.scale(scale: scale, child: child), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: ClaimsOverviewTheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.shield_outlined, + color: ClaimsOverviewTheme.primary, + size: 28, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Claims Overview', + style: GoogleFonts.poppins( + fontSize: 22, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Real-time policy & claims analytics', + style: GoogleFonts.poppins( + fontSize: 13, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + const SizedBox(width: 12), + const ClaimsLiveIndicator(), + ], + ), + ], + ), + ), + _buildPolicyBadge(), + const SizedBox(width: 12), + _headerActions(), + ], + ), + ], + ), + ); + } + + Widget _buildPolicyBadge() { + if (_activePolicies.isEmpty) { + return Material( + color: ClaimsOverviewTheme.pageBg, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: ClaimsOverviewTheme.border), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _selectedPolicyLabel(), + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + ClaimsRefreshIcon( + isRefreshing: _isRefreshing, + onPressed: _loadDashboard, + ), + ], + ), + ), + ); + } + + return Material( + color: ClaimsOverviewTheme.pageBg, + borderRadius: BorderRadius.circular(24), + child: SizedBox( + width: 320, + height: 40, + child: SearchAnchor( + viewBackgroundColor: ClaimsOverviewTheme.cardBg, + viewConstraints: const BoxConstraints(maxHeight: 260), + builder: (context, controller) { + return InkWell( + onTap: controller.openView, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.all(color: ClaimsOverviewTheme.border), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + Expanded( + child: Text( + _selectedPolicyLabel(), + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon( + Icons.arrow_drop_down, + color: ClaimsOverviewTheme.textSecondary, + ), + ClaimsRefreshIcon( + isRefreshing: _isRefreshing, + onPressed: _loadDashboard, + ), + ], + ), + ), + ); + }, + suggestionsBuilder: (context, controller) { + final input = controller.text.toLowerCase(); + return _activePolicies + .where((policy) { + final type = policy['type'].toString().toLowerCase(); + final policyNo = policy['policy_no'].toString().toLowerCase(); + return type.contains(input) || policyNo.contains(input); + }) + .map((policy) { + final label = '${policy['type']} - ${policy['policy_no']}'; + return ListTile( + dense: true, + title: Text( + label, + style: GoogleFonts.poppins(fontSize: 13), + ), + onTap: () { + controller.closeView(label); + _onPolicySelected( + policy['client_policy_id'].toString(), + ); + }, + ); + }) + .toList(); + }, + ), + ), + ); + } + + Widget _headerActions() { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: IconButton( + tooltip: 'Download all charts as PDF', + icon: _isExportingPdf + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 20), + style: IconButton.styleFrom( + foregroundColor: ClaimsOverviewTheme.textSecondary, + ), + onPressed: (_isRefreshing || _isExportingPdf || _isLoading) + ? null + : _downloadChartsPdf, + ), + ); + } + + Widget _buildTabBar() { + return Container( + margin: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + color: ClaimsOverviewTheme.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + children: [ + if (_isRefreshing) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Align( + alignment: Alignment.centerRight, + child: Text( + 'Updating…', + style: GoogleFonts.poppins( + fontSize: 11, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + ), + ), + TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: ClaimsOverviewTheme.primary, + unselectedLabelColor: ClaimsOverviewTheme.textSecondary, + indicatorColor: ClaimsOverviewTheme.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: GoogleFonts.poppins(fontSize: 13), + tabs: List.generate(_tabs.length, (i) { + final t = _tabs[i]; + final selected = _currentTab == i; + return Tab( + height: 48, + child: AnimatedScale( + scale: selected ? 1.02 : 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Icon( + t.$1, + key: ValueKey('$i-$selected'), + size: 18, + color: selected + ? ClaimsOverviewTheme.primary + : ClaimsOverviewTheme.textSecondary, + ), + ), + const SizedBox(width: 6), + Text(t.$2), + ], + ), + ), + ); + }), + ), + ], + ), + ); + } + + Widget _buildFooter() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: RichText( + text: TextSpan( + style: GoogleFonts.poppins( + fontSize: 12, + color: ClaimsOverviewTheme.textSecondary, + ), + children: [ + const TextSpan(text: 'Powered by '), + TextSpan( + text: 'Nhance', + style: GoogleFonts.poppins( + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.orange, + ), + ), + const TextSpan(text: ' Insights · Light Theme'), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/claims_overview/claims_overview_pdf_export.dart b/lib/presentation/claims_overview/claims_overview_pdf_export.dart new file mode 100644 index 0000000..b1da47f --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_pdf_export.dart @@ -0,0 +1,509 @@ +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', + 'Policy Experience', + 'Claims Analysis', + 'Demographics', + 'Hospitals & Geography', + 'Specialty Analysis', + 'Enrollment', +]; + +/// Fallback heights if layout measure fails (avoids second pass). +const _tabFallbackHeights = [ + 720.0, + 680.0, + 660.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: 20); +const _pdfEmbedMaxWidth = 960; +const _logoAsset = 'assets/Nhance-Logo-Final 1.png'; + +typedef ClaimsPdfExportProgress = void Function(int current, int total, String label); + +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; + + const _PdfBuildInput({ + required this.captures, + required this.logoBytes, + }); +} + +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. + } +} + +/// Shrinks and JPEG-encodes tab screenshots so [doc.save] stays fast. +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.average, + ); + } + + return Uint8List.fromList(img.encodeJpg(processed, quality: 82)); + } catch (e) { + debugPrint('PDF export: image compress skipped ($e)'); + return pngBytes; + } +} + +Future _buildPdfBytesSync(_PdfBuildInput input) async { + pw.MemoryImage? logo; + if (input.logoBytes != null && input.logoBytes!.isNotEmpty) { + logo = pw.MemoryImage(input.logoBytes!); + } + + final doc = pw.Document(); + + for (final capture in input.captures) { + final embed = _compressTabImageForPdf(capture.png); + doc.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(24), + build: (ctx) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + _pdfPageHeader(title: capture.tabName, logo: logo), + pw.SizedBox(height: 8), + pw.Expanded( + child: pw.Center( + child: pw.Image( + pw.MemoryImage(embed), + fit: pw.BoxFit.contain, + ), + ), + ), + ], + ), + ), + ); + } + + return doc.save(); +} + +/// Web: compress/build in chunks so the progress dialog can repaint. +Future _buildPdfBytesWithYields( + _PdfBuildInput input, + ClaimsPdfExportProgress? onProgress, + int totalSteps, +) async { + pw.MemoryImage? logo; + if (input.logoBytes != null && input.logoBytes!.isNotEmpty) { + logo = pw.MemoryImage(input.logoBytes!); + } + + 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); + doc.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(24), + build: (ctx) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + _pdfPageHeader(title: capture.tabName, logo: logo), + pw.SizedBox(height: 8), + pw.Expanded( + child: pw.Center( + child: pw.Image( + pw.MemoryImage(embed), + fit: pw.BoxFit.contain, + ), + ), + ), + ], + ), + ), + ); + } + + _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 { + await WidgetsBinding.instance.endOfFrame; + await Future.delayed(const Duration(milliseconds: 32)); +} + +double _capturePixelRatio({required bool isEnrollmentTab}) { + if (kIsWeb) { + return isEnrollmentTab ? 0.85 : 1.0; + } + return isEnrollmentTab ? 1.1 : 1.35; +} + +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, + 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: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _captureWidth, + maxHeight: isEnrollmentTab + ? _enrollmentMaxMeasureHeight + : _maxMeasureHeight, + ), + child: ClipRect(child: child), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + + overlay.insert(entry); + + try { + await _waitForPaint(); + + final renderObject = boundaryKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { + debugPrint('PDF export: missing RepaintBoundary'); + return null; + } + + 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(); + } +} + +pw.Widget _pdfPageHeader({ + required String title, + pw.MemoryImage? logo, + bool showSubtitle = false, + String? subtitle, +}) { + return pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + title, + style: pw.TextStyle( + fontSize: showSubtitle ? 22 : 14, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey800, + ), + ), + if (showSubtitle && subtitle != null) ...[ + pw.SizedBox(height: 6), + pw.Text( + subtitle, + style: const pw.TextStyle( + fontSize: 11, + color: PdfColors.grey700, + ), + ), + ], + ], + ), + ), + if (logo != null) + pw.Container( + alignment: pw.Alignment.centerRight, + child: pw.Image(logo, height: 28, fit: pw.BoxFit.contain), + ), + ], + ); +} + +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, + 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], + 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, + ); + + 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'); +} diff --git a/lib/presentation/claims_overview/claims_overview_pdf_layout.dart b/lib/presentation/claims_overview/claims_overview_pdf_layout.dart new file mode 100644 index 0000000..76a7c19 --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_pdf_layout.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import 'claims_overview_scope.dart'; + +/// Horizontal panel row for dashboard tabs; uses fixed widths during PDF capture. +class ClaimsTabPanelRow extends StatelessWidget { + final List panels; + final double gap; + final CrossAxisAlignment crossAxisAlignment; + + const ClaimsTabPanelRow({ + super.key, + required this.panels, + this.gap = 16, + this.crossAxisAlignment = CrossAxisAlignment.start, + }); + + @override + Widget build(BuildContext context) { + final laneWidth = ClaimsPdfExportScope.laneWidthOf(context); + final isPdf = ClaimsPdfExportScope.of(context) && laneWidth != null; + + if (!isPdf) { + return Row( + crossAxisAlignment: crossAxisAlignment, + children: _withGaps( + panels + .map((p) => Expanded(flex: p.flex, child: p.child)) + .toList(), + ), + ); + } + + final totalFlex = panels.fold(0, (sum, p) => sum + p.flex); + final gaps = gap * (panels.length - 1); + final available = laneWidth! - gaps; + + return Row( + crossAxisAlignment: crossAxisAlignment, + children: _withGaps( + panels.map((p) { + return SizedBox( + width: available * p.flex / totalFlex, + child: ClipRect(child: p.child), + ); + }).toList(), + ), + ); + } + + List _withGaps(List children) { + if (children.isEmpty) return const []; + final out = [children.first]; + for (var i = 1; i < children.length; i++) { + out.add(SizedBox(width: gap)); + out.add(children[i]); + } + return out; + } +} + +class ClaimsTabPanel { + final int flex; + final Widget child; + + const ClaimsTabPanel({required this.flex, required this.child}); +} + +/// Two-column mini grid (e.g. specialty analysis cards). +class ClaimsTabMiniGrid extends StatelessWidget { + final List items; + final double gap; + + const ClaimsTabMiniGrid({ + super.key, + required this.items, + this.gap = 12, + }); + + @override + Widget build(BuildContext context) { + assert(items.length == 4); + + return LayoutBuilder( + builder: (context, constraints) { + final maxW = constraints.maxWidth; + final isPdf = ClaimsPdfExportScope.of(context); + + Widget cell(int index) { + final child = items[index]; + if (isPdf && maxW.isFinite) { + final half = (maxW - gap) / 2; + return SizedBox(width: half, child: child); + } + return Expanded(child: child); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + cell(0), + SizedBox(width: gap), + cell(1), + ], + ), + SizedBox(height: gap), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + cell(2), + SizedBox(width: gap), + cell(3), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/presentation/claims_overview/claims_overview_scope.dart b/lib/presentation/claims_overview/claims_overview_scope.dart new file mode 100644 index 0000000..3ac6bfd --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_scope.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'claims_collection_kpi.dart'; +import 'enrollment_collection_kpi.dart'; + +/// When true, tab widgets skip scroll/entrance animations (PDF capture). +class ClaimsPdfExportScope extends InheritedWidget { + final bool enabled; + final double? laneWidth; + + const ClaimsPdfExportScope({ + super.key, + required this.enabled, + this.laneWidth, + required super.child, + }); + + static bool of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.enabled ?? + false; + } + + static double? laneWidthOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.laneWidth; + } + + @override + bool updateShouldNotify(ClaimsPdfExportScope oldWidget) { + return oldWidget.enabled != enabled || + oldWidget.laneWidth != laneWidth; + } +} + +/// Provides parsed KPI data to all Claims Overview tabs. +class ClaimsOverviewScope extends InheritedWidget { + final ClaimsOverviewViewData data; + + const ClaimsOverviewScope({ + super.key, + required this.data, + required super.child, + }); + + static ClaimsOverviewViewData of(BuildContext context) { + final scope = + context.dependOnInheritedWidgetOfExactType(); + return scope?.data ?? ClaimsOverviewViewData.empty(); + } + + @override + bool updateShouldNotify(ClaimsOverviewScope oldWidget) { + return oldWidget.data != data; + } +} + +/// Provides parsed enrollment KPI data to the Enrollment tab. +class EnrollmentOverviewScope extends InheritedWidget { + final EnrollmentOverviewViewData data; + + const EnrollmentOverviewScope({ + super.key, + required this.data, + required super.child, + }); + + static EnrollmentOverviewViewData of(BuildContext context) { + final scope = + context.dependOnInheritedWidgetOfExactType(); + return scope?.data ?? EnrollmentOverviewViewData.empty(); + } + + @override + bool updateShouldNotify(EnrollmentOverviewScope oldWidget) { + return oldWidget.data != data; + } +} diff --git a/lib/presentation/claims_overview/claims_overview_tabs.dart b/lib/presentation/claims_overview/claims_overview_tabs.dart new file mode 100644 index 0000000..f9b799b --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_tabs.dart @@ -0,0 +1,1349 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'claims_collection_kpi.dart'; +import 'claims_overview_animations.dart'; +import 'claims_overview_charts.dart'; +import 'claims_overview_pdf_layout.dart'; +import 'claims_overview_scope.dart'; +import 'claims_overview_theme.dart'; +import 'claims_overview_widgets.dart'; + +Widget _tabBodyWrapper({ + required bool forPdfExport, + required Widget child, +}) { + if (forPdfExport) return child; + return SingleChildScrollView(child: child); +} + +Widget _claimsChartPlaceholder(String message) { + return Center( + child: Text( + message, + style: GoogleFonts.poppins( + fontSize: 13, + color: ClaimsOverviewTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ); +} + +class ClaimsOverviewTab extends StatelessWidget { + final int index; + final int replayToken; + final bool isActive; + final bool forPdfExport; + + const ClaimsOverviewTab({ + super.key, + required this.index, + required this.replayToken, + this.isActive = false, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + return switch (index) { + 0 => _OverviewTab( + replayToken: replayToken, + forPdfExport: forPdfExport, + ), + 1 => _PolicyExperienceTab( + replayToken: replayToken, + isActive: isActive || forPdfExport, + forPdfExport: forPdfExport, + ), + 2 => _ClaimsAnalysisTab( + replayToken: replayToken, + isActive: isActive || forPdfExport, + forPdfExport: forPdfExport, + ), + 3 => _DemographicsTab( + replayToken: replayToken, + forPdfExport: forPdfExport, + ), + 4 => _HospitalsTab( + replayToken: replayToken, + forPdfExport: forPdfExport, + ), + 5 => _SpecialtyTab( + replayToken: replayToken, + forPdfExport: forPdfExport, + ), + 6 => _EnrollmentTab( + replayToken: replayToken, + isActive: isActive || forPdfExport, + forPdfExport: forPdfExport, + ), + _ => const SizedBox(), + }; + } +} + +class _OverviewTab extends StatelessWidget { + final int replayToken; + final bool forPdfExport; + + const _OverviewTab({ + required this.replayToken, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + final d = ClaimsOverviewScope.of(context); + + return _tabBodyWrapper( + forPdfExport: forPdfExport, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 0, + child: _sectionTitle( + Icons.description_outlined, + 'Policy Basic Information', + ), + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + return _OverviewMetricGrid( + replayToken: replayToken, + maxWidth: constraints.maxWidth, + cards: [ + _m(1, 'Policy Start Date', d.policyStartDate, + Icons.calendar_today, ClaimsOverviewTheme.blue, + animateValue: false), + _m(2, 'Policy End Date', d.policyEndDate, Icons.event, + ClaimsOverviewTheme.red, animateValue: false), + _m(3, 'Policy Run Days', d.policyRunDays, Icons.timelapse, + ClaimsOverviewTheme.purple), + _m(5, 'Premium As On Date', d.premiumAsOnDate, + Icons.account_balance_wallet, ClaimsOverviewTheme.orange), + _m(6, 'Earned Premium', d.earnedPremium, Icons.payments, + ClaimsOverviewTheme.green), + _m(7, 'Current Employee', d.currentEmployee, + Icons.business_center, ClaimsOverviewTheme.blue), + _m(8, 'Current Lives', d.currentLives, Icons.favorite_border, + ClaimsOverviewTheme.pink), + _m(9, 'Avg F-Size', d.avgFamilySize, Icons.groups, + ClaimsOverviewTheme.purple), + _m(11, 'Incurred Claims', d.incurredClaims, Icons.gps_fixed, + ClaimsOverviewTheme.red), + _m(12, 'Incurred Ratio', d.incurredRatio, Icons.speed, + ClaimsOverviewTheme.teal), + _m(13, 'Inception Employee', d.inceptionEmployee, + Icons.person_add_alt, ClaimsOverviewTheme.blue), + _m(14, 'Inception Lives', d.inceptionLives, + Icons.favorite_border, ClaimsOverviewTheme.pink), + _m(16, 'Projected Claims', d.projectedClaims, Icons.trending_up, + ClaimsOverviewTheme.red), + _m(17, 'Projected Ratio', d.projectedRatio, Icons.percent, + ClaimsOverviewTheme.orange), + _m(18, 'TPA', d.tpaName, Icons.apartment, + ClaimsOverviewTheme.teal, animateValue: false), + _m(19, 'Insurer', d.insurerName, Icons.military_tech, + ClaimsOverviewTheme.orange, animateValue: false), + ], + ); + }, + ), + ], + ), + ); + } + + Widget _sectionTitle(IconData icon, String title) { + return Row( + children: [ + Icon(icon, size: 18, color: ClaimsOverviewTheme.primary), + const SizedBox(width: 8), + Text( + title, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ], + ); + } + + ClaimsMetricCard _m( + int stagger, + String label, + String value, + IconData icon, + Color color, { + bool animateValue = true, + }) { + return ClaimsMetricCard( + label: label, + value: value, + icon: icon, + accentColor: color, + replayToken: replayToken, + animateValue: animateValue, + ); + } + +} + +/// 4-column metric grid — equal card width on every row. +class _OverviewMetricGrid extends StatelessWidget { + static const _columns = 4; + static const _gap = 12.0; + + final List cards; + final int replayToken; + final double maxWidth; + + const _OverviewMetricGrid({ + required this.cards, + required this.replayToken, + required this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + final cellWidth = + (maxWidth - _gap * (_columns - 1)) / _columns; + + return Wrap( + spacing: _gap, + runSpacing: _gap, + children: List.generate(cards.length, (i) { + return SizedBox( + width: cellWidth, + child: ClaimsStaggeredEntrance( + key: ValueKey('metric-$replayToken-$i'), + replayToken: replayToken, + index: i, + child: cards[i], + ), + ); + }), + ); + } +} + +class _PolicyExperienceTab extends StatefulWidget { + final int replayToken; + final bool isActive; + final bool forPdfExport; + + const _PolicyExperienceTab({ + required this.replayToken, + required this.isActive, + this.forPdfExport = false, + }); + + @override + State<_PolicyExperienceTab> createState() => _PolicyExperienceTabState(); +} + +class _PolicyExperienceTabState extends State<_PolicyExperienceTab> + with AutomaticKeepAliveClientMixin { + int _chartAnimKey = 0; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + if (widget.isActive) { + _chartAnimKey = 1; + } + } + + @override + void didUpdateWidget(covariant _PolicyExperienceTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive && !oldWidget.isActive) { + setState(() => _chartAnimKey++); + } + if (widget.replayToken != oldWidget.replayToken) { + setState(() => _chartAnimKey++); + } + } + + int get _horizontalChartKey => + widget.replayToken * 1000 + _chartAnimKey; + + int get _verticalChartKey => widget.replayToken * 1000 + _chartAnimKey; + + @override + Widget build(BuildContext context) { + super.build(context); + + final d = ClaimsOverviewScope.of(context); + final incurredSeries = d.incurredByClaimStatus; + final countSeries = d.claimsCountByStatus; + + final kpiMetrics = [ + ('Total Claims', d.sidebarTotalClaims, Icons.tag, ClaimsOverviewTheme.blue), + ( + 'Total Incurred Amount', + d.sidebarIncurredAmount, + Icons.currency_rupee, + ClaimsOverviewTheme.pink, + ), + ( + 'Total Reimbursement', + d.sidebarReimbursement, + Icons.account_balance_wallet_outlined, + ClaimsOverviewTheme.teal, + ), + ( + 'Reimbursement %', + d.sidebarReimbursementPct, + Icons.percent, + ClaimsOverviewTheme.orange, + ), + ( + 'Cashless Claim Amount', + d.sidebarCashless, + Icons.shopping_bag_outlined, + ClaimsOverviewTheme.purple, + ), + ( + 'Cashless %', + d.sidebarCashlessPct, + Icons.pie_chart_outline, + ClaimsOverviewTheme.teal, + ), + ]; + + return _tabBodyWrapper( + forPdfExport: widget.forPdfExport, + child: ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 7, + child: Column( + children: [ + ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: 0, + child: ClaimsSectionCard( + title: 'Total Incurred by Claim Status', + icon: Icons.bar_chart, + child: SizedBox( + height: 300, + child: incurredSeries.isEmpty + ? _claimsChartPlaceholder('No incurred-by-status data') + : ClaimsHorizontalIncurredBarChart( + labels: incurredSeries.labels, + valuesMillions: incurredSeries.values, + animationKey: _horizontalChartKey, + maxMillions: incurredSeries.maxY ?? 18, + ), + ), + ), + ), + const SizedBox(height: 16), + ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: 1, + child: ClaimsSectionCard( + title: 'Total Claims Count by Status', + icon: Icons.insert_chart_outlined, + child: SizedBox( + height: 280, + child: countSeries.isEmpty + ? _claimsChartPlaceholder('No claims count data') + : ClaimsCountByStatusBarChart( + labels: countSeries.labels, + values: countSeries.values, + animationKey: _verticalChartKey, + maxY: countSeries.maxY ?? 220, + ), + ), + ), + ), + ], + ), + ), + ClaimsTabPanel( + flex: 3, + child: Column( + children: [ + for (var i = 0; i < kpiMetrics.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: 2 + i, + child: ClaimsMetricCard( + label: kpiMetrics[i].$1, + value: kpiMetrics[i].$2, + icon: kpiMetrics[i].$3, + accentColor: kpiMetrics[i].$4, + replayToken: widget.replayToken, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _ClaimsAnalysisTab extends StatefulWidget { + final int replayToken; + final bool isActive; + final bool forPdfExport; + + const _ClaimsAnalysisTab({ + required this.replayToken, + required this.isActive, + this.forPdfExport = false, + }); + + @override + State<_ClaimsAnalysisTab> createState() => _ClaimsAnalysisTabState(); +} + +class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> + with AutomaticKeepAliveClientMixin { + int _chartAnimKey = 0; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + if (widget.isActive) _chartAnimKey = 1; + } + + @override + void didUpdateWidget(covariant _ClaimsAnalysisTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive && !oldWidget.isActive) { + setState(() => _chartAnimKey++); + } + if (widget.replayToken != oldWidget.replayToken) { + setState(() => _chartAnimKey++); + } + } + + int get _claimTypeChartKey => widget.replayToken * 1000 + _chartAnimKey; + int get _claimsAnalysisDonutKey => widget.replayToken * 1000 + _chartAnimKey + 50; + + @override + Widget build(BuildContext context) { + super.build(context); + + final d = ClaimsOverviewScope.of(context); + final claimTypeCounts = d.claimTypeCountSeries; + final claimTypeIncurred = d.claimTypeIncurredDonut; + final statusDonut = d.claimAmountByStatusDonut; + final monthSeries = d.amountByMonth; + + List _yTicks(double maxY) { + if (maxY <= 0) return const [0, 3, 6, 9, 12]; + final step = maxY / 4; + return List.generate(5, (i) => (step * i).roundToDouble()); + } + + return _tabBodyWrapper( + forPdfExport: widget.forPdfExport, + child: Column( + children: [ + _chartRow( + 0, + ClaimsSectionCard( + title: 'Claim Amount by Type', + icon: Icons.bar_chart, + child: SizedBox( + height: 280, + child: claimTypeCounts.isEmpty + ? _claimsChartPlaceholder('No claim type count data') + : ClaimsCountByClaimTypeChart( + labels: claimTypeCounts.labels, + values: claimTypeCounts.values, + animationKey: _claimTypeChartKey, + maxY: claimTypeCounts.maxY ?? 25, + ), + ), + ), + ClaimsSectionCard( + title: 'Total Incurred by Claim Type', + icon: Icons.donut_large, + child: SizedBox( + height: 260, + child: claimTypeIncurred.isEmpty + ? _claimsChartPlaceholder('No claim type incurred data') + : ClaimsDonutChart( + values: claimTypeIncurred.values, + labels: claimTypeIncurred.labels, + showLegend: true, + animationKey: _claimsAnalysisDonutKey, + replayToken: widget.replayToken, + ), + ), + ), + ), + const SizedBox(height: 16), + _chartRow( + 2, + ClaimsSectionCard( + title: 'Claim Amount by Claim Status', + icon: Icons.pie_chart, + child: SizedBox( + height: 280, + child: statusDonut.isEmpty + ? _claimsChartPlaceholder('No claim status amount data') + : ClaimsDonutChart( + values: statusDonut.values, + labels: statusDonut.labels, + showLegend: true, + animationKey: _claimsAnalysisDonutKey + 1, + replayToken: widget.replayToken, + ), + ), + ), + ClaimsSectionCard( + title: 'Amount by Month Wise', + icon: Icons.show_chart, + child: SizedBox( + height: 280, + child: monthSeries.isEmpty + ? _claimsChartPlaceholder('No monthly amount data') + : ClaimsAreaChart( + labels: monthSeries.labels, + values: monthSeries.values, + maxY: monthSeries.maxY ?? 12, + yTicks: _yTicks(monthSeries.maxY ?? 12), + replayToken: widget.replayToken, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _chartRow(int baseIndex, Widget left, Widget right) { + return ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: baseIndex, + child: left, + ), + ), + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: baseIndex + 1, + child: right, + ), + ), + ], + ); + } +} + +class _DemographicsTab extends StatelessWidget { + final int replayToken; + final bool forPdfExport; + + const _DemographicsTab({ + required this.replayToken, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + final d = ClaimsOverviewScope.of(context); + final ageBand = d.ageBandSeries; + final gender = d.genderDonut; + final relationships = d.relationshipClaims; + const relationshipColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ClaimsOverviewTheme.red, + ClaimsOverviewTheme.blue, + ClaimsOverviewTheme.pink, + ]; + + return _tabBodyWrapper( + forPdfExport: forPdfExport, + child: Column( + children: [ + if (relationships.isNotEmpty) ...[ + ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 0, + child: ClaimsSectionCard( + title: 'Relationship Wise Claim Details', + icon: Icons.people_outline, + child: Row( + children: [ + for (var i = 0; i < relationships.length; i++) + ClaimsRelationshipCard( + relation: relationships[i].relation, + claims: relationships[i].claims, + amount: relationships[i].amount, + color: relationshipColors[i % relationshipColors.length], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 1, + child: ClaimsSectionCard( + title: 'Age Band', + icon: Icons.bar_chart, + child: SizedBox( + height: 300, + child: ageBand.isEmpty + ? _claimsChartPlaceholder('No age band data') + : ClaimsVerticalBarChart( + labels: ageBand.labels, + values: ageBand.values, + maxY: ageBand.maxY ?? 16000000, + replayToken: replayToken, + ), + ), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 2, + child: ClaimsSectionCard( + title: 'Claim Amount by Gender', + icon: Icons.donut_large, + child: SizedBox( + height: 300, + child: gender.isEmpty + ? _claimsChartPlaceholder('No gender data') + : ClaimsDonutChart( + values: gender.values, + labels: gender.labels, + colors: const [ + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.teal, + ], + showLegend: true, + replayToken: replayToken, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _HospitalsTab extends StatelessWidget { + final int replayToken; + final bool forPdfExport; + + const _HospitalsTab({ + required this.replayToken, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + final d = ClaimsOverviewScope.of(context); + final hospitals = d.topHospitals; + final cities = d.cities; + final scatterSeries = d.claimsByMonthSeries; + final incidence = d.claimsIncidencePoint; + + final cityColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ]; + + final hospitalIcons = [ + Icons.remove_red_eye_outlined, + Icons.local_hospital, + Icons.medical_services_outlined, + Icons.healing, + Icons.domain, + ]; + final hospitalColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ]; + + return _tabBodyWrapper( + forPdfExport: forPdfExport, + child: Column( + children: [ + ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 0, + child: ClaimsSectionCard( + title: 'Top 5 Hospitals by Incurred Amount', + icon: Icons.local_hospital_outlined, + child: hospitals.isEmpty + ? _claimsChartPlaceholder('No hospital data') + : Column( + children: List.generate(hospitals.length, (i) { + final item = hospitals[i]; + return ClaimsListRow( + icon: hospitalIcons[i % hospitalIcons.length], + iconColor: + hospitalColors[i % hospitalColors.length], + title: item.title, + value: item.value, + ); + }), + ), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 1, + child: ClaimsSectionCard( + title: 'Total Incurred by City', + icon: Icons.location_on_outlined, + child: cities.isEmpty + ? _claimsChartPlaceholder('No city data') + : Column( + children: List.generate(cities.length, (i) { + final c = cities[i]; + return ClaimsCityBar( + city: c.city, + amount: c.amount, + fraction: c.fraction, + color: cityColors[i % cityColors.length], + replayToken: replayToken, + ); + }), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 2, + child: ClaimsSectionCard( + title: 'Claim Count by Year & Month', + icon: Icons.scatter_plot, + child: SizedBox( + height: 280, + child: scatterSeries.isEmpty + ? (incidence == null + ? _claimsChartPlaceholder('No monthly claim data') + : ClaimsScatterChart( + pointY: incidence.y, + label: incidence.label, + replayToken: replayToken, + )) + : ClaimsScatterChart( + values: scatterSeries.values, + labels: scatterSeries.labels, + maxY: scatterSeries.maxY, + replayToken: replayToken, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SpecialtyTab extends StatelessWidget { + final int replayToken; + final bool forPdfExport; + + const _SpecialtyTab({ + required this.replayToken, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + final d = ClaimsOverviewScope.of(context); + final ailments = d.topAilments; + const ailmentIcons = [ + Icons.healing, + Icons.visibility_outlined, + Icons.sick, + Icons.favorite, + Icons.water_drop_outlined, + Icons.medical_information_outlined, + ]; + const ailmentColors = [ + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ClaimsOverviewTheme.blue, + ]; + + return _tabBodyWrapper( + forPdfExport: forPdfExport, + child: Column( + children: [ + ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 0, + child: ClaimsSectionCard( + title: 'Pregnancy Analysis', + icon: Icons.pregnant_woman, + child: _miniGrid([ + _mini( + 'Normal Delivery (W/Limit)', + d.pregnancyNormalExceeded, + Icons.cookie, + ClaimsOverviewTheme.pink, + ), + _mini( + 'C-Sec Exceeded', + d.pregnancyCSecExceeded, + Icons.medical_services, + ClaimsOverviewTheme.red, + ), + _mini( + 'C-Sec Avg Exceed', + d.pregnancyCSecAvg, + Icons.medication, + ClaimsOverviewTheme.pink, + ), + _mini( + 'Normal Delivery Exceeded', + d.pregnancyNormalDeliveryExceeded, + Icons.child_care, + ClaimsOverviewTheme.purple, + ), + ]), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 1, + child: ClaimsSectionCard( + title: 'Cataract Analysis', + icon: Icons.visibility, + child: _miniGrid([ + _mini( + 'Cataract Exceeded Claim', + d.cataractExceededClaim, + Icons.remove_red_eye, + ClaimsOverviewTheme.teal, + ), + _mini( + 'Avg Exceeded Amt', + d.cataractAvgExceeded, + Icons.show_chart, + ClaimsOverviewTheme.blue, + ), + _mini( + 'Within Limit Claims', + d.cataractWithinLimit, + Icons.shield, + ClaimsOverviewTheme.green, + ), + _mini( + 'Total Cataract Claims', + d.totalCataractClaims, + Icons.numbers, + ClaimsOverviewTheme.blue, + ), + ]), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ClaimsStaggeredEntrance( + replayToken: replayToken, + index: 2, + child: ClaimsSectionCard( + title: 'Top Ailments', + icon: Icons.medical_services_outlined, + child: ailments.isEmpty + ? _claimsChartPlaceholder('No ailment data for this policy') + : Column( + children: [ + for (var i = 0; i < ailments.length; i++) ...[ + if (i > 0) + const Divider( + height: 1, + color: ClaimsOverviewTheme.border, + ), + ClaimsListRow( + icon: ailmentIcons[i % ailmentIcons.length], + iconColor: + ailmentColors[i % ailmentColors.length], + title: ailments[i].title, + value: ailments[i].value, + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _miniGrid(List items) { + return ClaimsTabMiniGrid(items: items); + } + + Widget _mini(String label, String value, IconData icon, Color color) { + return ClaimsMetricCard( + label: label, + value: value, + icon: icon, + accentColor: color, + replayToken: replayToken, + ); + } +} + +class _EnrollmentTab extends StatefulWidget { + final int replayToken; + final bool isActive; + final bool forPdfExport; + + const _EnrollmentTab({ + required this.replayToken, + required this.isActive, + this.forPdfExport = false, + }); + + @override + State<_EnrollmentTab> createState() => _EnrollmentTabState(); +} + +class _EnrollmentTabState extends State<_EnrollmentTab> + with AutomaticKeepAliveClientMixin { + int _chartAnimKey = 0; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + if (widget.isActive) _chartAnimKey = 1; + } + + @override + void didUpdateWidget(covariant _EnrollmentTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive && !oldWidget.isActive) { + setState(() => _chartAnimKey++); + } + if (widget.replayToken != oldWidget.replayToken) { + setState(() => _chartAnimKey++); + } + } + + int get _chartKey => widget.replayToken * 1000 + _chartAnimKey; + + @override + Widget build(BuildContext context) { + super.build(context); + final d = EnrollmentOverviewScope.of(context); + final gender = d.genderDonut; + final ageGroups = d.ageGroupsDonut; + final relationship = d.relationshipDonut; + final ageTrend = d.averageAgeTrend; + + const donutColors = [ + ClaimsOverviewTheme.blue, + ClaimsOverviewTheme.red, + ClaimsOverviewTheme.teal, + ClaimsOverviewTheme.orange, + ClaimsOverviewTheme.purple, + ClaimsOverviewTheme.green, + ClaimsOverviewTheme.yellow, + ClaimsOverviewTheme.pink, + ]; + + final donutHeight = widget.forPdfExport ? 190.0 : 300.0; + final trendHeight = widget.forPdfExport ? 228.0 : 320.0; + final sectionGap = widget.forPdfExport ? 10.0 : 20.0; + + Widget section(int index, Widget child) { + if (widget.forPdfExport) return child; + return ClaimsStaggeredEntrance( + replayToken: widget.replayToken, + index: index, + child: child, + ); + } + + final body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: section( + 0, + ClaimsSectionCard( + title: 'Financial Insights', + icon: Icons.account_balance_wallet_outlined, + child: _enrollmentMiniGrid([ + _mini( + 'Original Base Premium', + d.originalBasePremium, + Icons.savings_outlined, + ClaimsOverviewTheme.green, + ), + _mini( + 'Additions Premium', + d.subsequentBasePremium, + Icons.add_chart, + ClaimsOverviewTheme.orange, + ), + _mini( + 'Net Premium', + d.netPremium, + Icons.payments_outlined, + ClaimsOverviewTheme.teal, + ), + const SizedBox.shrink(), + ]), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: section( + 1, + ClaimsSectionCard( + title: 'Membership Metrics', + icon: Icons.groups_outlined, + child: _enrollmentMiniGrid([ + _mini( + 'Active Members', + d.activeMembers, + Icons.people, + ClaimsOverviewTheme.teal, + ), + _mini( + 'Originally Enrolled', + d.originallyEnrolled, + Icons.how_to_reg, + ClaimsOverviewTheme.blue, + ), + _mini( + 'Added Subsequently', + d.addedSubsequently, + Icons.person_add_alt_1, + ClaimsOverviewTheme.orange, + ), + const SizedBox.shrink(), + ]), + ), + ), + ), + ], + ), + SizedBox(height: sectionGap), + ClaimsTabPanelRow( + panels: [ + ClaimsTabPanel( + flex: 1, + child: section( + 2, + ClaimsSectionCard( + title: 'Gender Split', + icon: Icons.wc_outlined, + child: SizedBox( + height: donutHeight, + child: _enrollmentDonutChart( + series: gender, + totalLabel: d.donutTotal(gender), + forPdfExport: widget.forPdfExport, + colors: const [ + ClaimsOverviewTheme.blue, + ClaimsOverviewTheme.red, + ], + ), + ), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: section( + 3, + ClaimsSectionCard( + title: 'Age Groups', + icon: Icons.cake_outlined, + child: SizedBox( + height: donutHeight, + child: _enrollmentDonutChart( + series: ageGroups, + totalLabel: d.donutTotal(ageGroups), + forPdfExport: widget.forPdfExport, + colors: donutColors, + ), + ), + ), + ), + ), + ClaimsTabPanel( + flex: 1, + child: section( + 4, + ClaimsSectionCard( + title: 'Relationship', + icon: Icons.family_restroom, + child: SizedBox( + height: donutHeight, + child: _enrollmentDonutChart( + series: relationship, + totalLabel: d.donutTotal(relationship), + forPdfExport: widget.forPdfExport, + colors: donutColors, + ), + ), + ), + ), + ), + ], + ), + SizedBox(height: sectionGap), + section( + 5, + ClaimsSectionCard( + title: 'Age Trend', + icon: Icons.show_chart, + child: SizedBox( + height: trendHeight, + child: ageTrend.isEmpty + ? _claimsChartPlaceholder('No age trend data') + : _EnrollmentAgeTrendChart( + labels: ageTrend.labels, + values: ageTrend.values, + maxY: ageTrend.maxY ?? 40, + animationKey: _chartKey, + forPdfExport: widget.forPdfExport, + ), + ), + ), + ), + ], + ); + + return _tabBodyWrapper( + forPdfExport: widget.forPdfExport, + child: widget.forPdfExport ? ClipRect(child: body) : body, + ); + } + + Widget _enrollmentMiniGrid(List items) { + return ClaimsTabMiniGrid(items: items); + } + + Widget _mini(String label, String value, IconData icon, Color color) { + return ClaimsMetricCard( + label: label, + value: value, + icon: icon, + accentColor: color, + replayToken: widget.replayToken, + ); + } + + Widget _enrollmentDonutChart({ + required ClaimsChartSeries series, + required String totalLabel, + required List colors, + bool forPdfExport = false, + }) { + if (series.isEmpty) { + return _claimsChartPlaceholder('No data available'); + } + + final chart = ClaimsDonutChart( + values: series.values, + labels: series.labels, + colors: colors, + showLegend: false, + replayToken: widget.replayToken, + animationKey: _chartKey, + ); + + if (forPdfExport) { + return chart; + } + + return Column( + children: [ + Expanded(child: chart), + const SizedBox(height: 8), + _EnrollmentDonutLegend( + labels: series.labels, + values: series.values, + colors: colors, + totalLabel: totalLabel, + ), + ], + ); + } +} + +class _EnrollmentDonutLegend extends StatelessWidget { + final List labels; + final List values; + final List colors; + final String totalLabel; + + const _EnrollmentDonutLegend({ + required this.labels, + required this.values, + required this.colors, + required this.totalLabel, + }); + + @override + Widget build(BuildContext context) { + final total = values.fold(0, (a, b) => a + b); + return Wrap( + spacing: 12, + runSpacing: 8, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...List.generate(labels.length, (i) { + final pct = total > 0 ? (values[i] / total) * 100 : 0.0; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colors[i % colors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${labels[i]} ${pct.toStringAsFixed(1)}%', + style: GoogleFonts.poppins( + fontSize: 11, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + ], + ); + }), + Text( + 'Total: $totalLabel', + style: GoogleFonts.poppins( + fontSize: 11, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ], + ); + } +} + +class _EnrollmentAgeTrendChart extends StatelessWidget { + final List labels; + final List values; + final double maxY; + final int animationKey; + final bool forPdfExport; + + const _EnrollmentAgeTrendChart({ + required this.labels, + required this.values, + required this.maxY, + required this.animationKey, + this.forPdfExport = false, + }); + + @override + Widget build(BuildContext context) { + final ticks = [0, maxY * 0.25, maxY * 0.5, maxY * 0.75, maxY]; + return ClaimsCustomVerticalBarChart( + labels: labels, + values: values, + maxY: maxY, + colors: List.filled( + values.length, + ClaimsOverviewTheme.pink.withValues(alpha: 0.85), + ), + animationKey: animationKey, + yTicks: ticks, + barWidth: forPdfExport ? 40 : 56, + barSlotFraction: 0.72, + leftAxisWidth: forPdfExport ? 40 : 48, + bottomAxisHeight: forPdfExport ? 40 : 52, + formatYTick: (v) => v.toStringAsFixed(0), + tooltipValueLabel: 'avg age', + ); + } +} + diff --git a/lib/presentation/claims_overview/claims_overview_theme.dart b/lib/presentation/claims_overview/claims_overview_theme.dart new file mode 100644 index 0000000..79a72b0 --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_theme.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +/// Design tokens for Claims Overview dashboard (light theme). +abstract final class ClaimsOverviewTheme { + static const Color primary = Color(0xFF14A39A); + static const Color pageBg = Color(0xFFF9FAFB); + static const Color cardBg = Colors.white; + static const Color textPrimary = Color(0xFF1E293B); + static const Color textSecondary = Color(0xFF64748B); + static const Color border = Color(0xFFE2E8F0); + + static const Color teal = Color(0xFF14A39A); + static const Color orange = Color(0xFFF2853F); + static const Color purple = Color(0xFF6366F1); + static const Color green = Color(0xFF22C55E); + static const Color yellow = Color(0xFFF59E0B); + static const Color red = Color(0xFFEF4444); + static const Color blue = Color(0xFF3B82F6); + static const Color pink = Color(0xFFEC4899); + static const Color cyan = Color(0xFF06B6D4); + static const Color lavender = Color(0xFFA78BFA); + + static const List chartPalette = [ + teal, + orange, + purple, + green, + yellow, + red, + cyan, + lavender, + ]; +} diff --git a/lib/presentation/claims_overview/claims_overview_widgets.dart b/lib/presentation/claims_overview/claims_overview_widgets.dart new file mode 100644 index 0000000..fe7c336 --- /dev/null +++ b/lib/presentation/claims_overview/claims_overview_widgets.dart @@ -0,0 +1,373 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'claims_overview_animations.dart'; +import 'claims_overview_scope.dart'; +import 'claims_overview_theme.dart'; + +class ClaimsMetricCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color accentColor; + final int replayToken; + final bool animateValue; + + const ClaimsMetricCard({ + super.key, + required this.label, + required this.value, + required this.icon, + required this.accentColor, + this.replayToken = 0, + this.animateValue = true, + }); + + @override + Widget build(BuildContext context) { + final compact = ClaimsPdfExportScope.of(context); + final valueStyle = GoogleFonts.poppins( + fontSize: compact ? 15 : 20, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + height: 1.2, + ); + + return Container( + constraints: BoxConstraints(minHeight: compact ? 88 : 108), + decoration: BoxDecoration( + color: ClaimsOverviewTheme.cardBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + compact ? 10 : 14, + compact ? 10 : 12, + compact ? 10 : 14, + compact ? 6 : 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + label.toUpperCase(), + style: GoogleFonts.poppins( + fontSize: 10, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.textSecondary, + letterSpacing: 0.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: compact ? 16 : 18, color: accentColor), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + compact ? 10 : 14, + 0, + compact ? 10 : 14, + compact ? 8 : 10, + ), + child: SizedBox( + height: compact ? 44 : 52, + child: Align( + alignment: Alignment.centerLeft, + child: compact || !animateValue + ? Text( + value, + style: valueStyle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : ClaimsAnimatedValueText( + value: value, + replayToken: replayToken, + style: valueStyle, + ), + ), + ), + ), + if (compact) + Container( + height: 3, + decoration: BoxDecoration( + color: accentColor, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + ), + ) + else + TweenAnimationBuilder( + key: ValueKey('accent-$replayToken-$label'), + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, w, __) { + return ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: w.clamp(0.0, 1.0), + child: Container( + height: 3, + color: accentColor, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class ClaimsSectionCard extends StatelessWidget { + final String title; + final IconData icon; + final Widget child; + + const ClaimsSectionCard({ + super.key, + required this.title, + required this.icon, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: ClaimsOverviewTheme.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ClaimsOverviewTheme.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Row( + children: [ + Icon(icon, size: 20, color: ClaimsOverviewTheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: GoogleFonts.poppins( + fontSize: 15, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ], + ), + ); + } +} + +class ClaimsListRow extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String title; + final String value; + + const ClaimsListRow({ + super.key, + required this.icon, + required this.iconColor, + required this.title, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: GoogleFonts.poppins( + fontSize: 13, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ), + Text( + value, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ], + ), + ); + } +} + +class ClaimsRelationshipCard extends StatelessWidget { + final String relation; + final int claims; + final String amount; + final Color color; + + const ClaimsRelationshipCard({ + super.key, + required this.relation, + required this.claims, + required this.amount, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: ClaimsOverviewTheme.cardBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ClaimsOverviewTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.person_outline, color: color, size: 22), + const SizedBox(height: 8), + Text( + relation, + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + '$claims Claims', + style: GoogleFonts.poppins( + fontSize: 11, + color: ClaimsOverviewTheme.textSecondary, + ), + ), + Text( + 'Amount: $amount', + style: GoogleFonts.poppins( + fontSize: 11, + fontWeight: FontWeight.w600, + color: ClaimsOverviewTheme.textPrimary, + ), + ), + ], + ), + ), + ); + } +} + +class ClaimsCityBar extends StatelessWidget { + final String city; + final String amount; + final double fraction; + final Color color; + final int replayToken; + + const ClaimsCityBar({ + super.key, + required this.city, + required this.amount, + required this.fraction, + required this.color, + this.replayToken = 0, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(city, style: GoogleFonts.poppins(fontSize: 13)), + Text( + amount, + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + ClaimsAnimatedProgressBar( + value: fraction, + color: color, + replayToken: replayToken, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/claims_overview/enrollment_collection_kpi.dart b/lib/presentation/claims_overview/enrollment_collection_kpi.dart new file mode 100644 index 0000000..1234128 --- /dev/null +++ b/lib/presentation/claims_overview/enrollment_collection_kpi.dart @@ -0,0 +1,360 @@ +import 'dart:math' as math; + +import 'package:intl/intl.dart'; + +import 'claims_collection_kpi.dart'; + +/// KPI slugs for `employeeRest/enrollment-collection-v1`. +abstract final class EnrollmentKpiSlug { + static const originallyEnrolled = 'originally_enrolled'; + static const addedSubsequently = 'added_subsequently'; + static const genderSplit = 'gender_split'; + static const enrollmentRelationship = 'enrollment_relationship'; + static const averageAgeByEnrollmentMonth = + 'average_age_by_enrollment_month'; + static const originalBasePremium = 'original_base_premium'; + static const overallActive = 'overall_active'; + static const enrollmentAgeGroup = 'enrollment_age_group'; + static const additionsPremium = 'additions_premium'; + static const netPremium = 'net_premium'; +} + +/// Parsed view-model for the Enrollment tab. +class EnrollmentOverviewViewData { + final Map kpiBySlug; + final String? loadError; + + const EnrollmentOverviewViewData({ + required this.kpiBySlug, + this.loadError, + }); + + factory EnrollmentOverviewViewData.empty({String? error}) => + EnrollmentOverviewViewData(kpiBySlug: const {}, loadError: error); + + factory EnrollmentOverviewViewData.fromApiResponse( + Map response, + ) { + return EnrollmentOverviewViewData( + kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response), + ); + } + + /// Fills membership basics from policy list API when enrollment KPI rows are empty. + EnrollmentOverviewViewData withPolicyFallback(Map policy) { + final merged = Map.from(kpiBySlug); + final active = + policy['membersCountOfActive'] ?? policy['totalMembersCount']; + ClaimsKpiParser.ensureKpiField( + merged, + EnrollmentKpiSlug.overallActive, + 'Overall Active', + active, + ); + ClaimsKpiParser.ensureKpiField( + merged, + EnrollmentKpiSlug.originalBasePremium, + 'opening_premium', + policy['total_premium'], + ); + return EnrollmentOverviewViewData(kpiBySlug: merged, loadError: loadError); + } + + dynamic _kpi(String slug) => kpiBySlug[slug] ?? kpiBySlug[_idKey(slug)]; + + /// Reads a scalar from KPI rows; supports v1 label-keyed single-value rows. + static String _scalarFromKpi( + dynamic kpi, + List fields, { + String Function(String raw)? format, + }) { + if (kpi == null) return '—'; + + for (final field in fields) { + final raw = ClaimsKpiParser.kpiOnlyField(kpi, field); + if (raw != '—') { + return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw); + } + } + + final rows = ClaimsKpiParser.extractRows(kpi); + if (rows.isEmpty) return '—'; + + if (rows.length == 1) { + final row = rows.first; + if (row is Map) { + for (final field in fields) { + final value = ClaimsKpiParser.rowValue(row, field); + if (value != null) { + final raw = ClaimsKpiParser.cleanDisplay(value); + if (raw.isNotEmpty) { + return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw); + } + } + } + if (row.length == 1) { + final raw = ClaimsKpiParser.cleanDisplay(row.values.first); + if (raw.isNotEmpty) { + return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw); + } + } + } + } + + return '—'; + } + + String _scalar( + List slugs, + List fields, { + String Function(String raw)? format, + }) { + for (final slug in slugs) { + final value = _scalarFromKpi(_kpi(slug), fields, format: format); + if (value != '—') return value; + } + return '—'; + } + + ClaimsChartSeries _chartSeries( + List slugs, { + required String labelKey, + String valueKey = 'member_count', + }) { + for (final slug in slugs) { + final kpi = _kpi(slug); + if (kpi == null) continue; + + final explicit = ClaimsKpiParser.labelValueSeries( + kpi, + labelKey: labelKey, + valueKey: valueKey, + ); + if (!explicit.isEmpty) return explicit; + + final auto = ClaimsKpiParser.labelValueSeries(kpi); + if (!auto.isEmpty) return auto; + } + return const ClaimsChartSeries(labels: [], values: []); + } + + static String formatPremium(String raw) { + if (raw.isEmpty || raw == '—') return '—'; + if (raw.startsWith('₹')) return raw; + final n = ClaimsKpiParser.toDouble(raw); + if (n == null) return raw; + return formatPremiumAmount(n); + } + + static String formatPremiumAmount(double n) { + if (n.abs() >= 1000000) { + return '₹${(n / 1000000).toStringAsFixed(1)}M'; + } + if (n.abs() >= 1000) { + return '₹${(n / 1000).toStringAsFixed(1)}K'; + } + return '₹${NumberFormat('#,##0').format(n.round())}'; + } + + String get activeMembers => _scalar( + [EnrollmentKpiSlug.overallActive], + const [ + 'Overall Active', + 'overall_active', + 'active_members', + 'member_count', + 'count', + ], + ); + + String get originallyEnrolled => _scalar( + [EnrollmentKpiSlug.originallyEnrolled], + const [ + 'Originally Enrolled', + 'originally_enrolled', + 'member_count', + 'count', + ], + ); + + String get addedSubsequently => _scalar( + [EnrollmentKpiSlug.addedSubsequently], + const [ + 'Subsequent Additions', + 'added_subsequently', + 'member_count', + 'count', + ], + ); + + String get originalBasePremium => _scalar( + [EnrollmentKpiSlug.originalBasePremium], + const [ + 'opening_premium', + 'original_base_premium', + 'Original Base Premium', + 'premium', + 'amount', + ], + format: formatPremium, + ); + + String get subsequentBasePremium => _scalar( + [EnrollmentKpiSlug.additionsPremium], + const [ + 'additions_premium', + 'Additions Premium', + 'subsequent_base_premium', + 'premium', + 'amount', + ], + format: formatPremium, + ); + + String get netPremium => _scalar( + [EnrollmentKpiSlug.netPremium], + const [ + 'net_premium', + 'Net Premium', + 'premium', + 'amount', + ], + format: formatPremium, + ); + + ClaimsChartSeries get genderDonut { + final series = _chartSeries( + [EnrollmentKpiSlug.genderSplit], + labelKey: 'gender', + ); + if (series.isEmpty) return series; + final labels = series.labels + .map((g) => switch (g.toUpperCase()) { + 'M' => 'M', + 'F' => 'F', + 'MALE' => 'M', + 'FEMALE' => 'F', + _ => g, + }) + .toList(); + return ClaimsChartSeries( + labels: labels, + values: series.values, + maxY: series.maxY, + ); + } + + ClaimsChartSeries get ageGroupsDonut => _chartSeries( + [EnrollmentKpiSlug.enrollmentAgeGroup], + labelKey: 'age_group', + ); + + ClaimsChartSeries get relationshipDonut => _chartSeries( + [EnrollmentKpiSlug.enrollmentRelationship], + labelKey: 'relationship', + ); + + ClaimsChartSeries get averageAgeTrend { + final series = _monthAgeSeries(_kpi(EnrollmentKpiSlug.averageAgeByEnrollmentMonth)); + if (!series.isEmpty) return series; + return const ClaimsChartSeries(labels: [], values: []); + } + + ClaimsChartSeries _monthAgeSeries(dynamic kpi) { + final rows = ClaimsKpiParser.extractRows(kpi); + if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); + + const labelFields = [ + 'month_label', + 'month', + 'enrollment_month', + 'claim_month', + 'month_name', + 'period', + ]; + const valueFields = [ + 'average_age', + 'avg_age', + 'average_age_value', + 'age', + 'value', + ]; + const sortFields = ['enrollment_month', 'month_sort', 'sort', 'month_order']; + + final items = <({String sort, String label, double value})>[]; + for (final row in rows) { + if (row is! Map) continue; + + String? label; + for (final field in labelFields) { + final candidate = ClaimsKpiParser.rowValue(row, field)?.toString(); + if (candidate != null && candidate.isNotEmpty) { + label = candidate; + break; + } + } + + double? value; + for (final field in valueFields) { + value = ClaimsKpiParser.toDouble(ClaimsKpiParser.rowValue(row, field)); + if (value != null) break; + } + + if (label == null || value == null) continue; + + var sort = ''; + for (final field in sortFields) { + final candidate = ClaimsKpiParser.rowValue(row, field)?.toString(); + if (candidate != null && candidate.isNotEmpty) { + sort = candidate; + break; + } + } + if (sort.isEmpty) sort = label; + + items.add((sort: sort, label: label, value: value)); + } + + if (items.isEmpty) return const ClaimsChartSeries(labels: [], values: []); + + items.sort((a, b) => a.sort.compareTo(b.sort)); + final labels = items.map((e) => e.label).toList(); + final values = items.map((e) => e.value).toList(); + final max = values.reduce(math.max); + return ClaimsChartSeries( + labels: labels, + values: values, + maxY: _niceAgeMaxY(max), + ); + } + + String donutTotal(ClaimsChartSeries series) { + if (series.isEmpty) return '—'; + final total = series.values.fold(0, (a, b) => a + b); + return NumberFormat('#,##0').format(total.round()); + } + + static double _niceAgeMaxY(double max) { + if (max <= 0) return 40; + if (max <= 40) return 40; + final exp = (math.log(max) / math.ln10).floor(); + final magnitude = math.pow(10, exp).toDouble(); + return (max / magnitude).ceil() * magnitude * 1.1; + } + + static String? _idKey(String slug) { + const ids = { + EnrollmentKpiSlug.originallyEnrolled: '115', + EnrollmentKpiSlug.addedSubsequently: '116', + EnrollmentKpiSlug.genderSplit: '117', + EnrollmentKpiSlug.enrollmentRelationship: '119', + EnrollmentKpiSlug.averageAgeByEnrollmentMonth: '122', + EnrollmentKpiSlug.originalBasePremium: '123', + EnrollmentKpiSlug.overallActive: '124', + EnrollmentKpiSlug.enrollmentAgeGroup: '125', + EnrollmentKpiSlug.additionsPremium: '126', + EnrollmentKpiSlug.netPremium: '127', + }; + return ids[slug]; + } +} diff --git a/lib/service/api_service.dart b/lib/service/api_service.dart index 648d7f8..6d1b23e 100755 --- a/lib/service/api_service.dart +++ b/lib/service/api_service.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:nhancepolicy/service/token_storage_service.dart'; @@ -683,6 +685,71 @@ class ApiService { } } + /// All 31 claims-collection KPIs in one response. + Future> getClaimsCollectionV2All({ + required String clientPolicyId, + }) async { + if (_token == null) await _initializeToken(); + + final url = Uri.parse('${Environment.apiUrlPost}claims-collection-v2/all') + .replace( + queryParameters: {'client_policy': clientPolicyId}, + ); + + final headers = {'Authorization': 'Bearer ${_token ?? ''}'}; + return _makeGetRequest(url, headers); + } + + /// Single KPI by Metabase slug or numeric id. + Future> getClaimsCollectionV2Kpi( + String slugOrId, { + required String clientPolicyId, + }) async { + if (_token == null) await _initializeToken(); + + final encoded = Uri.encodeComponent(slugOrId); + final url = Uri.parse( + '${Environment.apiUrlPost}claims-collection-v2/kpi/$encoded', + ).replace( + queryParameters: {'client_policy': clientPolicyId}, + ); + final headers = {'Authorization': 'Bearer ${_token ?? ''}'}; + return _makeGetRequest(url, headers); + } + + /// All enrollment-collection KPIs in one response (`enrollment-collection-v1`). + Future> getEnrollmentCollectionV1All({ + required String clientPolicyId, + }) async { + if (_token == null) await _initializeToken(); + + final url = + Uri.parse('${Environment.apiUrlPost}enrollment-collection-v1/all') + .replace( + queryParameters: {'client_policy': clientPolicyId}, + ); + + final headers = {'Authorization': 'Bearer ${_token ?? ''}'}; + return _makeGetRequest(url, headers); + } + + /// Single enrollment KPI by slug or numeric id (`enrollment-collection-v1`). + Future> getEnrollmentCollectionV1Kpi( + String slugOrId, { + required String clientPolicyId, + }) async { + if (_token == null) await _initializeToken(); + + final encoded = Uri.encodeComponent(slugOrId); + final url = Uri.parse( + '${Environment.apiUrlPost}enrollment-collection-v1/kpi/$encoded', + ).replace( + queryParameters: {'client_policy': clientPolicyId}, + ); + final headers = {'Authorization': 'Bearer ${_token ?? ''}'}; + return _makeGetRequest(url, headers); + } + Future> postHrTpaDashboard(params, token) async { final url = Uri.parse('${Environment.apiUrlPost}getHrTpaDashboard'); @@ -1102,7 +1169,7 @@ class ApiService { // 'medium' => 403, // 'hard' => 451, if (response.statusCode == 200) { - return jsonDecode(response.body); + return _parseJsonBody(response); } else if (response.statusCode == 401) { if (!_isSessionOutToastShown) { _isSessionOutToastShown = true; @@ -1130,6 +1197,19 @@ class ApiService { } } + Map _parseJsonBody(http.Response response) { + try { + if (response.bodyBytes.isEmpty) return {}; + final text = utf8.decode(response.bodyBytes); + final parsed = jsonDecode(text); + if (parsed is Map) return parsed; + if (parsed is Map) return Map.from(parsed); + return {}; + } catch (_) { + return {}; + } + } + Future _clearLocalStorageAndRedirect() async { await tokenService.clearAll(); // 🔐 clears flutter_secure_storage diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c203066..b2a9008 100755 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); g_autoptr(FlPluginRegistrar) smart_auth_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin"); smart_auth_plugin_register_with_registrar(smart_auth_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index e87a02c..78596b8 100755 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + printing smart_auth url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5f792f1..e2f429c 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import file_picker import flutter_secure_storage_darwin import path_provider_foundation +import printing import shared_preferences_foundation import smart_auth import url_launcher_macos @@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index 7dcc7f3..8b0fd50 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,8 +59,11 @@ dependencies: flutter_secure_storage: ^10.0.0 flutter_svg: ^2.2.3 pdf: ^3.11.3 + printing: ^5.14.2 dotted_border: ^3.1.0 dropdown_button2: ^2.3.9 + fl_chart: ^1.2.0 + image: ^4.3.0 dev_dependencies: diff --git a/web/index.html b/web/index.html index 72422d6..ace055e 100755 --- a/web/index.html +++ b/web/index.html @@ -101,7 +101,11 @@ serviceWorkerVersion: serviceWorkerVersion, }, onEntrypointLoaded: function(engineInitializer) { - engineInitializer.initializeEngine().then(function(appRunner) { + // CanvasKit renderer is required for reliable chart capture (`toImage`) + // used by dashboard PDF export. + engineInitializer.initializeEngine({ + renderer: "canvaskit" + }).then(function(appRunner) { appRunner.runApp(); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 99c9874..5f94c94 100755 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); SmartAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SmartAuthPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f58587e..8e78cec 100755 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + printing smart_auth url_launcher_windows )