dashboard changes

This commit is contained in:
Surendiran 2026-06-11 09:21:16 +05:30
parent 440da00ee5
commit c6b15a050a
7 changed files with 969 additions and 439 deletions

View File

@ -241,6 +241,19 @@ class ClaimsOverviewViewData {
); );
} }
ClaimsChartSeries _seriesSortedByValueDesc(ClaimsChartSeries series) {
if (series.isEmpty) return series;
final pairs = List.generate(
series.labels.length,
(i) => (series.labels[i], series.values[i]),
)..sort((a, b) => b.$2.compareTo(a.$2));
return ClaimsChartSeries(
labels: pairs.map((p) => p.$1).toList(),
values: pairs.map((p) => p.$2).toList(),
maxY: series.maxY,
);
}
// --- Overview tab --- // --- Overview tab ---
String get policyStartDate => ClaimsKpiParser.rowField( String get policyStartDate => ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.policyStartDate), _kpi(ClaimsKpiSlug.policyStartDate),
@ -288,6 +301,17 @@ class ClaimsOverviewViewData {
), ),
); );
/// Last refresh time from `claims_incidence_rate.created_at` (dashboard KPI).
String? get liveUpdatedAt {
final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.claimsIncidenceRate));
if (rows.isEmpty || rows.first is! Map) return null;
final raw = ClaimsKpiParser.rowValue(rows.first as Map, 'created_at');
if (raw == null) return null;
final text = raw.toString().trim();
if (text.isEmpty) return null;
return ClaimsKpiParser.formatDate(text);
}
String get tpaName => ClaimsKpiParser.rowField( String get tpaName => ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.tpa), _kpi(ClaimsKpiSlug.tpa),
'tpa_name', 'tpa_name',
@ -349,18 +373,20 @@ class ClaimsOverviewViewData {
} }
// --- Policy experience --- // --- Policy experience ---
ClaimsChartSeries get incurredByClaimStatus => chartSeries( ClaimsChartSeries get incurredByClaimStatus =>
_seriesSortedByValueDesc(chartSeries(
ClaimsKpiSlug.totalIncurredByClaimStatus, ClaimsKpiSlug.totalIncurredByClaimStatus,
labelKey: 'Claim Status', labelKey: 'Claim Status',
valueKey: 'Total Incurred Amount', valueKey: 'Total Incurred Amount',
valuesInLakhs: true, valuesInLakhs: true,
); ));
ClaimsChartSeries get claimsCountByStatus => chartSeries( ClaimsChartSeries get claimsCountByStatus =>
_seriesSortedByValueDesc(chartSeries(
ClaimsKpiSlug.claimAmountByClaimStatus, ClaimsKpiSlug.claimAmountByClaimStatus,
labelKey: 'claim_status', labelKey: 'claim_status',
valueKey: 'claim_count', valueKey: 'claim_count',
); ));
String _kpiScalar(String slug, String field) => ClaimsKpiParser.formatDisplay( String _kpiScalar(String slug, String field) => ClaimsKpiParser.formatDisplay(
ClaimsKpiParser.kpiOnlyField(_kpi(slug), field), ClaimsKpiParser.kpiOnlyField(_kpi(slug), field),
@ -474,6 +500,12 @@ class ClaimsOverviewViewData {
.replaceAll(RegExp(r'-+'), '-'); .replaceAll(RegExp(r'-+'), '-');
} }
static int _relationshipSortIndex(String relation) {
final key = _normalizeRelationshipKey(relation);
final idx = _relationshipOrder.indexOf(key);
return idx >= 0 ? idx : _relationshipOrder.length;
}
static String formatRelationshipName(String relation) { static String formatRelationshipName(String relation) {
final key = _normalizeRelationshipKey(relation); final key = _normalizeRelationshipKey(relation);
const labels = { const labels = {
@ -500,12 +532,6 @@ class ClaimsOverviewViewData {
return word[0].toUpperCase() + word.substring(1).toLowerCase(); return word[0].toUpperCase() + word.substring(1).toLowerCase();
} }
static int _relationshipSortIndex(String relation) {
final key = _normalizeRelationshipKey(relation);
final idx = _relationshipOrder.indexOf(key);
return idx >= 0 ? idx : _relationshipOrder.length;
}
List<ClaimsRelationshipItem> _relationshipFromKpi(dynamic kpi) { List<ClaimsRelationshipItem> _relationshipFromKpi(dynamic kpi) {
final rows = ClaimsKpiParser.extractRows(kpi); final rows = ClaimsKpiParser.extractRows(kpi);
if (rows.isEmpty) return const []; if (rows.isEmpty) return const [];
@ -557,7 +583,7 @@ class ClaimsOverviewViewData {
final rows = ClaimsKpiParser.extractRows(kpi); final rows = ClaimsKpiParser.extractRows(kpi);
if (rows.isEmpty) return const []; if (rows.isEmpty) return const [];
final items = <ClaimsAilmentItem>[]; final items = <({double sortValue, ClaimsAilmentItem item})>[];
for (final row in rows) { for (final row in rows) {
if (row is! Map) continue; if (row is! Map) continue;
var title = ClaimsKpiParser.rowValue(row, 'ailment')?.toString() ?? var title = ClaimsKpiParser.rowValue(row, 'ailment')?.toString() ??
@ -584,15 +610,18 @@ class ClaimsOverviewViewData {
if (title == null || title.isEmpty || valueRaw == null) continue; if (title == null || title.isEmpty || valueRaw == null) continue;
title = _stripLeadingDash(title); title = _stripLeadingDash(title);
final sortValue = ClaimsKpiParser.toDouble(valueRaw) ?? 0;
items.add( items.add((
ClaimsAilmentItem( sortValue: sortValue,
item: ClaimsAilmentItem(
title: title, title: title,
value: ClaimsKpiParser.formatDisplay(valueRaw), value: ClaimsKpiParser.formatDisplay(valueRaw),
), ),
); ));
} }
return items; items.sort((a, b) => b.sortValue.compareTo(a.sortValue));
return items.map((e) => e.item).toList();
} }
static String _stripLeadingDash(String text) { static String _stripLeadingDash(String text) {
@ -626,10 +655,12 @@ class ClaimsOverviewViewData {
if (series.values.isEmpty) return series; if (series.values.isEmpty) return series;
final inLakhs = series.values.map((v) => v / 100000).toList(); final inLakhs = series.values.map((v) => v / 100000).toList();
final max = inLakhs.reduce(math.max); final max = inLakhs.reduce(math.max);
return ClaimsChartSeries( return _seriesSortedByValueDesc(
labels: series.labels, ClaimsChartSeries(
values: inLakhs, labels: series.labels,
maxY: ClaimsKpiParser._niceMaxY(max), values: inLakhs,
maxY: ClaimsKpiParser._niceMaxY(max),
),
); );
} }
@ -686,18 +717,27 @@ class ClaimsOverviewViewData {
// --- Hospitals --- // --- Hospitals ---
List<ClaimsListItem> get topHospitals { List<ClaimsListItem> get topHospitals {
final series = chartSeries( final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.top5Hospitals));
ClaimsKpiSlug.top5Hospitals, final items = <({double value, ClaimsListItem item})>[];
labelKey: 'hospital_name', for (final row in rows) {
valueKey: 'claim_value', if (row is! Map) continue;
); final name = ClaimsKpiParser.rowValue(row, 'hospital_name')?.toString();
if (series.isEmpty) return const []; if (name == null || name.isEmpty) continue;
return List.generate(series.labels.length, (i) { final raw = ClaimsKpiParser.rowValue(row, 'claim_value') ??
return ClaimsListItem( ClaimsKpiParser.rowValue(row, 'incurred_amount') ??
title: series.labels[i], ClaimsKpiParser.rowValue(row, 'total_incurred_amount');
value: ClaimsKpiParser.formatDisplay(series.values[i]), final value = ClaimsKpiParser.toDouble(raw);
); if (value == null) continue;
}); items.add((
value: value,
item: ClaimsListItem(
title: name,
value: ClaimsKpiParser.formatDisplay(value),
),
));
}
items.sort((a, b) => b.value.compareTo(a.value));
return items.take(5).map((e) => e.item).toList();
} }
List<ClaimsCityItem> get cities { List<ClaimsCityItem> get cities {
@ -1187,6 +1227,17 @@ abstract final class ClaimsKpiParser {
return text.contains('%') ? text : '$text%'; return text.contains('%') ? text : '$text%';
} }
static String? formatDate(String? raw) {
if (raw == null) return null;
final text = cleanDisplay(raw);
if (text.isEmpty || text == '' || text.toLowerCase() == 'null') return null;
try {
return DateFormat('d MMM yyyy').format(DateTime.parse(text).toLocal());
} catch (_) {
return text;
}
}
static String formatDisplay(dynamic value) { static String formatDisplay(dynamic value) {
if (value == null) return ''; if (value == null) return '';
if (value is num) { if (value is num) {

View File

@ -30,7 +30,9 @@ class ClaimsTopLoadingBar extends StatelessWidget {
/// Pulsing green dot + "Live" label for real-time analytics feel. /// Pulsing green dot + "Live" label for real-time analytics feel.
class ClaimsLiveIndicator extends StatefulWidget { class ClaimsLiveIndicator extends StatefulWidget {
const ClaimsLiveIndicator({super.key}); final String? createdAt;
const ClaimsLiveIndicator({super.key, this.createdAt});
@override @override
State<ClaimsLiveIndicator> createState() => _ClaimsLiveIndicatorState(); State<ClaimsLiveIndicator> createState() => _ClaimsLiveIndicatorState();
@ -57,6 +59,11 @@ class _ClaimsLiveIndicatorState extends State<ClaimsLiveIndicator>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final createdAt = widget.createdAt?.trim();
if (createdAt == null || createdAt.isEmpty) {
return const SizedBox.shrink();
}
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -84,13 +91,21 @@ class _ClaimsLiveIndicatorState extends State<ClaimsLiveIndicator>
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Live', 'Last Updated',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.green, color: ClaimsOverviewTheme.green,
), ),
), ),
const SizedBox(width: 6),
Text(
'· $createdAt',
style: GoogleFonts.poppins(
fontSize: 10,
color: ClaimsOverviewTheme.green,
),
),
], ],
); );
} }

View File

@ -1521,6 +1521,8 @@ class ClaimsAreaChart extends StatefulWidget {
final double maxY; final double maxY;
final List<double>? yTicks; final List<double>? yTicks;
final int replayToken; final int replayToken;
final Color lineColor;
final String Function(double value)? formatTooltipValue;
const ClaimsAreaChart({ const ClaimsAreaChart({
super.key, super.key,
@ -1529,6 +1531,8 @@ class ClaimsAreaChart extends StatefulWidget {
required this.maxY, required this.maxY,
this.yTicks, this.yTicks,
this.replayToken = 0, this.replayToken = 0,
this.lineColor = ClaimsOverviewTheme.teal,
this.formatTooltipValue,
}); });
@override @override
@ -1623,7 +1627,7 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
yTicks: _yTicks, yTicks: _yTicks,
animProgress: animProgress, animProgress: animProgress,
hoveredIndex: _hoveredIndex, hoveredIndex: _hoveredIndex,
lineColor: ClaimsOverviewTheme.teal, lineColor: widget.lineColor,
axisColor: _chartAxisColor, axisColor: _chartAxisColor,
gridColor: ClaimsOverviewTheme.border, gridColor: ClaimsOverviewTheme.border,
plotBorderColor: ClaimsOverviewTheme.border, plotBorderColor: ClaimsOverviewTheme.border,
@ -1638,7 +1642,8 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
top: (_tooltipAnchor!.dy - 26) top: (_tooltipAnchor!.dy - 26)
.clamp(4.0, plotSize.height - 24), .clamp(4.0, plotSize.height - 24),
child: _BarValueLabel( child: _BarValueLabel(
value: _formatLakhsAxisTick( value: (widget.formatTooltipValue ??
_formatLakhsAxisTick)(
widget.values[_hoveredIndex], widget.values[_hoveredIndex],
), ),
), ),
@ -1875,88 +1880,105 @@ class ClaimsScatterChart extends StatelessWidget {
final labs = labels!; final labs = labels!;
final chartMaxY = maxY ?? (vals.reduce((a, b) => a > b ? a : b) * 1.1); final chartMaxY = maxY ?? (vals.reduce((a, b) => a > b ? a : b) * 1.1);
final yInterval = chartMaxY > 0 ? chartMaxY / 4 : 1.0; final yInterval = chartMaxY > 0 ? chartMaxY / 4 : 1.0;
final maxX = math.max(1.0, (vals.length - 1).toDouble()); final lastIndex = math.max(0, vals.length - 1);
final maxX = math.max(1.0, lastIndex.toDouble());
const xEdgePadding = 0.5;
return LineChart( return Padding(
duration: _chartAnimDurationFor(context), padding: const EdgeInsets.only(right: 6),
curve: _chartAnimCurve, child: LineChart(
key: ValueKey('scatter-multi-$replayToken-${vals.join(",")}'), duration: _chartAnimDurationFor(context),
LineChartData( curve: _chartAnimCurve,
maxY: chartMaxY, key: ValueKey('scatter-multi-$replayToken-${vals.join(",")}'),
minY: 0, LineChartData(
minX: 0, clipData: const FlClipData.none(),
maxX: maxX, maxY: chartMaxY,
gridData: FlGridData( minY: 0,
show: true, minX: -xEdgePadding,
drawVerticalLine: false, maxX: maxX + xEdgePadding,
getDrawingHorizontalLine: (v) => FlLine( gridData: FlGridData(
color: ClaimsOverviewTheme.border, show: true,
strokeWidth: 1, drawVerticalLine: false,
dashArray: [4, 4], getDrawingHorizontalLine: (v) => FlLine(
color: ClaimsOverviewTheme.border,
strokeWidth: 1,
dashArray: [4, 4],
),
), ),
), borderData: FlBorderData(
borderData: FlBorderData( show: true,
show: true, border: const Border(
border: const Border( left: BorderSide(color: _chartAxisColor, width: 1),
left: BorderSide(color: _chartAxisColor, width: 1), bottom: BorderSide(color: _chartAxisColor, width: 1),
bottom: BorderSide(color: _chartAxisColor, width: 1), ),
), ),
), titlesData: FlTitlesData(
titlesData: FlTitlesData( topTitles:
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: rightTitles: const AxisTitles(
const AxisTitles(sideTitles: SideTitles(showTitles: false)), sideTitles: SideTitles(showTitles: false, reservedSize: 8),
leftTitles: AxisTitles( ),
sideTitles: SideTitles( leftTitles: AxisTitles(
showTitles: true, sideTitles: SideTitles(
reservedSize: 40, showTitles: true,
interval: yInterval, reservedSize: 40,
getTitlesWidget: (v, _) => Text( interval: yInterval,
v >= 1 ? v.toStringAsFixed(1) : v.toStringAsFixed(2), getTitlesWidget: (v, _) => Text(
style: GoogleFonts.poppins(fontSize: 10, color: Colors.grey), 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,
),
),
);
},
), ),
), ),
), ),
bottomTitles: AxisTitles( lineBarsData: [
sideTitles: SideTitles( LineChartBarData(
showTitles: true, spots: [
reservedSize: 40, for (var i = 0; i < vals.length; i++)
interval: 1, FlSpot(i.toDouble(), vals[i]),
getTitlesWidget: (v, _) { ],
final i = v.round(); color: ClaimsOverviewTheme.orange,
if (i < 0 || i >= labs.length) return const SizedBox.shrink(); barWidth: 0,
final text = labs[i]; dotData: FlDotData(
final short = text.length > 8 ? text.substring(0, 7) : text; show: true,
return Padding( getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
padding: const EdgeInsets.only(top: 4), radius: 6,
child: Text( color: ClaimsOverviewTheme.orange,
short, strokeWidth: 2,
style: GoogleFonts.poppins(fontSize: 9, color: Colors.black54), strokeColor: Colors.white,
), ),
);
},
),
),
),
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),
), ),
belowBarData: BarAreaData(show: false), ],
), ),
],
), ),
); );
} }

