enrollment-app/lib/presentation/claims_overview/claims_overview_dashboard.dart
2026-07-02 11:06:41 +05:30

852 lines
27 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
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 ScrollController _scrollController;
late final List<GlobalKey> _sectionKeys;
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 = [];
Uint8List? _clientLogoBytes;
static const _tabs = [
(Icons.dashboard_outlined, 'Overview'),
(Icons.timeline, 'Claims Overview'),
(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, 'Insured'),
];
@override
void initState() {
super.initState();
_apiService = ApiService(context);
_scrollController = ScrollController();
_sectionKeys = List.generate(_tabs.length, (_) => GlobalKey());
_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);
}
}
void _scrollToSection(int index) {
setState(() => _currentTab = index);
WidgetsBinding.instance.addPostFrameCallback((_) {
final sectionContext = _sectionKeys[index].currentContext;
if (sectionContext == null) return;
Scrollable.ensureVisible(
sectionContext,
duration: const Duration(milliseconds: 450),
curve: Curves.easeInOut,
alignment: 0.02,
);
});
}
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;
}
await _loadClientLogoForSelectedPolicy();
} else {
_selectedPolicyId = null;
_clientLogoBytes = 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;
}
Future<void> _loadClientLogoForSelectedPolicy() async {
final url = _selectedPolicy()?['client_logo']?.toString().trim();
if (url == null || url.isEmpty) {
if (mounted) setState(() => _clientLogoBytes = null);
return;
}
try {
final response = await http.get(Uri.parse(url));
if (!mounted) return;
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
setState(() => _clientLogoBytes = response.bodyBytes);
} else {
setState(() => _clientLogoBytes = null);
}
} catch (_) {
if (mounted) setState(() => _clientLogoBytes = null);
}
}
void _onPolicySelected(String policyId) {
if (policyId == _selectedPolicyId) return;
setState(() => _selectedPolicyId = policyId);
_loadClientLogoForSelectedPolicy();
_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 {
if (_clientLogoBytes == null) {
await _loadClientLogoForSelectedPolicy();
}
await exportClaimsOverviewChartsPdf(
overlay: overlay,
replayToken: _replayToken,
viewData: _viewData,
enrollmentViewData: _enrollmentViewData,
policyId: _selectedPolicyId ?? '',
clientLogoBytes: _clientLogoBytes,
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();
_scrollController.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: ClaimsTabAnimatedShell(
isLoading: _isLoading,
skeletonBlocks: 5,
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _scrollController,
physics: _isRefreshing
? const NeverScrollableScrollPhysics()
: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _tabs.length; i++) ...[
_buildSection(
index: i,
replayToken: _replayToken,
),
if (i < _tabs.length - 1)
const SizedBox(height: 8),
],
const SizedBox(height: 24),
],
),
),
),
),
),
),
),
// _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,
),
),
if (_viewData.liveUpdatedAt != null &&
_viewData.liveUpdatedAt!.isNotEmpty) ...[
const SizedBox(width: 12),
ClaimsLiveIndicator(
createdAt: _viewData.liveUpdatedAt,
),
],
],
),
],
),
),
_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,
onTap: _scrollToSection,
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 _buildSection({
required int index,
required int replayToken,
}) {
final tab = _tabs[index];
return Container(
key: _sectionKeys[index],
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: ClaimsOverviewTheme.cardBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ClaimsOverviewTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
Icon(
tab.$1,
size: 22,
color: ClaimsOverviewTheme.primary,
),
const SizedBox(width: 10),
Text(
tab.$2,
style: GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
),
),
],
),
),
const SizedBox(height: 12),
ClaimsOverviewTab(
index: index,
replayToken: replayToken,
isActive: true,
scrollable: false,
),
const SizedBox(height: 16),
],
),
);
}
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'),
],
),
),
),
);
}
}