enrollment-app/lib/presentation/claims_overview/claims_overview_tabs.dart
2026-07-02 11:06:41 +05:30

1316 lines
40 KiB
Dart

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,
bool scrollable = true,
}) {
if (forPdfExport) return child;
if (!scrollable) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: child,
);
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
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;
final bool scrollable;
const ClaimsOverviewTab({
super.key,
required this.index,
required this.replayToken,
this.isActive = false,
this.forPdfExport = false,
this.scrollable = true,
});
@override
Widget build(BuildContext context) {
return switch (index) {
0 => _OverviewTab(
replayToken: replayToken,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
1 => _PolicyExperienceTab(
replayToken: replayToken,
isActive: isActive || forPdfExport,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
2 => _ClaimsAnalysisTab(
replayToken: replayToken,
isActive: isActive || forPdfExport,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
3 => _DemographicsTab(
replayToken: replayToken,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
4 => _HospitalsTab(
replayToken: replayToken,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
5 => _SpecialtyTab(
replayToken: replayToken,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
6 => _EnrollmentTab(
replayToken: replayToken,
isActive: isActive || forPdfExport,
forPdfExport: forPdfExport,
scrollable: scrollable,
),
_ => const SizedBox(),
};
}
}
class _OverviewTab extends StatelessWidget {
final int replayToken;
final bool forPdfExport;
final bool scrollable;
const _OverviewTab({
required this.replayToken,
this.forPdfExport = false,
this.scrollable = true,
});
@override
Widget build(BuildContext context) {
final d = ClaimsOverviewScope.of(context);
return _tabBodyWrapper(
forPdfExport: forPdfExport,
scrollable: scrollable,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_overviewTwoColumnRow(
baseIndex: 0,
left: ClaimsSectionCard(
title: 'Policy Information',
icon: Icons.description_outlined,
child: ClaimsPolicyInformationBody(
policyStartDate: d.policyStartDate,
policyEndDate: d.policyEndDate,
policyRunDays: d.policyRunDays,
tpaName: d.tpaName,
insurerName: d.insurerName,
),
),
right: ClaimsSectionCard(
title: 'Premium & Membership',
icon: Icons.groups_outlined,
child: ClaimsPremiumMembershipBody(
premiumAsOnDate: d.premiumAsOnDate,
earnedPremium: d.earnedPremium,
currentEmployee: d.currentEmployee,
currentLives: d.currentLives,
avgFamilySize: d.avgFamilySize,
),
),
),
const SizedBox(height: 12),
_overviewTwoColumnRow(
baseIndex: 2,
left: ClaimsSectionCard(
title: 'Claims Experience',
icon: Icons.analytics_outlined,
minHeight: ClaimsSectionCard.pairedSectionMinHeight,
child: ClaimsExperienceBody(
incurredClaims: d.incurredClaims,
incurredRatio: d.incurredRatio,
projectedClaims: d.projectedClaims,
projectedRatio: d.projectedRatio,
claimsIncidenceRate: d.claimsIncidenceRate,
),
),
right: ClaimsSectionCard(
title: 'Inception',
icon: Icons.flag_outlined,
minHeight: ClaimsSectionCard.pairedSectionMinHeight,
child: ClaimsInceptionBody(
inceptionEmployee: d.inceptionEmployee,
inceptionLives: d.inceptionLives,
),
),
),
],
),
);
}
Widget _overviewTwoColumnRow({
required int baseIndex,
required Widget left,
required Widget right,
bool equalizeHeight = false,
}) {
final row = ClaimsTabPanelRow(
crossAxisAlignment: equalizeHeight
? CrossAxisAlignment.stretch
: CrossAxisAlignment.start,
panels: [
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: baseIndex,
child: left,
),
),
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: baseIndex + 1,
child: right,
),
),
],
);
if (!equalizeHeight) return row;
return IntrinsicHeight(child: row);
}
}
class _PolicyExperienceTab extends StatefulWidget {
final int replayToken;
final bool isActive;
final bool forPdfExport;
final bool scrollable;
const _PolicyExperienceTab({
required this.replayToken,
required this.isActive,
this.forPdfExport = false,
this.scrollable = true,
});
@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, null, Icons.tag, ClaimsOverviewTheme.blue),
(
'Total Incurred Amount',
d.sidebarIncurredAmount,
null,
Icons.currency_rupee,
ClaimsOverviewTheme.pink,
),
(
'Total Reimbursement',
d.sidebarReimbursement,
d.sidebarReimbursementPct,
Icons.account_balance_wallet_outlined,
ClaimsOverviewTheme.teal,
),
(
'Cashless Claim Amount',
d.sidebarCashless,
d.sidebarCashlessPct,
Icons.shopping_bag_outlined,
ClaimsOverviewTheme.purple,
),
];
return _tabBodyWrapper(
forPdfExport: widget.forPdfExport,
scrollable: widget.scrollable,
child: ClaimsTabPanelRow(
panels: [
ClaimsTabPanel(
flex: 7,
child: Column(
mainAxisSize: MainAxisSize.min,
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,
valuesLakhs: incurredSeries.values,
animationKey: _horizontalChartKey,
maxLakhs: incurredSeries.maxY ?? 180,
),
),
),
),
const SizedBox(height: 12),
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(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < kpiMetrics.length; i++) ...[
if (i > 0) const SizedBox(height: 8),
ClaimsStaggeredEntrance(
replayToken: widget.replayToken,
index: 2 + i,
child: ClaimsMetricCard(
label: kpiMetrics[i].$1,
value: kpiMetrics[i].$2,
secondaryValue: kpiMetrics[i].$3,
icon: kpiMetrics[i].$4,
accentColor: kpiMetrics[i].$5,
replayToken: widget.replayToken,
),
),
],
],
),
),
],
),
);
}
}
class _ClaimsAnalysisTab extends StatefulWidget {
final int replayToken;
final bool isActive;
final bool forPdfExport;
final bool scrollable;
const _ClaimsAnalysisTab({
required this.replayToken,
required this.isActive,
this.forPdfExport = false,
this.scrollable = true,
});
@override
State<_ClaimsAnalysisTab> createState() => _ClaimsAnalysisTabState();
}
class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
with AutomaticKeepAliveClientMixin {
double get _pairedChartHeight => widget.forPdfExport ? 210.0 : 280.0;
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<double> _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,
scrollable: widget.scrollable,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_chartRow(
0,
ClaimsSectionCard(
title: 'Claim Amount by Type',
icon: Icons.bar_chart,
child: SizedBox(
height: _pairedChartHeight,
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: _pairedChartHeight,
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: 12),
_chartRow(
2,
ClaimsSectionCard(
title: 'Claim Amount by Claim Status',
icon: Icons.pie_chart,
child: SizedBox(
height: _pairedChartHeight,
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: _pairedChartHeight,
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) {
final row = ClaimsTabPanelRow(
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
),
),
],
);
return IntrinsicHeight(child: row);
}
}
class _DemographicsTab extends StatelessWidget {
final int replayToken;
final bool forPdfExport;
final bool scrollable;
const _DemographicsTab({
required this.replayToken,
this.forPdfExport = false,
this.scrollable = true,
});
@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,
scrollable: scrollable,
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: 12),
],
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;
final bool scrollable;
const _HospitalsTab({
required this.replayToken,
this.forPdfExport = false,
this.scrollable = true,
});
@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,
scrollable: scrollable,
child: Column(
children: [
IntrinsicHeight(
child: ClaimsTabPanelRow(
crossAxisAlignment: CrossAxisAlignment.stretch,
panels: [
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: 0,
child: ClaimsSectionCard(
title: 'Top 5 Hospitals by Incurred Amount',
icon: Icons.local_hospital_outlined,
fillHeight: true,
child: hospitals.isEmpty
? _claimsChartPlaceholder('No hospital data')
: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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,
fillHeight: true,
child: cities.isEmpty
? _claimsChartPlaceholder('No city data')
: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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: 12),
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;
final bool scrollable;
const _SpecialtyTab({
required this.replayToken,
this.forPdfExport = false,
this.scrollable = true,
});
@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,
scrollable: scrollable,
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: 12),
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<Widget> 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;
final bool scrollable;
const _EnrollmentTab({
required this.replayToken,
required this.isActive,
this.forPdfExport = false,
this.scrollable = true,
});
@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 ? 220.0 : 300.0;
final trendHeight = widget.forPdfExport ? 228.0 : 320.0;
final sectionGap = widget.forPdfExport ? 8.0 : 16.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,
),
]),
),
),
),
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,
),
]),
),
),
),
],
),
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')
: ClaimsAreaChart(
labels: ageTrend.labels,
values: ageTrend.values,
maxY: ageTrend.maxY ?? 40,
replayToken: _chartKey,
formatTooltipValue: (v) => v.toStringAsFixed(0),
),
),
),
),
],
);
return _tabBodyWrapper(
forPdfExport: widget.forPdfExport,
scrollable: widget.scrollable,
child: widget.forPdfExport ? ClipRect(child: body) : body,
);
}
Widget _enrollmentMiniGrid(List<Widget> items) {
return ClaimsTabMiniGrid(items: items, columns: 3);
}
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<Color> 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,
);
return Column(
children: [
Expanded(child: chart),
SizedBox(height: forPdfExport ? 4 : 8),
_EnrollmentDonutLegend(
labels: series.labels,
values: series.values,
colors: colors,
totalLabel: totalLabel,
compact: forPdfExport,
),
],
);
}
}
class _EnrollmentDonutLegend extends StatelessWidget {
final List<String> labels;
final List<double> values;
final List<Color> colors;
final String totalLabel;
final bool compact;
const _EnrollmentDonutLegend({
required this.labels,
required this.values,
required this.colors,
required this.totalLabel,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final total = values.fold<double>(0, (a, b) => a + b);
final fontSize = compact ? 9.0 : 11.0;
return Wrap(
spacing: compact ? 8 : 12,
runSpacing: compact ? 4 : 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: compact ? 8 : 10,
height: compact ? 8 : 10,
decoration: BoxDecoration(
color: colors[i % colors.length],
shape: BoxShape.circle,
),
),
SizedBox(width: compact ? 4 : 6),
Text(
'${labels[i]} ${pct.toStringAsFixed(1)}%',
style: GoogleFonts.poppins(
fontSize: fontSize,
color: ClaimsOverviewTheme.textSecondary,
),
),
],
);
}),
Text(
'Total: $totalLabel',
style: GoogleFonts.poppins(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),
),
],
);
}
}