dashboard changes done

This commit is contained in:
Surendiran 2026-06-05 12:28:57 +05:30
parent c66c39e57c
commit 64d0b0b7d9
8 changed files with 679 additions and 547 deletions

View File

@ -231,13 +231,13 @@ class ClaimsOverviewViewData {
String slug, {
String? labelKey,
String? valueKey,
bool valuesInMillions = false,
bool valuesInLakhs = false,
}) {
return ClaimsKpiParser.labelValueSeries(
_kpi(slug),
labelKey: labelKey,
valueKey: valueKey,
valuesInMillions: valuesInMillions,
valuesInLakhs: valuesInLakhs,
);
}
@ -280,6 +280,14 @@ class ClaimsOverviewViewData {
String get projectedRatio => _expField('projected_ratio');
String get claimsIncidenceRate => ClaimsKpiParser.formatPercent(
ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.claimsIncidenceRate),
'claims_incidence_rate',
fallback: '',
),
);
String get tpaName => ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.tpa),
'tpa_name',
@ -345,7 +353,7 @@ class ClaimsOverviewViewData {
ClaimsKpiSlug.totalIncurredByClaimStatus,
labelKey: 'Claim Status',
valueKey: 'Total Incurred Amount',
valuesInMillions: true,
valuesInLakhs: true,
);
ClaimsChartSeries get claimsCountByStatus => chartSeries(
@ -446,11 +454,63 @@ class ClaimsOverviewViewData {
return const [];
}
static const _relationshipOrder = [
'self',
'spouse',
'son',
'daughter',
'father',
'mother',
'father-in-law',
'mother-in-law',
];
static String _normalizeRelationshipKey(String relation) {
return relation
.trim()
.toLowerCase()
.replaceAll('_', '-')
.replaceAll(RegExp(r'\s+'), '-')
.replaceAll(RegExp(r'-+'), '-');
}
static String formatRelationshipName(String relation) {
final key = _normalizeRelationshipKey(relation);
const labels = {
'self': 'Self',
'spouse': 'Spouse',
'son': 'Son',
'daughter': 'Daughter',
'father': 'Father',
'mother': 'Mother',
'father-in-law': 'Father-in-Law',
'mother-in-law': 'Mother-in-Law',
};
if (labels.containsKey(key)) return labels[key]!;
if (key.contains('-')) {
return key.split('-').map(_capitalizeWord).join('-');
}
return _capitalizeWord(key);
}
static String _capitalizeWord(String word) {
if (word.isEmpty) return word;
if (word.length == 1) return word.toUpperCase();
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}
static int _relationshipSortIndex(String relation) {
final key = _normalizeRelationshipKey(relation);
final idx = _relationshipOrder.indexOf(key);
return idx >= 0 ? idx : _relationshipOrder.length;
}
List<ClaimsRelationshipItem> _relationshipFromKpi(dynamic kpi) {
final rows = ClaimsKpiParser.extractRows(kpi);
if (rows.isEmpty) return const [];
final items = <ClaimsRelationshipItem>[];
final parsed = <({String raw, ClaimsRelationshipItem item})>[];
for (final row in rows) {
if (row is! Map) continue;
final relation = ClaimsKpiParser.rowValue(row, 'relationship')
@ -473,15 +533,24 @@ class ClaimsOverviewViewData {
ClaimsKpiParser.rowValue(row, 'amount') ??
ClaimsKpiParser.rowValue(row, 'incurred_amount');
items.add(
ClaimsRelationshipItem(
relation: relation,
parsed.add((
raw: relation,
item: ClaimsRelationshipItem(
relation: formatRelationshipName(relation),
claims: count.round(),
amount: ClaimsKpiParser.formatDisplay(amountRaw),
),
);
));
}
return items;
parsed.sort((a, b) {
final order = _relationshipSortIndex(a.raw).compareTo(
_relationshipSortIndex(b.raw),
);
if (order != 0) return order;
return a.raw.compareTo(b.raw);
});
return parsed.map((e) => e.item).toList();
}
List<ClaimsAilmentItem> _ailmentsFromKpi(dynamic kpi) {
@ -514,6 +583,8 @@ class ClaimsOverviewViewData {
if (title == null || title.isEmpty || valueRaw == null) continue;
title = _stripLeadingDash(title);
items.add(
ClaimsAilmentItem(
title: title,
@ -524,6 +595,10 @@ class ClaimsOverviewViewData {
return items;
}
static String _stripLeadingDash(String text) {
return text.replaceFirst(RegExp(r'^[\s\-–—]+'), '').trim();
}
// --- Claims analysis ---
ClaimsChartSeries get claimAmountByStatusDonut => chartSeries(
ClaimsKpiSlug.claimAmountByClaimStatus,
@ -549,11 +624,11 @@ class ClaimsOverviewViewData {
('Cashless', cashless ?? 0),
]);
if (series.values.isEmpty) return series;
final inMillions = series.values.map((v) => v / 1000000).toList();
final max = inMillions.reduce(math.max);
final inLakhs = series.values.map((v) => v / 100000).toList();
final max = inLakhs.reduce(math.max);
return ClaimsChartSeries(
labels: series.labels,
values: inMillions,
values: inLakhs,
maxY: ClaimsKpiParser._niceMaxY(max),
);
}
@ -578,7 +653,7 @@ class ClaimsOverviewViewData {
ClaimsChartSeries get amountByMonth => ClaimsKpiParser.monthValueSeries(
_kpi(ClaimsKpiSlug.claimValueByMonth),
valuesInMillions: true,
valuesInLakhs: true,
);
// --- Demographics ---
@ -658,7 +733,7 @@ class ClaimsOverviewViewData {
ClaimsChartSeries get claimsByMonthSeries => ClaimsKpiParser.monthValueSeries(
_kpi(ClaimsKpiSlug.claimValueByMonth),
valuesInMillions: true,
valuesInLakhs: true,
);
/// Incidence rate (%) when monthly series is unavailable.
@ -1167,7 +1242,7 @@ abstract final class ClaimsKpiParser {
dynamic kpi, {
String? labelKey,
String? valueKey,
bool valuesInMillions = false,
bool valuesInLakhs = false,
}) {
final rows = extractRows(kpi);
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
@ -1186,7 +1261,7 @@ abstract final class ClaimsKpiParser {
final value = toDouble(rawValue);
if (label == null || value == null) continue;
labels.add(label);
values.add(valuesInMillions ? value / 1000000 : value);
values.add(valuesInLakhs ? value / 100000 : value);
}
double? maxY;
@ -1201,7 +1276,7 @@ abstract final class ClaimsKpiParser {
/// Sorted monthly claim values (`month_sort`, `claim_month`, `claim_value`).
static ClaimsChartSeries monthValueSeries(
dynamic kpi, {
bool valuesInMillions = false,
bool valuesInLakhs = false,
}) {
final rows = extractRows(kpi);
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
@ -1220,7 +1295,7 @@ abstract final class ClaimsKpiParser {
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)
.map((e) => valuesInLakhs ? e.value / 100000 : e.value)
.toList();
final max = values.reduce(math.max);
return ClaimsChartSeries(

View File

@ -29,10 +29,20 @@ List<double> _yTicksForMaxY(double maxY) {
return List<double>.generate(5, (i) => i == 4 ? maxY : (maxY / 4) * i);
}
String _formatMillionsAxisTick(double v) {
String _formatLakhsAxisTick(double v) {
if (v == 0) return '0';
if (v >= 10) return '${v.round()}M';
return '${v.toStringAsFixed(1)}M';
if (v >= 100) return '${v.round()}L';
return '${v.toStringAsFixed(1)}L';
}
/// Lakhs display for KPI tiles (e.g. incurred-by-status grid).
String formatClaimsLakhsDisplay(double lakhs) => _formatLakhsAxisTick(lakhs);
String _formatLakhsAmountTick(double amount) {
if (amount == 0) return '0';
final lakhs = amount / 100000;
if (lakhs >= 100) return '${lakhs.round()}L';
return '${lakhs.toStringAsFixed(1)}L';
}
/// Colors aligned with the Nhance reference design (top bottom).
@ -46,19 +56,19 @@ final incurredByStatusColors = [
ClaimsOverviewTheme.cyan,
];
/// Horizontal incurred-by-status chart with hover tooltip and track highlight.
/// Horizontal incurred-by-status chart with value labels at bar front (right end).
class ClaimsHorizontalIncurredBarChart extends StatefulWidget {
final List<String> labels;
final List<double> valuesMillions;
final List<double> valuesLakhs;
final int animationKey;
final double maxMillions;
final double maxLakhs;
ClaimsHorizontalIncurredBarChart({
super.key,
required this.labels,
required this.valuesMillions,
required this.valuesLakhs,
required this.animationKey,
this.maxMillions = 18,
this.maxLakhs = 180,
});
@override
@ -68,49 +78,63 @@ class ClaimsHorizontalIncurredBarChart extends StatefulWidget {
class _ClaimsHorizontalIncurredBarChartState
extends State<ClaimsHorizontalIncurredBarChart> {
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;
double get _maxAmount => widget.maxLakhs * 100000;
List<double> get _xTicks {
final max = _maxAmount;
if (max <= 0) {
return const [0, 4500000, 9000000, 13500000, 18000000];
return const [0, 450000, 900000, 1350000, 1800000];
}
final step = max / 4;
return [0, step, step * 2, step * 3, max];
}
double _toAmount(double millions) => millions * 1000000;
double _toAmount(double lakhs) => lakhs * 100000;
void _onPlotHover(Offset local, Size plotSize, int rowCount, List<double> barValues) {
if (rowCount == 0) return;
final rowH = plotSize.height / rowCount;
final index = (local.dy / rowH).floor().clamp(0, rowCount - 1);
if (index == _hoveredIndex) return;
List<Widget> _barValueOverlays({
required Size plotSize,
required List<double> barValues,
required double animProgress,
}) {
final n = barValues.length;
if (n == 0) return const [];
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,
final rowH = plotSize.height / n;
const labelH = 22.0;
return List.generate(n, (i) {
if (barValues[i] <= 0 || animProgress <= 0) {
return const SizedBox.shrink();
}
final centerY = i * rowH + rowH / 2;
final barW = _maxAmount > 0
? (barValues[i] / _maxAmount) * plotSize.width * animProgress
: 0.0;
if (barW <= 0) return const SizedBox.shrink();
const labelW = 52.0;
const edgePad = 6.0;
// Always outside the bar tip (front / right end), vertically centered.
final left = (barW + edgePad).clamp(4.0, plotSize.width - labelW - 4);
final top =
(centerY - labelH / 2).clamp(2.0, plotSize.height - labelH - 2);
return Positioned(
left: left,
top: top,
child: _BarValueLabel(value: _formatLakhsAmountTick(barValues[i])),
);
});
}
@override
Widget build(BuildContext context) {
final barValues = widget.valuesMillions.map(_toAmount).toList();
final barValues = widget.valuesLakhs.map(_toAmount).toList();
final rowCount = widget.labels.length;
return LayoutBuilder(
@ -167,53 +191,24 @@ class _ClaimsHorizontalIncurredBarChartState
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,
),
CustomPaint(
size: plotSize,
painter: _IncurredHorizontalPlotPainter(
values: barValues,
maxValue: _maxAmount,
colors: incurredByStatusColors,
animProgress: animProgress,
barThickness: _barThickness,
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(),
),
),
),
],
..._barValueOverlays(
plotSize: plotSize,
barValues: barValues,
animProgress: animProgress,
),
],
),
),
),
@ -330,7 +325,7 @@ class _IncurredBottomAxis extends StatelessWidget {
required bool isLast,
}) {
final label = Text(
t.toInt().toString(),
_formatLakhsAmountTick(t),
style: GoogleFonts.poppins(
fontSize: 9,
color: ClaimsOverviewTheme.textSecondary,
@ -370,45 +365,26 @@ class _IncurredBottomAxis extends StatelessWidget {
}
}
class _IncurredTooltip extends StatelessWidget {
final String title;
final int value;
class _BarValueLabel extends StatelessWidget {
final String value;
const _IncurredTooltip({required this.title, required this.value});
const _BarValueLabel({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,
),
),
],
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: ClaimsOverviewTheme.border),
),
child: Text(
value,
style: GoogleFonts.poppins(
fontSize: 10,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),
),
);
@ -420,9 +396,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
final double maxValue;
final List<Color> colors;
final double animProgress;
final int hoveredIndex;
final double barThickness;
final Color hoverTrackColor;
final Color axisColor;
final Color gridColor;
@ -431,9 +405,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
required this.maxValue,
required this.colors,
required this.animProgress,
required this.hoveredIndex,
required this.barThickness,
required this.hoverTrackColor,
required this.axisColor,
required this.gridColor,
});
@ -485,14 +457,6 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
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;
@ -510,9 +474,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
@override
bool shouldRepaint(covariant _IncurredHorizontalPlotPainter old) {
return old.animProgress != animProgress ||
old.hoveredIndex != hoveredIndex ||
old.values != values;
return old.animProgress != animProgress || old.values != values;
}
}
@ -551,7 +513,6 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
final double bottomAxisHeight;
final double plotRightInset;
final String Function(double value)? formatYTick;
final String tooltipValueLabel;
const ClaimsCustomVerticalBarChart({
super.key,
@ -567,7 +528,6 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
this.bottomAxisHeight = 52,
this.plotRightInset = 0,
this.formatYTick,
this.tooltipValueLabel = 'count',
});
@override
@ -577,41 +537,48 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
class _ClaimsCustomVerticalBarChartState
extends State<ClaimsCustomVerticalBarChart> {
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) {
String _formatBarValue(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;
List<Widget> _barValueOverlays({
required Size plotSize,
required double animProgress,
}) {
final n = widget.values.length;
if (n == 0 || widget.maxY <= 0) return const [];
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);
final chartW = plotSize.width - widget.plotRightInset;
final slotW = chartW / n;
const labelH = 22.0;
return List.generate(n, (i) {
if (widget.values[i] <= 0 || animProgress <= 0) {
return const SizedBox.shrink();
}
final centerX = (i + 0.5) * slotW;
final barH =
(widget.values[i] / widget.maxY) * plotSize.height * animProgress;
final barTop = plotSize.height - barH;
return Positioned(
left: (centerX - 38).clamp(2.0, chartW - 74),
width: 76,
top: (barTop - labelH - 4).clamp(2.0, plotSize.height - labelH),
child: Center(
child: _BarValueLabel(value: _formatBarValue(widget.values[i])),
),
);
});
}
Widget _buildChartBody(double animProgress) {
final count = widget.labels.length;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -640,52 +607,25 @@ class _ClaimsCustomVerticalBarChartState
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,
),
CustomPaint(
size: plotSize,
painter: _VerticalBarPlotPainter(
values: widget.values,
maxY: widget.maxY,
yTicks: widget.yTicks,
colors: widget.colors,
animProgress: animProgress,
barWidth: widget.barWidth,
barSlotFraction: widget.barSlotFraction,
plotRightInset: widget.plotRightInset,
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,
),
),
),
..._barValueOverlays(
plotSize: plotSize,
animProgress: animProgress,
),
],
),
),
@ -872,67 +812,15 @@ class _VerticalCategoryAxis extends StatelessWidget {
}
}
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<double> values;
final double maxY;
final List<double> yTicks;
final List<Color> 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;
@ -942,11 +830,9 @@ class _VerticalBarPlotPainter extends CustomPainter {
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,
});
@ -1000,14 +886,6 @@ class _VerticalBarPlotPainter extends CustomPainter {
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;
@ -1026,9 +904,7 @@ class _VerticalBarPlotPainter extends CustomPainter {
@override
bool shouldRepaint(covariant _VerticalBarPlotPainter old) {
return old.animProgress != animProgress ||
old.hoveredIndex != hoveredIndex ||
old.values != values;
return old.animProgress != animProgress || old.values != values;
}
}
@ -1043,7 +919,7 @@ final claimsCountByStatusColors = [
ClaimsOverviewTheme.cyan,
];
/// Vertical claims-count chart: wide bars, hover tooltip, slow rise animation.
/// Vertical claims-count chart: wide bars, value labels on top, rise animation.
class ClaimsCountByStatusBarChart extends StatefulWidget {
final List<String> labels;
final List<double> values;
@ -1120,8 +996,7 @@ class _ClaimsCountByClaimTypeChartState
colors: claimTypeCountColors,
animationKey: widget.animationKey,
yTicks: _yTicksForMaxY(maxY),
formatYTick: _formatMillionsAxisTick,
tooltipValueLabel: 'amount',
formatYTick: _formatLakhsAxisTick,
leftAxisWidth: 48,
barWidth: 56,
bottomAxisHeight: 52,
@ -1169,7 +1044,6 @@ class ClaimsVerticalBarChart extends StatelessWidget {
bottomAxisHeight: 48,
plotRightInset: 8,
formatYTick: (v) => v.toInt().toString(),
tooltipValueLabel: 'value',
);
}
}
@ -1759,14 +1633,13 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
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],
left: (_tooltipAnchor!.dx - 38)
.clamp(4.0, plotSize.width - 76),
top: (_tooltipAnchor!.dy - 26)
.clamp(4.0, plotSize.height - 24),
child: _BarValueLabel(
value: _formatLakhsAxisTick(
widget.values[_hoveredIndex],
),
),
),
@ -1792,57 +1665,6 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
}
}
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<double> values;
final double maxY;

