Dashboard charts
This commit is contained in:
parent
20ef0b593c
commit
c66c39e57c
@ -52,6 +52,8 @@ class Environment {
|
|||||||
switch (routeName) {
|
switch (routeName) {
|
||||||
case 'hrDashboard':
|
case 'hrDashboard':
|
||||||
return "$base - Insights";
|
return "$base - Insights";
|
||||||
|
case 'claimsOverviewDashboard':
|
||||||
|
return "$base - Dashboard";
|
||||||
case 'policies':
|
case 'policies':
|
||||||
return "$base - Policies";
|
return "$base - Policies";
|
||||||
case 'CdPoliciesList':
|
case 'CdPoliciesList':
|
||||||
|
|||||||
@ -323,6 +323,18 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
if (postModules.isNotEmpty)
|
||||||
|
_SideItem(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.space_dashboard_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
label: "Dashboard",
|
||||||
|
isActive: activeRoute == 'claimsOverviewDashboard',
|
||||||
|
onTap: () => _navigate('claimsOverviewDashboard'),
|
||||||
|
),
|
||||||
|
|
||||||
if (postModules.isNotEmpty && postModules.contains(5))
|
if (postModules.isNotEmpty && postModules.contains(5))
|
||||||
_SideItem(
|
_SideItem(
|
||||||
// icon: Icons.dashboard,
|
// icon: Icons.dashboard,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import 'package:nhancepolicy/service/session/session_service.dart';
|
|||||||
import 'package:nhancepolicy/service/token_storage_service.dart';
|
import 'package:nhancepolicy/service/token_storage_service.dart';
|
||||||
import 'package:nhancepolicy/home.dart';
|
import 'package:nhancepolicy/home.dart';
|
||||||
import 'package:nhancepolicy/presentation/hrDashboard.dart';
|
import 'package:nhancepolicy/presentation/hrDashboard.dart';
|
||||||
|
import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:nhancepolicy/hrLogin.dart';
|
import 'package:nhancepolicy/hrLogin.dart';
|
||||||
import 'package:nhancepolicy/presentation/hrPolicyDetails.dart';
|
import 'package:nhancepolicy/presentation/hrPolicyDetails.dart';
|
||||||
@ -267,6 +268,7 @@ final Map<String, WidgetBuilder> appRoutes = {
|
|||||||
'addOnsDetails': (context) => addOnsDetails(),
|
'addOnsDetails': (context) => addOnsDetails(),
|
||||||
'empReviewDetails': (context) => empReviewDetails(),
|
'empReviewDetails': (context) => empReviewDetails(),
|
||||||
'hrDashboard': (context) => hrDashboard(),
|
'hrDashboard': (context) => hrDashboard(),
|
||||||
|
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
|
||||||
'hrPolicyDetails': (context) => hrPolicyDetails(
|
'hrPolicyDetails': (context) => hrPolicyDetails(
|
||||||
ClientId: '',
|
ClientId: '',
|
||||||
policyTypeId: '',
|
policyTypeId: '',
|
||||||
|
|||||||
1306
lib/presentation/claims_overview/claims_collection_kpi.dart
Normal file
1306
lib/presentation/claims_overview/claims_collection_kpi.dart
Normal file
File diff suppressed because it is too large
Load Diff
530
lib/presentation/claims_overview/claims_overview_animations.dart
Normal file
530
lib/presentation/claims_overview/claims_overview_animations.dart
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import 'claims_overview_scope.dart';
|
||||||
|
import 'claims_overview_theme.dart';
|
||||||
|
|
||||||
|
/// Thin indeterminate bar shown while dashboard data reloads.
|
||||||
|
class ClaimsTopLoadingBar extends StatelessWidget {
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
const ClaimsTopLoadingBar({super.key, required this.visible});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: visible
|
||||||
|
? const LinearProgressIndicator(
|
||||||
|
minHeight: 3,
|
||||||
|
backgroundColor: Color(0xFFE2E8F0),
|
||||||
|
color: ClaimsOverviewTheme.primary,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulsing green dot + "Live" label for real-time analytics feel.
|
||||||
|
class ClaimsLiveIndicator extends StatefulWidget {
|
||||||
|
const ClaimsLiveIndicator({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClaimsLiveIndicator> createState() => _ClaimsLiveIndicatorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClaimsLiveIndicatorState extends State<ClaimsLiveIndicator>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1400),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (_, __) {
|
||||||
|
final scale = 0.85 + (_controller.value * 0.3);
|
||||||
|
final opacity = 0.45 + (_controller.value * 0.55);
|
||||||
|
return Container(
|
||||||
|
width: 8 * scale,
|
||||||
|
height: 8 * scale,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: ClaimsOverviewTheme.green.withValues(alpha: opacity),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: ClaimsOverviewTheme.green.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 6 * scale,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Live',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ClaimsOverviewTheme.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fade + slide entrance; replays when [replayToken] changes.
|
||||||
|
class ClaimsStaggeredEntrance extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final int index;
|
||||||
|
final int replayToken;
|
||||||
|
final Duration baseDelay;
|
||||||
|
const ClaimsStaggeredEntrance({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.index = 0,
|
||||||
|
this.replayToken = 0,
|
||||||
|
this.baseDelay = const Duration(milliseconds: 55),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClaimsStaggeredEntrance> createState() => _ClaimsStaggeredEntranceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClaimsStaggeredEntranceState extends State<ClaimsStaggeredEntrance>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _fade;
|
||||||
|
late Animation<Offset> _slide;
|
||||||
|
|
||||||
|
bool _animationStarted = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (ClaimsPdfExportScope.of(context)) {
|
||||||
|
_controller.value = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_animationStarted) {
|
||||||
|
_animationStarted = true;
|
||||||
|
_runAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ClaimsStaggeredEntrance oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (ClaimsPdfExportScope.of(context)) {
|
||||||
|
_controller.value = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.replayToken != widget.replayToken) {
|
||||||
|
_controller.reset();
|
||||||
|
_animationStarted = true;
|
||||||
|
_runAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initController() {
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 520),
|
||||||
|
);
|
||||||
|
_fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||||
|
_slide = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.08),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(_fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runAnimation() async {
|
||||||
|
if (!mounted || ClaimsPdfExportScope.of(context)) {
|
||||||
|
if (mounted) _controller.value = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(widget.baseDelay * widget.index);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ClaimsPdfExportScope.of(context)) return widget.child;
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fade,
|
||||||
|
child: SlideTransition(position: _slide, child: widget.child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps tab body: skeleton while loading, then staggered content.
|
||||||
|
class ClaimsTabAnimatedShell extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final Widget child;
|
||||||
|
final int skeletonBlocks;
|
||||||
|
|
||||||
|
const ClaimsTabAnimatedShell({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.child,
|
||||||
|
this.skeletonBlocks = 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading) {
|
||||||
|
return ClaimsDashboardSkeleton(blockCount: skeletonBlocks);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer placeholder grid while dummy data "loads".
|
||||||
|
class ClaimsDashboardSkeleton extends StatefulWidget {
|
||||||
|
final int blockCount;
|
||||||
|
|
||||||
|
const ClaimsDashboardSkeleton({super.key, this.blockCount = 4});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClaimsDashboardSkeleton> createState() => _ClaimsDashboardSkeletonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClaimsDashboardSkeletonState extends State<ClaimsDashboardSkeleton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _shimmer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_shimmer = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_shimmer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(widget.blockCount, (i) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _ShimmerBlock(animation: _shimmer, height: i == 0 ? 72 : 120),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShimmerBlock extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
const _ShimmerBlock({required this.animation, required this.height});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (_, __) {
|
||||||
|
return Container(
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment(-1 + animation.value * 2, 0),
|
||||||
|
end: Alignment(animation.value * 2, 0),
|
||||||
|
colors: const [
|
||||||
|
Color(0xFFE8ECF0),
|
||||||
|
Color(0xFFF8FAFC),
|
||||||
|
Color(0xFFE8ECF0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts up numeric portions of [value] (e.g. "29,293,880" or "51.16%").
|
||||||
|
class ClaimsAnimatedValueText extends StatelessWidget {
|
||||||
|
final String value;
|
||||||
|
final TextStyle style;
|
||||||
|
final int replayToken;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const ClaimsAnimatedValueText({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.style,
|
||||||
|
required this.replayToken,
|
||||||
|
this.duration = const Duration(milliseconds: 1100),
|
||||||
|
});
|
||||||
|
|
||||||
|
static double? _parseNumeric(String raw) {
|
||||||
|
final cleaned = raw.replaceAll(',', '').replaceAll('%', '').trim();
|
||||||
|
return double.tryParse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatLikeOriginal(double n, String original) {
|
||||||
|
final hasPercent = original.contains('%');
|
||||||
|
final hasComma = original.contains(',');
|
||||||
|
final decimals = original.contains('.')
|
||||||
|
? original.split('.').last.replaceAll(RegExp(r'[^0-9]'), '').length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
String formatted;
|
||||||
|
if (decimals > 0) {
|
||||||
|
formatted = n.toStringAsFixed(decimals);
|
||||||
|
} else {
|
||||||
|
formatted = n.round().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasComma) {
|
||||||
|
final parts = formatted.split('.');
|
||||||
|
final intPart = parts[0];
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < intPart.length; i++) {
|
||||||
|
if (i > 0 && (intPart.length - i) % 3 == 0) buf.write(',');
|
||||||
|
buf.write(intPart[i]);
|
||||||
|
}
|
||||||
|
formatted = parts.length > 1 ? '${buf}.${parts[1]}' : buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPercent ? '$formatted%' : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final target = _parseNumeric(value);
|
||||||
|
if (target == null) {
|
||||||
|
return Text(value, style: style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
key: ValueKey('$replayToken-$value'),
|
||||||
|
tween: Tween(begin: 0, end: target),
|
||||||
|
duration: duration,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, v, __) {
|
||||||
|
return Text(
|
||||||
|
_formatLikeOriginal(v, value),
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress bar that animates from 0 → [value].
|
||||||
|
class ClaimsAnimatedProgressBar extends StatelessWidget {
|
||||||
|
final double value;
|
||||||
|
final Color color;
|
||||||
|
final double minHeight;
|
||||||
|
final int replayToken;
|
||||||
|
|
||||||
|
const ClaimsAnimatedProgressBar({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
this.minHeight = 8,
|
||||||
|
required this.replayToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
key: ValueKey('$replayToken-$value'),
|
||||||
|
tween: Tween(begin: 0, end: value.clamp(0.0, 1.0)),
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, v, __) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: v,
|
||||||
|
minHeight: minHeight,
|
||||||
|
backgroundColor: ClaimsOverviewTheme.border,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Center loading indicator for full tab refresh.
|
||||||
|
class ClaimsTabLoadingIndicator extends StatelessWidget {
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const ClaimsTabLoadingIndicator({super.key, this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: ClaimsOverviewTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message!,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotating refresh icon while [isRefreshing].
|
||||||
|
class ClaimsRefreshIcon extends StatelessWidget {
|
||||||
|
final bool isRefreshing;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const ClaimsRefreshIcon({
|
||||||
|
super.key,
|
||||||
|
required this.isRefreshing,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: isRefreshing ? null : onPressed,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
icon: isRefreshing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: ClaimsOverviewTheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _SpinOnHover(
|
||||||
|
child: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
size: 18,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary.withValues(alpha: 0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpinOnHover extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _SpinOnHover({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SpinOnHover> createState() => _SpinOnHoverState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpinOnHoverState extends State<_SpinOnHover> {
|
||||||
|
bool _hovering = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovering = true),
|
||||||
|
onExit: (_) => setState(() => _hovering = false),
|
||||||
|
child: AnimatedRotation(
|
||||||
|
turns: _hovering ? 0.25 : 0,
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chart fade-in wrapper keyed to [replayToken].
|
||||||
|
class ClaimsChartEntrance extends StatelessWidget {
|
||||||
|
final int replayToken;
|
||||||
|
final int index;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ClaimsChartEntrance({
|
||||||
|
super.key,
|
||||||
|
required this.replayToken,
|
||||||
|
this.index = 0,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClaimsStaggeredEntrance(
|
||||||
|
replayToken: replayToken,
|
||||||
|
index: index,
|
||||||
|
baseDelay: const Duration(milliseconds: 80),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int claimsStaggerIndex({required int row, required int col, int cols = 4}) {
|
||||||
|
return row * cols + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
double claimsClampFraction(double v) => math.min(1.0, math.max(0.0, v));
|
||||||
2232
lib/presentation/claims_overview/claims_overview_charts.dart
Normal file
2232
lib/presentation/claims_overview/claims_overview_charts.dart
Normal file
File diff suppressed because it is too large
Load Diff
737
lib/presentation/claims_overview/claims_overview_dashboard.dart
Normal file
737
lib/presentation/claims_overview/claims_overview_dashboard.dart
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import '../../customAppBar/base_layout.dart';
|
||||||
|
import '../../customAppBar/toastHelper.dart';
|
||||||
|
import '../../service/api_service.dart';
|
||||||
|
import '../../service/secure_pop_scope.dart';
|
||||||
|
import '../../service/token_storage_service.dart';
|
||||||
|
import 'claims_collection_kpi.dart';
|
||||||
|
import 'enrollment_collection_kpi.dart';
|
||||||
|
import 'claims_overview_animations.dart';
|
||||||
|
import 'claims_overview_pdf_export.dart';
|
||||||
|
import 'claims_overview_scope.dart';
|
||||||
|
import 'claims_overview_tabs.dart';
|
||||||
|
import 'claims_overview_theme.dart';
|
||||||
|
|
||||||
|
class ClaimsOverviewDashboard extends StatefulWidget {
|
||||||
|
const ClaimsOverviewDashboard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClaimsOverviewDashboard> createState() =>
|
||||||
|
_ClaimsOverviewDashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
late AnimationController _headerFadeController;
|
||||||
|
late Animation<double> _headerFade;
|
||||||
|
|
||||||
|
late final ApiService _apiService;
|
||||||
|
final _tokenService = TokenStorageService();
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
bool _isExportingPdf = false;
|
||||||
|
int _replayToken = 0;
|
||||||
|
int _currentTab = 0;
|
||||||
|
|
||||||
|
ClaimsOverviewViewData _viewData = ClaimsOverviewViewData.empty();
|
||||||
|
EnrollmentOverviewViewData _enrollmentViewData =
|
||||||
|
EnrollmentOverviewViewData.empty();
|
||||||
|
String? _clientId;
|
||||||
|
String? _clientBranchId;
|
||||||
|
String? _selectedPolicyId;
|
||||||
|
List<Map<String, dynamic>> _activePolicies = [];
|
||||||
|
|
||||||
|
static const _tabs = [
|
||||||
|
(Icons.dashboard_outlined, 'Overview'),
|
||||||
|
(Icons.timeline, 'Policy Experience'),
|
||||||
|
(Icons.analytics_outlined, 'Claims Analysis'),
|
||||||
|
(Icons.people_outline, 'Demographics'),
|
||||||
|
(Icons.location_city_outlined, 'Hospitals & Geography'),
|
||||||
|
(Icons.medical_services_outlined, 'Specialty Analysis'),
|
||||||
|
(Icons.group_add_outlined, 'Enrollment'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_apiService = ApiService(context);
|
||||||
|
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
|
|
||||||
|
_headerFadeController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
);
|
||||||
|
_headerFade = CurvedAnimation(
|
||||||
|
parent: _headerFadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
_headerFadeController.forward();
|
||||||
|
|
||||||
|
_loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTabChanged() {
|
||||||
|
if (_tabController.indexIsChanging) return;
|
||||||
|
final index = _tabController.index;
|
||||||
|
if (_currentTab != index) {
|
||||||
|
setState(() => _currentTab = index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDashboard({bool reloadPolicies = false}) async {
|
||||||
|
final isInitialLoad = _replayToken == 0;
|
||||||
|
setState(() {
|
||||||
|
if (isInitialLoad) {
|
||||||
|
_isLoading = true;
|
||||||
|
} else {
|
||||||
|
_isRefreshing = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
_clientId ??= await _tokenService.readValue('empClientId');
|
||||||
|
_clientBranchId ??= await _tokenService.readValue('empClientBranchId');
|
||||||
|
final hrId = await _tokenService.readValue('empHrId');
|
||||||
|
final token = await _tokenService.getCurrentToken();
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
await _apiService.getTokenLoadAPI(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialLoad || reloadPolicies || _activePolicies.isEmpty) {
|
||||||
|
await _loadPolicyList(token ?? '', hrId ?? '');
|
||||||
|
if (isInitialLoad && _activePolicies.isNotEmpty) {
|
||||||
|
_selectedPolicyId =
|
||||||
|
_activePolicies.first['client_policy_id'].toString();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedPolicyId == null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_viewData = ClaimsOverviewViewData.empty(
|
||||||
|
error: 'No active policy found',
|
||||||
|
);
|
||||||
|
_enrollmentViewData = EnrollmentOverviewViewData.empty();
|
||||||
|
_isLoading = false;
|
||||||
|
_isRefreshing = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final policyId = _selectedPolicyId!;
|
||||||
|
final response = await _apiService.getClaimsCollectionV2All(
|
||||||
|
clientPolicyId: policyId,
|
||||||
|
);
|
||||||
|
final enrollmentResponse =
|
||||||
|
await _apiService.getEnrollmentCollectionV1All(
|
||||||
|
clientPolicyId: policyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final claimsOk = ClaimsKpiParser.isSuccessResponse(response);
|
||||||
|
var claimsData = await ClaimsOverviewViewData.enrichFromApiResponse(
|
||||||
|
response,
|
||||||
|
fetchKpi: (slug) => _apiService.getClaimsCollectionV2Kpi(
|
||||||
|
slug,
|
||||||
|
clientPolicyId: policyId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
var enrollmentData = _parseEnrollmentResponse(enrollmentResponse);
|
||||||
|
|
||||||
|
final selectedPolicy = _selectedPolicy();
|
||||||
|
if (selectedPolicy != null) {
|
||||||
|
claimsData = claimsData.withPolicyFallback(selectedPolicy);
|
||||||
|
enrollmentData = enrollmentData.withPolicyFallback(selectedPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claimsOk) {
|
||||||
|
final message =
|
||||||
|
response['message']?.toString() ?? 'Failed to load claims data';
|
||||||
|
claimsData = ClaimsOverviewViewData(
|
||||||
|
kpiBySlug: claimsData.kpiBySlug,
|
||||||
|
loadError: message,
|
||||||
|
);
|
||||||
|
if (claimsData.kpiBySlug.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_viewData = claimsData;
|
||||||
|
_enrollmentViewData = enrollmentData;
|
||||||
|
_isLoading = false;
|
||||||
|
_isRefreshing = false;
|
||||||
|
_replayToken++;
|
||||||
|
});
|
||||||
|
ToastHelper.showErrorToast(context, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_viewData = claimsData;
|
||||||
|
_enrollmentViewData = enrollmentData;
|
||||||
|
_isLoading = false;
|
||||||
|
_isRefreshing = false;
|
||||||
|
_replayToken++;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_viewData = ClaimsOverviewViewData.empty(error: e.toString());
|
||||||
|
_enrollmentViewData =
|
||||||
|
EnrollmentOverviewViewData.empty(error: e.toString());
|
||||||
|
_isLoading = false;
|
||||||
|
_isRefreshing = false;
|
||||||
|
});
|
||||||
|
ToastHelper.showErrorToast(context, 'Failed to load claims overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPolicyList(String token, String hrId) async {
|
||||||
|
_clientId ??= await _tokenService.readValue('empClientId');
|
||||||
|
_clientBranchId ??= await _tokenService.readValue('empClientBranchId');
|
||||||
|
|
||||||
|
if (_clientId == null || _clientBranchId == null) return;
|
||||||
|
|
||||||
|
final policyRes = await _apiService.getActiveCashDepositDetailsToApi(
|
||||||
|
_clientId!,
|
||||||
|
_clientBranchId!,
|
||||||
|
hrId,
|
||||||
|
token,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (policyRes['status'] != 'success' || policyRes['data'] is! List) {
|
||||||
|
_activePolicies = [];
|
||||||
|
_selectedPolicyId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activePolicies = List<Map<String, dynamic>>.from(policyRes['data'])
|
||||||
|
..sort((a, b) {
|
||||||
|
final aLabel = '${a['type']} - ${a['policy_no']}';
|
||||||
|
final bLabel = '${b['type']} - ${b['policy_no']}';
|
||||||
|
return aLabel.compareTo(bLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_activePolicies.isNotEmpty) {
|
||||||
|
final firstId =
|
||||||
|
_activePolicies.first['client_policy_id'].toString();
|
||||||
|
final stillValid = _selectedPolicyId != null &&
|
||||||
|
_activePolicies.any(
|
||||||
|
(p) => p['client_policy_id'].toString() == _selectedPolicyId,
|
||||||
|
);
|
||||||
|
if (!stillValid) {
|
||||||
|
_selectedPolicyId = firstId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedPolicyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EnrollmentOverviewViewData _parseEnrollmentResponse(
|
||||||
|
Map<String, dynamic> response,
|
||||||
|
) {
|
||||||
|
if (!ClaimsKpiParser.isSuccessResponse(response)) {
|
||||||
|
return EnrollmentOverviewViewData.empty(
|
||||||
|
error: response['message']?.toString() ??
|
||||||
|
'Failed to load enrollment data',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EnrollmentOverviewViewData.fromApiResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _selectedPolicy() {
|
||||||
|
if (_selectedPolicyId == null) return null;
|
||||||
|
for (final policy in _activePolicies) {
|
||||||
|
if (policy['client_policy_id'].toString() == _selectedPolicyId) {
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPolicySelected(String policyId) {
|
||||||
|
if (policyId == _selectedPolicyId) return;
|
||||||
|
setState(() => _selectedPolicyId = policyId);
|
||||||
|
_loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _selectedPolicyLabel() {
|
||||||
|
if (_selectedPolicyId == null) return 'Select Policy';
|
||||||
|
for (final policy in _activePolicies) {
|
||||||
|
if (policy['client_policy_id'].toString() == _selectedPolicyId) {
|
||||||
|
return '${policy['type']} - ${policy['policy_no']}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Policy $_selectedPolicyId';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadChartsPdf() async {
|
||||||
|
if (_isExportingPdf || _isLoading) return;
|
||||||
|
|
||||||
|
setState(() => _isExportingPdf = true);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
final overlay = navigator.overlay;
|
||||||
|
if (overlay == null) {
|
||||||
|
setState(() => _isExportingPdf = false);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Could not generate PDF. Overlay unavailable.',
|
||||||
|
style: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var progressLabel = 'Preparing PDF…';
|
||||||
|
void Function(void Function())? updateProgressDialog;
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
useRootNavigator: true,
|
||||||
|
builder: (ctx) => PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
updateProgressDialog = setDialogState;
|
||||||
|
return AlertDialog(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Generating PDF…\n$progressLabel',
|
||||||
|
style: GoogleFonts.poppins(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportClaimsOverviewChartsPdf(
|
||||||
|
overlay: overlay,
|
||||||
|
replayToken: _replayToken,
|
||||||
|
viewData: _viewData,
|
||||||
|
enrollmentViewData: _enrollmentViewData,
|
||||||
|
policyId: _selectedPolicyId ?? '',
|
||||||
|
onProgress: (_, __, label) {
|
||||||
|
updateProgressDialog?.call(() {
|
||||||
|
progressLabel = label;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Claims Overview PDF downloaded',
|
||||||
|
style: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Could not generate PDF. Please try again. ($e)',
|
||||||
|
style: GoogleFonts.poppins(),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted && navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _isExportingPdf = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
_tabController.dispose();
|
||||||
|
_headerFadeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BaseLayout(
|
||||||
|
child: SecurePopScope(
|
||||||
|
child: Container(
|
||||||
|
color: ClaimsOverviewTheme.pageBg,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FadeTransition(
|
||||||
|
opacity: _headerFade,
|
||||||
|
child: _buildHeader(),
|
||||||
|
),
|
||||||
|
ClaimsTopLoadingBar(visible: _isRefreshing),
|
||||||
|
_buildTabBar(),
|
||||||
|
Expanded(
|
||||||
|
child: ClaimsOverviewScope(
|
||||||
|
key: ValueKey('claims-$_selectedPolicyId-$_replayToken'),
|
||||||
|
data: _viewData,
|
||||||
|
child: EnrollmentOverviewScope(
|
||||||
|
key: ValueKey('enrollment-$_selectedPolicyId-$_replayToken'),
|
||||||
|
data: _enrollmentViewData,
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
physics: _isRefreshing
|
||||||
|
? const NeverScrollableScrollPhysics()
|
||||||
|
: null,
|
||||||
|
children: List.generate(
|
||||||
|
_tabs.length,
|
||||||
|
(i) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: ClaimsTabAnimatedShell(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
skeletonBlocks: i == 0 ? 5 : 4,
|
||||||
|
child: ClaimsOverviewTab(
|
||||||
|
index: i,
|
||||||
|
replayToken: _replayToken,
|
||||||
|
isActive: _currentTab == i,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.cardBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.85, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
builder: (_, scale, child) =>
|
||||||
|
Transform.scale(scale: scale, child: child),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.shield_outlined,
|
||||||
|
color: ClaimsOverviewTheme.primary,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Claims Overview',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Real-time policy & claims analytics',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const ClaimsLiveIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildPolicyBadge(),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_headerActions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPolicyBadge() {
|
||||||
|
if (_activePolicies.isEmpty) {
|
||||||
|
return Material(
|
||||||
|
color: ClaimsOverviewTheme.pageBg,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_selectedPolicyLabel(),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
ClaimsRefreshIcon(
|
||||||
|
isRefreshing: _isRefreshing,
|
||||||
|
onPressed: _loadDashboard,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: ClaimsOverviewTheme.pageBg,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 320,
|
||||||
|
height: 40,
|
||||||
|
child: SearchAnchor(
|
||||||
|
viewBackgroundColor: ClaimsOverviewTheme.cardBg,
|
||||||
|
viewConstraints: const BoxConstraints(maxHeight: 260),
|
||||||
|
builder: (context, controller) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: controller.openView,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_selectedPolicyLabel(),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
ClaimsRefreshIcon(
|
||||||
|
isRefreshing: _isRefreshing,
|
||||||
|
onPressed: _loadDashboard,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
suggestionsBuilder: (context, controller) {
|
||||||
|
final input = controller.text.toLowerCase();
|
||||||
|
return _activePolicies
|
||||||
|
.where((policy) {
|
||||||
|
final type = policy['type'].toString().toLowerCase();
|
||||||
|
final policyNo = policy['policy_no'].toString().toLowerCase();
|
||||||
|
return type.contains(input) || policyNo.contains(input);
|
||||||
|
})
|
||||||
|
.map((policy) {
|
||||||
|
final label = '${policy['type']} - ${policy['policy_no']}';
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.poppins(fontSize: 13),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
controller.closeView(label);
|
||||||
|
_onPolicySelected(
|
||||||
|
policy['client_policy_id'].toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _headerActions() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Download all charts as PDF',
|
||||||
|
icon: _isExportingPdf
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download_outlined, size: 20),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
foregroundColor: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: (_isRefreshing || _isExportingPdf || _isLoading)
|
||||||
|
? null
|
||||||
|
: _downloadChartsPdf,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabBar() {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.cardBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_isRefreshing)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Text(
|
||||||
|
'Updating…',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 11,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: TabAlignment.start,
|
||||||
|
labelColor: ClaimsOverviewTheme.primary,
|
||||||
|
unselectedLabelColor: ClaimsOverviewTheme.textSecondary,
|
||||||
|
indicatorColor: ClaimsOverviewTheme.primary,
|
||||||
|
indicatorWeight: 3,
|
||||||
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
|
labelStyle: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
unselectedLabelStyle: GoogleFonts.poppins(fontSize: 13),
|
||||||
|
tabs: List.generate(_tabs.length, (i) {
|
||||||
|
final t = _tabs[i];
|
||||||
|
final selected = _currentTab == i;
|
||||||
|
return Tab(
|
||||||
|
height: 48,
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: selected ? 1.02 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Icon(
|
||||||
|
t.$1,
|
||||||
|
key: ValueKey('$i-$selected'),
|
||||||
|
size: 18,
|
||||||
|
color: selected
|
||||||
|
? ClaimsOverviewTheme.primary
|
||||||
|
: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(t.$2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFooter() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: 'Powered by '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'Nhance',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ClaimsOverviewTheme.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: ' Insights · Light Theme'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
509
lib/presentation/claims_overview/claims_overview_pdf_export.dart
Normal file
509
lib/presentation/claims_overview/claims_overview_pdf_export.dart
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import 'package:universal_html/html.dart' as html;
|
||||||
|
|
||||||
|
import 'claims_collection_kpi.dart';
|
||||||
|
import 'enrollment_collection_kpi.dart';
|
||||||
|
import 'claims_overview_scope.dart';
|
||||||
|
import 'claims_overview_tabs.dart';
|
||||||
|
import 'claims_overview_theme.dart';
|
||||||
|
|
||||||
|
const _tabNames = [
|
||||||
|
'Overview',
|
||||||
|
'Policy Experience',
|
||||||
|
'Claims Analysis',
|
||||||
|
'Demographics',
|
||||||
|
'Hospitals & Geography',
|
||||||
|
'Specialty Analysis',
|
||||||
|
'Enrollment',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Fallback heights if layout measure fails (avoids second pass).
|
||||||
|
const _tabFallbackHeights = [
|
||||||
|
720.0,
|
||||||
|
680.0,
|
||||||
|
660.0,
|
||||||
|
760.0,
|
||||||
|
680.0,
|
||||||
|
580.0,
|
||||||
|
820.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
const _captureWidth = 1100.0;
|
||||||
|
const _capturePadding = 16.0;
|
||||||
|
const _maxMeasureHeight = 1400.0;
|
||||||
|
const _enrollmentMaxMeasureHeight = 1020.0;
|
||||||
|
const _minCaptureHeight = 360.0;
|
||||||
|
const _captureTimeout = Duration(seconds: 20);
|
||||||
|
const _pdfEmbedMaxWidth = 960;
|
||||||
|
const _logoAsset = 'assets/Nhance-Logo-Final 1.png';
|
||||||
|
|
||||||
|
typedef ClaimsPdfExportProgress = void Function(int current, int total, String label);
|
||||||
|
|
||||||
|
class _TabCapture {
|
||||||
|
final String tabName;
|
||||||
|
final Uint8List png;
|
||||||
|
|
||||||
|
const _TabCapture({required this.tabName, required this.png});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PdfBuildInput {
|
||||||
|
final List<_TabCapture> captures;
|
||||||
|
final Uint8List? logoBytes;
|
||||||
|
|
||||||
|
const _PdfBuildInput({
|
||||||
|
required this.captures,
|
||||||
|
required this.logoBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadFonts() async {
|
||||||
|
try {
|
||||||
|
await GoogleFonts.pendingFonts([
|
||||||
|
GoogleFonts.poppins(),
|
||||||
|
GoogleFonts.poppins(fontWeight: FontWeight.w600),
|
||||||
|
]).timeout(const Duration(milliseconds: 600));
|
||||||
|
} catch (_) {
|
||||||
|
// Continue export even if web font CDN is slow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shrinks and JPEG-encodes tab screenshots so [doc.save] stays fast.
|
||||||
|
Uint8List _compressTabImageForPdf(Uint8List pngBytes) {
|
||||||
|
try {
|
||||||
|
final decoded = img.decodePng(pngBytes);
|
||||||
|
if (decoded == null) return pngBytes;
|
||||||
|
|
||||||
|
img.Image processed = decoded;
|
||||||
|
if (decoded.width > _pdfEmbedMaxWidth) {
|
||||||
|
processed = img.copyResize(
|
||||||
|
decoded,
|
||||||
|
width: _pdfEmbedMaxWidth,
|
||||||
|
interpolation: img.Interpolation.average,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8List.fromList(img.encodeJpg(processed, quality: 82));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('PDF export: image compress skipped ($e)');
|
||||||
|
return pngBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
|
||||||
|
pw.MemoryImage? logo;
|
||||||
|
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) {
|
||||||
|
logo = pw.MemoryImage(input.logoBytes!);
|
||||||
|
}
|
||||||
|
|
||||||
|
final doc = pw.Document();
|
||||||
|
|
||||||
|
for (final capture in input.captures) {
|
||||||
|
final embed = _compressTabImageForPdf(capture.png);
|
||||||
|
doc.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4.landscape,
|
||||||
|
margin: const pw.EdgeInsets.all(24),
|
||||||
|
build: (ctx) => pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_pdfPageHeader(title: capture.tabName, logo: logo),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Center(
|
||||||
|
child: pw.Image(
|
||||||
|
pw.MemoryImage(embed),
|
||||||
|
fit: pw.BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web: compress/build in chunks so the progress dialog can repaint.
|
||||||
|
Future<Uint8List> _buildPdfBytesWithYields(
|
||||||
|
_PdfBuildInput input,
|
||||||
|
ClaimsPdfExportProgress? onProgress,
|
||||||
|
int totalSteps,
|
||||||
|
) async {
|
||||||
|
pw.MemoryImage? logo;
|
||||||
|
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) {
|
||||||
|
logo = pw.MemoryImage(input.logoBytes!);
|
||||||
|
}
|
||||||
|
|
||||||
|
final doc = pw.Document();
|
||||||
|
|
||||||
|
final total = input.captures.length;
|
||||||
|
for (var i = 0; i < total; i++) {
|
||||||
|
final capture = input.captures[i];
|
||||||
|
_reportProgress(
|
||||||
|
onProgress,
|
||||||
|
_tabNames.length + 1,
|
||||||
|
totalSteps,
|
||||||
|
'Compressing ${capture.tabName} (${i + 1}/$total)…',
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final embed = _compressTabImageForPdf(capture.png);
|
||||||
|
doc.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4.landscape,
|
||||||
|
margin: const pw.EdgeInsets.all(24),
|
||||||
|
build: (ctx) => pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_pdfPageHeader(title: capture.tabName, logo: logo),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Center(
|
||||||
|
child: pw.Image(
|
||||||
|
pw.MemoryImage(embed),
|
||||||
|
fit: pw.BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_reportProgress(
|
||||||
|
onProgress,
|
||||||
|
_tabNames.length + 1,
|
||||||
|
totalSteps,
|
||||||
|
'Writing PDF file…',
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabCapture({
|
||||||
|
required ClaimsOverviewViewData data,
|
||||||
|
required EnrollmentOverviewViewData enrollmentData,
|
||||||
|
required int tabIndex,
|
||||||
|
required int replayToken,
|
||||||
|
required String tabName,
|
||||||
|
}) {
|
||||||
|
final laneWidth = _captureWidth - _capturePadding * 2;
|
||||||
|
|
||||||
|
return ClaimsOverviewScope(
|
||||||
|
data: data,
|
||||||
|
child: EnrollmentOverviewScope(
|
||||||
|
data: enrollmentData,
|
||||||
|
child: ClaimsPdfExportScope(
|
||||||
|
enabled: true,
|
||||||
|
laneWidth: laneWidth,
|
||||||
|
child: Container(
|
||||||
|
width: _captureWidth,
|
||||||
|
color: ClaimsOverviewTheme.pageBg,
|
||||||
|
padding: const EdgeInsets.all(_capturePadding),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tabName,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ClaimsOverviewTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClaimsOverviewTab(
|
||||||
|
index: tabIndex,
|
||||||
|
replayToken: replayToken,
|
||||||
|
isActive: true,
|
||||||
|
forPdfExport: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitForPaint() async {
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
double _capturePixelRatio({required bool isEnrollmentTab}) {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return isEnrollmentTab ? 0.85 : 1.0;
|
||||||
|
}
|
||||||
|
return isEnrollmentTab ? 1.1 : 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ui.Image?> _rasterizeBoundary(
|
||||||
|
RenderRepaintBoundary boundary,
|
||||||
|
double pixelRatio,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await boundary
|
||||||
|
.toImage(pixelRatio: pixelRatio)
|
||||||
|
.timeout(_captureTimeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('PDF export: rasterize timed out at ratio $pixelRatio');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single overlay pass: layout tab, measure height, rasterize to PNG.
|
||||||
|
Future<Uint8List?> _captureTabPng({
|
||||||
|
required OverlayState overlay,
|
||||||
|
required Widget child,
|
||||||
|
required double fallbackHeight,
|
||||||
|
bool isEnrollmentTab = false,
|
||||||
|
ClaimsPdfExportProgress? onProgress,
|
||||||
|
String? progressLabel,
|
||||||
|
}) async {
|
||||||
|
final boundaryKey = GlobalKey();
|
||||||
|
late OverlayEntry entry;
|
||||||
|
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (_) => Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: _captureWidth,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.01,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
key: boundaryKey,
|
||||||
|
child: MediaQuery(
|
||||||
|
data: const MediaQueryData(),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: _captureWidth,
|
||||||
|
maxHeight: isEnrollmentTab
|
||||||
|
? _enrollmentMaxMeasureHeight
|
||||||
|
: _maxMeasureHeight,
|
||||||
|
),
|
||||||
|
child: ClipRect(child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
overlay.insert(entry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _waitForPaint();
|
||||||
|
|
||||||
|
final renderObject = boundaryKey.currentContext?.findRenderObject();
|
||||||
|
if (renderObject is! RenderRepaintBoundary) {
|
||||||
|
debugPrint('PDF export: missing RepaintBoundary');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call(0, 0, '$progressLabel (rasterize…)');
|
||||||
|
|
||||||
|
var ratio = _capturePixelRatio(isEnrollmentTab: isEnrollmentTab);
|
||||||
|
ui.Image? image = await _rasterizeBoundary(renderObject, ratio);
|
||||||
|
if (image == null && ratio > 0.65) {
|
||||||
|
ratio *= 0.75;
|
||||||
|
image = await _rasterizeBoundary(renderObject, ratio);
|
||||||
|
}
|
||||||
|
if (image == null) return null;
|
||||||
|
|
||||||
|
onProgress?.call(0, 0, '$progressLabel (encode…)');
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
return byteData?.buffer.asUint8List();
|
||||||
|
} catch (e, stack) {
|
||||||
|
debugPrint('PDF export capture failed: $e\n$stack');
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
entry.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Widget _pdfPageHeader({
|
||||||
|
required String title,
|
||||||
|
pw.MemoryImage? logo,
|
||||||
|
bool showSubtitle = false,
|
||||||
|
String? subtitle,
|
||||||
|
}) {
|
||||||
|
return pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(
|
||||||
|
title,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: showSubtitle ? 22 : 14,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: PdfColors.grey800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showSubtitle && subtitle != null) ...[
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
pw.Text(
|
||||||
|
subtitle,
|
||||||
|
style: const pw.TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (logo != null)
|
||||||
|
pw.Container(
|
||||||
|
alignment: pw.Alignment.centerRight,
|
||||||
|
child: pw.Image(logo, height: 28, fit: pw.BoxFit.contain),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePdfBytes(Uint8List bytes, String fileName) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
final blob = html.Blob([bytes], 'application/pdf');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
final anchor = html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', fileName)
|
||||||
|
..style.display = 'none';
|
||||||
|
html.document.body?.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
} else {
|
||||||
|
await Printing.sharePdf(bytes: bytes, filename: fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportProgress(
|
||||||
|
ClaimsPdfExportProgress? onProgress,
|
||||||
|
int current,
|
||||||
|
int total,
|
||||||
|
String label,
|
||||||
|
) {
|
||||||
|
onProgress?.call(current, total, label);
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Let the progress dialog repaint before heavy work on web.
|
||||||
|
SchedulerBinding.instance.scheduleFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures each dashboard tab (same layout as on screen) into a PDF.
|
||||||
|
Future<void> exportClaimsOverviewChartsPdf({
|
||||||
|
required OverlayState overlay,
|
||||||
|
required int replayToken,
|
||||||
|
required ClaimsOverviewViewData viewData,
|
||||||
|
required EnrollmentOverviewViewData enrollmentViewData,
|
||||||
|
required String policyId,
|
||||||
|
ClaimsPdfExportProgress? onProgress,
|
||||||
|
}) async {
|
||||||
|
await _preloadFonts();
|
||||||
|
Uint8List? logoBytes;
|
||||||
|
try {
|
||||||
|
final data = await rootBundle.load(_logoAsset);
|
||||||
|
logoBytes = data.buffer.asUint8List();
|
||||||
|
} catch (_) {
|
||||||
|
logoBytes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalSteps = _tabNames.length + 2;
|
||||||
|
_reportProgress(onProgress, 0, totalSteps, 'Preparing captures…');
|
||||||
|
|
||||||
|
final captures = <_TabCapture>[];
|
||||||
|
|
||||||
|
for (var i = 0; i < _tabNames.length; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final tabName = _tabNames[i];
|
||||||
|
_reportProgress(onProgress, i + 1, totalSteps, 'Capturing $tabName…');
|
||||||
|
|
||||||
|
final tabWidget = _buildTabCapture(
|
||||||
|
data: viewData,
|
||||||
|
enrollmentData: enrollmentViewData,
|
||||||
|
tabIndex: i,
|
||||||
|
replayToken: replayToken,
|
||||||
|
tabName: tabName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final captureLabel = 'Capturing $tabName…';
|
||||||
|
final png = await _captureTabPng(
|
||||||
|
overlay: overlay,
|
||||||
|
child: tabWidget,
|
||||||
|
fallbackHeight: _tabFallbackHeights[i],
|
||||||
|
isEnrollmentTab: i == 6,
|
||||||
|
onProgress: onProgress,
|
||||||
|
progressLabel: captureLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (png == null) continue;
|
||||||
|
captures.add(_TabCapture(tabName: tabName, png: png));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captures.isEmpty) {
|
||||||
|
throw Exception('Could not capture any tabs for PDF export');
|
||||||
|
}
|
||||||
|
|
||||||
|
_reportProgress(
|
||||||
|
onProgress,
|
||||||
|
_tabNames.length + 1,
|
||||||
|
totalSteps,
|
||||||
|
'Building PDF file…',
|
||||||
|
);
|
||||||
|
|
||||||
|
final buildInput = _PdfBuildInput(
|
||||||
|
captures: captures,
|
||||||
|
logoBytes: logoBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
if (kIsWeb) {
|
||||||
|
bytes = await _buildPdfBytesWithYields(buildInput, onProgress, totalSteps);
|
||||||
|
} else {
|
||||||
|
bytes = await _buildPdfBytesSync(buildInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
_reportProgress(onProgress, totalSteps, totalSteps, 'Downloading…');
|
||||||
|
await _savePdfBytes(bytes, 'Claims_Overview_$policyId.pdf');
|
||||||
|
}
|
||||||
123
lib/presentation/claims_overview/claims_overview_pdf_layout.dart
Normal file
123
lib/presentation/claims_overview/claims_overview_pdf_layout.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'claims_overview_scope.dart';
|
||||||
|
|
||||||
|
/// Horizontal panel row for dashboard tabs; uses fixed widths during PDF capture.
|
||||||
|
class ClaimsTabPanelRow extends StatelessWidget {
|
||||||
|
final List<ClaimsTabPanel> panels;
|
||||||
|
final double gap;
|
||||||
|
final CrossAxisAlignment crossAxisAlignment;
|
||||||
|
|
||||||
|
const ClaimsTabPanelRow({
|
||||||
|
super.key,
|
||||||
|
required this.panels,
|
||||||
|
this.gap = 16,
|
||||||
|
this.crossAxisAlignment = CrossAxisAlignment.start,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final laneWidth = ClaimsPdfExportScope.laneWidthOf(context);
|
||||||
|
final isPdf = ClaimsPdfExportScope.of(context) && laneWidth != null;
|
||||||
|
|
||||||
|
if (!isPdf) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
|
children: _withGaps(
|
||||||
|
panels
|
||||||
|
.map((p) => Expanded(flex: p.flex, child: p.child))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalFlex = panels.fold<int>(0, (sum, p) => sum + p.flex);
|
||||||
|
final gaps = gap * (panels.length - 1);
|
||||||
|
final available = laneWidth! - gaps;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
|
children: _withGaps(
|
||||||
|
panels.map((p) {
|
||||||
|
return SizedBox(
|
||||||
|
width: available * p.flex / totalFlex,
|
||||||
|
child: ClipRect(child: p.child),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _withGaps(List<Widget> children) {
|
||||||
|
if (children.isEmpty) return const [];
|
||||||
|
final out = <Widget>[children.first];
|
||||||
|
for (var i = 1; i < children.length; i++) {
|
||||||
|
out.add(SizedBox(width: gap));
|
||||||
|
out.add(children[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaimsTabPanel {
|
||||||
|
final int flex;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ClaimsTabPanel({required this.flex, required this.child});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-column mini grid (e.g. specialty analysis cards).
|
||||||
|
class ClaimsTabMiniGrid extends StatelessWidget {
|
||||||
|
final List<Widget> items;
|
||||||
|
final double gap;
|
||||||
|
|
||||||
|
const ClaimsTabMiniGrid({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
this.gap = 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(items.length == 4);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxW = constraints.maxWidth;
|
||||||
|
final isPdf = ClaimsPdfExportScope.of(context);
|
||||||
|
|
||||||
|
Widget cell(int index) {
|
||||||
|
final child = items[index];
|
||||||
|
if (isPdf && maxW.isFinite) {
|
||||||
|
final half = (maxW - gap) / 2;
|
||||||
|
return SizedBox(width: half, child: child);
|
||||||
|
}
|
||||||
|
return Expanded(child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
cell(0),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
cell(1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: gap),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
cell(2),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
cell(3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/presentation/claims_overview/claims_overview_scope.dart
Normal file
80
lib/presentation/claims_overview/claims_overview_scope.dart
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'claims_collection_kpi.dart';
|
||||||
|
import 'enrollment_collection_kpi.dart';
|
||||||
|
|
||||||
|
/// When true, tab widgets skip scroll/entrance animations (PDF capture).
|
||||||
|
class ClaimsPdfExportScope extends InheritedWidget {
|
||||||
|
final bool enabled;
|
||||||
|
final double? laneWidth;
|
||||||
|
|
||||||
|
const ClaimsPdfExportScope({
|
||||||
|
super.key,
|
||||||
|
required this.enabled,
|
||||||
|
this.laneWidth,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool of(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<ClaimsPdfExportScope>()
|
||||||
|
?.enabled ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double? laneWidthOf(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<ClaimsPdfExportScope>()
|
||||||
|
?.laneWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(ClaimsPdfExportScope oldWidget) {
|
||||||
|
return oldWidget.enabled != enabled ||
|
||||||
|
oldWidget.laneWidth != laneWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides parsed KPI data to all Claims Overview tabs.
|
||||||
|
class ClaimsOverviewScope extends InheritedWidget {
|
||||||
|
final ClaimsOverviewViewData data;
|
||||||
|
|
||||||
|
const ClaimsOverviewScope({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static ClaimsOverviewViewData of(BuildContext context) {
|
||||||
|
final scope =
|
||||||
|
context.dependOnInheritedWidgetOfExactType<ClaimsOverviewScope>();
|
||||||
|
return scope?.data ?? ClaimsOverviewViewData.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(ClaimsOverviewScope oldWidget) {
|
||||||
|
return oldWidget.data != data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides parsed enrollment KPI data to the Enrollment tab.
|
||||||
|
class EnrollmentOverviewScope extends InheritedWidget {
|
||||||
|
final EnrollmentOverviewViewData data;
|
||||||
|
|
||||||
|
const EnrollmentOverviewScope({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static EnrollmentOverviewViewData of(BuildContext context) {
|
||||||
|
final scope =
|
||||||
|
context.dependOnInheritedWidgetOfExactType<EnrollmentOverviewScope>();
|
||||||
|
return scope?.data ?? EnrollmentOverviewViewData.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(EnrollmentOverviewScope oldWidget) {
|
||||||
|
return oldWidget.data != data;
|
||||||
|
}
|
||||||
|
}
|
||||||
1349
lib/presentation/claims_overview/claims_overview_tabs.dart
Normal file
1349
lib/presentation/claims_overview/claims_overview_tabs.dart
Normal file
File diff suppressed because it is too large
Load Diff
33
lib/presentation/claims_overview/claims_overview_theme.dart
Normal file
33
lib/presentation/claims_overview/claims_overview_theme.dart
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Design tokens for Claims Overview dashboard (light theme).
|
||||||
|
abstract final class ClaimsOverviewTheme {
|
||||||
|
static const Color primary = Color(0xFF14A39A);
|
||||||
|
static const Color pageBg = Color(0xFFF9FAFB);
|
||||||
|
static const Color cardBg = Colors.white;
|
||||||
|
static const Color textPrimary = Color(0xFF1E293B);
|
||||||
|
static const Color textSecondary = Color(0xFF64748B);
|
||||||
|
static const Color border = Color(0xFFE2E8F0);
|
||||||
|
|
||||||
|
static const Color teal = Color(0xFF14A39A);
|
||||||
|
static const Color orange = Color(0xFFF2853F);
|
||||||
|
static const Color purple = Color(0xFF6366F1);
|
||||||
|
static const Color green = Color(0xFF22C55E);
|
||||||
|
static const Color yellow = Color(0xFFF59E0B);
|
||||||
|
static const Color red = Color(0xFFEF4444);
|
||||||
|
static const Color blue = Color(0xFF3B82F6);
|
||||||
|
static const Color pink = Color(0xFFEC4899);
|
||||||
|
static const Color cyan = Color(0xFF06B6D4);
|
||||||
|
static const Color lavender = Color(0xFFA78BFA);
|
||||||
|
|
||||||
|
static const List<Color> chartPalette = [
|
||||||
|
teal,
|
||||||
|
orange,
|
||||||
|
purple,
|
||||||
|
green,
|
||||||
|
yellow,
|
||||||
|
red,
|
||||||
|
cyan,
|
||||||
|
lavender,
|
||||||
|
];
|
||||||
|
}
|
||||||
373
lib/presentation/claims_overview/claims_overview_widgets.dart
Normal file
373
lib/presentation/claims_overview/claims_overview_widgets.dart
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import 'claims_overview_animations.dart';
|
||||||
|
import 'claims_overview_scope.dart';
|
||||||
|
import 'claims_overview_theme.dart';
|
||||||
|
|
||||||
|
class ClaimsMetricCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color accentColor;
|
||||||
|
final int replayToken;
|
||||||
|
final bool animateValue;
|
||||||
|
|
||||||
|
const ClaimsMetricCard({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.accentColor,
|
||||||
|
this.replayToken = 0,
|
||||||
|
this.animateValue = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final compact = ClaimsPdfExportScope.of(context);
|
||||||
|
final valueStyle = GoogleFonts.poppins(
|
||||||
|
fontSize: compact ? 15 : 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(minHeight: compact ? 88 : 108),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
compact ? 10 : 14,
|
||||||
|
compact ? 10 : 12,
|
||||||
|
compact ? 10 : 14,
|
||||||
|
compact ? 6 : 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label.toUpperCase(),
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: compact ? 16 : 18, color: accentColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
compact ? 10 : 14,
|
||||||
|
0,
|
||||||
|
compact ? 10 : 14,
|
||||||
|
compact ? 8 : 10,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: compact ? 44 : 52,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: compact || !animateValue
|
||||||
|
? Text(
|
||||||
|
value,
|
||||||
|
style: valueStyle,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: ClaimsAnimatedValueText(
|
||||||
|
value: value,
|
||||||
|
replayToken: replayToken,
|
||||||
|
style: valueStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (compact)
|
||||||
|
Container(
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(10),
|
||||||
|
bottomRight: Radius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TweenAnimationBuilder<double>(
|
||||||
|
key: ValueKey('accent-$replayToken-$label'),
|
||||||
|
tween: Tween(begin: 0, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 700),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, w, __) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(10),
|
||||||
|
bottomRight: Radius.circular(10),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: w.clamp(0.0, 1.0),
|
||||||
|
child: Container(
|
||||||
|
height: 3,
|
||||||
|
color: accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaimsSectionCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ClaimsSectionCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.cardBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: ClaimsOverviewTheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaimsListRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const ClaimsListRow({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: iconColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaimsRelationshipCard extends StatelessWidget {
|
||||||
|
final String relation;
|
||||||
|
final int claims;
|
||||||
|
final String amount;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const ClaimsRelationshipCard({
|
||||||
|
super.key,
|
||||||
|
required this.relation,
|
||||||
|
required this.claims,
|
||||||
|
required this.amount,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ClaimsOverviewTheme.cardBg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: ClaimsOverviewTheme.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_outline, color: color, size: 22),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
relation,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'$claims Claims',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 11,
|
||||||
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Amount: $amount',
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ClaimsOverviewTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClaimsCityBar extends StatelessWidget {
|
||||||
|
final String city;
|
||||||
|
final String amount;
|
||||||
|
final double fraction;
|
||||||
|
final Color color;
|
||||||
|
final int replayToken;
|
||||||
|
|
||||||
|
const ClaimsCityBar({
|
||||||
|
super.key,
|
||||||
|
required this.city,
|
||||||
|
required this.amount,
|
||||||
|
required this.fraction,
|
||||||
|
required this.color,
|
||||||
|
this.replayToken = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(city, style: GoogleFonts.poppins(fontSize: 13)),
|
||||||
|
Text(
|
||||||
|
amount,
|
||||||
|
style: GoogleFonts.poppins(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClaimsAnimatedProgressBar(
|
||||||
|
value: fraction,
|
||||||
|
color: color,
|
||||||
|
replayToken: replayToken,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
360
lib/presentation/claims_overview/enrollment_collection_kpi.dart
Normal file
360
lib/presentation/claims_overview/enrollment_collection_kpi.dart
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'claims_collection_kpi.dart';
|
||||||
|
|
||||||
|
/// KPI slugs for `employeeRest/enrollment-collection-v1`.
|
||||||
|
abstract final class EnrollmentKpiSlug {
|
||||||
|
static const originallyEnrolled = 'originally_enrolled';
|
||||||
|
static const addedSubsequently = 'added_subsequently';
|
||||||
|
static const genderSplit = 'gender_split';
|
||||||
|
static const enrollmentRelationship = 'enrollment_relationship';
|
||||||
|
static const averageAgeByEnrollmentMonth =
|
||||||
|
'average_age_by_enrollment_month';
|
||||||
|
static const originalBasePremium = 'original_base_premium';
|
||||||
|
static const overallActive = 'overall_active';
|
||||||
|
static const enrollmentAgeGroup = 'enrollment_age_group';
|
||||||
|
static const additionsPremium = 'additions_premium';
|
||||||
|
static const netPremium = 'net_premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed view-model for the Enrollment tab.
|
||||||
|
class EnrollmentOverviewViewData {
|
||||||
|
final Map<String, dynamic> kpiBySlug;
|
||||||
|
final String? loadError;
|
||||||
|
|
||||||
|
const EnrollmentOverviewViewData({
|
||||||
|
required this.kpiBySlug,
|
||||||
|
this.loadError,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EnrollmentOverviewViewData.empty({String? error}) =>
|
||||||
|
EnrollmentOverviewViewData(kpiBySlug: const {}, loadError: error);
|
||||||
|
|
||||||
|
factory EnrollmentOverviewViewData.fromApiResponse(
|
||||||
|
Map<String, dynamic> response,
|
||||||
|
) {
|
||||||
|
return EnrollmentOverviewViewData(
|
||||||
|
kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills membership basics from policy list API when enrollment KPI rows are empty.
|
||||||
|
EnrollmentOverviewViewData withPolicyFallback(Map<String, dynamic> policy) {
|
||||||
|
final merged = Map<String, dynamic>.from(kpiBySlug);
|
||||||
|
final active =
|
||||||
|
policy['membersCountOfActive'] ?? policy['totalMembersCount'];
|
||||||
|
ClaimsKpiParser.ensureKpiField(
|
||||||
|
merged,
|
||||||
|
EnrollmentKpiSlug.overallActive,
|
||||||
|
'Overall Active',
|
||||||
|
active,
|
||||||
|
);
|
||||||
|
ClaimsKpiParser.ensureKpiField(
|
||||||
|
merged,
|
||||||
|
EnrollmentKpiSlug.originalBasePremium,
|
||||||
|
'opening_premium',
|
||||||
|
policy['total_premium'],
|
||||||
|
);
|
||||||
|
return EnrollmentOverviewViewData(kpiBySlug: merged, loadError: loadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _kpi(String slug) => kpiBySlug[slug] ?? kpiBySlug[_idKey(slug)];
|
||||||
|
|
||||||
|
/// Reads a scalar from KPI rows; supports v1 label-keyed single-value rows.
|
||||||
|
static String _scalarFromKpi(
|
||||||
|
dynamic kpi,
|
||||||
|
List<String> fields, {
|
||||||
|
String Function(String raw)? format,
|
||||||
|
}) {
|
||||||
|
if (kpi == null) return '—';
|
||||||
|
|
||||||
|
for (final field in fields) {
|
||||||
|
final raw = ClaimsKpiParser.kpiOnlyField(kpi, field);
|
||||||
|
if (raw != '—') {
|
||||||
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final rows = ClaimsKpiParser.extractRows(kpi);
|
||||||
|
if (rows.isEmpty) return '—';
|
||||||
|
|
||||||
|
if (rows.length == 1) {
|
||||||
|
final row = rows.first;
|
||||||
|
if (row is Map) {
|
||||||
|
for (final field in fields) {
|
||||||
|
final value = ClaimsKpiParser.rowValue(row, field);
|
||||||
|
if (value != null) {
|
||||||
|
final raw = ClaimsKpiParser.cleanDisplay(value);
|
||||||
|
if (raw.isNotEmpty) {
|
||||||
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.length == 1) {
|
||||||
|
final raw = ClaimsKpiParser.cleanDisplay(row.values.first);
|
||||||
|
if (raw.isNotEmpty) {
|
||||||
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _scalar(
|
||||||
|
List<String> slugs,
|
||||||
|
List<String> fields, {
|
||||||
|
String Function(String raw)? format,
|
||||||
|
}) {
|
||||||
|
for (final slug in slugs) {
|
||||||
|
final value = _scalarFromKpi(_kpi(slug), fields, format: format);
|
||||||
|
if (value != '—') return value;
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
ClaimsChartSeries _chartSeries(
|
||||||
|
List<String> slugs, {
|
||||||
|
required String labelKey,
|
||||||
|
String valueKey = 'member_count',
|
||||||
|
}) {
|
||||||
|
for (final slug in slugs) {
|
||||||
|
final kpi = _kpi(slug);
|
||||||
|
if (kpi == null) continue;
|
||||||
|
|
||||||
|
final explicit = ClaimsKpiParser.labelValueSeries(
|
||||||
|
kpi,
|
||||||
|
labelKey: labelKey,
|
||||||
|
valueKey: valueKey,
|
||||||
|
);
|
||||||
|
if (!explicit.isEmpty) return explicit;
|
||||||
|
|
||||||
|
final auto = ClaimsKpiParser.labelValueSeries(kpi);
|
||||||
|
if (!auto.isEmpty) return auto;
|
||||||
|
}
|
||||||
|
return const ClaimsChartSeries(labels: [], values: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatPremium(String raw) {
|
||||||
|
if (raw.isEmpty || raw == '—') return '—';
|
||||||
|
if (raw.startsWith('₹')) return raw;
|
||||||
|
final n = ClaimsKpiParser.toDouble(raw);
|
||||||
|
if (n == null) return raw;
|
||||||
|
return formatPremiumAmount(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatPremiumAmount(double n) {
|
||||||
|
if (n.abs() >= 1000000) {
|
||||||
|
return '₹${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
}
|
||||||
|
if (n.abs() >= 1000) {
|
||||||
|
return '₹${(n / 1000).toStringAsFixed(1)}K';
|
||||||
|
}
|
||||||
|
return '₹${NumberFormat('#,##0').format(n.round())}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get activeMembers => _scalar(
|
||||||
|
[EnrollmentKpiSlug.overallActive],
|
||||||
|
const [
|
||||||
|
'Overall Active',
|
||||||
|
'overall_active',
|
||||||
|
'active_members',
|
||||||
|
'member_count',
|
||||||
|
'count',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
String get originallyEnrolled => _scalar(
|
||||||
|
[EnrollmentKpiSlug.originallyEnrolled],
|
||||||
|
const [
|
||||||
|
'Originally Enrolled',
|
||||||
|
'originally_enrolled',
|
||||||
|
'member_count',
|
||||||
|
'count',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
String get addedSubsequently => _scalar(
|
||||||
|
[EnrollmentKpiSlug.addedSubsequently],
|
||||||
|
const [
|
||||||
|
'Subsequent Additions',
|
||||||
|
'added_subsequently',
|
||||||
|
'member_count',
|
||||||
|
'count',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
String get originalBasePremium => _scalar(
|
||||||
|
[EnrollmentKpiSlug.originalBasePremium],
|
||||||
|
const [
|
||||||
|
'opening_premium',
|
||||||
|
'original_base_premium',
|
||||||
|
'Original Base Premium',
|
||||||
|
'premium',
|
||||||
|
'amount',
|
||||||
|
],
|
||||||
|
format: formatPremium,
|
||||||
|
);
|
||||||
|
|
||||||
|
String get subsequentBasePremium => _scalar(
|
||||||
|
[EnrollmentKpiSlug.additionsPremium],
|
||||||
|
const [
|
||||||
|
'additions_premium',
|
||||||
|
'Additions Premium',
|
||||||
|
'subsequent_base_premium',
|
||||||
|
'premium',
|
||||||
|
'amount',
|
||||||
|
],
|
||||||
|
format: formatPremium,
|
||||||
|
);
|
||||||
|
|
||||||
|
String get netPremium => _scalar(
|
||||||
|
[EnrollmentKpiSlug.netPremium],
|
||||||
|
const [
|
||||||
|
'net_premium',
|
||||||
|
'Net Premium',
|
||||||
|
'premium',
|
||||||
|
'amount',
|
||||||
|
],
|
||||||
|
format: formatPremium,
|
||||||
|
);
|
||||||
|
|
||||||
|
ClaimsChartSeries get genderDonut {
|
||||||
|
final series = _chartSeries(
|
||||||
|
[EnrollmentKpiSlug.genderSplit],
|
||||||
|
labelKey: 'gender',
|
||||||
|
);
|
||||||
|
if (series.isEmpty) return series;
|
||||||
|
final labels = series.labels
|
||||||
|
.map((g) => switch (g.toUpperCase()) {
|
||||||
|
'M' => 'M',
|
||||||
|
'F' => 'F',
|
||||||
|
'MALE' => 'M',
|
||||||
|
'FEMALE' => 'F',
|
||||||
|
_ => g,
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return ClaimsChartSeries(
|
||||||
|
labels: labels,
|
||||||
|
values: series.values,
|
||||||
|
maxY: series.maxY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClaimsChartSeries get ageGroupsDonut => _chartSeries(
|
||||||
|
[EnrollmentKpiSlug.enrollmentAgeGroup],
|
||||||
|
labelKey: 'age_group',
|
||||||
|
);
|
||||||
|
|
||||||
|
ClaimsChartSeries get relationshipDonut => _chartSeries(
|
||||||
|
[EnrollmentKpiSlug.enrollmentRelationship],
|
||||||
|
labelKey: 'relationship',
|
||||||
|
);
|
||||||
|
|
||||||
|
ClaimsChartSeries get averageAgeTrend {
|
||||||
|
final series = _monthAgeSeries(_kpi(EnrollmentKpiSlug.averageAgeByEnrollmentMonth));
|
||||||
|
if (!series.isEmpty) return series;
|
||||||
|
return const ClaimsChartSeries(labels: [], values: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClaimsChartSeries _monthAgeSeries(dynamic kpi) {
|
||||||
|
final rows = ClaimsKpiParser.extractRows(kpi);
|
||||||
|
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
|
||||||
|
|
||||||
|
const labelFields = [
|
||||||
|
'month_label',
|
||||||
|
'month',
|
||||||
|
'enrollment_month',
|
||||||
|
'claim_month',
|
||||||
|
'month_name',
|
||||||
|
'period',
|
||||||
|
];
|
||||||
|
const valueFields = [
|
||||||
|
'average_age',
|
||||||
|
'avg_age',
|
||||||
|
'average_age_value',
|
||||||
|
'age',
|
||||||
|
'value',
|
||||||
|
];
|
||||||
|
const sortFields = ['enrollment_month', 'month_sort', 'sort', 'month_order'];
|
||||||
|
|
||||||
|
final items = <({String sort, String label, double value})>[];
|
||||||
|
for (final row in rows) {
|
||||||
|
if (row is! Map) continue;
|
||||||
|
|
||||||
|
String? label;
|
||||||
|
for (final field in labelFields) {
|
||||||
|
final candidate = ClaimsKpiParser.rowValue(row, field)?.toString();
|
||||||
|
if (candidate != null && candidate.isNotEmpty) {
|
||||||
|
label = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double? value;
|
||||||
|
for (final field in valueFields) {
|
||||||
|
value = ClaimsKpiParser.toDouble(ClaimsKpiParser.rowValue(row, field));
|
||||||
|
if (value != null) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (label == null || value == null) continue;
|
||||||
|
|
||||||
|
var sort = '';
|
||||||
|
for (final field in sortFields) {
|
||||||
|
final candidate = ClaimsKpiParser.rowValue(row, field)?.toString();
|
||||||
|
if (candidate != null && candidate.isNotEmpty) {
|
||||||
|
sort = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sort.isEmpty) sort = label;
|
||||||
|
|
||||||
|
items.add((sort: sort, label: label, value: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
|
||||||
|
|
||||||
|
items.sort((a, b) => a.sort.compareTo(b.sort));
|
||||||
|
final labels = items.map((e) => e.label).toList();
|
||||||
|
final values = items.map((e) => e.value).toList();
|
||||||
|
final max = values.reduce(math.max);
|
||||||
|
return ClaimsChartSeries(
|
||||||
|
labels: labels,
|
||||||
|
values: values,
|
||||||
|
maxY: _niceAgeMaxY(max),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String donutTotal(ClaimsChartSeries series) {
|
||||||
|
if (series.isEmpty) return '—';
|
||||||
|
final total = series.values.fold<double>(0, (a, b) => a + b);
|
||||||
|
return NumberFormat('#,##0').format(total.round());
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _niceAgeMaxY(double max) {
|
||||||
|
if (max <= 0) return 40;
|
||||||
|
if (max <= 40) return 40;
|
||||||
|
final exp = (math.log(max) / math.ln10).floor();
|
||||||
|
final magnitude = math.pow(10, exp).toDouble();
|
||||||
|
return (max / magnitude).ceil() * magnitude * 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _idKey(String slug) {
|
||||||
|
const ids = {
|
||||||
|
EnrollmentKpiSlug.originallyEnrolled: '115',
|
||||||
|
EnrollmentKpiSlug.addedSubsequently: '116',
|
||||||
|
EnrollmentKpiSlug.genderSplit: '117',
|
||||||
|
EnrollmentKpiSlug.enrollmentRelationship: '119',
|
||||||
|
EnrollmentKpiSlug.averageAgeByEnrollmentMonth: '122',
|
||||||
|
EnrollmentKpiSlug.originalBasePremium: '123',
|
||||||
|
EnrollmentKpiSlug.overallActive: '124',
|
||||||
|
EnrollmentKpiSlug.enrollmentAgeGroup: '125',
|
||||||
|
EnrollmentKpiSlug.additionsPremium: '126',
|
||||||
|
EnrollmentKpiSlug.netPremium: '127',
|
||||||
|
};
|
||||||
|
return ids[slug];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nhancepolicy/service/token_storage_service.dart';
|
import 'package:nhancepolicy/service/token_storage_service.dart';
|
||||||
@ -683,6 +685,71 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All 31 claims-collection KPIs in one response.
|
||||||
|
Future<Map<String, dynamic>> getClaimsCollectionV2All({
|
||||||
|
required String clientPolicyId,
|
||||||
|
}) async {
|
||||||
|
if (_token == null) await _initializeToken();
|
||||||
|
|
||||||
|
final url = Uri.parse('${Environment.apiUrlPost}claims-collection-v2/all')
|
||||||
|
.replace(
|
||||||
|
queryParameters: {'client_policy': clientPolicyId},
|
||||||
|
);
|
||||||
|
|
||||||
|
final headers = {'Authorization': 'Bearer ${_token ?? ''}'};
|
||||||
|
return _makeGetRequest(url, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single KPI by Metabase slug or numeric id.
|
||||||
|
Future<Map<String, dynamic>> getClaimsCollectionV2Kpi(
|
||||||
|
String slugOrId, {
|
||||||
|
required String clientPolicyId,
|
||||||
|
}) async {
|
||||||
|
if (_token == null) await _initializeToken();
|
||||||
|
|
||||||
|
final encoded = Uri.encodeComponent(slugOrId);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'${Environment.apiUrlPost}claims-collection-v2/kpi/$encoded',
|
||||||
|
).replace(
|
||||||
|
queryParameters: {'client_policy': clientPolicyId},
|
||||||
|
);
|
||||||
|
final headers = {'Authorization': 'Bearer ${_token ?? ''}'};
|
||||||
|
return _makeGetRequest(url, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All enrollment-collection KPIs in one response (`enrollment-collection-v1`).
|
||||||
|
Future<Map<String, dynamic>> getEnrollmentCollectionV1All({
|
||||||
|
required String clientPolicyId,
|
||||||
|
}) async {
|
||||||
|
if (_token == null) await _initializeToken();
|
||||||
|
|
||||||
|
final url =
|
||||||
|
Uri.parse('${Environment.apiUrlPost}enrollment-collection-v1/all')
|
||||||
|
.replace(
|
||||||
|
queryParameters: {'client_policy': clientPolicyId},
|
||||||
|
);
|
||||||
|
|
||||||
|
final headers = {'Authorization': 'Bearer ${_token ?? ''}'};
|
||||||
|
return _makeGetRequest(url, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single enrollment KPI by slug or numeric id (`enrollment-collection-v1`).
|
||||||
|
Future<Map<String, dynamic>> getEnrollmentCollectionV1Kpi(
|
||||||
|
String slugOrId, {
|
||||||
|
required String clientPolicyId,
|
||||||
|
}) async {
|
||||||
|
if (_token == null) await _initializeToken();
|
||||||
|
|
||||||
|
final encoded = Uri.encodeComponent(slugOrId);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'${Environment.apiUrlPost}enrollment-collection-v1/kpi/$encoded',
|
||||||
|
).replace(
|
||||||
|
queryParameters: {'client_policy': clientPolicyId},
|
||||||
|
);
|
||||||
|
final headers = {'Authorization': 'Bearer ${_token ?? ''}'};
|
||||||
|
return _makeGetRequest(url, headers);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> postHrTpaDashboard(params, token) async {
|
Future<Map<String, dynamic>> postHrTpaDashboard(params, token) async {
|
||||||
final url = Uri.parse('${Environment.apiUrlPost}getHrTpaDashboard');
|
final url = Uri.parse('${Environment.apiUrlPost}getHrTpaDashboard');
|
||||||
|
|
||||||
@ -1102,7 +1169,7 @@ class ApiService {
|
|||||||
// 'medium' => 403,
|
// 'medium' => 403,
|
||||||
// 'hard' => 451,
|
// 'hard' => 451,
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return jsonDecode(response.body);
|
return _parseJsonBody(response);
|
||||||
} else if (response.statusCode == 401) {
|
} else if (response.statusCode == 401) {
|
||||||
if (!_isSessionOutToastShown) {
|
if (!_isSessionOutToastShown) {
|
||||||
_isSessionOutToastShown = true;
|
_isSessionOutToastShown = true;
|
||||||
@ -1130,6 +1197,19 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _parseJsonBody(http.Response response) {
|
||||||
|
try {
|
||||||
|
if (response.bodyBytes.isEmpty) return {};
|
||||||
|
final text = utf8.decode(response.bodyBytes);
|
||||||
|
final parsed = jsonDecode(text);
|
||||||
|
if (parsed is Map<String, dynamic>) return parsed;
|
||||||
|
if (parsed is Map) return Map<String, dynamic>.from(parsed);
|
||||||
|
return {};
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearLocalStorageAndRedirect() async {
|
Future<void> _clearLocalStorageAndRedirect() async {
|
||||||
await tokenService.clearAll(); // 🔐 clears flutter_secure_storage
|
await tokenService.clearAll(); // 🔐 clears flutter_secure_storage
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <printing/printing_plugin.h>
|
||||||
#include <smart_auth/smart_auth_plugin.h>
|
#include <smart_auth/smart_auth_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||||
|
printing_plugin_register_with_registrar(printing_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) smart_auth_registrar =
|
g_autoptr(FlPluginRegistrar) smart_auth_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
|
||||||
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
|
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
printing
|
||||||
smart_auth
|
smart_auth
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import Foundation
|
|||||||
import file_picker
|
import file_picker
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
import printing
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import smart_auth
|
import smart_auth
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
|
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
@ -59,8 +59,11 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
flutter_svg: ^2.2.3
|
flutter_svg: ^2.2.3
|
||||||
pdf: ^3.11.3
|
pdf: ^3.11.3
|
||||||
|
printing: ^5.14.2
|
||||||
dotted_border: ^3.1.0
|
dotted_border: ^3.1.0
|
||||||
dropdown_button2: ^2.3.9
|
dropdown_button2: ^2.3.9
|
||||||
|
fl_chart: ^1.2.0
|
||||||
|
image: ^4.3.0
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@ -101,7 +101,11 @@
|
|||||||
serviceWorkerVersion: serviceWorkerVersion,
|
serviceWorkerVersion: serviceWorkerVersion,
|
||||||
},
|
},
|
||||||
onEntrypointLoaded: function(engineInitializer) {
|
onEntrypointLoaded: function(engineInitializer) {
|
||||||
engineInitializer.initializeEngine().then(function(appRunner) {
|
// CanvasKit renderer is required for reliable chart capture (`toImage`)
|
||||||
|
// used by dashboard PDF export.
|
||||||
|
engineInitializer.initializeEngine({
|
||||||
|
renderer: "canvaskit"
|
||||||
|
}).then(function(appRunner) {
|
||||||
appRunner.runApp();
|
appRunner.runApp();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,15 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <printing/printing_plugin.h>
|
||||||
#include <smart_auth/smart_auth_plugin.h>
|
#include <smart_auth/smart_auth_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
PrintingPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||||
SmartAuthPluginRegisterWithRegistrar(
|
SmartAuthPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("SmartAuthPlugin"));
|
registry->GetRegistrarForPlugin("SmartAuthPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
printing
|
||||||
smart_auth
|
smart_auth
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user