View File

@ -457,7 +457,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
), ),
), ),
), ),
_buildFooter(), // _buildFooter(),
], ],
), ),
), ),
@ -520,8 +520,13 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
color: ClaimsOverviewTheme.textSecondary, color: ClaimsOverviewTheme.textSecondary,
), ),
), ),
const SizedBox(width: 12), if (_viewData.liveUpdatedAt != null &&
const ClaimsLiveIndicator(), _viewData.liveUpdatedAt!.isNotEmpty) ...[
const SizedBox(width: 12),
ClaimsLiveIndicator(
createdAt: _viewData.liveUpdatedAt,
),
],
], ],
), ),
], ],

View File

@ -66,35 +66,54 @@ class ClaimsTabPanel {
const ClaimsTabPanel({required this.flex, required this.child}); const ClaimsTabPanel({required this.flex, required this.child});
} }
/// Two-column mini grid (e.g. specialty analysis cards). /// Mini grid for metric tiles inside section cards.
class ClaimsTabMiniGrid extends StatelessWidget { class ClaimsTabMiniGrid extends StatelessWidget {
final List<Widget> items; final List<Widget> items;
final double gap; final double gap;
final int columns;
const ClaimsTabMiniGrid({ const ClaimsTabMiniGrid({
super.key, super.key,
required this.items, required this.items,
this.gap = 12, this.gap = 8,
this.columns = 2,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(items.length == 3 || items.length == 4); assert(items.length == 3 || items.length == 4);
assert(columns == 2 || (columns == 3 && items.length == 3));
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxW = constraints.maxWidth; final maxW = constraints.maxWidth;
final isPdf = ClaimsPdfExportScope.of(context); final isPdf = ClaimsPdfExportScope.of(context);
Widget cell(int index, {bool fullWidth = false}) { Widget cell(int index, {bool fullWidth = false, int? rowColumns}) {
final child = items[index]; final child = items[index];
if (isPdf && maxW.isFinite) { if (isPdf && maxW.isFinite) {
final width = fullWidth ? maxW : (maxW - gap) / 2; final cols = rowColumns ?? columns;
final width = fullWidth
? maxW
: (maxW - gap * (cols - 1)) / cols;
return SizedBox(width: width, child: child); return SizedBox(width: width, child: child);
} }
return Expanded(child: child); return Expanded(child: child);
} }
if (items.length == 3 && columns == 3) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
cell(0, rowColumns: 3),
SizedBox(width: gap),
cell(1, rowColumns: 3),
SizedBox(width: gap),
cell(2, rowColumns: 3),
],
);
}
if (items.length == 3) { if (items.length == 3) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -87,23 +87,6 @@ class ClaimsOverviewTab extends StatelessWidget {
} }
class _OverviewTab extends StatelessWidget { class _OverviewTab extends StatelessWidget {
static const _metricGap = 12.0;
static const _sectionBodyPadding = 32.0;
static const _sectionHeaderHeight = 46.0;
/// Fixed height from the larger tile count in a paired row (no flex in scroll view).
static double _pairedSectionHeight(
int leftTiles,
int rightTiles, {
int columns = 2,
}) {
final maxTiles = leftTiles > rightTiles ? leftTiles : rightTiles;
final rows = (maxTiles + columns - 1) ~/ columns;
final gridHeight = rows * ClaimsMetricCard.layoutHeight +
(rows > 1 ? (rows - 1) * _metricGap : 0);
return _sectionHeaderHeight + _sectionBodyPadding + gridHeight;
}
final int replayToken; final int replayToken;
final bool forPdfExport; final bool forPdfExport;
@ -115,9 +98,6 @@ class _OverviewTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final d = ClaimsOverviewScope.of(context); final d = ClaimsOverviewScope.of(context);
final policyRowHeight = _pairedSectionHeight(5, 5);
final claimsRowHeight = _pairedSectionHeight(5, 2);
return _tabBodyWrapper( return _tabBodyWrapper(
forPdfExport: forPdfExport, forPdfExport: forPdfExport,
child: Column( child: Column(
@ -125,76 +105,50 @@ class _OverviewTab extends StatelessWidget {
children: [ children: [
_overviewTwoColumnRow( _overviewTwoColumnRow(
baseIndex: 0, baseIndex: 0,
left: _overviewGroup( left: ClaimsSectionCard(
title: 'Policy Information', title: 'Policy Information',
icon: Icons.description_outlined, icon: Icons.description_outlined,
sectionHeight: policyRowHeight, child: ClaimsPolicyInformationBody(
columns: 2, policyStartDate: d.policyStartDate,
cards: [ policyEndDate: d.policyEndDate,
_m('Policy Start Date', d.policyStartDate, policyRunDays: d.policyRunDays,
Icons.calendar_today, ClaimsOverviewTheme.blue, tpaName: d.tpaName,
animateValue: false), insurerName: d.insurerName,
_m('Policy End Date', d.policyEndDate, Icons.event, ),
ClaimsOverviewTheme.red, animateValue: false),
_m('Policy Run Days', d.policyRunDays, Icons.timelapse,
ClaimsOverviewTheme.purple),
_m('TPA', d.tpaName, Icons.apartment, ClaimsOverviewTheme.teal,
animateValue: false),
_m('Insurer', d.insurerName, Icons.military_tech,
ClaimsOverviewTheme.orange, animateValue: false),
],
), ),
right: _overviewGroup( right: ClaimsSectionCard(
title: 'Premium & Membership', title: 'Premium & Membership',
icon: Icons.groups_outlined, icon: Icons.groups_outlined,
sectionHeight: policyRowHeight, child: ClaimsPremiumMembershipBody(
columns: 2, premiumAsOnDate: d.premiumAsOnDate,
cards: [ earnedPremium: d.earnedPremium,
_m('Premium As On Date', d.premiumAsOnDate, currentEmployee: d.currentEmployee,
Icons.account_balance_wallet, ClaimsOverviewTheme.orange), currentLives: d.currentLives,
_m('Earned Premium', d.earnedPremium, Icons.payments, avgFamilySize: d.avgFamilySize,
ClaimsOverviewTheme.green), ),
_m('Current Employee', d.currentEmployee,
Icons.business_center, ClaimsOverviewTheme.blue),
_m('Current Lives', d.currentLives, Icons.favorite_border,
ClaimsOverviewTheme.pink),
_m('Avg F-Size', d.avgFamilySize, Icons.groups,
ClaimsOverviewTheme.purple),
],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_overviewTwoColumnRow( _overviewTwoColumnRow(
baseIndex: 2, baseIndex: 2,
left: _overviewGroup( left: ClaimsSectionCard(
title: 'Claims Experience', title: 'Claims Experience',
icon: Icons.analytics_outlined, icon: Icons.analytics_outlined,
sectionHeight: claimsRowHeight, child: ClaimsExperienceBody(
columns: 2, incurredClaims: d.incurredClaims,
cards: [ incurredRatio: d.incurredRatio,
_m('Incurred Claims', d.incurredClaims, Icons.gps_fixed, projectedClaims: d.projectedClaims,
ClaimsOverviewTheme.red), projectedRatio: d.projectedRatio,
_m('Incurred Ratio', d.incurredRatio, Icons.speed, claimsIncidenceRate: d.claimsIncidenceRate,
ClaimsOverviewTheme.teal), ),
_m('Projected Claims', d.projectedClaims, Icons.trending_up,
ClaimsOverviewTheme.red),
_m('Projected Ratio', d.projectedRatio, Icons.percent,
ClaimsOverviewTheme.orange),
_m('Claims Incidence Rate', d.claimsIncidenceRate,
Icons.timeline, ClaimsOverviewTheme.cyan),
],
), ),
right: _overviewGroup( right: ClaimsSectionCard(
title: 'Inception', title: 'Inception',
icon: Icons.flag_outlined, icon: Icons.flag_outlined,
sectionHeight: claimsRowHeight, child: ClaimsInceptionBody(
columns: 2, inceptionEmployee: d.inceptionEmployee,
cards: [ inceptionLives: d.inceptionLives,
_m('Inception Employee', d.inceptionEmployee, ),
Icons.person_add_alt, ClaimsOverviewTheme.blue),
_m('Inception Lives', d.inceptionLives, Icons.favorite_border,
ClaimsOverviewTheme.pink),
],
), ),
), ),
], ],
@ -229,94 +183,6 @@ class _OverviewTab extends StatelessWidget {
); );
} }
Widget _overviewGroup({
required String title,
required IconData icon,
required List<ClaimsMetricCard> cards,
required double sectionHeight,
int columns = 2,
}) {
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: sectionHeight,
maxHeight: sectionHeight,
),
child: ClaimsSectionCard(
title: title,
icon: icon,
child: LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: (ClaimsPdfExportScope.laneWidthOf(context) ?? 800);
return _OverviewMetricGrid(
replayToken: replayToken,
maxWidth: maxW,
columns: columns,
cards: cards,
);
},
),
),
);
}
ClaimsMetricCard _m(
String label,
String value,
IconData icon,
Color color, {
bool animateValue = true,
}) {
return ClaimsMetricCard(
label: label,
value: value,
icon: icon,
accentColor: color,
replayToken: replayToken,
animateValue: animateValue,
);
}
}
/// Metric grid with equal card width per row inside a section card.
class _OverviewMetricGrid extends StatelessWidget {
static const _gap = 12.0;
final List<Widget> cards;
final int replayToken;
final double maxWidth;
final int columns;
const _OverviewMetricGrid({
required this.cards,
required this.replayToken,
required this.maxWidth,
this.columns = 4,
});
@override
Widget build(BuildContext context) {
final cols = columns.clamp(1, 4);
final cellWidth = (maxWidth - _gap * (cols - 1)) / cols;
return Wrap(
spacing: _gap,
runSpacing: _gap,
children: List.generate(cards.length, (i) {
return SizedBox(
width: cellWidth,
child: ClaimsStaggeredEntrance(
key: ValueKey('metric-$replayToken-$i'),
replayToken: replayToken,
index: i,
child: cards[i],
),
);
}),
);
}
} }
class _PolicyExperienceTab extends StatefulWidget { class _PolicyExperienceTab extends StatefulWidget {
@ -437,7 +303,7 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab>
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
ClaimsStaggeredEntrance( ClaimsStaggeredEntrance(
replayToken: widget.replayToken, replayToken: widget.replayToken,
index: 1, index: 1,
@ -466,7 +332,7 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
for (var i = 0; i < kpiMetrics.length; i++) ...[ for (var i = 0; i < kpiMetrics.length; i++) ...[
if (i > 0) const SizedBox(height: 12), if (i > 0) const SizedBox(height: 8),
ClaimsStaggeredEntrance( ClaimsStaggeredEntrance(
replayToken: widget.replayToken, replayToken: widget.replayToken,
index: 2 + i, index: 2 + i,
@ -586,7 +452,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
_chartRow( _chartRow(
2, 2,
ClaimsSectionCard( ClaimsSectionCard(
@ -704,7 +570,7 @@ class _DemographicsTab extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
], ],
ClaimsTabPanelRow( ClaimsTabPanelRow(
panels: [ panels: [
@ -765,8 +631,6 @@ class _DemographicsTab extends StatelessWidget {
} }
class _HospitalsTab extends StatelessWidget { class _HospitalsTab extends StatelessWidget {
static const _hospitalsPairHeight = 360.0;
final int replayToken; final int replayToken;
final bool forPdfExport; final bool forPdfExport;
@ -810,63 +674,69 @@ class _HospitalsTab extends StatelessWidget {
forPdfExport: forPdfExport, forPdfExport: forPdfExport,
child: Column( child: Column(
children: [ children: [
ClaimsTabPanelRow( IntrinsicHeight(
panels: [ child: ClaimsTabPanelRow(
ClaimsTabPanel( crossAxisAlignment: CrossAxisAlignment.stretch,
flex: 1, panels: [
child: ClaimsStaggeredEntrance( ClaimsTabPanel(
replayToken: replayToken, flex: 1,
index: 0, child: ClaimsStaggeredEntrance(
child: ClaimsSectionCard( replayToken: replayToken,
title: 'Top 5 Hospitals by Incurred Amount', index: 0,
icon: Icons.local_hospital_outlined, child: ClaimsSectionCard(
height: _hospitalsPairHeight, title: 'Top 5 Hospitals by Incurred Amount',
child: hospitals.isEmpty icon: Icons.local_hospital_outlined,
? _claimsChartPlaceholder('No hospital data') fillHeight: true,
: Column( child: hospitals.isEmpty
children: List.generate(hospitals.length, (i) { ? _claimsChartPlaceholder('No hospital data')
final item = hospitals[i]; : Column(
return ClaimsListRow( mainAxisAlignment: MainAxisAlignment.spaceBetween,
icon: hospitalIcons[i % hospitalIcons.length], children: List.generate(hospitals.length, (i) {
iconColor: final item = hospitals[i];
hospitalColors[i % hospitalColors.length], return ClaimsListRow(
title: item.title, icon:
value: item.value, hospitalIcons[i % hospitalIcons.length],
); iconColor: hospitalColors[
}), i % hospitalColors.length],
), title: item.title,
value: item.value,
);
}),
),
),
),
), ),
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: 1,
child: ClaimsSectionCard(
title: 'Total Incurred by City',
icon: Icons.location_on_outlined,
fillHeight: true,
child: cities.isEmpty
? _claimsChartPlaceholder('No city data')
: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(cities.length, (i) {
final c = cities[i];
return ClaimsCityBar(
city: c.city,
amount: c.amount,
fraction: c.fraction,
color: cityColors[i % cityColors.length],
replayToken: replayToken,
);
}),
),
),
),
), ),
), ],
ClaimsTabPanel( ),
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: 1,
child: ClaimsSectionCard(
title: 'Total Incurred by City',
icon: Icons.location_on_outlined,
height: _hospitalsPairHeight,
child: cities.isEmpty
? _claimsChartPlaceholder('No city data')
: Column(
children: List.generate(cities.length, (i) {
final c = cities[i];
return ClaimsCityBar(
city: c.city,
amount: c.amount,
fraction: c.fraction,
color: cityColors[i % cityColors.length],
replayToken: replayToken,
);
}),
),
),
),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
ClaimsStaggeredEntrance( ClaimsStaggeredEntrance(
replayToken: replayToken, replayToken: replayToken,
index: 2, index: 2,
@ -1010,7 +880,7 @@ class _SpecialtyTab extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
ClaimsStaggeredEntrance( ClaimsStaggeredEntrance(
replayToken: replayToken, replayToken: replayToken,
index: 2, index: 2,
@ -1122,7 +992,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
final donutHeight = widget.forPdfExport ? 190.0 : 300.0; final donutHeight = widget.forPdfExport ? 190.0 : 300.0;
final trendHeight = widget.forPdfExport ? 228.0 : 320.0; final trendHeight = widget.forPdfExport ? 228.0 : 320.0;
final sectionGap = widget.forPdfExport ? 10.0 : 20.0; final sectionGap = widget.forPdfExport ? 8.0 : 16.0;
Widget section(int index, Widget child) { Widget section(int index, Widget child) {
if (widget.forPdfExport) return child; if (widget.forPdfExport) return child;
@ -1276,12 +1146,12 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
height: trendHeight, height: trendHeight,
child: ageTrend.isEmpty child: ageTrend.isEmpty
? _claimsChartPlaceholder('No age trend data') ? _claimsChartPlaceholder('No age trend data')
: _EnrollmentAgeTrendChart( : ClaimsAreaChart(
labels: ageTrend.labels, labels: ageTrend.labels,
values: ageTrend.values, values: ageTrend.values,
maxY: ageTrend.maxY ?? 40, maxY: ageTrend.maxY ?? 40,
animationKey: _chartKey, replayToken: _chartKey,
forPdfExport: widget.forPdfExport, formatTooltipValue: (v) => v.toStringAsFixed(0),
), ),
), ),
), ),
@ -1296,7 +1166,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
} }
Widget _enrollmentMiniGrid(List<Widget> items) { Widget _enrollmentMiniGrid(List<Widget> items) {
return ClaimsTabMiniGrid(items: items); return ClaimsTabMiniGrid(items: items, columns: 3);
} }
Widget _mini(String label, String value, IconData icon, Color color) { Widget _mini(String label, String value, IconData icon, Color color) {
@ -1406,40 +1276,3 @@ class _EnrollmentDonutLegend extends StatelessWidget {
} }
} }
class _EnrollmentAgeTrendChart extends StatelessWidget {
final List<String> labels;
final List<double> values;
final double maxY;
final int animationKey;
final bool forPdfExport;
const _EnrollmentAgeTrendChart({
required this.labels,
required this.values,
required this.maxY,
required this.animationKey,
this.forPdfExport = false,
});
@override
Widget build(BuildContext context) {
final ticks = <double>[0, maxY * 0.25, maxY * 0.5, maxY * 0.75, maxY];
return ClaimsCustomVerticalBarChart(
labels: labels,
values: values,
maxY: maxY,
colors: List.filled(
values.length,
ClaimsOverviewTheme.pink.withValues(alpha: 0.85),
),
animationKey: animationKey,
yTicks: ticks,
barWidth: forPdfExport ? 40 : 56,
barSlotFraction: 0.72,
leftAxisWidth: forPdfExport ? 40 : 48,
bottomAxisHeight: forPdfExport ? 40 : 52,
formatYTick: (v) => v.toStringAsFixed(0),
);
}
}

View File

@ -5,9 +5,592 @@ import 'claims_overview_animations.dart';
import 'claims_overview_scope.dart'; import 'claims_overview_scope.dart';
import 'claims_overview_theme.dart'; import 'claims_overview_theme.dart';
/// Hover / tap focus wrapper for dashboard tiles.
class ClaimsFocusCard extends StatefulWidget {
final Widget child;
final BorderRadius borderRadius;
final Color accentColor;
final EdgeInsetsGeometry? margin;
final BoxConstraints? constraints;
final double? width;
final VoidCallback? onTap;
const ClaimsFocusCard({
super.key,
required this.child,
this.borderRadius = const BorderRadius.all(Radius.circular(10)),
this.accentColor = ClaimsOverviewTheme.primary,
this.margin,
this.constraints,
this.width,
this.onTap,
});
@override
State<ClaimsFocusCard> createState() => _ClaimsFocusCardState();
}
class _ClaimsFocusCardState extends State<ClaimsFocusCard> {
bool _hovering = false;
bool _selected = false;
bool get _focused => _hovering || _selected;
@override
Widget build(BuildContext context) {
if (ClaimsPdfExportScope.of(context)) {
return _staticCard();
}
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap ?? () => setState(() => _selected = !_selected),
child: _animatedCard(),
),
);
}
Widget _staticCard() {
return Container(
width: widget.width,
margin: widget.margin,
constraints: widget.constraints,
decoration: _cardDecoration(focused: false),
child: widget.child,
);
}
Widget _animatedCard() {
return AnimatedContainer(
width: widget.width,
margin: widget.margin,
constraints: widget.constraints,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
transform: _focused
? (Matrix4.identity()..translateByDouble(0, -3, 0, 1))
: Matrix4.identity(),
decoration: _cardDecoration(focused: _focused),
child: widget.child,
);
}
BoxDecoration _cardDecoration({required bool focused}) {
return BoxDecoration(
color: ClaimsOverviewTheme.cardBg,
borderRadius: widget.borderRadius,
border: Border.all(
color: focused
? widget.accentColor.withValues(alpha: 0.55)
: ClaimsOverviewTheme.border,
width: focused ? 1.5 : 1,
),
boxShadow: focused
? [
BoxShadow(
color: widget.accentColor.withValues(alpha: 0.16),
blurRadius: 16,
offset: const Offset(0, 6),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
);
}
}
class ClaimsPolicyInformationBody extends StatelessWidget {
final String policyStartDate;
final String policyEndDate;
final String policyRunDays;
final String tpaName;
final String insurerName;
const ClaimsPolicyInformationBody({
super.key,
required this.policyStartDate,
required this.policyEndDate,
required this.policyRunDays,
required this.tpaName,
required this.insurerName,
});
@override
Widget build(BuildContext context) {
final startTile = _OverviewInnerTile(
label: 'Policy Start Date',
icon: Icons.calendar_today,
accentColor: ClaimsOverviewTheme.blue,
child: _OverviewTileValue(policyStartDate),
);
final endTile = _OverviewInnerTile(
label: 'Policy End Date',
icon: Icons.event,
accentColor: ClaimsOverviewTheme.red,
child: _OverviewTileValue(policyEndDate),
);
final runDaysTile = _OverviewInnerTile(
label: 'Policy Run Days',
icon: Icons.timelapse,
accentColor: ClaimsOverviewTheme.purple,
child: _OverviewTileValue(policyRunDays),
);
final tpaTile = _OverviewInnerTile(
label: 'TPA',
icon: Icons.apartment,
accentColor: ClaimsOverviewTheme.teal,
child: _OverviewTileValue(tpaName, maxLines: 2),
);
final insurerTile = _OverviewInnerTile(
label: 'Insurer',
icon: Icons.military_tech,
accentColor: ClaimsOverviewTheme.orange,
child: _OverviewTileValue(insurerName, maxLines: 2),
);
return LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 520;
if (narrow) {
return Column(
children: [
startTile,
const SizedBox(height: 12),
endTile,
const SizedBox(height: 12),
runDaysTile,
const SizedBox(height: 12),
tpaTile,
const SizedBox(height: 12),
insurerTile,
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_OverviewThreeColumnRow(
children: [startTile, endTile, runDaysTile],
),
const SizedBox(height: 12),
_OverviewTwoColumnRow(
children: [tpaTile, insurerTile],
),
],
);
},
);
}
}
class ClaimsExperienceBody extends StatelessWidget {
final String incurredClaims;
final String incurredRatio;
final String projectedClaims;
final String projectedRatio;
final String claimsIncidenceRate;
const ClaimsExperienceBody({
super.key,
required this.incurredClaims,
required this.incurredRatio,
required this.projectedClaims,
required this.projectedRatio,
required this.claimsIncidenceRate,
});
@override
Widget build(BuildContext context) {
final incurredTile = _OverviewInnerTile(
label: 'Incurred Claims',
icon: Icons.gps_fixed,
accentColor: ClaimsOverviewTheme.red,
child: _ClaimsExperienceDualValues(
primaryValue: incurredClaims,
secondaryValue: incurredRatio,
),
);
final projectedTile = _OverviewInnerTile(
label: 'Projected Claims',
icon: Icons.trending_up,
accentColor: ClaimsOverviewTheme.orange,
child: _ClaimsExperienceDualValues(
primaryValue: projectedClaims,
secondaryValue: projectedRatio,
),
);
final incidenceTile = _OverviewInnerTile(
label: 'Claims Incidence Rate',
icon: Icons.timeline,
accentColor: ClaimsOverviewTheme.cyan,
child: Text(
claimsIncidenceRate,
style: GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
height: 1.25,
),
),
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 520) {
return Column(
children: [
incurredTile,
const SizedBox(height: 12),
projectedTile,
const SizedBox(height: 12),
incidenceTile,
],
);
}
return _OverviewThreeColumnRow(
children: [incurredTile, projectedTile, incidenceTile],
);
},
);
}
}
class ClaimsInceptionBody extends StatelessWidget {
final String inceptionEmployee;
final String inceptionLives;
const ClaimsInceptionBody({
super.key,
required this.inceptionEmployee,
required this.inceptionLives,
});
@override
Widget build(BuildContext context) {
final valueStyle = GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
height: 1.25,
);
final employeeTile = _OverviewInnerTile(
label: 'Inception Employee',
icon: Icons.person_add_alt,
accentColor: ClaimsOverviewTheme.blue,
child: Text(inceptionEmployee, style: valueStyle),
);
final livesTile = _OverviewInnerTile(
label: 'Inception Lives',
icon: Icons.favorite_border,
accentColor: ClaimsOverviewTheme.pink,
child: Text(inceptionLives, style: valueStyle),
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 400) {
return Column(
children: [
employeeTile,
const SizedBox(height: 12),
livesTile,
],
);
}
return _OverviewTwoColumnRow(
children: [employeeTile, livesTile],
);
},
);
}
}
class _OverviewTileValue extends StatelessWidget {
final String value;
final int maxLines;
const _OverviewTileValue(this.value, {this.maxLines = 1});
@override
Widget build(BuildContext context) {
return Text(
value,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.poppins(
fontSize: maxLines > 1 ? 15 : 18,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
height: 1.25,
),
);
}
}
class _OverviewInnerTile extends StatelessWidget {
final String label;
final IconData icon;
final Color accentColor;
final Widget child;
const _OverviewInnerTile({
required this.label,
required this.icon,
required this.accentColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return ClaimsFocusCard(
accentColor: accentColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label.toUpperCase(),
style: GoogleFonts.poppins(
fontSize: 10,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textSecondary,
letterSpacing: 0.4,
height: 1.3,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: accentColor),
),
],
),
const SizedBox(height: 10),
child,
],
),
),
Container(
height: 3,
decoration: BoxDecoration(
color: accentColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(10),
bottomRight: Radius.circular(10),
),
),
),
],
),
);
}
}
class _ClaimsExperienceDualValues extends StatelessWidget {
final String primaryValue;
final String secondaryValue;
const _ClaimsExperienceDualValues({
required this.primaryValue,
required this.secondaryValue,
});
@override
Widget build(BuildContext context) {
final valueStyle = GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
height: 1.2,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
primaryValue,
maxLines: 1,
style: valueStyle,
),
),
),
const SizedBox(width: 8),
Text(
secondaryValue,
style: valueStyle.copyWith(fontSize: 16),
),
],
);
}
}
class ClaimsPremiumMembershipBody extends StatelessWidget {
final String premiumAsOnDate;
final String earnedPremium;
final String currentEmployee;
final String currentLives;
final String avgFamilySize;
const ClaimsPremiumMembershipBody({
super.key,
required this.premiumAsOnDate,
required this.earnedPremium,
required this.currentEmployee,
required this.currentLives,
required this.avgFamilySize,
});
@override
Widget build(BuildContext context) {
final premiumTile = _OverviewInnerTile(
label: 'Premium As On Date',
icon: Icons.account_balance_wallet,
accentColor: ClaimsOverviewTheme.orange,
child: _OverviewTileValue(premiumAsOnDate),
);
final earnedTile = _OverviewInnerTile(
label: 'Earned Premium',
icon: Icons.payments,
accentColor: ClaimsOverviewTheme.green,
child: _OverviewTileValue(earnedPremium),
);
final employeeTile = _OverviewInnerTile(
label: 'Current Employee',
icon: Icons.business_center,
accentColor: ClaimsOverviewTheme.blue,
child: _OverviewTileValue(currentEmployee),
);
final livesTile = _OverviewInnerTile(
label: 'Current Lives',
icon: Icons.favorite_border,
accentColor: ClaimsOverviewTheme.pink,
child: _OverviewTileValue(currentLives),
);
final avgSizeTile = _OverviewInnerTile(
label: 'Avg F-Size',
icon: Icons.groups,
accentColor: ClaimsOverviewTheme.purple,
child: _OverviewTileValue(avgFamilySize),
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 520) {
return Column(
children: [
premiumTile,
const SizedBox(height: 12),
earnedTile,
const SizedBox(height: 12),
employeeTile,
const SizedBox(height: 12),
livesTile,
const SizedBox(height: 12),
avgSizeTile,
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_OverviewThreeColumnRow(
children: [premiumTile, earnedTile, employeeTile],
),
const SizedBox(height: 12),
_OverviewTwoColumnRow(
children: [livesTile, avgSizeTile],
),
],
);
},
);
}
}
/// Two equal columns used for TPA / Insurer side-by-side rows.
class _OverviewTwoColumnRow extends StatelessWidget {
final List<Widget> children;
const _OverviewTwoColumnRow({required this.children});
@override
Widget build(BuildContext context) {
assert(children.length == 2);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: children[0]),
const SizedBox(width: 12),
Expanded(child: children[1]),
],
);
}
}
/// Three equal columns matches Policy Information top-row alignment.
class _OverviewThreeColumnRow extends StatelessWidget {
final List<Widget> children;
const _OverviewThreeColumnRow({required this.children});
@override
Widget build(BuildContext context) {
assert(children.isNotEmpty && children.length <= 3);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(
child: i < children.length
? children[i]
: const SizedBox.shrink(),
),
],
],
);
}
}
class ClaimsMetricCard extends StatelessWidget { class ClaimsMetricCard extends StatelessWidget {
/// Measured layout: label row (~50) + value block (62) + accent (3). /// Measured layout: label row (~46) + value block (54) + accent (3).
static const double layoutHeight = 115; static const double layoutHeight = 107;
final String label; final String label;
final String value; final String value;
@ -36,21 +619,10 @@ class ClaimsMetricCard extends StatelessWidget {
height: 1.2, height: 1.2,
); );
return Container( return ClaimsFocusCard(
width: double.infinity, width: double.infinity,
constraints: BoxConstraints(minHeight: compact ? 88 : 108), constraints: BoxConstraints(minHeight: compact ? 80 : 100),
decoration: BoxDecoration( accentColor: accentColor,
color: ClaimsOverviewTheme.cardBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ClaimsOverviewTheme.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -58,9 +630,9 @@ class ClaimsMetricCard extends StatelessWidget {
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
compact ? 10 : 14, compact ? 10 : 14,
compact ? 10 : 12, compact ? 8 : 10,
compact ? 10 : 14, compact ? 10 : 14,
compact ? 6 : 8, compact ? 4 : 6,
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -95,10 +667,10 @@ class ClaimsMetricCard extends StatelessWidget {
compact ? 10 : 14, compact ? 10 : 14,
0, 0,
compact ? 10 : 14, compact ? 10 : 14,
compact ? 8 : 10, compact ? 6 : 8,
), ),
child: SizedBox( child: SizedBox(
height: compact ? 44 : 52, height: compact ? 40 : 48,
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: compact || !animateValue child: compact || !animateValue
@ -177,12 +749,12 @@ class ClaimsSectionCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final body = Padding( final body = Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
child: child, child: child,
); );
final header = Padding( final header = Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 20, color: ClaimsOverviewTheme.primary), Icon(icon, size: 20, color: ClaimsOverviewTheme.primary),
@ -202,14 +774,7 @@ class ClaimsSectionCard extends StatelessWidget {
); );
final useExpandedBody = fillHeight || height != null; final useExpandedBody = fillHeight || height != null;
final bodySlot = useExpandedBody final bodySlot = useExpandedBody ? Expanded(child: body) : body;
? Expanded(
child: Align(
alignment: Alignment.topLeft,
child: body,
),
)
: body;
return Container( return Container(
width: double.infinity, width: double.infinity,
@ -241,8 +806,8 @@ class ClaimsSectionCard extends StatelessWidget {
/// Metric tiles in a grid [columns] per row (default 3), title + value inside each card. /// Metric tiles in a grid [columns] per row (default 3), title + value inside each card.
class ClaimsMetricTileGrid extends StatelessWidget { class ClaimsMetricTileGrid extends StatelessWidget {
static const _gap = 12.0; static const _gap = 8.0;
static const _tileMinHeight = 108.0; static const _tileMinHeight = 100.0;
final List<String> labels; final List<String> labels;
final List<String> values; final List<String> values;
@ -331,7 +896,7 @@ class ClaimsListRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
children: [ children: [
Container( Container(
@ -383,27 +948,35 @@ class ClaimsRelationshipCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Expanded( return Expanded(
child: Container( child: ClaimsFocusCard(
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(14), accentColor: color,
decoration: BoxDecoration( child: Padding(
color: ClaimsOverviewTheme.cardBg, padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(10), child: Column(
border: Border.all(color: ClaimsOverviewTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.person_outline, color: color, size: 22), Icon(Icons.person_outline, color: color, size: 22),
const SizedBox(height: 8), const SizedBox(height: 4),
Text( SizedBox(
relation, height: 20,
style: GoogleFonts.poppins( child: Align(
fontSize: 13, alignment: Alignment.centerLeft,
fontWeight: FontWeight.w600, child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
relation,
maxLines: 1,
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
Text( Text(
'$claims Claims', '$claims Claims',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
@ -411,15 +984,27 @@ class ClaimsRelationshipCard extends StatelessWidget {
color: ClaimsOverviewTheme.textSecondary, color: ClaimsOverviewTheme.textSecondary,
), ),
), ),
Text( SizedBox(
'Amount: $amount', height: 12,
style: GoogleFonts.poppins( child: Align(
fontSize: 11, alignment: Alignment.centerLeft,
fontWeight: FontWeight.w600, child: FittedBox(
color: ClaimsOverviewTheme.textPrimary, fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'Amount: $amount',
maxLines: 1,
style: GoogleFonts.poppins(
fontSize: 11,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),
),
),
), ),
), ),
], ],
),
), ),
), ),
); );
@ -445,7 +1030,7 @@ class ClaimsCityBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -462,7 +1047,7 @@ class ClaimsCityBar extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
ClaimsAnimatedProgressBar( ClaimsAnimatedProgressBar(
value: fraction, value: fraction,
color: color, color: color,