View File

@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import '../../customAppBar/base_layout.dart';
import '../../customAppBar/toastHelper.dart';
@ -44,15 +47,16 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
String? _clientBranchId;
String? _selectedPolicyId;
List<Map<String, dynamic>> _activePolicies = [];
Uint8List? _clientLogoBytes;
static const _tabs = [
(Icons.dashboard_outlined, 'Overview'),
(Icons.timeline, 'Policy Experience'),
(Icons.timeline, 'Claims Overview'),
(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'),
(Icons.group_add_outlined, 'Insurred'),
];
@override
@ -231,8 +235,10 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
if (!stillValid) {
_selectedPolicyId = firstId;
}
await _loadClientLogoForSelectedPolicy();
} else {
_selectedPolicyId = null;
_clientLogoBytes = null;
}
}
@ -258,9 +264,30 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
return null;
}
Future<void> _loadClientLogoForSelectedPolicy() async {
final url = _selectedPolicy()?['client_logo']?.toString().trim();
if (url == null || url.isEmpty) {
if (mounted) setState(() => _clientLogoBytes = null);
return;
}
try {
final response = await http.get(Uri.parse(url));
if (!mounted) return;
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
setState(() => _clientLogoBytes = response.bodyBytes);
} else {
setState(() => _clientLogoBytes = null);
}
} catch (_) {
if (mounted) setState(() => _clientLogoBytes = null);
}
}
void _onPolicySelected(String policyId) {
if (policyId == _selectedPolicyId) return;
setState(() => _selectedPolicyId = policyId);
_loadClientLogoForSelectedPolicy();
_loadDashboard();
}
@ -329,12 +356,17 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
);
try {
if (_clientLogoBytes == null) {
await _loadClientLogoForSelectedPolicy();
}
await exportClaimsOverviewChartsPdf(
overlay: overlay,
replayToken: _replayToken,
viewData: _viewData,
enrollmentViewData: _enrollmentViewData,
policyId: _selectedPolicyId ?? '',
clientLogoBytes: _clientLogoBytes,
onProgress: (_, __, label) {
updateProgressDialog?.call(() {
progressLabel = label;

View File

@ -23,12 +23,12 @@ import 'claims_overview_theme.dart';
const _tabNames = [
'Overview',
'Policy Experience',
'Claims Overview',
'Claims Analysis',
'Demographics',
'Hospitals & Geography',
'Specialty Analysis',
'Enrollment',
'Insurred',
];
/// Fallback heights if layout measure fails (avoids second pass).
@ -63,10 +63,12 @@ class _TabCapture {
class _PdfBuildInput {
final List<_TabCapture> captures;
final Uint8List? logoBytes;
final Uint8List? clientLogoBytes;
const _PdfBuildInput({
required this.captures,
required this.logoBytes,
this.clientLogoBytes,
});
}
@ -103,11 +105,14 @@ Uint8List _compressTabImageForPdf(Uint8List pngBytes) {
}
}
pw.MemoryImage? _pdfMemoryImage(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return pw.MemoryImage(bytes);
}
Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
pw.MemoryImage? logo;
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) {
logo = pw.MemoryImage(input.logoBytes!);
}
final logo = _pdfMemoryImage(input.logoBytes);
final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
final doc = pw.Document();
@ -120,7 +125,7 @@ Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
_pdfPageHeader(title: capture.tabName, logo: logo),
_pdfPageHeader(clientLogo: clientLogo, logo: logo),
pw.SizedBox(height: 8),
pw.Expanded(
child: pw.Center(
@ -145,10 +150,8 @@ Future<Uint8List> _buildPdfBytesWithYields(
ClaimsPdfExportProgress? onProgress,
int totalSteps,
) async {
pw.MemoryImage? logo;
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) {
logo = pw.MemoryImage(input.logoBytes!);
}
final logo = _pdfMemoryImage(input.logoBytes);
final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
final doc = pw.Document();
@ -171,7 +174,7 @@ Future<Uint8List> _buildPdfBytesWithYields(
build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
_pdfPageHeader(title: capture.tabName, logo: logo),
_pdfPageHeader(clientLogo: clientLogo, logo: logo),
pw.SizedBox(height: 8),
pw.Expanded(
child: pw.Center(
@ -359,45 +362,24 @@ Future<Uint8List?> _captureTabPng({
}
pw.Widget _pdfPageHeader({
required String title,
pw.MemoryImage? clientLogo,
pw.MemoryImage? logo,
bool showSubtitle = false,
String? subtitle,
}) {
return pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
crossAxisAlignment: pw.CrossAxisAlignment.center,
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)
if (clientLogo != null)
pw.Container(
alignment: pw.Alignment.centerRight,
child: pw.Image(logo, height: 28, fit: pw.BoxFit.contain),
),
height: 28,
constraints: const pw.BoxConstraints(maxWidth: 120),
alignment: pw.Alignment.centerLeft,
child: pw.Image(clientLogo, fit: pw.BoxFit.contain),
)
else
pw.SizedBox.shrink(),
if (logo != null)
pw.Image(logo, height: 28, fit: pw.BoxFit.contain),
],
);
}
@ -438,6 +420,7 @@ Future<void> exportClaimsOverviewChartsPdf({
required ClaimsOverviewViewData viewData,
required EnrollmentOverviewViewData enrollmentViewData,
required String policyId,
Uint8List? clientLogoBytes,
ClaimsPdfExportProgress? onProgress,
}) async {
await _preloadFonts();
@ -495,6 +478,7 @@ Future<void> exportClaimsOverviewChartsPdf({
final buildInput = _PdfBuildInput(
captures: captures,
logoBytes: logoBytes,
clientLogoBytes: clientLogoBytes,
);
final Uint8List bytes;

View File

@ -79,22 +79,43 @@ class ClaimsTabMiniGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(items.length == 4);
assert(items.length == 3 || items.length == 4);
return LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth;
final isPdf = ClaimsPdfExportScope.of(context);
Widget cell(int index) {
Widget cell(int index, {bool fullWidth = false}) {
final child = items[index];
if (isPdf && maxW.isFinite) {
final half = (maxW - gap) / 2;
return SizedBox(width: half, child: child);
final width = fullWidth ? maxW : (maxW - gap) / 2;
return SizedBox(width: width, child: child);
}
return Expanded(child: child);
}
if (items.length == 3) {
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, fullWidth: true)],
),
],
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@ -14,7 +14,10 @@ Widget _tabBodyWrapper({
required Widget child,
}) {
if (forPdfExport) return child;
return SingleChildScrollView(child: child);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: child,
);
}
Widget _claimsChartPlaceholder(String message) {
@ -84,6 +87,23 @@ class ClaimsOverviewTab extends StatelessWidget {
}
class _OverviewTab extends StatelessWidget {
static const _metricGap = 12.0;
static const _sectionBodyPadding = 32.0;
static const _sectionHeaderHeight = 46.0;
/// Fixed height from the larger tile count in a paired row (no flex in scroll view).
static double _pairedSectionHeight(
int leftTiles,
int rightTiles, {
int columns = 2,
}) {
final maxTiles = leftTiles > rightTiles ? leftTiles : rightTiles;
final rows = (maxTiles + columns - 1) ~/ columns;
final gridHeight = rows * ClaimsMetricCard.layoutHeight +
(rows > 1 ? (rows - 1) * _metricGap : 0);
return _sectionHeaderHeight + _sectionBodyPadding + gridHeight;
}
final int replayToken;
final bool forPdfExport;
@ -95,88 +115,153 @@ class _OverviewTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
final d = ClaimsOverviewScope.of(context);
final policyRowHeight = _pairedSectionHeight(5, 5);
final claimsRowHeight = _pairedSectionHeight(5, 2);
return _tabBodyWrapper(
forPdfExport: forPdfExport,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClaimsStaggeredEntrance(
replayToken: replayToken,
index: 0,
child: _sectionTitle(
Icons.description_outlined,
'Policy Basic Information',
_overviewTwoColumnRow(
baseIndex: 0,
left: _overviewGroup(
title: 'Policy Information',
icon: Icons.description_outlined,
sectionHeight: policyRowHeight,
columns: 2,
cards: [
_m('Policy Start Date', d.policyStartDate,
Icons.calendar_today, ClaimsOverviewTheme.blue,
animateValue: false),
_m('Policy End Date', d.policyEndDate, Icons.event,
ClaimsOverviewTheme.red, animateValue: false),
_m('Policy Run Days', d.policyRunDays, Icons.timelapse,
ClaimsOverviewTheme.purple),
_m('TPA', d.tpaName, Icons.apartment, ClaimsOverviewTheme.teal,
animateValue: false),
_m('Insurer', d.insurerName, Icons.military_tech,
ClaimsOverviewTheme.orange, animateValue: false),
],
),
right: _overviewGroup(
title: 'Premium & Membership',
icon: Icons.groups_outlined,
sectionHeight: policyRowHeight,
columns: 2,
cards: [
_m('Premium As On Date', d.premiumAsOnDate,
Icons.account_balance_wallet, ClaimsOverviewTheme.orange),
_m('Earned Premium', d.earnedPremium, Icons.payments,
ClaimsOverviewTheme.green),
_m('Current Employee', d.currentEmployee,
Icons.business_center, ClaimsOverviewTheme.blue),
_m('Current Lives', d.currentLives, Icons.favorite_border,
ClaimsOverviewTheme.pink),
_m('Avg F-Size', d.avgFamilySize, Icons.groups,
ClaimsOverviewTheme.purple),
],
),
),
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),
],
);
},
const SizedBox(height: 16),
_overviewTwoColumnRow(
baseIndex: 2,
left: _overviewGroup(
title: 'Claims Experience',
icon: Icons.analytics_outlined,
sectionHeight: claimsRowHeight,
columns: 2,
cards: [
_m('Incurred Claims', d.incurredClaims, Icons.gps_fixed,
ClaimsOverviewTheme.red),
_m('Incurred Ratio', d.incurredRatio, Icons.speed,
ClaimsOverviewTheme.teal),
_m('Projected Claims', d.projectedClaims, Icons.trending_up,
ClaimsOverviewTheme.red),
_m('Projected Ratio', d.projectedRatio, Icons.percent,
ClaimsOverviewTheme.orange),
_m('Claims Incidence Rate', d.claimsIncidenceRate,
Icons.timeline, ClaimsOverviewTheme.cyan),
],
),
right: _overviewGroup(
title: 'Inception',
icon: Icons.flag_outlined,
sectionHeight: claimsRowHeight,
columns: 2,
cards: [
_m('Inception Employee', d.inceptionEmployee,
Icons.person_add_alt, ClaimsOverviewTheme.blue),
_m('Inception Lives', d.inceptionLives, Icons.favorite_border,
ClaimsOverviewTheme.pink),
],
),
),
],
),
);
}
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,
Widget _overviewTwoColumnRow({
required int baseIndex,
required Widget left,
required Widget right,
}) {
return ClaimsTabPanelRow(
panels: [
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: baseIndex,
child: left,
),
),
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: baseIndex + 1,
child: right,
),
),
],
);
}
Widget _overviewGroup({
required String title,
required IconData icon,
required List<ClaimsMetricCard> cards,
required double sectionHeight,
int columns = 2,
}) {
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: sectionHeight,
maxHeight: sectionHeight,
),
child: ClaimsSectionCard(
title: title,
icon: icon,
child: LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: (ClaimsPdfExportScope.laneWidthOf(context) ?? 800);
return _OverviewMetricGrid(
replayToken: replayToken,
maxWidth: maxW,
columns: columns,
cards: cards,
);
},
),
),
);
}
ClaimsMetricCard _m(
int stagger,
String label,
String value,
IconData icon,
@ -195,25 +280,26 @@ class _OverviewTab extends StatelessWidget {
}
/// 4-column metric grid equal card width on every row.
/// Metric grid with equal card width per row inside a section card.
class _OverviewMetricGrid extends StatelessWidget {
static const _columns = 4;
static const _gap = 12.0;
final List<Widget> cards;
final int replayToken;
final double maxWidth;
final int columns;
const _OverviewMetricGrid({
required this.cards,
required this.replayToken,
required this.maxWidth,
this.columns = 4,
});
@override
Widget build(BuildContext context) {
final cellWidth =
(maxWidth - _gap * (_columns - 1)) / _columns;
final cols = columns.clamp(1, 4);
final cellWidth = (maxWidth - _gap * (cols - 1)) / cols;
return Wrap(
spacing: _gap,
@ -325,74 +411,78 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab>
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,
),
),
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: 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,
),
),
),
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(
),
ClaimsTabPanel(
flex: 3,
child: Column(
mainAxisSize: MainAxisSize.min,
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,
index: 2 + i,
child: ClaimsMetricCard(
label: kpiMetrics[i].$1,
value: kpiMetrics[i].$2,
icon: kpiMetrics[i].$3,
accentColor: kpiMetrics[i].$4,
replayToken: widget.replayToken,
),
),
],
),
],
),
],
),
],
),
],
),
);
}
@ -415,6 +505,8 @@ class _ClaimsAnalysisTab extends StatefulWidget {
class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
with AutomaticKeepAliveClientMixin {
static const _pairedChartHeight = 280.0;
int _chartAnimKey = 0;
@override
@ -466,7 +558,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Claim Amount by Type',
icon: Icons.bar_chart,
child: SizedBox(
height: 280,
height: _pairedChartHeight,
child: claimTypeCounts.isEmpty
? _claimsChartPlaceholder('No claim type count data')
: ClaimsCountByClaimTypeChart(
@ -481,7 +573,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Total Incurred by Claim Type',
icon: Icons.donut_large,
child: SizedBox(
height: 260,
height: _pairedChartHeight,
child: claimTypeIncurred.isEmpty
? _claimsChartPlaceholder('No claim type incurred data')
: ClaimsDonutChart(
@ -501,7 +593,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Claim Amount by Claim Status',
icon: Icons.pie_chart,
child: SizedBox(
height: 280,
height: _pairedChartHeight,
child: statusDonut.isEmpty
? _claimsChartPlaceholder('No claim status amount data')
: ClaimsDonutChart(
@ -517,7 +609,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Amount by Month Wise',
icon: Icons.show_chart,
child: SizedBox(
height: 280,
height: _pairedChartHeight,
child: monthSeries.isEmpty
? _claimsChartPlaceholder('No monthly amount data')
: ClaimsAreaChart(
@ -536,7 +628,8 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
}
Widget _chartRow(int baseIndex, Widget left, Widget right) {
return ClaimsTabPanelRow(
final row = ClaimsTabPanelRow(
crossAxisAlignment: CrossAxisAlignment.stretch,
panels: [
ClaimsTabPanel(
flex: 1,
@ -556,6 +649,8 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
),
],
);
if (widget.forPdfExport) return row;
return IntrinsicHeight(child: row);
}
}
@ -670,6 +765,8 @@ class _DemographicsTab extends StatelessWidget {
}
class _HospitalsTab extends StatelessWidget {
static const _hospitalsPairHeight = 360.0;
final int replayToken;
final bool forPdfExport;
@ -723,6 +820,7 @@ class _HospitalsTab extends StatelessWidget {
child: ClaimsSectionCard(
title: 'Top 5 Hospitals by Incurred Amount',
icon: Icons.local_hospital_outlined,
height: _hospitalsPairHeight,
child: hospitals.isEmpty
? _claimsChartPlaceholder('No hospital data')
: Column(
@ -748,6 +846,7 @@ class _HospitalsTab extends StatelessWidget {
child: ClaimsSectionCard(
title: 'Total Incurred by City',
icon: Icons.location_on_outlined,
height: _hospitalsPairHeight,
child: cities.isEmpty
? _claimsChartPlaceholder('No city data')
: Column(
@ -1066,7 +1165,6 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
Icons.payments_outlined,
ClaimsOverviewTheme.teal,
),
const SizedBox.shrink(),
]),
),
),
@ -1097,7 +1195,6 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
Icons.person_add_alt_1,
ClaimsOverviewTheme.orange,
),
const SizedBox.shrink(),
]),
),
),
@ -1342,7 +1439,6 @@ class _EnrollmentAgeTrendChart extends StatelessWidget {
leftAxisWidth: forPdfExport ? 40 : 48,
bottomAxisHeight: forPdfExport ? 40 : 52,
formatYTick: (v) => v.toStringAsFixed(0),
tooltipValueLabel: 'avg age',
);
}
}

View File

@ -6,6 +6,9 @@ import 'claims_overview_scope.dart';
import 'claims_overview_theme.dart';
class ClaimsMetricCard extends StatelessWidget {
/// Measured layout: label row (~50) + value block (62) + accent (3).
static const double layoutHeight = 115;
final String label;
final String value;
final IconData icon;
@ -34,6 +37,7 @@ class ClaimsMetricCard extends StatelessWidget {
);
return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: compact ? 88 : 108),
decoration: BoxDecoration(
color: ClaimsOverviewTheme.cardBg,
@ -49,6 +53,7 @@ class ClaimsMetricCard extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(
@ -157,17 +162,58 @@ class ClaimsSectionCard extends StatelessWidget {
final String title;
final IconData icon;
final Widget child;
final bool fillHeight;
final double? height;
const ClaimsSectionCard({
super.key,
required this.title,
required this.icon,
required this.child,
this.fillHeight = false,
this.height,
});
@override
Widget build(BuildContext context) {
final body = Padding(
padding: const EdgeInsets.all(16),
child: child,
);
final header = 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,
),
),
),
],
),
);
final useExpandedBody = fillHeight || height != null;
final bodySlot = useExpandedBody
? Expanded(
child: Align(
alignment: Alignment.topLeft,
child: body,
),
)
: body;
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: ClaimsOverviewTheme.cardBg,
borderRadius: BorderRadius.circular(12),
@ -182,36 +228,92 @@ class ClaimsSectionCard extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize:
useExpandedBody ? MainAxisSize.max : MainAxisSize.min,
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,
),
header,
bodySlot,
],
),
);
}
}
/// Metric tiles in a grid [columns] per row (default 3), title + value inside each card.
class ClaimsMetricTileGrid extends StatelessWidget {
static const _gap = 12.0;
static const _tileMinHeight = 108.0;
final List<String> labels;
final List<String> values;
final List<Color> accentColors;
final int replayToken;
final int columns;
final IconData icon;
const ClaimsMetricTileGrid({
super.key,
required this.labels,
required this.values,
required this.accentColors,
required this.replayToken,
this.columns = 3,
this.icon = Icons.insights_outlined,
});
@override
Widget build(BuildContext context) {
assert(labels.length == values.length);
final cols = columns.clamp(1, 4);
return LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: 800.0;
final cellWidth = (maxW - _gap * (cols - 1)) / cols;
final rowCount = (labels.length + cols - 1) ~/ cols;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(rowCount, (row) {
final start = row * cols;
return Padding(
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(cols, (col) {
final index = start + col;
if (index >= labels.length) {
return SizedBox(width: cellWidth);
}
final color =
accentColors[index % accentColors.length];
return SizedBox(
width: cellWidth,
child: ConstrainedBox(
constraints:
const BoxConstraints(minHeight: _tileMinHeight),
child: ClaimsMetricCard(
label: labels[index],
value: values[index],
icon: icon,
accentColor: color,
replayToken: replayToken,
),
),
);
}),
),
);
}),
);
},
);
}
}
class ClaimsListRow extends StatelessWidget {
final IconData icon;
final Color iconColor;

View File

@ -147,8 +147,8 @@ class EnrollmentOverviewViewData {
}
static String formatPremiumAmount(double n) {
if (n.abs() >= 1000000) {
return '${(n / 1000000).toStringAsFixed(1)}M';
if (n.abs() >= 100000) {
return '${(n / 100000).toStringAsFixed(n.abs() >= 1000000 ? 1 : 2)}L';
}
if (n.abs() >= 1000) {
return '${(n / 1000).toStringAsFixed(1)}K';