reminder and config and ecard

This commit is contained in:
Surendiran 2026-06-26 12:07:23 +05:30
parent 3abde687ca
commit 2120e997f0
9 changed files with 701 additions and 220 deletions

View File

@ -91,10 +91,10 @@ class _ClaimsLiveIndicatorState extends State<ClaimsLiveIndicator>
),
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<ClaimsLiveIndicator>
Text(
'· $createdAt',
style: GoogleFonts.poppins(
fontSize: 10,
fontSize: 14,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.green,
),
),

View File

@ -30,6 +30,8 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
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();
@ -63,6 +65,8 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
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<ClaimsOverviewDashboard>
}
}
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(() {
@ -407,6 +425,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_scrollController.dispose();
_headerFadeController.dispose();
super.dispose();
}
@ -433,23 +452,30 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
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<ClaimsOverviewDashboard>
),
TabBar(
controller: _tabController,
onTap: _scrollToSection,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelColor: ClaimsOverviewTheme.primary,
@ -745,6 +772,56 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
);
}
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),

View File

@ -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<void> _waitForPaint() async {
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 32));
for (var i = 0; i < 3; i++) {
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 48));
}
}
double _capturePixelRatio({required bool isEnrollmentTab}) {
@ -285,6 +287,7 @@ Future<Uint8List?> _captureTabPng({
required OverlayState overlay,
required Widget child,
required double fallbackHeight,
double? maxMeasureHeight,
bool isEnrollmentTab = false,
ClaimsPdfExportProgress? onProgress,
String? progressLabel,
@ -309,14 +312,19 @@ Future<Uint8List?> _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<Uint8List?> _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<void> exportClaimsOverviewChartsPdf({
overlay: overlay,
child: tabWidget,
fallbackHeight: _tabFallbackHeights[i],
maxMeasureHeight: i == 2 ? 1000.0 : null,
isEnrollmentTab: i == 6,
onProgress: onProgress,
progressLabel: captureLabel,

View File

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

View File

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

View File

@ -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<hrPolicyDetails>
}
}
Future<void> 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<void> 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<void> _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<hrPolicyDetails>
}
filteredData = data.toList();
hasAnyEcardLink = filteredData.any(
(item) => item['ecard_download_link'] != null,
);
}
void search(String query) {
@ -640,6 +695,173 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
}
}
dynamic _parseNumericId(dynamic value) {
if (value == null) return value;
return int.tryParse(value.toString()) ?? value;
}
List<String> _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<void> 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<String>(
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<String>(
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,
),
),
),
),

View File

@ -874,6 +874,73 @@ class ApiService {
return response;
}
Future<Map<String, dynamic>> 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<Map<String, dynamic>> downloadInception({
required dynamic client,
required dynamic branch,
required dynamic policies,
required List<String> 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<Map<String, dynamic>> getNonEBClaimPoliciesToApi(
String token, request) async {
final url = Uri.parse('${Environment.apiUrlPost}api/v1/non-eb-claim/policies');

20
web/flutter_bootstrap.js Normal file
View File

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

View File

@ -80,47 +80,11 @@
}
</style>
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body style="overflow:hidden">
<div id="loading_indicator" class="container overlay">
<img class="indicator" src="assets/nhance-loader.gif">
</div>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
// CanvasKit renderer is required for reliable chart capture (`toImage`)
// used by dashboard PDF export.
engineInitializer.initializeEngine({
renderer: "canvaskit"
}).then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
<script>
window.onLoad = function(){
setTimeout(function () {
var loadingIndicator = document.getElementById("loading_indicator");
if(loadingIndicator){
loadingIndicator.remove();
}
},5000);
};
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>