dashboard changes done
This commit is contained in:
parent
c66c39e57c
commit
64d0b0b7d9
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user