Dashboard charts
This commit is contained in:
parent
20ef0b593c
commit
c66c39e57c
@ -52,6 +52,8 @@ class Environment {
|
||||
switch (routeName) {
|
||||
case 'hrDashboard':
|
||||
return "$base - Insights";
|
||||
case 'claimsOverviewDashboard':
|
||||
return "$base - Dashboard";
|
||||
case 'policies':
|
||||
return "$base - Policies";
|
||||
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))
|
||||
_SideItem(
|
||||
// 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/home.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:nhancepolicy/hrLogin.dart';
|
||||
import 'package:nhancepolicy/presentation/hrPolicyDetails.dart';
|
||||
@ -267,6 +268,7 @@ final Map<String, WidgetBuilder> appRoutes = {
|
||||
'addOnsDetails': (context) => addOnsDetails(),
|
||||
'empReviewDetails': (context) => empReviewDetails(),
|
||||
'hrDashboard': (context) => hrDashboard(),
|
||||
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
|
||||
'hrPolicyDetails': (context) => hrPolicyDetails(
|
||||
ClientId: '',
|
||||
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:http/http.dart' as http;
|
||||
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 {
|
||||
final url = Uri.parse('${Environment.apiUrlPost}getHrTpaDashboard');
|
||||
|
||||
@ -1102,7 +1169,7 @@ class ApiService {
|
||||
// 'medium' => 403,
|
||||
// 'hard' => 451,
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
return _parseJsonBody(response);
|
||||
} else if (response.statusCode == 401) {
|
||||
if (!_isSessionOutToastShown) {
|
||||
_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 {
|
||||
await tokenService.clearAll(); // 🔐 clears flutter_secure_storage
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.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 <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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
|
||||
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
printing
|
||||
smart_auth
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@ import Foundation
|
||||
import file_picker
|
||||
import flutter_secure_storage_darwin
|
||||
import path_provider_foundation
|
||||
import printing
|
||||
import shared_preferences_foundation
|
||||
import smart_auth
|
||||
import url_launcher_macos
|
||||
@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
||||
@ -59,8 +59,11 @@ dependencies:
|
||||
flutter_secure_storage: ^10.0.0
|
||||
flutter_svg: ^2.2.3
|
||||
pdf: ^3.11.3
|
||||
printing: ^5.14.2
|
||||
dotted_border: ^3.1.0
|
||||
dropdown_button2: ^2.3.9
|
||||
fl_chart: ^1.2.0
|
||||
image: ^4.3.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
@ -101,7 +101,11 @@
|
||||
serviceWorkerVersion: serviceWorkerVersion,
|
||||
},
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,12 +7,15 @@
|
||||
#include "generated_plugin_registrant.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 <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
SmartAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SmartAuthPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
printing
|
||||
smart_auth
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user