diff --git a/lib/presentation/claims_overview/claims_collection_kpi.dart b/lib/presentation/claims_overview/claims_collection_kpi.dart index c6140c3..62b0362 100644 --- a/lib/presentation/claims_overview/claims_collection_kpi.dart +++ b/lib/presentation/claims_overview/claims_collection_kpi.dart @@ -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 _relationshipFromKpi(dynamic kpi) { final rows = ClaimsKpiParser.extractRows(kpi); if (rows.isEmpty) return const []; - final items = []; + 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 _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( diff --git a/lib/presentation/claims_overview/claims_overview_charts.dart b/lib/presentation/claims_overview/claims_overview_charts.dart index 751c7e1..ddd7689 100644 --- a/lib/presentation/claims_overview/claims_overview_charts.dart +++ b/lib/presentation/claims_overview/claims_overview_charts.dart @@ -29,10 +29,20 @@ List _yTicksForMaxY(double maxY) { return List.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 labels; - final List valuesMillions; + final List 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 { - 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 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 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 _barValueOverlays({ + required Size plotSize, + required List 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 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 { - 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 _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 values; final double maxY; final List yTicks; final List colors; final double animProgress; - final int hoveredIndex; final double barWidth; final double barSlotFraction; final double plotRightInset; - final Color hoverTrackColor; final Color axisColor; final Color gridColor; @@ -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 labels; final List 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 { 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 { } } -class _AreaChartTooltip extends StatelessWidget { - final String title; - final double value; - - const _AreaChartTooltip({required this.title, required this.value}); - - String get _valueText { - if (value == value.roundToDouble()) return value.round().toString(); - return value.toStringAsFixed(1); - } - - @override - Widget build(BuildContext context) { - return Material( - elevation: 4, - shadowColor: Colors.black26, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: ClaimsOverviewTheme.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: GoogleFonts.poppins( - fontSize: 14, - fontWeight: FontWeight.w700, - color: ClaimsOverviewTheme.textPrimary, - ), - ), - Text( - 'value : $_valueText', - style: GoogleFonts.poppins( - fontSize: 12, - fontWeight: FontWeight.w500, - color: ClaimsOverviewTheme.teal, - ), - ), - ], - ), - ), - ); - } -} - class _AreaLinePlotPainter extends CustomPainter { final List values; final double maxY; diff --git a/lib/presentation/claims_overview/claims_overview_dashboard.dart b/lib/presentation/claims_overview/claims_overview_dashboard.dart index 8e7f67a..40f1523 100644 --- a/lib/presentation/claims_overview/claims_overview_dashboard.dart +++ b/lib/presentation/claims_overview/claims_overview_dashboard.dart @@ -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 String? _clientBranchId; String? _selectedPolicyId; List> _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 if (!stillValid) { _selectedPolicyId = firstId; } + await _loadClientLogoForSelectedPolicy(); } else { _selectedPolicyId = null; + _clientLogoBytes = null; } } @@ -258,9 +264,30 @@ class _ClaimsOverviewDashboardState extends State return null; } + Future _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 ); 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; diff --git a/lib/presentation/claims_overview/claims_overview_pdf_export.dart b/lib/presentation/claims_overview/claims_overview_pdf_export.dart index b1da47f..7e7d072 100644 --- a/lib/presentation/claims_overview/claims_overview_pdf_export.dart +++ b/lib/presentation/claims_overview/claims_overview_pdf_export.dart @@ -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 _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 _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 _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 _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 _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 exportClaimsOverviewChartsPdf({ required ClaimsOverviewViewData viewData, required EnrollmentOverviewViewData enrollmentViewData, required String policyId, + Uint8List? clientLogoBytes, ClaimsPdfExportProgress? onProgress, }) async { await _preloadFonts(); @@ -495,6 +478,7 @@ Future exportClaimsOverviewChartsPdf({ final buildInput = _PdfBuildInput( captures: captures, logoBytes: logoBytes, + clientLogoBytes: clientLogoBytes, ); final Uint8List bytes; diff --git a/lib/presentation/claims_overview/claims_overview_pdf_layout.dart b/lib/presentation/claims_overview/claims_overview_pdf_layout.dart index 76a7c19..29d6953 100644 --- a/lib/presentation/claims_overview/claims_overview_pdf_layout.dart +++ b/lib/presentation/claims_overview/claims_overview_pdf_layout.dart @@ -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: [ diff --git a/lib/presentation/claims_overview/claims_overview_tabs.dart b/lib/presentation/claims_overview/claims_overview_tabs.dart index f9b799b..b0b679c 100644 --- a/lib/presentation/claims_overview/claims_overview_tabs.dart +++ b/lib/presentation/claims_overview/claims_overview_tabs.dart @@ -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 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 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', ); } } diff --git a/lib/presentation/claims_overview/claims_overview_widgets.dart b/lib/presentation/claims_overview/claims_overview_widgets.dart index fe7c336..bf8ba13 100644 --- a/lib/presentation/claims_overview/claims_overview_widgets.dart +++ b/lib/presentation/claims_overview/claims_overview_widgets.dart @@ -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 labels; + final List values; + final List 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; diff --git a/lib/presentation/claims_overview/enrollment_collection_kpi.dart b/lib/presentation/claims_overview/enrollment_collection_kpi.dart index 1234128..f819caf 100644 --- a/lib/presentation/claims_overview/enrollment_collection_kpi.dart +++ b/lib/presentation/claims_overview/enrollment_collection_kpi.dart @@ -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';