Dashboard charts

This commit is contained in:
Surendiran 2026-06-04 14:47:40 +05:30
parent 20ef0b593c
commit c66c39e57c
22 changed files with 7748 additions and 2 deletions

View File

@ -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':

View File

@ -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,

View File

@ -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: '',

File diff suppressed because it is too large Load Diff

View 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));

File diff suppressed because it is too large Load Diff

View 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'),
],
),
),
),
);
}
}

View 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');
}

View 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),
],
),
],
);
},
);
}
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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,
];
}

View 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,
),
],
),
);
}
}

View 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];
}
}

View File

@ -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

View File

@ -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);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
printing
smart_auth
url_launcher_linux
)

View File

@ -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"))

View File

@ -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:

View File

@ -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();
});
}

View File

@ -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(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
printing
smart_auth
url_launcher_windows
)