diff --git a/lib/presentation/claims_overview/claims_overview_animations.dart b/lib/presentation/claims_overview/claims_overview_animations.dart index 76951f8..21424f1 100644 --- a/lib/presentation/claims_overview/claims_overview_animations.dart +++ b/lib/presentation/claims_overview/claims_overview_animations.dart @@ -91,10 +91,10 @@ class _ClaimsLiveIndicatorState extends State ), const SizedBox(width: 6), Text( - 'Last Updated', + 'MIS Creation Date', style: GoogleFonts.poppins( fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w400, color: ClaimsOverviewTheme.green, ), ), @@ -102,7 +102,8 @@ class _ClaimsLiveIndicatorState extends State Text( '· $createdAt', style: GoogleFonts.poppins( - fontSize: 10, + fontSize: 14, + fontWeight: FontWeight.w600, color: ClaimsOverviewTheme.green, ), ), diff --git a/lib/presentation/claims_overview/claims_overview_dashboard.dart b/lib/presentation/claims_overview/claims_overview_dashboard.dart index 20c7dfb..c60949e 100644 --- a/lib/presentation/claims_overview/claims_overview_dashboard.dart +++ b/lib/presentation/claims_overview/claims_overview_dashboard.dart @@ -30,6 +30,8 @@ class _ClaimsOverviewDashboardState extends State late TabController _tabController; late AnimationController _headerFadeController; late Animation _headerFade; + late final ScrollController _scrollController; + late final List _sectionKeys; late final ApiService _apiService; final _tokenService = TokenStorageService(); @@ -63,6 +65,8 @@ class _ClaimsOverviewDashboardState extends State 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); @@ -87,6 +91,20 @@ class _ClaimsOverviewDashboardState extends State } } + 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 _loadDashboard({bool reloadPolicies = false}) async { final isInitialLoad = _replayToken == 0; setState(() { @@ -407,6 +425,7 @@ class _ClaimsOverviewDashboardState extends State void dispose() { _tabController.removeListener(_onTabChanged); _tabController.dispose(); + _scrollController.dispose(); _headerFadeController.dispose(); super.dispose(); } @@ -433,23 +452,30 @@ class _ClaimsOverviewDashboardState extends State 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, - ), + 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), + ], ), ), ), @@ -697,6 +723,7 @@ class _ClaimsOverviewDashboardState extends State ), TabBar( controller: _tabController, + onTap: _scrollToSection, isScrollable: true, tabAlignment: TabAlignment.start, labelColor: ClaimsOverviewTheme.primary, @@ -745,6 +772,56 @@ class _ClaimsOverviewDashboardState extends State ); } + 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), diff --git a/lib/presentation/claims_overview/claims_overview_pdf_export.dart b/lib/presentation/claims_overview/claims_overview_pdf_export.dart index 7e7d072..71179e9 100644 --- a/lib/presentation/claims_overview/claims_overview_pdf_export.dart +++ b/lib/presentation/claims_overview/claims_overview_pdf_export.dart @@ -35,7 +35,7 @@ const _tabNames = [ const _tabFallbackHeights = [ 720.0, 680.0, - 660.0, + 820.0, 760.0, 680.0, 580.0, @@ -255,8 +255,10 @@ Widget _buildTabCapture({ } Future _waitForPaint() async { - await WidgetsBinding.instance.endOfFrame; - await Future.delayed(const Duration(milliseconds: 32)); + for (var i = 0; i < 3; i++) { + await WidgetsBinding.instance.endOfFrame; + await Future.delayed(const Duration(milliseconds: 48)); + } } double _capturePixelRatio({required bool isEnrollmentTab}) { @@ -285,6 +287,7 @@ Future _captureTabPng({ required OverlayState overlay, required Widget child, required double fallbackHeight, + double? maxMeasureHeight, bool isEnrollmentTab = false, ClaimsPdfExportProgress? onProgress, String? progressLabel, @@ -309,14 +312,19 @@ Future _captureTabPng({ key: boundaryKey, child: MediaQuery( data: const MediaQueryData(), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: _captureWidth, - maxHeight: isEnrollmentTab - ? _enrollmentMaxMeasureHeight - : _maxMeasureHeight, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _captureWidth, + minHeight: fallbackHeight, + maxHeight: maxMeasureHeight ?? + (isEnrollmentTab + ? _enrollmentMaxMeasureHeight + : _maxMeasureHeight), + ), + child: child, ), - child: ClipRect(child: child), ), ), ), @@ -339,6 +347,15 @@ Future _captureTabPng({ return null; } + final measuredHeight = renderObject.size.height; + if (measuredHeight < fallbackHeight * 0.85) { + debugPrint( + 'PDF export: $progressLabel measured ${measuredHeight.toStringAsFixed(0)}px ' + '(expected >= ${fallbackHeight.toStringAsFixed(0)}px)', + ); + await _waitForPaint(); + } + onProgress?.call(0, 0, '$progressLabel (rasterize…)'); var ratio = _capturePixelRatio(isEnrollmentTab: isEnrollmentTab); @@ -455,6 +472,7 @@ Future exportClaimsOverviewChartsPdf({ overlay: overlay, child: tabWidget, fallbackHeight: _tabFallbackHeights[i], + maxMeasureHeight: i == 2 ? 1000.0 : null, isEnrollmentTab: i == 6, onProgress: onProgress, progressLabel: captureLabel, diff --git a/lib/presentation/claims_overview/claims_overview_tabs.dart b/lib/presentation/claims_overview/claims_overview_tabs.dart index 735e10e..88cde37 100644 --- a/lib/presentation/claims_overview/claims_overview_tabs.dart +++ b/lib/presentation/claims_overview/claims_overview_tabs.dart @@ -12,8 +12,15 @@ import 'claims_overview_widgets.dart'; Widget _tabBodyWrapper({ required bool forPdfExport, required Widget child, + bool scrollable = true, }) { if (forPdfExport) return child; + if (!scrollable) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: child, + ); + } return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), child: child, @@ -38,6 +45,7 @@ class ClaimsOverviewTab extends StatelessWidget { final int replayToken; final bool isActive; final bool forPdfExport; + final bool scrollable; const ClaimsOverviewTab({ super.key, @@ -45,6 +53,7 @@ class ClaimsOverviewTab extends StatelessWidget { required this.replayToken, this.isActive = false, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -53,33 +62,40 @@ class ClaimsOverviewTab extends StatelessWidget { 0 => _OverviewTab( replayToken: replayToken, forPdfExport: forPdfExport, + scrollable: scrollable, ), 1 => _PolicyExperienceTab( replayToken: replayToken, isActive: isActive || forPdfExport, forPdfExport: forPdfExport, + scrollable: scrollable, ), 2 => _ClaimsAnalysisTab( replayToken: replayToken, isActive: isActive || forPdfExport, forPdfExport: forPdfExport, + scrollable: scrollable, ), 3 => _DemographicsTab( replayToken: replayToken, forPdfExport: forPdfExport, + scrollable: scrollable, ), 4 => _HospitalsTab( replayToken: replayToken, forPdfExport: forPdfExport, + scrollable: scrollable, ), 5 => _SpecialtyTab( replayToken: replayToken, forPdfExport: forPdfExport, + scrollable: scrollable, ), 6 => _EnrollmentTab( replayToken: replayToken, isActive: isActive || forPdfExport, forPdfExport: forPdfExport, + scrollable: scrollable, ), _ => const SizedBox(), }; @@ -89,10 +105,12 @@ class ClaimsOverviewTab extends StatelessWidget { class _OverviewTab extends StatelessWidget { final int replayToken; final bool forPdfExport; + final bool scrollable; const _OverviewTab({ required this.replayToken, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -100,6 +118,7 @@ class _OverviewTab extends StatelessWidget { final d = ClaimsOverviewScope.of(context); return _tabBodyWrapper( forPdfExport: forPdfExport, + scrollable: scrollable, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -134,6 +153,7 @@ class _OverviewTab extends StatelessWidget { left: ClaimsSectionCard( title: 'Claims Experience', icon: Icons.analytics_outlined, + minHeight: ClaimsSectionCard.pairedSectionMinHeight, child: ClaimsExperienceBody( incurredClaims: d.incurredClaims, incurredRatio: d.incurredRatio, @@ -145,6 +165,7 @@ class _OverviewTab extends StatelessWidget { right: ClaimsSectionCard( title: 'Inception', icon: Icons.flag_outlined, + minHeight: ClaimsSectionCard.pairedSectionMinHeight, child: ClaimsInceptionBody( inceptionEmployee: d.inceptionEmployee, inceptionLives: d.inceptionLives, @@ -160,8 +181,12 @@ class _OverviewTab extends StatelessWidget { required int baseIndex, required Widget left, required Widget right, + bool equalizeHeight = false, }) { - return ClaimsTabPanelRow( + final row = ClaimsTabPanelRow( + crossAxisAlignment: equalizeHeight + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, panels: [ ClaimsTabPanel( flex: 1, @@ -181,6 +206,8 @@ class _OverviewTab extends StatelessWidget { ), ], ); + if (!equalizeHeight) return row; + return IntrinsicHeight(child: row); } } @@ -189,11 +216,13 @@ class _PolicyExperienceTab extends StatefulWidget { final int replayToken; final bool isActive; final bool forPdfExport; + final bool scrollable; const _PolicyExperienceTab({ required this.replayToken, required this.isActive, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -240,41 +269,33 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab> final countSeries = d.claimsCountByStatus; final kpiMetrics = [ - ('Total Claims', d.sidebarTotalClaims, Icons.tag, ClaimsOverviewTheme.blue), + ('Total Claims', d.sidebarTotalClaims, null, Icons.tag, ClaimsOverviewTheme.blue), ( 'Total Incurred Amount', d.sidebarIncurredAmount, + null, Icons.currency_rupee, ClaimsOverviewTheme.pink, ), ( 'Total Reimbursement', d.sidebarReimbursement, + d.sidebarReimbursementPct, Icons.account_balance_wallet_outlined, ClaimsOverviewTheme.teal, ), - ( - 'Reimbursement %', - d.sidebarReimbursementPct, - Icons.percent, - ClaimsOverviewTheme.orange, - ), ( 'Cashless Claim Amount', d.sidebarCashless, + d.sidebarCashlessPct, Icons.shopping_bag_outlined, ClaimsOverviewTheme.purple, ), - ( - 'Cashless %', - d.sidebarCashlessPct, - Icons.pie_chart_outline, - ClaimsOverviewTheme.teal, - ), ]; return _tabBodyWrapper( forPdfExport: widget.forPdfExport, + scrollable: widget.scrollable, child: ClaimsTabPanelRow( panels: [ ClaimsTabPanel( @@ -339,8 +360,9 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab> child: ClaimsMetricCard( label: kpiMetrics[i].$1, value: kpiMetrics[i].$2, - icon: kpiMetrics[i].$3, - accentColor: kpiMetrics[i].$4, + secondaryValue: kpiMetrics[i].$3, + icon: kpiMetrics[i].$4, + accentColor: kpiMetrics[i].$5, replayToken: widget.replayToken, ), ), @@ -358,11 +380,13 @@ class _ClaimsAnalysisTab extends StatefulWidget { final int replayToken; final bool isActive; final bool forPdfExport; + final bool scrollable; const _ClaimsAnalysisTab({ required this.replayToken, required this.isActive, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -371,7 +395,7 @@ class _ClaimsAnalysisTab extends StatefulWidget { class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> with AutomaticKeepAliveClientMixin { - static const _pairedChartHeight = 280.0; + double get _pairedChartHeight => widget.forPdfExport ? 210.0 : 280.0; int _chartAnimKey = 0; @@ -416,7 +440,9 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> return _tabBodyWrapper( forPdfExport: widget.forPdfExport, + scrollable: widget.scrollable, child: Column( + mainAxisSize: MainAxisSize.min, children: [ _chartRow( 0, @@ -515,7 +541,6 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> ), ], ); - if (widget.forPdfExport) return row; return IntrinsicHeight(child: row); } } @@ -523,10 +548,12 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> class _DemographicsTab extends StatelessWidget { final int replayToken; final bool forPdfExport; + final bool scrollable; const _DemographicsTab({ required this.replayToken, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -548,6 +575,7 @@ class _DemographicsTab extends StatelessWidget { return _tabBodyWrapper( forPdfExport: forPdfExport, + scrollable: scrollable, child: Column( children: [ if (relationships.isNotEmpty) ...[ @@ -633,10 +661,12 @@ class _DemographicsTab extends StatelessWidget { class _HospitalsTab extends StatelessWidget { final int replayToken; final bool forPdfExport; + final bool scrollable; const _HospitalsTab({ required this.replayToken, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -672,6 +702,7 @@ class _HospitalsTab extends StatelessWidget { return _tabBodyWrapper( forPdfExport: forPdfExport, + scrollable: scrollable, child: Column( children: [ IntrinsicHeight( @@ -771,10 +802,12 @@ class _HospitalsTab extends StatelessWidget { class _SpecialtyTab extends StatelessWidget { final int replayToken; final bool forPdfExport; + final bool scrollable; const _SpecialtyTab({ required this.replayToken, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -800,6 +833,7 @@ class _SpecialtyTab extends StatelessWidget { return _tabBodyWrapper( forPdfExport: forPdfExport, + scrollable: scrollable, child: Column( children: [ ClaimsTabPanelRow( @@ -933,11 +967,13 @@ class _EnrollmentTab extends StatefulWidget { final int replayToken; final bool isActive; final bool forPdfExport; + final bool scrollable; const _EnrollmentTab({ required this.replayToken, required this.isActive, this.forPdfExport = false, + this.scrollable = true, }); @override @@ -1161,6 +1197,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab> return _tabBodyWrapper( forPdfExport: widget.forPdfExport, + scrollable: widget.scrollable, child: widget.forPdfExport ? ClipRect(child: body) : body, ); } diff --git a/lib/presentation/claims_overview/claims_overview_widgets.dart b/lib/presentation/claims_overview/claims_overview_widgets.dart index 91d2c48..de660ae 100644 --- a/lib/presentation/claims_overview/claims_overview_widgets.dart +++ b/lib/presentation/claims_overview/claims_overview_widgets.dart @@ -216,42 +216,40 @@ class ClaimsExperienceBody extends StatelessWidget { @override Widget build(BuildContext context) { - final incurredTile = _OverviewInnerTile( - label: 'Incurred Claims', - icon: Icons.gps_fixed, - accentColor: ClaimsOverviewTheme.red, - child: _ClaimsExperienceDualValues( - primaryValue: incurredClaims, - secondaryValue: incurredRatio, - ), - ); - final projectedTile = _OverviewInnerTile( - label: 'Projected Claims', - icon: Icons.trending_up, - accentColor: ClaimsOverviewTheme.orange, - child: _ClaimsExperienceDualValues( - primaryValue: projectedClaims, - secondaryValue: projectedRatio, - ), - ); - final incidenceTile = _OverviewInnerTile( - label: 'Claims Incidence Rate', - icon: Icons.timeline, - accentColor: ClaimsOverviewTheme.cyan, - child: Text( - claimsIncidenceRate, - style: GoogleFonts.poppins( - fontSize: 18, - fontWeight: FontWeight.w700, - color: ClaimsOverviewTheme.textPrimary, - height: 1.25, - ), - ), - ); - return LayoutBuilder( builder: (context, constraints) { - if (constraints.maxWidth < 520) { + final wide = constraints.maxWidth >= 520; + final incurredTile = _OverviewInnerTile( + label: 'Incurred Claims', + icon: Icons.gps_fixed, + accentColor: ClaimsOverviewTheme.red, + matchRowHeight: wide, + child: _ClaimsExperienceDualValues( + primaryValue: incurredClaims, + secondaryValue: incurredRatio, + ), + ); + final projectedTile = _OverviewInnerTile( + label: 'Projected Claims', + icon: Icons.trending_up, + accentColor: ClaimsOverviewTheme.orange, + matchRowHeight: wide, + child: _ClaimsExperienceDualValues( + primaryValue: projectedClaims, + secondaryValue: projectedRatio, + ), + ); + final incidenceTile = _OverviewInnerTile( + label: 'Claims Incidence Rate', + icon: Icons.timeline, + accentColor: ClaimsOverviewTheme.cyan, + matchRowHeight: wide, + child: _ClaimsExperienceDualValues( + primaryValue: claimsIncidenceRate, + ), + ); + + if (!wide) { return Column( children: [ incurredTile, @@ -263,6 +261,7 @@ class ClaimsExperienceBody extends StatelessWidget { ); } return _OverviewThreeColumnRow( + equalizeHeight: true, children: [incurredTile, projectedTile, incidenceTile], ); }, @@ -282,29 +281,32 @@ class ClaimsInceptionBody extends StatelessWidget { @override Widget build(BuildContext context) { - final valueStyle = GoogleFonts.poppins( - fontSize: 18, - fontWeight: FontWeight.w700, - color: ClaimsOverviewTheme.textPrimary, - height: 1.25, - ); - - final employeeTile = _OverviewInnerTile( - label: 'Inception Employee', - icon: Icons.person_add_alt, - accentColor: ClaimsOverviewTheme.blue, - child: Text(inceptionEmployee, style: valueStyle), - ); - final livesTile = _OverviewInnerTile( - label: 'Inception Lives', - icon: Icons.favorite_border, - accentColor: ClaimsOverviewTheme.pink, - child: Text(inceptionLives, style: valueStyle), - ); - return LayoutBuilder( builder: (context, constraints) { - if (constraints.maxWidth < 400) { + final wide = constraints.maxWidth >= 400; + final valueStyle = GoogleFonts.poppins( + fontSize: 18, + fontWeight: FontWeight.w700, + color: ClaimsOverviewTheme.textPrimary, + height: 1.25, + ); + + final employeeTile = _OverviewInnerTile( + label: 'Inception Employee', + icon: Icons.person_add_alt, + accentColor: ClaimsOverviewTheme.blue, + matchRowHeight: wide, + child: Text(inceptionEmployee, style: valueStyle), + ); + final livesTile = _OverviewInnerTile( + label: 'Inception Lives', + icon: Icons.favorite_border, + accentColor: ClaimsOverviewTheme.pink, + matchRowHeight: wide, + child: Text(inceptionLives, style: valueStyle), + ); + + if (!wide) { return Column( children: [ employeeTile, @@ -314,6 +316,7 @@ class ClaimsInceptionBody extends StatelessWidget { ); } return _OverviewTwoColumnRow( + equalizeHeight: true, children: [employeeTile, livesTile], ); }, @@ -348,12 +351,14 @@ class _OverviewInnerTile extends StatelessWidget { final IconData icon; final Color accentColor; final Widget child; + final bool matchRowHeight; const _OverviewInnerTile({ required this.label, required this.icon, required this.accentColor, required this.child, + this.matchRowHeight = false, }); @override @@ -362,12 +367,14 @@ class _OverviewInnerTile extends StatelessWidget { accentColor: accentColor, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: matchRowHeight ? MainAxisSize.max : MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( @@ -401,6 +408,7 @@ class _OverviewInnerTile extends StatelessWidget { ], ), ), + if (matchRowHeight) const Spacer(), Container( height: 3, decoration: BoxDecoration( @@ -419,11 +427,11 @@ class _OverviewInnerTile extends StatelessWidget { class _ClaimsExperienceDualValues extends StatelessWidget { final String primaryValue; - final String secondaryValue; + final String? secondaryValue; const _ClaimsExperienceDualValues({ required this.primaryValue, - required this.secondaryValue, + this.secondaryValue, }); @override @@ -435,25 +443,26 @@ class _ClaimsExperienceDualValues extends StatelessWidget { height: 1.2, ); - return Row( - crossAxisAlignment: CrossAxisAlignment.end, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - primaryValue, - maxLines: 1, - style: valueStyle, - ), - ), - ), - const SizedBox(width: 8), Text( - secondaryValue, - style: valueStyle.copyWith(fontSize: 16), + primaryValue, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: valueStyle, ), + if (secondaryValue != null && secondaryValue!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + secondaryValue!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: valueStyle.copyWith(fontSize: 16), + ), + ], ], ); } @@ -546,34 +555,48 @@ class ClaimsPremiumMembershipBody extends StatelessWidget { /// Two equal columns — used for TPA / Insurer side-by-side rows. class _OverviewTwoColumnRow extends StatelessWidget { final List children; + final bool equalizeHeight; - const _OverviewTwoColumnRow({required this.children}); + const _OverviewTwoColumnRow({ + required this.children, + this.equalizeHeight = false, + }); @override Widget build(BuildContext context) { assert(children.length == 2); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, + final row = Row( + crossAxisAlignment: equalizeHeight + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, children: [ Expanded(child: children[0]), const SizedBox(width: 12), Expanded(child: children[1]), ], ); + if (!equalizeHeight) return row; + return IntrinsicHeight(child: row); } } /// Three equal columns — matches Policy Information top-row alignment. class _OverviewThreeColumnRow extends StatelessWidget { final List children; + final bool equalizeHeight; - const _OverviewThreeColumnRow({required this.children}); + const _OverviewThreeColumnRow({ + required this.children, + this.equalizeHeight = false, + }); @override Widget build(BuildContext context) { assert(children.isNotEmpty && children.length <= 3); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, + final row = Row( + crossAxisAlignment: equalizeHeight + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, children: [ for (var i = 0; i < 3; i++) ...[ if (i > 0) const SizedBox(width: 12), @@ -585,6 +608,8 @@ class _OverviewThreeColumnRow extends StatelessWidget { ], ], ); + if (!equalizeHeight) return row; + return IntrinsicHeight(child: row); } } @@ -594,6 +619,7 @@ class ClaimsMetricCard extends StatelessWidget { final String label; final String value; + final String? secondaryValue; final IconData icon; final Color accentColor; final int replayToken; @@ -603,6 +629,7 @@ class ClaimsMetricCard extends StatelessWidget { super.key, required this.label, required this.value, + this.secondaryValue, required this.icon, required this.accentColor, this.replayToken = 0, @@ -673,17 +700,42 @@ class ClaimsMetricCard extends StatelessWidget { height: compact ? 40 : 48, child: Align( alignment: Alignment.centerLeft, - child: compact || !animateValue - ? Text( - value, - style: valueStyle, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) - : ClaimsAnimatedValueText( - value: value, - replayToken: replayToken, - style: valueStyle, + child: secondaryValue == null + ? (compact || !animateValue + ? Text( + value, + style: valueStyle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : ClaimsAnimatedValueText( + value: value, + replayToken: replayToken, + style: valueStyle, + )) + : Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: compact || !animateValue + ? Text( + value, + style: valueStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : ClaimsAnimatedValueText( + value: value, + replayToken: replayToken, + style: valueStyle, + ), + ), + const SizedBox(width: 8), + Text( + secondaryValue!, + style: valueStyle.copyWith(fontSize: compact ? 13 : 16), + ), + ], ), ), ), @@ -731,11 +783,15 @@ class ClaimsMetricCard extends StatelessWidget { } class ClaimsSectionCard extends StatelessWidget { + /// Min height for side-by-side overview pairs (e.g. Claims Experience / Inception). + static const double pairedSectionMinHeight = 170; + final String title; final IconData icon; final Widget child; final bool fillHeight; final double? height; + final double? minHeight; const ClaimsSectionCard({ super.key, @@ -744,6 +800,7 @@ class ClaimsSectionCard extends StatelessWidget { required this.child, this.fillHeight = false, this.height, + this.minHeight, }); @override @@ -773,12 +830,16 @@ class ClaimsSectionCard extends StatelessWidget { ), ); - final useExpandedBody = fillHeight || height != null; + final useExpandedBody = height != null; + final stretchVertically = fillHeight || height != null; final bodySlot = useExpandedBody ? Expanded(child: body) : body; return Container( width: double.infinity, height: height, + constraints: height == null && minHeight != null + ? BoxConstraints(minHeight: minHeight!) + : null, decoration: BoxDecoration( color: ClaimsOverviewTheme.cardBg, borderRadius: BorderRadius.circular(12), @@ -794,7 +855,7 @@ class ClaimsSectionCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: - useExpandedBody ? MainAxisSize.max : MainAxisSize.min, + stretchVertically ? MainAxisSize.max : MainAxisSize.min, children: [ header, bodySlot, diff --git a/lib/presentation/hrPolicyDetails.dart b/lib/presentation/hrPolicyDetails.dart index 052265d..716da55 100755 --- a/lib/presentation/hrPolicyDetails.dart +++ b/lib/presentation/hrPolicyDetails.dart @@ -17,6 +17,7 @@ import 'package:universal_html/html.dart' as html; import 'dart:typed_data'; import 'dart:io'; import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; @@ -448,6 +449,57 @@ class _HrPolicyDetailsState extends State } } + Future copyEcardLink(String? ecardLink) async { + if (ecardLink == null || ecardLink.trim().isEmpty) { + if (mounted) { + ToastHelper.showErrorToast(context, 'E-card link not available'); + } + return; + } + + if (kIsWeb) { + await html.window.navigator.clipboard?.writeText(ecardLink); + } else { + await Clipboard.setData(ClipboardData(text: ecardLink)); + } + if (mounted) { + ToastHelper.showSuccessToast(context, 'Link copied to clipboard'); + } + } + + Future sendEcardViaEmail( + dynamic empPolicyId, + dynamic clientPolicyId, + ) async { + try { + final response = await apiService.sendMailForIndividualEmployeeEcard( + empPolicyId: empPolicyId, + clientPolicyId: clientPolicyId, + token: localToken!, + ); + if (!mounted) return; + if (response['status'] == true) { + ToastHelper.showSuccessToast( + context, + response['message']?.toString() ?? 'Mail sent successfully', + ); + } else { + ToastHelper.showErrorToast( + context, + response['message']?.toString() ?? 'Failed to send mail.', + ); + } + } catch (e) { + logDebug('❌ sendEcardViaEmail error: $e'); + if (mounted) { + ToastHelper.showErrorToast( + context, + 'Failed to send mail. Please try again.', + ); + } + } + } + Future _launchURL(String url) async { final Uri uri = Uri.parse(url); // Parse the URL properly logDebug('_launchURL $uri'); @@ -530,6 +582,9 @@ class _HrPolicyDetailsState extends State } filteredData = data.toList(); + hasAnyEcardLink = filteredData.any( + (item) => item['ecard_download_link'] != null, + ); } void search(String query) { @@ -640,6 +695,173 @@ class _HrPolicyDetailsState extends State } } + dynamic _parseNumericId(dynamic value) { + if (value == null) return value; + return int.tryParse(value.toString()) ?? value; + } + + List _getInceptionExportStatus() { + switch (_preStatusFilter) { + case 'draft': + return ['draft']; + case 'enrolled': + return ['enrolled']; + default: + return ['enrolled']; + } + } + + ({String empCode, String empName}) _getInceptionSearchParams() { + final query = searchController.text.trim(); + if (query.isEmpty) { + return (empCode: '', empName: ''); + } + + final lowerQuery = query.toLowerCase(); + final codeMatch = originalData.any( + (row) => + row['emp_code']?.toString().toLowerCase().contains(lowerQuery) ?? + false, + ); + final nameMatch = originalData.any( + (row) => + row['name']?.toString().toLowerCase().contains(lowerQuery) ?? false, + ); + + if (codeMatch && !nameMatch) { + return (empCode: query, empName: ''); + } + if (nameMatch && !codeMatch) { + return (empCode: '', empName: query); + } + return (empCode: query, empName: query); + } + + Future downloadInceptionExport() async { + try { + final clientId = await tokenService.readValue('enrollmentClient_id') ?? + localClientId ?? + widget.ClientId; + final branchId = + await tokenService.readValue('enrollmentEmpClientBranchId') ?? + localClientBranchId ?? + widget.clientBranchId; + final policyId = localClientPolicyId ?? widget.ClientPoliyId; + final searchParams = _getInceptionSearchParams(); + final status = _getInceptionExportStatus(); + + logDebug( + 'downloadInceptionExport client=$clientId branch=$branchId ' + 'policies=$policyId status=$status ' + 'empCode=${searchParams.empCode} empName=${searchParams.empName}', + ); + + final response = await apiService.downloadInception( + client: _parseNumericId(clientId), + branch: _parseNumericId(branchId), + policies: _parseNumericId(policyId), + status: status, + empCode: searchParams.empCode, + empName: searchParams.empName, + token: localToken!, + ); + if (!mounted) return; + if (response['status'] == true) { + final downloadUrl = response['data']?['downloadUrl']; + if (downloadUrl != null && downloadUrl.toString().isNotEmpty) { + await _launchURL(downloadUrl.toString()); + ToastHelper.showSuccessToast( + context, + response['message']?.toString() ?? + 'Inception export generated successfully.', + ); + } else { + ToastHelper.showErrorToast(context, 'Download URL not available'); + } + } else { + ToastHelper.showErrorToast( + context, + response['message']?.toString() ?? + 'Failed to generate inception export.', + ); + } + } catch (e) { + logDebug('downloadInceptionExport error: $e'); + if (mounted) { + ToastHelper.showErrorToast( + context, + 'Failed to generate inception export. Please try again.', + ); + } + } + } + + Widget _buildExportButton() { + final buttonStyle = ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE26728), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ); + final textStyle = GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 1, + ); + + if (localTokenType == 'pre') { + return SizedBox( + width: 116, + height: 37, + child: PopupMenuButton( + offset: const Offset(0, 37), + onSelected: (value) { + if (value == 'csv') { + exportToCsv(filteredData); + } else if (value == 'inception') { + downloadInceptionExport(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'csv', + child: Text( + 'Export', + style: GoogleFonts.poppins(fontSize: 13), + ), + ), + PopupMenuItem( + value: 'inception', + child: Text( + 'Inception Export', + style: GoogleFonts.poppins(fontSize: 13), + ), + ), + ], + child: IgnorePointer( + child: ElevatedButton( + onPressed: () {}, + style: buttonStyle, + child: Text('Export', style: textStyle), + ), + ), + ), + ); + } + + return SizedBox( + width: 116, + height: 37, + child: ElevatedButton( + onPressed: () => exportToCsv(filteredData), + style: buttonStyle, + child: Text('Export', style: textStyle), + ), + ); + } + String _capitalize(String? value) { if (value == null || value.isEmpty) return ''; return value[0].toUpperCase() + value.substring(1).toLowerCase(); @@ -1446,31 +1668,7 @@ HR Team'''; const SizedBox(width: 12), /// ⬇️ Export Button - SizedBox( - width: 116, - height: 37, - child: ElevatedButton( - onPressed: () { - exportToCsv(filteredData); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE26728), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Text( - 'Export', - style: GoogleFonts.poppins( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - letterSpacing: 1, - ), - ), - ), - ), + _buildExportButton(), ], ), ]), @@ -2143,7 +2341,7 @@ HR Team'''; mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - /// --- eCard Button (Fixed Space) --- + /// --- eCard Menu (Fixed Space) --- SizedBox( width: 40, height: 40, @@ -2152,30 +2350,68 @@ HR Team'''; maintainSize: true, maintainAnimation: true, maintainState: true, - child: Tooltip( - message: 'Download e-Card', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { + child: PopupMenuButton( + tooltip: 'Ecard', + offset: const Offset(0, 40), + onSelected: (value) { + switch (value) { + case 'download': getEcardDownload( item['emp_code'], item['employee_id'], item['client_policy_id'], item['policy_no'], ); - }, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFE6F5F6), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(6), - child: Image.asset( - 'assets/credit_card.png', - fit: BoxFit.contain, + break; + case 'email': + sendEcardViaEmail( + item['id'], + item['client_policy_id'], + ); + break; + case 'copy': + copyEcardLink( + item['ecard_download_link']?.toString(), + ); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'download', + child: Text( + 'Download Ecard', + style: GoogleFonts.poppins(fontSize: 13), + ), + ), + if (isSelf) + PopupMenuItem( + value: 'email', + child: Text( + 'Send E-card via email', + style: GoogleFonts.poppins(fontSize: 13), ), ), + PopupMenuItem( + value: 'copy', + child: Text( + 'Copy link', + style: GoogleFonts.poppins(fontSize: 13), + ), + ), + ], + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFE6F5F6), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(6), + child: Image.asset( + 'assets/credit_card.png', + fit: BoxFit.contain, + ), ), ), ), diff --git a/lib/service/api_service.dart b/lib/service/api_service.dart index 717ae58..d4abbcd 100755 --- a/lib/service/api_service.dart +++ b/lib/service/api_service.dart @@ -874,6 +874,73 @@ class ApiService { return response; } + Future> sendMailForIndividualEmployeeEcard({ + required dynamic empPolicyId, + required dynamic clientPolicyId, + required String token, + }) async { + final url = Uri.parse( + '${Environment.apiUrlPost}sendMailForIndividualEmployeeEcard'); + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + final body = { + 'emp_policy_id': empPolicyId, + 'client_policy_id': clientPolicyId, + }; + final response = await http.post( + url, + headers: headers, + body: jsonEncode(body), + ); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception( + 'Failed to send e-card mail: ${response.statusCode} ${response.body}'); + } + } + + Future> downloadInception({ + required dynamic client, + required dynamic branch, + required dynamic policies, + required List status, + required String empCode, + required String empName, + required String token, + }) async { + final url = Uri.parse('${Environment.apiUrl}download_inception'); + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + final body = { + 'client': client, + 'branch': branch, + 'policies': policies, + 'status': status, + 'empCode': empCode, + 'empName': empName, + }; + final response = await http.post( + url, + headers: headers, + body: jsonEncode(body), + ); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception( + 'Failed to download inception export: ${response.statusCode} ${response.body}'); + } + } + Future> getNonEBClaimPoliciesToApi( String token, request) async { final url = Uri.parse('${Environment.apiUrlPost}api/v1/non-eb-claim/policies'); diff --git a/web/flutter_bootstrap.js b/web/flutter_bootstrap.js new file mode 100644 index 0000000..93d1290 --- /dev/null +++ b/web/flutter_bootstrap.js @@ -0,0 +1,20 @@ +{{flutter_js}} +{{flutter_build_config}} + +const loadingIndicator = document.getElementById('loading_indicator'); + +_flutter.loader.load({ + config: { + // CanvasKit is required for reliable chart capture (`toImage`) in PDF export. + renderer: 'canvaskit', + }, + onEntrypointLoaded: async function (engineInitializer) { + const appRunner = await engineInitializer.initializeEngine({ + renderer: 'canvaskit', + }); + await appRunner.runApp(); + if (loadingIndicator) { + loadingIndicator.remove(); + } + }, +}); diff --git a/web/index.html b/web/index.html index ace055e..12fddc2 100755 --- a/web/index.html +++ b/web/index.html @@ -80,47 +80,11 @@ } - - - - -
- - +