import 'dart:math' as math; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart' show NumberFormat; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'claims_overview_scope.dart'; import 'claims_overview_theme.dart'; const _chartAnimDuration = Duration(milliseconds: 900); const _chartRiseDuration = Duration(milliseconds: 1200); Duration _chartRiseDurationFor(BuildContext context) => ClaimsPdfExportScope.of(context) ? Duration.zero : _chartRiseDuration; Duration _chartAnimDurationFor(BuildContext context) => ClaimsPdfExportScope.of(context) ? Duration.zero : _chartAnimDuration; const _chartAnimCurve = Curves.easeOutCubic; const _chartAxisColor = Color(0xFF9CA3AF); const _axisLabelGap = 6.0; const _axisTickLength = 8.0; const _verticalLeftAxisWidth = 44.0; /// Five Y-axis ticks from 0 → [maxY] (for dynamic-scale vertical charts). List _yTicksForMaxY(double maxY) { if (maxY <= 0) return const [0.0, 5.0, 10.0, 15.0, 20.0]; return List.generate(5, (i) => i == 4 ? maxY : (maxY / 4) * i); } String _formatLakhsAxisTick(double v) { if (v == 0) return '0'; 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). final incurredByStatusColors = [ ClaimsOverviewTheme.teal, const Color(0xFFFFB74D), ClaimsOverviewTheme.purple, ClaimsOverviewTheme.green, ClaimsOverviewTheme.orange, ClaimsOverviewTheme.red, ClaimsOverviewTheme.cyan, ]; /// Horizontal incurred-by-status chart with value labels at bar front (right end). class ClaimsHorizontalIncurredBarChart extends StatefulWidget { final List labels; final List valuesLakhs; final int animationKey; final double maxLakhs; ClaimsHorizontalIncurredBarChart({ super.key, required this.labels, required this.valuesLakhs, required this.animationKey, this.maxLakhs = 180, }); @override State createState() => _ClaimsHorizontalIncurredBarChartState(); } class _ClaimsHorizontalIncurredBarChartState extends State { static const double _labelColumnWidth = 112; static const double _bottomAxisHeight = 36; static const double _xAxisRightInset = 32; static const double _barThickness = 26; double get _maxAmount => widget.maxLakhs * 100000; List get _xTicks { final max = _maxAmount; if (max <= 0) { return const [0, 450000, 900000, 1350000, 1800000]; } final step = max / 4; return [0, step, step * 2, step * 3, max]; } double _toAmount(double lakhs) => lakhs * 100000; List _barValueOverlays({ required Size plotSize, required List barValues, required double animProgress, }) { final n = barValues.length; if (n == 0) return const []; 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.valuesLakhs.map(_toAmount).toList(); final rowCount = widget.labels.length; return LayoutBuilder( builder: (context, constraints) { return TweenAnimationBuilder( key: ValueKey(widget.animationKey), tween: Tween(begin: 0, end: 1), duration: _chartRiseDurationFor(context), curve: Curves.easeOutCubic, builder: (context, animProgress, _) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: _labelColumnWidth, child: Column( children: [ Expanded( child: Column( children: List.generate(rowCount, (i) { return Expanded( child: Align( alignment: Alignment.centerRight, child: _IncurredStatusLabel( label: widget.labels[i], axisColor: _chartAxisColor, ), ), ); }), ), ), const SizedBox(height: _bottomAxisHeight), ], ), ), Expanded( child: LayoutBuilder( builder: (context, plotConstraints) { final chartWidth = plotConstraints.maxWidth - _xAxisRightInset; final plotHeight = plotConstraints.maxHeight - _bottomAxisHeight; final plotSize = Size(chartWidth, plotHeight); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: plotHeight, child: Padding( padding: const EdgeInsets.only( right: _xAxisRightInset, ), child: Stack( clipBehavior: Clip.hardEdge, children: [ CustomPaint( size: plotSize, painter: _IncurredHorizontalPlotPainter( values: barValues, maxValue: _maxAmount, colors: incurredByStatusColors, animProgress: animProgress, barThickness: _barThickness, axisColor: _chartAxisColor, gridColor: ClaimsOverviewTheme.border, ), ), ..._barValueOverlays( plotSize: plotSize, barValues: barValues, animProgress: animProgress, ), ], ), ), ), _IncurredBottomAxis( ticks: _xTicks, maxValue: _maxAmount, axisColor: _chartAxisColor, rightInset: _xAxisRightInset, ), ], ); }, ), ), ], ); }, ); }, ); } } /// Status label with tick mark pointing at the Y-axis line. class _IncurredStatusLabel extends StatelessWidget { final String label; final Color axisColor; const _IncurredStatusLabel({ required this.label, required this.axisColor, }); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( child: Text( label, style: GoogleFonts.poppins( fontSize: 10, color: ClaimsOverviewTheme.textPrimary, height: 1.2, ), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: _axisLabelGap), Container(width: _axisTickLength, height: 1, color: axisColor), ], ); } } class _IncurredBottomAxis extends StatelessWidget { final List ticks; final double maxValue; final Color axisColor; final double rightInset; const _IncurredBottomAxis({ required this.ticks, required this.maxValue, required this.axisColor, required this.rightInset, }); @override Widget build(BuildContext context) { return SizedBox( height: _ClaimsHorizontalIncurredBarChartState._bottomAxisHeight, child: LayoutBuilder( builder: (context, constraints) { final chartW = constraints.maxWidth - rightInset; return Column( children: [ Padding( padding: EdgeInsets.only(right: rightInset), child: Container(height: 1, color: axisColor), ), Expanded( child: Padding( padding: EdgeInsets.only(right: rightInset), child: Stack( clipBehavior: Clip.hardEdge, children: [ for (var i = 0; i < ticks.length; i++) _tickLabel( ticks[i], chartW, isFirst: i == 0, isLast: i == ticks.length - 1, ), ], ), ), ), ], ); }, ), ); } Widget _tickLabel( double t, double chartW, { required bool isFirst, required bool isLast, }) { final label = Text( _formatLakhsAmountTick(t), style: GoogleFonts.poppins( fontSize: 9, color: ClaimsOverviewTheme.textSecondary, ), ); final tick = Container(width: 1, height: 6, color: axisColor); final column = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: isLast ? CrossAxisAlignment.end : isFirst ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ tick, const SizedBox(height: 4), label, ], ); if (isFirst) { return Positioned(left: 0, top: 0, child: column); } if (isLast) { return Positioned(right: 0, top: 0, child: column); } final x = (t / maxValue) * chartW; return Positioned( left: x, top: 0, child: FractionalTranslation( translation: const Offset(-0.5, 0), child: column, ), ); } } class _BarValueLabel extends StatelessWidget { final String value; const _BarValueLabel({required this.value}); @override Widget build(BuildContext context) { 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, ), ), ); } } class _IncurredHorizontalPlotPainter extends CustomPainter { final List values; final double maxValue; final List colors; final double animProgress; final double barThickness; final Color axisColor; final Color gridColor; _IncurredHorizontalPlotPainter({ required this.values, required this.maxValue, required this.colors, required this.animProgress, required this.barThickness, required this.axisColor, required this.gridColor, }); @override void paint(Canvas canvas, Size size) { final n = values.length; if (n == 0) return; final rowH = size.height / n; final axisPaint = Paint() ..color = axisColor ..strokeWidth = 1; // Dashed grid — vertical (value steps). final xSteps = [0.0, 0.25, 0.5, 0.75, 1.0]; for (final f in xSteps) { final x = size.width * f; _drawDashedLine( canvas, Offset(x, 0), Offset(x, size.height), gridColor, ); } // Dashed grid — horizontal (between rows). for (var i = 0; i <= n; i++) { final y = i * rowH; _drawDashedLine( canvas, Offset(0, y), Offset(size.width, y), gridColor, ); } // Solid L-shaped axes meeting at bottom-left origin. canvas.drawLine(Offset.zero, Offset(0, size.height), axisPaint); canvas.drawLine( Offset(0, size.height), Offset(size.width, size.height), axisPaint, ); for (var i = 0; i < n; i++) { final rowTop = i * rowH; final centerY = rowTop + rowH / 2; final barTop = centerY - barThickness / 2; final barBottom = centerY + barThickness / 2; final barW = (values[i] / maxValue) * size.width * animProgress; if (barW <= 0) continue; final barRect = RRect.fromRectAndCorners( Rect.fromLTRB(0, barTop, barW, barBottom), topRight: const Radius.circular(8), bottomRight: const Radius.circular(8), ); canvas.drawRRect( barRect, Paint()..color = colors[i % colors.length], ); } } @override bool shouldRepaint(covariant _IncurredHorizontalPlotPainter old) { return old.animProgress != animProgress || old.values != values; } } void _drawDashedLine(Canvas canvas, Offset a, Offset b, Color color) { const dash = 4.0; const gap = 4.0; final paint = Paint() ..color = color ..strokeWidth = 1; final total = (b - a).distance; if (total == 0) return; final dir = (b - a) / total; var dist = 0.0; while (dist < total) { final end = dist + dash; canvas.drawLine( a + dir * dist, a + dir * end.clamp(0.0, total), paint, ); dist += dash + gap; } } /// Shared vertical bar chart: aligned axes, labels inside card, rise animation. class ClaimsCustomVerticalBarChart extends StatefulWidget { final List labels; final List values; final double maxY; final List colors; final int animationKey; final double barWidth; final double barSlotFraction; final List yTicks; final double leftAxisWidth; final double bottomAxisHeight; final double plotRightInset; final String Function(double value)? formatYTick; const ClaimsCustomVerticalBarChart({ super.key, required this.labels, required this.values, required this.maxY, required this.colors, required this.animationKey, required this.yTicks, this.barWidth = 56, this.barSlotFraction = 0.72, this.leftAxisWidth = _verticalLeftAxisWidth, this.bottomAxisHeight = 52, this.plotRightInset = 0, this.formatYTick, }); @override State createState() => _ClaimsCustomVerticalBarChartState(); } class _ClaimsCustomVerticalBarChartState extends State { String _formatTick(double v) => widget.formatYTick?.call(v) ?? v.toInt().toString(); String _formatBarValue(double v) { if (widget.formatYTick != null) { return widget.formatYTick!(v); } return v.round().toString(); } List _barValueOverlays({ required Size plotSize, required double animProgress, }) { final n = widget.values.length; if (n == 0 || widget.maxY <= 0) return const []; 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) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _VerticalLeftAxis( yTicks: widget.yTicks, maxY: widget.maxY, axisWidth: widget.leftAxisWidth, bottomSpacer: widget.bottomAxisHeight, axisColor: _chartAxisColor, formatTick: _formatTick, ), Expanded( child: LayoutBuilder( builder: (context, plotConstraints) { final plotHeight = plotConstraints.maxHeight - widget.bottomAxisHeight; final plotSize = Size( plotConstraints.maxWidth, plotHeight, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: plotHeight, child: Stack( clipBehavior: Clip.hardEdge, children: [ 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, ), ), ..._barValueOverlays( plotSize: plotSize, animProgress: animProgress, ), ], ), ), _VerticalCategoryAxis( labels: widget.labels, height: widget.bottomAxisHeight, axisColor: _chartAxisColor, rightInset: widget.plotRightInset, ), ], ); }, ), ), ], ); } @override Widget build(BuildContext context) { if (ClaimsPdfExportScope.of(context)) { return LayoutBuilder( builder: (context, constraints) => _buildChartBody(1.0), ); } return LayoutBuilder( builder: (context, constraints) { return TweenAnimationBuilder( key: ValueKey(widget.animationKey), tween: Tween(begin: 0, end: 1), duration: _chartRiseDurationFor(context), curve: Curves.easeOutCubic, builder: (context, animProgress, _) => _buildChartBody(animProgress), ); }, ); } } /// Y-axis value with gap + tick (matches horizontal status labels). class _VerticalYTickLabel extends StatelessWidget { final String text; final Color axisColor; const _VerticalYTickLabel({ required this.text, required this.axisColor, }); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( text, style: GoogleFonts.poppins( fontSize: 10, color: ClaimsOverviewTheme.textSecondary, height: 1.2, ), textAlign: TextAlign.right, ), const SizedBox(width: _axisLabelGap), Container(width: _axisTickLength, height: 1, color: axisColor), ], ); } } class _VerticalLeftAxis extends StatelessWidget { final List yTicks; final double maxY; final double axisWidth; final double bottomSpacer; final Color axisColor; final String Function(double) formatTick; const _VerticalLeftAxis({ required this.yTicks, required this.maxY, required this.axisWidth, required this.bottomSpacer, required this.axisColor, required this.formatTick, }); @override Widget build(BuildContext context) { return SizedBox( width: axisWidth, child: Column( children: [ Expanded( child: LayoutBuilder( builder: (context, constraints) { final h = constraints.maxHeight; return Stack( clipBehavior: Clip.hardEdge, children: [ for (final t in yTicks) Positioned( right: 0, bottom: (t / maxY) * h, child: FractionalTranslation( translation: const Offset(0, -0.5), child: _VerticalYTickLabel( text: formatTick(t), axisColor: axisColor, ), ), ), ], ); }, ), ), SizedBox(height: bottomSpacer), ], ), ); } } class _VerticalCategoryAxis extends StatelessWidget { final List labels; final double height; final Color axisColor; final double rightInset; const _VerticalCategoryAxis({ required this.labels, required this.height, required this.axisColor, this.rightInset = 0, }); @override Widget build(BuildContext context) { return SizedBox( height: height, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: EdgeInsets.only(right: rightInset), child: Container(height: 1, color: axisColor), ), Expanded( child: Padding( padding: EdgeInsets.only(right: rightInset), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: labels.map((label) { return Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container(width: 1, height: 6, color: axisColor), const SizedBox(height: 4), Text( label, style: GoogleFonts.poppins( fontSize: 10, color: ClaimsOverviewTheme.textPrimary, height: 1.2, ), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ); }).toList(), ), ), ), ], ), ); } } class _VerticalBarPlotPainter extends CustomPainter { final List values; final double maxY; final List yTicks; final List colors; final double animProgress; final double barWidth; final double barSlotFraction; final double plotRightInset; final Color axisColor; final Color gridColor; _VerticalBarPlotPainter({ required this.values, required this.maxY, required this.yTicks, required this.colors, required this.animProgress, required this.barWidth, required this.barSlotFraction, required this.plotRightInset, required this.axisColor, required this.gridColor, }); @override void paint(Canvas canvas, Size size) { final n = values.length; if (n == 0) return; const chartLeft = 0.0; final chartW = size.width - plotRightInset; final slotW = chartW / n; final axisPaint = Paint() ..color = axisColor ..strokeWidth = 1; for (final t in yTicks) { final y = size.height - (t / maxY) * size.height; _drawDashedLine( canvas, Offset(chartLeft, y), Offset(chartLeft + chartW, y), gridColor, ); } for (var i = 0; i <= n; i++) { final x = chartLeft + i * slotW; _drawDashedLine( canvas, Offset(x, 0), Offset(x, size.height), gridColor, ); } canvas.drawLine( Offset(chartLeft, 0), Offset(chartLeft, size.height), axisPaint, ); canvas.drawLine( Offset(chartLeft, size.height), Offset(chartLeft + chartW, size.height), axisPaint, ); final effectiveBarW = barWidth.clamp(8.0, slotW * barSlotFraction); for (var i = 0; i < n; i++) { final centerX = chartLeft + (i + 0.5) * slotW; final left = centerX - effectiveBarW / 2; final barH = (values[i] / maxY) * size.height * animProgress; if (barH <= 0) continue; final barTop = size.height - barH; final barRect = RRect.fromRectAndCorners( Rect.fromLTRB(left, barTop, left + effectiveBarW, size.height), topLeft: const Radius.circular(8), topRight: const Radius.circular(8), ); canvas.drawRRect( barRect, Paint()..color = colors[i % colors.length], ); } } @override bool shouldRepaint(covariant _VerticalBarPlotPainter old) { return old.animProgress != animProgress || old.values != values; } } /// Colors for Policy Experience — Total Claims Count by Status. final claimsCountByStatusColors = [ ClaimsOverviewTheme.teal, ClaimsOverviewTheme.orange, const Color(0xFF4169E1), ClaimsOverviewTheme.green, ClaimsOverviewTheme.yellow, ClaimsOverviewTheme.red, ClaimsOverviewTheme.cyan, ]; /// Vertical claims-count chart: wide bars, value labels on top, rise animation. class ClaimsCountByStatusBarChart extends StatefulWidget { final List labels; final List values; final int animationKey; final double maxY; ClaimsCountByStatusBarChart({ super.key, required this.labels, required this.values, required this.animationKey, this.maxY = 220, }); @override State createState() => _ClaimsCountByStatusBarChartState(); } class _ClaimsCountByStatusBarChartState extends State { static const _yTicks = [0.0, 55.0, 110.0, 165.0, 220.0]; @override Widget build(BuildContext context) { return ClaimsCustomVerticalBarChart( labels: widget.labels, values: widget.values, maxY: widget.maxY, colors: claimsCountByStatusColors, animationKey: widget.animationKey, yTicks: _yTicks, barWidth: 56, bottomAxisHeight: 52, ); } } /// Colors for Claims Analysis — Total Counts by Claim Type. final claimTypeCountColors = [ ClaimsOverviewTheme.teal, ClaimsOverviewTheme.orange, ClaimsOverviewTheme.purple, ]; /// Vertical claim-type count chart with L-border axes and wide rounded bars. class ClaimsCountByClaimTypeChart extends StatefulWidget { final List labels; final List values; final int animationKey; final double maxY; ClaimsCountByClaimTypeChart({ super.key, required this.labels, required this.values, required this.animationKey, this.maxY = 260, }); @override State createState() => _ClaimsCountByClaimTypeChartState(); } class _ClaimsCountByClaimTypeChartState extends State { @override Widget build(BuildContext context) { final maxY = widget.maxY > 0 ? widget.maxY.toDouble() : 25.0; return ClaimsCustomVerticalBarChart( labels: widget.labels, values: widget.values, maxY: maxY, colors: claimTypeCountColors, animationKey: widget.animationKey, yTicks: _yTicksForMaxY(maxY), formatYTick: _formatLakhsAxisTick, leftAxisWidth: 48, barWidth: 56, bottomAxisHeight: 52, ); } } class ClaimsVerticalBarChart extends StatelessWidget { final List labels; final List values; final double maxY; final List? colors; final int replayToken; const ClaimsVerticalBarChart({ super.key, required this.labels, required this.values, required this.maxY, this.colors, this.replayToken = 0, }); static const _ageBandYTicks = [ 0.0, 4000000.0, 8000000.0, 12000000.0, 16000000.0, ]; @override Widget build(BuildContext context) { final palette = colors ?? ClaimsOverviewTheme.chartPalette; return ClaimsCustomVerticalBarChart( labels: labels, values: values, maxY: maxY, colors: palette, animationKey: replayToken, yTicks: _ageBandYTicks, barWidth: 48, barSlotFraction: 0.88, leftAxisWidth: 56, bottomAxisHeight: 48, plotRightInset: 8, formatYTick: (v) => v.toInt().toString(), ); } } /// Donut chart with external indicator lines, segment dots, sweep animation, legend. class ClaimsDonutChart extends StatefulWidget { final List values; final List? labels; final List? colors; final bool showLegend; final double size; final int replayToken; final int? animationKey; const ClaimsDonutChart({ super.key, required this.values, this.labels, this.colors, this.showLegend = false, this.size = 200, this.replayToken = 0, this.animationKey, }); @override State createState() => _ClaimsDonutChartState(); } class _ClaimsDonutChartState extends State { int _hoveredIndex = -1; int get _animKey => widget.animationKey ?? widget.replayToken; List get _palette { if (widget.colors != null && widget.colors!.length >= widget.values.length) { return widget.colors!; } return List.generate( widget.values.length, (i) => ClaimsOverviewTheme.chartPalette[i % ClaimsOverviewTheme.chartPalette.length], ); } int? _hitSegment(Offset local, Size size) { if (widget.values.isEmpty) return null; final center = Offset(size.width / 2, size.height / 2); final outerR = math.min(size.width, size.height) / 2 * 0.78; final innerR = outerR * 0.58; final dx = local.dx - center.dx; final dy = local.dy - center.dy; final dist = math.sqrt(dx * dx + dy * dy); if (dist < innerR || dist > outerR) return null; var angle = math.atan2(dy, dx) - _ClaimsDonutPainter._startAngle; while (angle < 0) { angle += 2 * math.pi; } while (angle >= 2 * math.pi) { angle -= 2 * math.pi; } final total = widget.values.fold(0, (a, b) => a + b); if (total <= 0) return null; var cumulative = 0.0; for (var i = 0; i < widget.values.length; i++) { final sweep = (widget.values[i] / total) * 2 * math.pi; if (angle >= cumulative && angle < cumulative + sweep) return i; cumulative += sweep; } return widget.values.length - 1; } String _formatDonutValue(double v) { final total = widget.values.fold(0, (a, b) => a + b); if (total > 0 && total <= 100.5) { return '${v.toStringAsFixed(v == v.roundToDouble() ? 0 : 2)}%'; } if (v == v.roundToDouble()) { return NumberFormat('#,##0').format(v.round()); } return NumberFormat('#,##0.##').format(v); } Widget _buildDonutColumn(BuildContext context, double animProgress) { final showLegend = widget.showLegend && widget.labels != null && widget.labels!.isNotEmpty; final isPdf = ClaimsPdfExportScope.of(context); return Column( children: [ Expanded( child: Center( child: AspectRatio( aspectRatio: 1, child: LayoutBuilder( builder: (context, constraints) { final side = math.min( constraints.maxWidth, constraints.maxHeight, ); final chartSize = Size(side, side); final chart = CustomPaint( size: chartSize, painter: _ClaimsDonutPainter( values: widget.values, colors: _palette, animProgress: animProgress, hoveredIndex: isPdf ? -1 : _hoveredIndex, compactIndicators: isPdf, ), ); if (isPdf) { return ClipRect(child: chart); } return MouseRegion( onHover: (e) { final hit = _hitSegment(e.localPosition, chartSize); final next = hit ?? -1; if (next != _hoveredIndex) { setState(() => _hoveredIndex = next); } }, onExit: (_) { if (_hoveredIndex != -1) { setState(() => _hoveredIndex = -1); } }, cursor: SystemMouseCursors.click, child: Stack( clipBehavior: Clip.none, children: [ chart, if (_hoveredIndex >= 0) Positioned.fill( child: IgnorePointer( child: Center( child: _DonutHoverTooltip( title: widget.labels != null && _hoveredIndex < widget.labels!.length ? widget.labels![_hoveredIndex] : 'Segment ${_hoveredIndex + 1}', value: _formatDonutValue( widget.values[_hoveredIndex], ), color: _palette[_hoveredIndex], ), ), ), ), ], ), ); }, ), ), ), ), if (showLegend) ...[ const SizedBox(height: 8), _DonutLegend( labels: widget.labels!, colors: _palette, ), ], ], ); } @override Widget build(BuildContext context) { if (ClaimsPdfExportScope.of(context)) { return _buildDonutColumn(context, 1.0); } return TweenAnimationBuilder( key: ValueKey('donut-$_animKey-${widget.values.join()}'), tween: Tween(begin: 0, end: 1), duration: _chartRiseDurationFor(context), curve: Curves.easeOutCubic, builder: (context, animProgress, _) => _buildDonutColumn(context, animProgress), ); } } class _DonutHoverTooltip extends StatelessWidget { final String title; final String value; final Color color; const _DonutHoverTooltip({ required this.title, required this.value, required this.color, }); @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: Container( constraints: const BoxConstraints(minWidth: 88), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: ClaimsOverviewTheme.border), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 3), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title, style: GoogleFonts.poppins( fontSize: 14, fontWeight: FontWeight.w600, color: color, ), ), const SizedBox(height: 4), Text( value, style: GoogleFonts.poppins( fontSize: 22, fontWeight: FontWeight.w700, color: ClaimsOverviewTheme.textPrimary, height: 1.1, ), ), ], ), ), ); } } class _DonutLegend extends StatelessWidget { final List labels; final List colors; const _DonutLegend({required this.labels, required this.colors}); @override Widget build(BuildContext context) { return Wrap( spacing: 20, runSpacing: 8, alignment: WrapAlignment.center, children: List.generate(labels.length, (i) { final color = colors[i % colors.length]; return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 6), Text( labels[i], style: GoogleFonts.poppins( fontSize: 12, fontWeight: FontWeight.w500, color: color, ), ), ], ); }), ); } } class _ClaimsDonutPainter extends CustomPainter { final List values; final List colors; final double animProgress; final int hoveredIndex; final bool compactIndicators; _ClaimsDonutPainter({ required this.values, required this.colors, required this.animProgress, this.hoveredIndex = -1, this.compactIndicators = false, }); static const double _sectionGapRad = 0.04; static const double _startAngle = -math.pi / 2; @override void paint(Canvas canvas, Size size) { if (values.isEmpty) return; final center = Offset(size.width / 2, size.height / 2); final outerR = math.min(size.width, size.height) / 2 * 0.78; final innerR = outerR * 0.58; final ringWidth = outerR - innerR; final midR = (outerR + innerR) / 2; final total = values.fold(0, (a, b) => a + b); if (total <= 0) return; var angle = _startAngle; for (var i = 0; i < values.length; i++) { final fraction = values[i] / total; final fullSweep = fraction * 2 * math.pi; final gap = values.length > 1 ? _sectionGapRad : 0.0; final drawableSweep = math.max(fullSweep - gap, 0.0); final animatedSweep = drawableSweep * animProgress; final sectionStart = angle + gap / 2; if (animatedSweep > 0) { final baseColor = colors[i % colors.length]; final isHovered = i == hoveredIndex; final ringPaint = Paint() ..color = isHovered ? baseColor : baseColor.withValues(alpha: hoveredIndex >= 0 ? 0.45 : 1.0) ..style = PaintingStyle.stroke ..strokeWidth = isHovered ? ringWidth + 4 : ringWidth ..strokeCap = StrokeCap.butt; canvas.drawArc( Rect.fromCircle(center: center, radius: midR), sectionStart, animatedSweep, false, ringPaint, ); } // Indicators + labels use full segment mid-angle (stable once drawn). if (animProgress > 0.8) { final midAngle = sectionStart + drawableSweep / 2; _drawIndicator( canvas, center: center, midAngle: midAngle, outerR: outerR, value: values[i], color: colors[i % colors.length], segmentCount: values.length, opacity: ((animProgress - 0.8) / 0.2).clamp(0.0, 1.0), ); } angle += fullSweep; } // Center hole (white donut core). canvas.drawCircle( center, innerR - 1, Paint()..color = Colors.white, ); } void _drawIndicator( Canvas canvas, { required Offset center, required double midAngle, required double outerR, required double value, required Color color, required int segmentCount, required double opacity, }) { if (opacity <= 0) return; final c = color.withValues(alpha: opacity); final fontSize = compactIndicators ? (segmentCount <= 2 ? 12.0 : 10.0) : segmentCount <= 2 ? 18.0 : segmentCount <= 4 ? 15.0 : 12.0; final leaderLen = compactIndicators ? 6.0 : segmentCount <= 4 ? 14.0 : 10.0; final unitX = math.cos(midAngle); final unitY = math.sin(midAngle); final arcEdge = Offset( center.dx + outerR * unitX, center.dy + outerR * unitY, ); final label = _formatValue(value); final tp = TextPainter( text: TextSpan( text: label, style: GoogleFonts.poppins( fontSize: fontSize, fontWeight: FontWeight.w700, color: c, ), ), textDirection: TextDirection.ltr, )..layout(); // Place value right after the tick (minimal 2px padding). const labelPadding = 2.0; final textCenterDist = outerR + leaderLen + labelPadding + _textExtentAlongRay(tp, midAngle); final lineEnd = Offset( center.dx + (outerR + leaderLen) * unitX, center.dy + (outerR + leaderLen) * unitY, ); final labelCenter = Offset( center.dx + textCenterDist * unitX, center.dy + textCenterDist * unitY, ); canvas.drawLine( arcEdge, lineEnd, Paint() ..color = c ..strokeWidth = 1.2, ); tp.paint( canvas, labelCenter - Offset(tp.width / 2, tp.height / 2), ); } String _formatValue(double v) { if (v == v.roundToDouble()) { return NumberFormat('#,##0').format(v.round()); } return NumberFormat('#,##0.#').format(v); } /// Half of the text bbox projected along the indicator ray. double _textExtentAlongRay(TextPainter tp, double angle) { final c = math.cos(angle).abs(); final s = math.sin(angle).abs(); return (tp.width * c + tp.height * s) / 2; } @override bool shouldRepaint(covariant _ClaimsDonutPainter old) { return old.animProgress != animProgress || old.values != values || old.hoveredIndex != hoveredIndex || old.colors != colors || old.compactIndicators != compactIndicators; } } /// Area / line chart — L-axes, ticks, dashed grid, hover crosshair, draw animation. class ClaimsAreaChart extends StatefulWidget { final List labels; final List values; final double maxY; final List? yTicks; final int replayToken; final Color lineColor; final String Function(double value)? formatTooltipValue; const ClaimsAreaChart({ super.key, required this.labels, required this.values, required this.maxY, this.yTicks, this.replayToken = 0, this.lineColor = ClaimsOverviewTheme.teal, this.formatTooltipValue, }); @override State createState() => _ClaimsAreaChartState(); } class _ClaimsAreaChartState extends State { static const double _bottomAxisHeight = 48; static const double _plotRightInset = 8; int _hoveredIndex = -1; Offset? _tooltipAnchor; List get _yTicks => widget.yTicks ?? [0, widget.maxY * 0.25, widget.maxY * 0.5, widget.maxY * 0.75, widget.maxY]; void _onPlotHover(Offset local, Size plotSize, int count) { if (count == 0) return; final chartW = plotSize.width; final slotW = chartW / count; final index = (local.dx / slotW).floor().clamp(0, count - 1); if (index == _hoveredIndex) return; final value = widget.values[index]; final pointY = widget.maxY > 0 ? plotSize.height * (1 - value / widget.maxY) : plotSize.height; setState(() { _hoveredIndex = index; _tooltipAnchor = Offset((index + 0.5) * slotW, pointY); }); } @override Widget build(BuildContext context) { final count = widget.labels.length; return TweenAnimationBuilder( key: ValueKey('area-${widget.replayToken}-${widget.values.join()}'), tween: Tween(begin: 0, end: 1), duration: _chartRiseDurationFor(context), curve: Curves.easeOutCubic, builder: (context, animProgress, _) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _VerticalLeftAxis( yTicks: _yTicks, maxY: widget.maxY, axisWidth: _verticalLeftAxisWidth, bottomSpacer: _bottomAxisHeight, axisColor: _chartAxisColor, formatTick: (v) => v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(1), ), Expanded( child: LayoutBuilder( builder: (context, constraints) { final plotHeight = constraints.maxHeight - _bottomAxisHeight; final plotSize = Size( constraints.maxWidth - _plotRightInset, plotHeight, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: plotHeight, child: Padding( padding: const EdgeInsets.only(right: _plotRightInset), child: Stack( clipBehavior: Clip.hardEdge, children: [ MouseRegion( onHover: (e) => _onPlotHover( e.localPosition, plotSize, count, ), onExit: (_) => setState(() { _hoveredIndex = -1; _tooltipAnchor = null; }), cursor: SystemMouseCursors.click, child: CustomPaint( size: plotSize, painter: _AreaLinePlotPainter( values: widget.values, maxY: widget.maxY, yTicks: _yTicks, animProgress: animProgress, hoveredIndex: _hoveredIndex, lineColor: widget.lineColor, axisColor: _chartAxisColor, gridColor: ClaimsOverviewTheme.border, plotBorderColor: ClaimsOverviewTheme.border, ), ), ), if (_hoveredIndex >= 0 && _tooltipAnchor != null) Positioned( left: (_tooltipAnchor!.dx - 38) .clamp(4.0, plotSize.width - 76), top: (_tooltipAnchor!.dy - 26) .clamp(4.0, plotSize.height - 24), child: _BarValueLabel( value: (widget.formatTooltipValue ?? _formatLakhsAxisTick)( widget.values[_hoveredIndex], ), ), ), ], ), ), ), _VerticalCategoryAxis( labels: widget.labels, height: _bottomAxisHeight, axisColor: _chartAxisColor, rightInset: _plotRightInset, ), ], ); }, ), ), ], ); }, ); } } class _AreaLinePlotPainter extends CustomPainter { final List values; final double maxY; final List yTicks; final double animProgress; final int hoveredIndex; final Color lineColor; final Color axisColor; final Color gridColor; final Color plotBorderColor; _AreaLinePlotPainter({ required this.values, required this.maxY, required this.yTicks, required this.animProgress, required this.hoveredIndex, required this.lineColor, required this.axisColor, required this.gridColor, required this.plotBorderColor, }); List _chartPoints(Size size) { final n = values.length; if (n == 0) return []; final chartW = size.width; final slotW = chartW / n; return List.generate(n, (i) { final x = (i + 0.5) * slotW; final y = size.height - (values[i] / maxY) * size.height; return Offset(x, y); }); } Path _smoothLinePath(List points) { final path = Path(); if (points.isEmpty) return path; if (points.length == 1) { path.moveTo(points[0].dx, points[0].dy); return path; } path.moveTo(points[0].dx, points[0].dy); for (var i = 0; i < points.length - 1; i++) { final current = points[i]; final next = points[i + 1]; final controlX = (current.dx + next.dx) / 2; path.cubicTo( controlX, current.dy, controlX, next.dy, next.dx, next.dy, ); } return path; } Path _animatedPath(Path fullPath, double progress) { if (progress >= 1) return fullPath; final metrics = fullPath.computeMetrics(); final out = Path(); for (final metric in metrics) { out.addPath(metric.extractPath(0, metric.length * progress), Offset.zero); } return out; } Path _fillPath(Path linePath, Size size) { final fill = Path.from(linePath); final bounds = linePath.getBounds(); fill.lineTo(bounds.right, size.height); fill.lineTo(bounds.left, size.height); fill.close(); return fill; } @override void paint(Canvas canvas, Size size) { if (values.isEmpty) return; final plotRect = Rect.fromLTWH(0, 0, size.width, size.height); final axisPaint = Paint() ..color = axisColor ..strokeWidth = 1; // Light inner plot border. canvas.drawRect( plotRect, Paint() ..color = plotBorderColor.withValues(alpha: 0.65) ..style = PaintingStyle.stroke ..strokeWidth = 1, ); final n = values.length; final slotW = size.width / n; for (final t in yTicks) { final y = size.height - (t / maxY) * size.height; _drawDashedLine(canvas, Offset(0, y), Offset(size.width, y), gridColor); } for (var i = 0; i <= n; i++) { final x = i * slotW; _drawDashedLine(canvas, Offset(x, 0), Offset(x, size.height), gridColor); } canvas.drawLine(Offset.zero, Offset(0, size.height), axisPaint); canvas.drawLine( Offset(0, size.height), Offset(size.width, size.height), axisPaint, ); final points = _chartPoints(size); final linePath = _smoothLinePath(points); final animatedLine = _animatedPath(linePath, animProgress); final fillPath = _fillPath(animatedLine, size); final fillPaint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ lineColor.withValues(alpha: 0.35), lineColor.withValues(alpha: 0.02), ], ).createShader(plotRect); canvas.drawPath(fillPath, fillPaint); canvas.drawPath( animatedLine, Paint() ..color = lineColor ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round, ); if (hoveredIndex >= 0 && hoveredIndex < points.length) { final pt = points[hoveredIndex]; canvas.drawLine( Offset(pt.dx, size.height), Offset(pt.dx, pt.dy), Paint() ..color = const Color(0xFFD1D5DB) ..strokeWidth = 1, ); canvas.drawCircle( pt, 5, Paint()..color = Colors.white, ); canvas.drawCircle( pt, 5, Paint() ..color = lineColor ..style = PaintingStyle.stroke ..strokeWidth = 2, ); } } @override bool shouldRepaint(covariant _AreaLinePlotPainter old) { return old.animProgress != animProgress || old.hoveredIndex != hoveredIndex || old.values != values; } } class ClaimsScatterChart extends StatelessWidget { final List? values; final List? labels; final double? maxY; final double? pointY; final String? label; final int replayToken; const ClaimsScatterChart({ super.key, this.values, this.labels, this.maxY, this.pointY, this.label, this.replayToken = 0, }); bool get _isMulti => values != null && labels != null && values!.isNotEmpty && labels!.isNotEmpty; @override Widget build(BuildContext context) { if (_isMulti) { return _buildMulti(context); } return _buildSingle(context); } Widget _buildMulti(BuildContext context) { final vals = values!; final labs = labels!; final chartMaxY = maxY ?? (vals.reduce((a, b) => a > b ? a : b) * 1.1); final yInterval = chartMaxY > 0 ? chartMaxY / 4 : 1.0; final lastIndex = math.max(0, vals.length - 1); final maxX = math.max(1.0, lastIndex.toDouble()); const xEdgePadding = 0.5; return Padding( padding: const EdgeInsets.only(right: 6), child: LineChart( duration: _chartAnimDurationFor(context), curve: _chartAnimCurve, key: ValueKey('scatter-multi-$replayToken-${vals.join(",")}'), LineChartData( clipData: const FlClipData.none(), maxY: chartMaxY, minY: 0, minX: -xEdgePadding, maxX: maxX + xEdgePadding, gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (v) => FlLine( color: ClaimsOverviewTheme.border, strokeWidth: 1, dashArray: [4, 4], ), ), borderData: FlBorderData( show: true, border: const Border( left: BorderSide(color: _chartAxisColor, width: 1), bottom: BorderSide(color: _chartAxisColor, width: 1), ), ), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false, reservedSize: 8), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, interval: yInterval, getTitlesWidget: (v, _) => Text( v >= 1 ? v.toStringAsFixed(1) : v.toStringAsFixed(2), style: GoogleFonts.poppins(fontSize: 10, color: Colors.grey), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 48, interval: 1, getTitlesWidget: (v, meta) { final i = v.round(); if (i < 0 || i >= labs.length) { return const SizedBox.shrink(); } return SideTitleWidget( meta: meta, space: 6, fitInside: SideTitleFitInsideData.fromTitleMeta(meta), child: Text( labs[i], textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.visible, style: GoogleFonts.poppins( fontSize: 9, color: Colors.black54, ), ), ); }, ), ), ), lineBarsData: [ LineChartBarData( spots: [ for (var i = 0; i < vals.length; i++) FlSpot(i.toDouble(), vals[i]), ], color: ClaimsOverviewTheme.orange, barWidth: 0, dotData: FlDotData( show: true, getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( radius: 6, color: ClaimsOverviewTheme.orange, strokeWidth: 2, strokeColor: Colors.white, ), ), belowBarData: BarAreaData(show: false), ), ], ), ), ); } Widget _buildSingle(BuildContext context) { final y = pointY ?? 0; final bottomLabel = label ?? ''; return LineChart( duration: _chartAnimDurationFor(context), curve: _chartAnimCurve, key: ValueKey('scatter-$replayToken-$y'), LineChartData( maxY: 600, minY: 0, minX: 0, maxX: 2, gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (v) => FlLine( color: ClaimsOverviewTheme.border, strokeWidth: 1, dashArray: [4, 4], ), ), borderData: FlBorderData( show: true, border: const Border( left: BorderSide(color: _chartAxisColor, width: 1), bottom: BorderSide(color: _chartAxisColor, width: 1), ), ), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 36, interval: 150, getTitlesWidget: (v, _) => Text( v.toInt().toString(), style: GoogleFonts.poppins(fontSize: 10, color: Colors.grey), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, interval: 1, reservedSize: 36, getTitlesWidget: (v, __) { if (v.round() != 1) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 4), child: Text( bottomLabel, style: GoogleFonts.poppins(fontSize: 10, color: Colors.black54), ), ); }, ), ), ), lineBarsData: [ LineChartBarData( spots: [FlSpot(1, y)], color: ClaimsOverviewTheme.orange, barWidth: 0, dotData: FlDotData( show: true, getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( radius: 8, color: ClaimsOverviewTheme.orange, strokeWidth: 2, strokeColor: Colors.white, ), ), belowBarData: BarAreaData(show: false), ), ], extraLinesData: ExtraLinesData( verticalLines: [ VerticalLine( x: 1, color: ClaimsOverviewTheme.orange.withValues(alpha: 0.4), dashArray: [4, 4], ), ], ), ), ); } }