2077 lines
61 KiB
Dart
2077 lines
61 KiB
Dart
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<double> _yTicksForMaxY(double maxY) {
|
|
if (maxY <= 0) return const [0.0, 5.0, 10.0, 15.0, 20.0];
|
|
return List<double>.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<String> labels;
|
|
final List<double> valuesLakhs;
|
|
final int animationKey;
|
|
final double maxLakhs;
|
|
|
|
ClaimsHorizontalIncurredBarChart({
|
|
super.key,
|
|
required this.labels,
|
|
required this.valuesLakhs,
|
|
required this.animationKey,
|
|
this.maxLakhs = 180,
|
|
});
|
|
|
|
@override
|
|
State<ClaimsHorizontalIncurredBarChart> createState() =>
|
|
_ClaimsHorizontalIncurredBarChartState();
|
|
}
|
|
|
|
class _ClaimsHorizontalIncurredBarChartState
|
|
extends State<ClaimsHorizontalIncurredBarChart> {
|
|
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<double> 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<Widget> _barValueOverlays({
|
|
required Size plotSize,
|
|
required List<double> 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<double>(
|
|
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<double> 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<double> values;
|
|
final double maxValue;
|
|
final List<Color> 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<String> labels;
|
|
final List<double> values;
|
|
final double maxY;
|
|
final List<Color> colors;
|
|
final int animationKey;
|
|
final double barWidth;
|
|
final double barSlotFraction;
|
|
final List<double> 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<ClaimsCustomVerticalBarChart> createState() =>
|
|
_ClaimsCustomVerticalBarChartState();
|
|
}
|
|
|
|
class _ClaimsCustomVerticalBarChartState
|
|
extends State<ClaimsCustomVerticalBarChart> {
|
|
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<Widget> _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<double>(
|
|
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<double> 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<String> 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<double> values;
|
|
final double maxY;
|
|
final List<double> yTicks;
|
|
final List<Color> 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<String> labels;
|
|
final List<double> values;
|
|
final int animationKey;
|
|
final double maxY;
|
|
|
|
ClaimsCountByStatusBarChart({
|
|
super.key,
|
|
required this.labels,
|
|
required this.values,
|
|
required this.animationKey,
|
|
this.maxY = 220,
|
|
});
|
|
|
|
@override
|
|
State<ClaimsCountByStatusBarChart> createState() =>
|
|
_ClaimsCountByStatusBarChartState();
|
|
}
|
|
|
|
class _ClaimsCountByStatusBarChartState extends State<ClaimsCountByStatusBarChart> {
|
|
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<String> labels;
|
|
final List<double> values;
|
|
final int animationKey;
|
|
final double maxY;
|
|
|
|
ClaimsCountByClaimTypeChart({
|
|
super.key,
|
|
required this.labels,
|
|
required this.values,
|
|
required this.animationKey,
|
|
this.maxY = 260,
|
|
});
|
|
|
|
@override
|
|
State<ClaimsCountByClaimTypeChart> createState() =>
|
|
_ClaimsCountByClaimTypeChartState();
|
|
}
|
|
|
|
class _ClaimsCountByClaimTypeChartState
|
|
extends State<ClaimsCountByClaimTypeChart> {
|
|
@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<String> labels;
|
|
final List<double> values;
|
|
final double maxY;
|
|
final List<Color>? 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<double> values;
|
|
final List<String>? labels;
|
|
final List<Color>? 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<ClaimsDonutChart> createState() => _ClaimsDonutChartState();
|
|
}
|
|
|
|
class _ClaimsDonutChartState extends State<ClaimsDonutChart> {
|
|
int _hoveredIndex = -1;
|
|
|
|
int get _animKey => widget.animationKey ?? widget.replayToken;
|
|
|
|
List<Color> 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<double>(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<double>(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<double>(
|
|
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<String> labels;
|
|
final List<Color> 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<double> values;
|
|
final List<Color> 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<double>(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<String> labels;
|
|
final List<double> values;
|
|
final double maxY;
|
|
final List<double>? 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<ClaimsAreaChart> createState() => _ClaimsAreaChartState();
|
|
}
|
|
|
|
class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
|
|
static const double _bottomAxisHeight = 48;
|
|
static const double _plotRightInset = 8;
|
|
|
|
int _hoveredIndex = -1;
|
|
Offset? _tooltipAnchor;
|
|
|
|
List<double> 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<double>(
|
|
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<double> values;
|
|
final double maxY;
|
|
final List<double> 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<Offset> _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<Offset> 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<double>? values;
|
|
final List<String>? 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],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|