From 1288a67256ca0e8ebd6e1ee566f7bc9061ebb380 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Tue, 19 May 2026 12:30:28 +0530 Subject: [PATCH] saml flow --- android/app/build.gradle | 4 +- assets/images/login/microsoft.png | Bin 0 -> 200 bytes lib/config/environment.dart | 23 +- lib/logger.dart | 35 +- lib/main.dart | 126 +- lib/pages/changePassword.dart | 2 +- lib/pages/email_verify.dart | 139 +- lib/pages/enrollment/addons.dart | 1321 ++++++++- lib/pages/enrollment/empDetails.dart | 191 +- lib/pages/enrollment/empReview.dart | 524 +++- lib/pages/helpers/browser_href.dart | 7 + lib/pages/helpers/browser_href_stub.dart | 3 + lib/pages/helpers/browser_href_web.dart | 22 + lib/pages/helpers/sso_same_tab_redirect.dart | 4 + .../helpers/sso_same_tab_redirect_stub.dart | 2 + .../helpers/sso_same_tab_redirect_web.dart | 6 + lib/pages/login.dart | 2444 ++++++++++------- lib/pages/login_saml_auth.dart | 274 ++ lib/pages/login_saml_webview_screen.dart | 86 + lib/pages/postEnrollment/claims.dart | 6 +- lib/pages/postEnrollment/help.dart | 4 +- lib/pages/postEnrollment/planclaimsform.dart | 92 +- .../postEnrollment/raisedTicketList.dart | 2 +- lib/pages/postEnrollment/retailClaimForm.dart | 2 +- lib/pages/postEnrollment/tickettracklist.dart | 2 +- lib/pages/service/SessionManager.dart | 17 + lib/pages/session/SetPinBiometric.dart | 14 +- lib/pages/session/changePin.dart | 2 +- .../session/settingUpPinAndBiometric.dart | 8 +- lib/pages/setPassword.dart | 2 +- lib/pages/sso_login_return_page.dart | 62 + lib/pages/verify.dart | 10 +- pubspec.yaml | 5 +- web/index.html | 19 + 34 files changed, 4281 insertions(+), 1179 deletions(-) create mode 100644 assets/images/login/microsoft.png create mode 100644 lib/pages/helpers/browser_href.dart create mode 100644 lib/pages/helpers/browser_href_stub.dart create mode 100644 lib/pages/helpers/browser_href_web.dart create mode 100644 lib/pages/helpers/sso_same_tab_redirect.dart create mode 100644 lib/pages/helpers/sso_same_tab_redirect_stub.dart create mode 100644 lib/pages/helpers/sso_same_tab_redirect_web.dart create mode 100644 lib/pages/login_saml_auth.dart create mode 100644 lib/pages/login_saml_webview_screen.dart create mode 100644 lib/pages/sso_login_return_page.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index b17ea21..bb4c586 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,12 +19,12 @@ if (project.hasProperty('google-services.json')) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - flutterVersionCode = '58' + flutterVersionCode = '61' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - flutterVersionName = '2.0.20' + flutterVersionName = '2.0.23' } def keystoreProperties = new Properties() diff --git a/assets/images/login/microsoft.png b/assets/images/login/microsoft.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a9f7aca6a3f7702a30de22a1e9779e613f4d34 GIT binary patch literal 200 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3oVGw3ym^DWNC|Kp` z;uumf=j|0kt_A}Emy4|2?;lp`yeT>sGVh@E&Hle7&IdUDvEOE!_GbwfgPmvY5uXy=4&QY5_{oSuVpeq DateTime.now().toIso8601String(); enum LogLevel { debug, info, warning, error } class LoggerConfig { - // Logging is always disabled in non-debug builds. static bool enabled = true; - // Keep debug logs off by default to avoid local slowdown on noisy screens. + /// Logs below this level are dropped ([logDebug] when this is [LogLevel.info]). static LogLevel minLevel = LogLevel.info; // Stack parsing is expensive; keep it disabled unless specifically needed. @@ -42,8 +42,20 @@ void _log( Object? error, StackTrace? stackTrace, }) { - if (!kDebugMode || !LoggerConfig.enabled) return; - if (level.index < LoggerConfig.minLevel.index) return; + if (!LoggerConfig.enabled) return; + + // Verbose minimum: UAT (all modes), or local Dev when using a debug Flutter build. + final LogLevel effectiveMinLevel = switch (Environment.flavor) { + Flavor.uat => LogLevel.debug, + Flavor.dev when kDebugMode => LogLevel.debug, + _ => LoggerConfig.minLevel, + }; + if (level.index < effectiveMinLevel.index) return; + + // Debug lines: debug Flutter build (typical `flutter run`), or any UAT build. + if (level == LogLevel.debug && !kDebugMode && Environment.flavor != Flavor.uat) { + return; + } final inferredTag = tag ?? (LoggerConfig.includeCallerFromStack @@ -58,8 +70,21 @@ void _log( error: error, stackTrace: stackTrace, ); + + // `developer.log` is easy to miss in release/profile (IDE filters, logcat tags). + // UAT: always mirror to stdout-style logging so `adb logcat`, Xcode, and web consoles show lines. + // Debug builds: mirror too for parity with `print` during local runs. + if (Environment.flavor == Flavor.uat || kDebugMode) { + debugPrint(fullMessage); + if (Environment.flavor == Flavor.uat && (error != null || stackTrace != null)) { + debugPrint('$error\n$stackTrace'); + } + } } +/// **Dev + localhost:** shown when you use `flutter run` (debug mode); not in `flutter run --release`. +/// **UAT:** also emitted in profile/release (verbose). +/// **Prod / Prod1:** debug Flutter build only; in release/profile use [logInfo] or `print`. void logDebug(Object? message, {String? tag}) { _log(LogLevel.debug, message, tag: tag); } diff --git a/lib/main.dart b/lib/main.dart index dc9568e..a0b1a8c 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:nhance_app_pwa/pages/enrollment/addons.dart'; import 'package:nhance_app_pwa/pages/enrollment/empDetails.dart'; import 'package:nhance_app_pwa/pages/enrollment/empReview.dart'; import 'package:nhance_app_pwa/pages/login.dart'; +import 'package:nhance_app_pwa/pages/sso_login_return_page.dart'; import 'package:nhance_app_pwa/pages/postEnrollment/AddPolicyScreen.dart'; import 'package:nhance_app_pwa/pages/postEnrollment/claimprocess.dart'; import 'package:nhance_app_pwa/pages/postEnrollment/claims.dart'; @@ -98,6 +99,7 @@ Future tokenRedirectLogic( logDebug('ABCDEFGH'); const guestRoutes = [ '/login', + '/sso-login', '/verify', '/mailVerify', '/pinPage', @@ -107,21 +109,32 @@ Future tokenRedirectLogic( final hasToken = await TokenService.hasValidToken(); logDebug('hasToken : $hasToken'); final location = state.matchedLocation; + final session = SessionManager(); logDebug('Location: $location'); logDebug('hasToken: $hasToken'); - // Already logged in → prevent guest pages + if (hasToken) { + await session.restoreSession(); + } + + if (!kIsWeb && hasToken) { + final reauth = await _mobileReauthRedirectIfNeeded(location); + if (reauth != null) return reauth; + } + + // Logged in on a guest route → home (only when mobile session is unlocked). if (hasToken && guestRoutes.contains(location)) { + if (!kIsWeb && !session.isMobileAppSessionUnlocked) { + return null; + } return '/home'; } - // Not logged in → allow guest pages directly if (!hasToken && guestRoutes.contains(location)) { return null; } -// Protected route without token if (!hasToken && !guestRoutes.contains(location)) { WidgetsBinding.instance.addPostFrameCallback((_) { ToastHelper.showErrorToast( @@ -130,15 +143,56 @@ Future tokenRedirectLogic( return '/login'; } - // ✅ Restore session (decode JWT etc.) - await SessionManager().restoreSession(); + return null; +} - return null; // no redirect +/// After a full app restart (new process), require MPIN or password login before +/// protected routes. Tokens remain in secure storage. Minimizing does not lock. +Future _mobileReauthRedirectIfNeeded(String location) async { + const allowedWhileLocked = { + '/login', + '/pinPage', + '/verify', + '/mailVerify', + '/sso-login', + '/pinSettingPage', + '/changePin', + }; + final session = SessionManager(); + if (session.isMobileAppSessionUnlocked) return null; + if (allowedWhileLocked.contains(location)) return null; + + final prefs = await SharedPreferences.getInstance(); + final skip = prefs.getString('is_mpin_skipped'); + return skip == '0' ? '/pinPage' : '/login'; } +/// When not using `-t lib/config/main_uat.dart`, pass e.g. `--dart-define=APP_FLAVOR=uat` +/// so [Environment.flavor] (and logger UAT rules) match the build. +void _applyAppFlavorFromDartDefine() { + const fromDefine = String.fromEnvironment('APP_FLAVOR', defaultValue: ''); + switch (fromDefine.toLowerCase()) { + case 'uat': + Environment.flavor = Flavor.uat; + break; + case 'prod': + Environment.flavor = Flavor.prod; + break; + case 'prod1': + Environment.flavor = Flavor.prod1; + break; + case 'dev': + Environment.flavor = Flavor.dev; + break; + default: + break; + } +} + Future startApp() async { WidgetsFlutterBinding.ensureInitialized(); + _applyAppFlavorFromDartDefine(); await TokenService.clearLegacyWebAuthFromSharedPreferences(); @@ -156,8 +210,11 @@ Future startApp() async { routes: [ GoRoute( path: '/splash', builder: (context, state) => const SplashScreen()), - GoRoute(path: '/login', builder: (context, state) => login()), - + GoRoute(path: '/login', builder: (context, state) => const login()), + GoRoute( + path: '/sso-login', + builder: (context, state) => const SsoLoginReturnPage(), + ), GoRoute( path: '/mailVerify', @@ -179,57 +236,74 @@ Future startApp() async { path: '/changePin', builder: (context, state) => changePin(), ), - GoRoute(path: '/home', builder: (context, state) => Home()), - GoRoute(path: '/profile', builder: (context, state) => profile()), - GoRoute(path: '/help', builder: (context, state) => help()), GoRoute( - path: '/claimprocess', builder: (context, state) => claimprocess()), - GoRoute(path: '/wellness', builder: (context, state) => wellness()), + path: '/home', + builder: (context, state) => ChatbotHost( + child: Home(), + triggerIntroOnHome: true, + ), + ), + GoRoute( + path: '/profile', + builder: (context, state) => ChatbotHost(child: profile()), + ), + GoRoute( + path: '/help', + builder: (context, state) => ChatbotHost(child: help()), + ), + GoRoute( + path: '/claimprocess', builder: (context, state) => ChatbotHost(child: claimprocess()), + ), + GoRoute(path: '/wellness', builder: (context, state) => ChatbotHost(child:wellness()), + ), GoRoute( path: '/privacypolicy', builder: (context, state) => privacypolicy()), GoRoute(path: '/termsofuse', builder: (context, state) => termsofuse()), GoRoute( - path: '/generalExclusionsDeductibles', - builder: (context, state) => generalExclusionsDeductibles()), + path: '/generalExclusionsDeductibles', + builder: (context, state) => ChatbotHost(child:generalExclusionsDeductibles()), + ), - GoRoute(path: '/tickets', builder: (context, state) => tickets()), + GoRoute(path: '/tickets', builder: (context, state) => ChatbotHost(child:tickets()), + ), GoRoute( path: '/policies', builder: (context, state) { final arguments = state.extra as Map?; // 👈 receive here - return policies(arguments: arguments); + return ChatbotHost(child:policies(arguments: arguments)); }, ), GoRoute( path: '/claims', builder: (context, state) { final int tabIndex = state.extra as int? ?? 0; // default to 0 - return claims(initialTab: tabIndex); + return ChatbotHost(child: claims(initialTab: tabIndex)); }, ), GoRoute( path: '/planclaimsform', builder: (context, state) { final details = state.extra as Map?; - return planclaimsform(details: details); + return ChatbotHost(child: planclaimsform(details: details)); }, ), GoRoute( path: '/retailClaimForm', builder: (context, state) { final details = state.extra as Map?; - return retailClaimForm(details: details); + return ChatbotHost(child:retailClaimForm(details: details)); }, ), GoRoute( - path: '/raisedTicketHistory', - builder: (context, state) => raisedTicketHistory()), + path: '/raisedTicketHistory', + builder: (context, state) => ChatbotHost(child:raisedTicketHistory()), + ), GoRoute( path: '/tickettracklist/:ticketID', builder: (context, state) { final ticketID = state.pathParameters['ticketID']!; - return tickettracklist(ticketID: ticketID); + return ChatbotHost(child:tickettracklist(ticketID: ticketID)); }, ), // GoRoute( @@ -245,7 +319,7 @@ Future startApp() async { ), GoRoute( path: '/faqs', - builder: (context, state) => faqs(), + builder: (context, state) => ChatbotHost(child: faqs()), ), GoRoute( path: '/addOnsDetails', builder: (context, state) => addOnsDetails()), @@ -294,7 +368,7 @@ Future startApp() async { // ✅ Middleware hook redirect: (context, state) async => - await tokenRedirectLogic(context, state), + await tokenRedirectLogic(context, state), ); runApp( @@ -332,4 +406,4 @@ class _MyAppState extends State { debugShowCheckedModeBanner: false, ); } -} +} \ No newline at end of file diff --git a/lib/pages/changePassword.dart b/lib/pages/changePassword.dart index 6bfab11..ce35596 100755 --- a/lib/pages/changePassword.dart +++ b/lib/pages/changePassword.dart @@ -173,7 +173,7 @@ class _changesPasswordState extends State { setState(() { _isLoading = false; }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } diff --git a/lib/pages/email_verify.dart b/lib/pages/email_verify.dart index 94e921c..524c775 100755 --- a/lib/pages/email_verify.dart +++ b/lib/pages/email_verify.dart @@ -307,29 +307,42 @@ class _MyEmailVerifyState extends State { if (!context.mounted) return; context.go('/login'); - } else if (response.statusCode == 403) { - setState(() { - _isLoading = false; - }); - await SessionManager().clear(); - ToastHelper.showErrorToast(context, 'Session Out'); - if (!context.mounted) return; - context.go('/login'); - - } else if (response.statusCode == 451) { - setState(() { - _isLoading = false; - }); - final body = jsonDecode(response.body); - final message = body['message']; - ToastHelper.showWarningToast(context, message); } else if (response.statusCode == 429) { setState(() { _isLoading = false; }); - final body = jsonDecode(response.body); - final message = body['message']; - ToastHelper.showWarningToast(context, message); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { setState(() { _isLoading = false; @@ -340,7 +353,7 @@ class _MyEmailVerifyState extends State { final SharedPreferences prefs = await SharedPreferences.getInstance(); prefs.clear(); logDebug('Error: $e'); - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); // Show a Snackbar if there's an error while verifying OTP logDebug('Failed to verify OTP. Please try again.'); } finally { @@ -630,12 +643,48 @@ class _MyEmailVerifyState extends State { ToastHelper.showErrorToast(context, message); logDebug('Invalid mobile number'); } + } else if (response.statusCode == 429) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -689,10 +738,12 @@ class _MyEmailVerifyState extends State { if (_postToken != null && _postToken.isNotEmpty) { ToastHelper.showSuccessToast(context, 'Successfully Logged In'); + SessionManager().unlockMobileAppSession(); context.replace('/home'); // context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.replace('/empDetails'); // context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); @@ -716,12 +767,48 @@ class _MyEmailVerifyState extends State { context.go('/pinSettingPage'); // Navigator.pushReplacementNamed(context, 'pinSettingPage'); } + } else if (response.statusCode == 429) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify pin number'); } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -760,11 +847,11 @@ class _MyEmailVerifyState extends State { ); } } else { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify pin number'); } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } diff --git a/lib/pages/enrollment/addons.dart b/lib/pages/enrollment/addons.dart index 1ce11dd..9509063 100755 --- a/lib/pages/enrollment/addons.dart +++ b/lib/pages/enrollment/addons.dart @@ -33,6 +33,7 @@ class _addOnsDetailsState extends State { List typeOrder = [ 'GPA', 'GMC', + 'OPD', 'GMC - Parents', 'GMC - Topup', 'GMC - Topup(Parents)' @@ -46,6 +47,7 @@ class _addOnsDetailsState extends State { // bool isPremiumSummery = false; bool _topUpSiSwitcher = false; bool _topUpParentSiSwitcher = false; + bool _opdSwitcher = false; dynamic _token; dynamic enrollmentEmpCodeString; dynamic enrollmentEmpPrimaryId; @@ -87,6 +89,22 @@ class _addOnsDetailsState extends State { dynamic topUpSiPolicies = []; dynamic topUpTypeName; int topUpOpenForEnrollment = 0; + dynamic opdPolicies = []; + dynamic opdClientPolicyId; + dynamic opdSlabRates; + dynamic opdFamilyFloater; + dynamic opdPolicyName; + dynamic opdPolicyType; + dynamic opdMappedFamilyFloatersSi; + dynamic opdECardDownload; + int opdOpenForEnrollment = 0; + dynamic opdTypeName; + dynamic opdSiValue; + dynamic opdSiSelectedSI; + dynamic opdSiPremiumValue; + dynamic opdSiPremiumGst; + dynamic opdSiTotalAmt; + bool opdIsPremiumSummary = false; dynamic topUpSiParentPolicies = []; dynamic topUpParentClientPolicyId; dynamic topUpParentSlabRates; @@ -231,6 +249,7 @@ class _addOnsDetailsState extends State { getClientLogoAndDetails(); } getGmcSiTopUp(); + getGmcOPD(); getGmcSiParentTopUp(); getGmcDependentAddOns(); fetchRelationshipList(); @@ -240,10 +259,48 @@ class _addOnsDetailsState extends State { } bool isPremiumEnabled(dynamic value) { - final intValue = int.tryParse(value?.toString() ?? '0') ?? 0; + if (value == null) return false; + if (value is bool) return value; + if (value is num) return value.toInt() != 0; + final normalized = value.toString().trim().toLowerCase(); + if (normalized == 'true' || normalized == 'yes') return true; + final intValue = int.tryParse(normalized) ?? 0; return intValue != 0; } + bool shouldShowPremiumSummary({ + required bool isPremiumSummary, + dynamic premiumValue, + dynamic gstValue, + dynamic totalValue, + }) { + return isPremiumSummary || + premiumValue != null || + gstValue != null || + totalValue != null; + } + + List _extractUniqueSiValues(dynamic slabRates) { + if (slabRates is! List) return []; + final seen = {}; + final values = []; + for (final addOn in slabRates) { + if (addOn is Map && addOn['si'] != null) { + final si = addOn['si'].toString().trim(); + if (si.isNotEmpty && seen.add(si)) { + values.add(si); + } + } + } + return values; + } + + String? _safeSelectedSi(String? selected, dynamic slabRates) { + if (selected == null) return null; + final options = _extractUniqueSiValues(slabRates); + return options.contains(selected) ? selected : null; + } + // Future _loadToken() async { // final SharedPreferences prefs = await SharedPreferences.getInstance(); // final String? token = prefs.getString('enrollToken'); @@ -388,7 +445,7 @@ class _addOnsDetailsState extends State { gpaDataIsEmpty = 0; }); // Handle other status codes - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); logDebug('Request failed with status: ${response['code']}'); } } catch (e) { @@ -532,13 +589,11 @@ class _addOnsDetailsState extends State { if (topUpSiSelectedSI != null) { setState(() { _topUpSiSwitcher = true; - topUpSiPremiumValue = (topUpSiPolicies['gmc_si_topup'] - ['family_floaters_of_only_si_premium_value'] as double) - .toInt(); + topUpSiPremiumValue = toDoubleSafe(topUpSiPolicies['gmc_si_topup'] + ['family_floaters_of_only_si_premium_value']).toInt(); - topUpSiPremiumGst = (topUpSiPolicies['gmc_si_topup'] - ['family_floaters_of_only_si_gst_value'] as double) - .toInt(); + topUpSiPremiumGst = toDoubleSafe(topUpSiPolicies['gmc_si_topup'] + ['family_floaters_of_only_si_gst_value']).toInt(); topUpSiTotalAmt = topUpSiPremiumValue + topUpSiPremiumGst; @@ -568,6 +623,128 @@ class _addOnsDetailsState extends State { } } + Future getGmcOPD() async { + try { + if (enrollmentClient_id == null || + enrollmentEmpCodeString == null || + enrollmentEmpClientBranchId == null) { + return; + } + final response = await apiService.getGmcSiTopUpToApi( + enrollmentClient_id!, + enrollmentEmpCodeString!, + 'GMC-OPD', + enrollmentEmpClientBranchId!); + if (response['status'] == 'success') { + if (response.containsKey('data')) { + setState(() { + opdPolicies = response['data']; + }); + if (opdPolicies.isNotEmpty) { + // setState(() { + opdClientPolicyId = + await opdPolicies['gmc_opd']['client_policy_id']; + + opdSlabRates = await opdPolicies['gmc_opd']['SlabRates']; + + opdPolicyName = + await opdPolicies['gmc_opd']['policy_name']; + + opdPolicyType = await opdPolicies['gmc_opd']['type']; + + opdFamilyFloater = await opdPolicies['gmc_opd'] + ['policy_terms']['family_floater']; + + opdMappedFamilyFloatersSi = await opdPolicies['gmc_opd'] + ['family_floaters_of_only_si_array']; + + opdECardDownload = + opdPolicies['gmc_opd']['eCardDownload']; + final dynamic openForEnrollmentRaw = + opdPolicies['gmc_opd']['OpenForEnrollment']; + if (openForEnrollmentRaw is int) { + opdOpenForEnrollment = openForEnrollmentRaw; + } else { + opdOpenForEnrollment = + int.tryParse(openForEnrollmentRaw?.toString() ?? '0') ?? 0; + } + + opdTypeName = opdPolicies['gmc_opd']['type']; + final dynamic opdDisclaimer = opdPolicies['gmc_opd']['disclaimer']; + + opdSiValue = await opdPolicies['gmc_opd'] + ['family_floaters_of_only_si_value']; + opdSiSelectedSI = + opdSiValue != 0 ? opdSiValue.toString() : null; + if (opdSiSelectedSI != null) { + setState(() { + _opdSwitcher = true; + opdSiPremiumValue = toDoubleSafe(opdPolicies['gmc_opd'] + ['family_floaters_of_only_si_premium_value']).toInt(); + + opdSiPremiumGst = toDoubleSafe(opdPolicies['gmc_opd'] + ['family_floaters_of_only_si_gst_value']).toInt(); + + opdSiTotalAmt = opdSiPremiumValue + opdSiPremiumGst; + + setState(() { + opdIsPremiumSummary = isPremiumEnabled(opdPolicies['gmc_opd']?['is_premium_summery']); + }); + + + // opdSiTotalAmt = (opdSum as double).toInt(); + }); + } + + if (opdOpenForEnrollment == 1 && + opdDisclaimer != null && + opdDisclaimer.toString().isNotEmpty) { + List> newEntries = []; + + if (opdDisclaimer is String) { + newEntries.add({ + 'id': allDisclaimer.length + 1, + 'text': opdDisclaimer, + 'checked': false, + 'type': opdTypeName + }); + } else if (opdDisclaimer is List) { + newEntries.addAll( + List>.from( + opdDisclaimer.map((text) => { + 'id': allDisclaimer.length + newEntries.length + 1, + 'text': text, + 'checked': false, + 'type': opdTypeName + }), + ), + ); + } else { + logDebug('Unexpected OPD disclaimer type'); + } + + newEntries.forEach((entry) { + if (!allDisclaimer.any((item) => item['text'] == entry['text'])) { + allDisclaimer.add(entry); + } + }); + _sortDisclaimerByTypeOrder(); + } + // }); + } else { + logDebug('No data found in the response'); + } + } else { + logDebug('API request failed with status: ${response['status']}'); + } + } else { + logDebug('Request failed with status: ${response['code']}'); + } + } catch (e) { + logDebug('Exception occurred: $e'); + } + } + Future getGmcSiParentTopUp() async { try { if (enrollmentClient_id == null || @@ -611,10 +788,11 @@ class _addOnsDetailsState extends State { topUpParentECardDownload = topUpSiParentPolicies['gmc_si_parent_topup']['eCardDownload']; - String intOpenForEnrollment = + final dynamic parentOpenForEnrollmentRaw = topUpSiParentPolicies['gmc_si_parent_topup'] ['OpenForEnrollment']; - topUpParentOpenForEnrollment = int.parse(intOpenForEnrollment); + topUpParentOpenForEnrollment = + int.tryParse(parentOpenForEnrollmentRaw?.toString() ?? '0') ?? 0; topUpParentTypeName = topUpSiParentPolicies['gmc_si_parent_topup']['type']; @@ -627,16 +805,13 @@ class _addOnsDetailsState extends State { if (topUpParentSiSelectedSI != null) { setState(() { _topUpParentSiSwitcher = true; - topUpParentSiPremiumValue = - (topUpSiParentPolicies['gmc_si_parent_topup'] - ['family_floaters_of_only_si_premium_value'] - as double) - .toInt(); + topUpParentSiPremiumValue = toDoubleSafe( + topUpSiParentPolicies['gmc_si_parent_topup'] + ['family_floaters_of_only_si_premium_value']).toInt(); - topUpParentSiPremiumGst = - (topUpSiParentPolicies['gmc_si_parent_topup'] - ['family_floaters_of_only_si_gst_value'] as double) - .toInt(); + topUpParentSiPremiumGst = toDoubleSafe( + topUpSiParentPolicies['gmc_si_parent_topup'] + ['family_floaters_of_only_si_gst_value']).toInt(); topUpParentSiTotalAmt = topUpParentSiPremiumValue + topUpParentSiPremiumGst; @@ -711,12 +886,12 @@ class _addOnsDetailsState extends State { addOnsDependentPolicies['gmc_dependent_addon'] ['family_floaters_of_dependent_and_si_array']; logDebug('6 $addOnsDependentMappedFamilyFloatersDependent'); - String intOpenForEnrollment = + final dynamic dependentOpenForEnrollmentRaw = addOnsDependentPolicies['gmc_dependent_addon'] ['OpenForEnrollment']; - logDebug('7 $intOpenForEnrollment'); + logDebug('7 $dependentOpenForEnrollmentRaw'); addOnsDependentOpenForEnrollment = - int.parse(intOpenForEnrollment); + int.tryParse(dependentOpenForEnrollmentRaw?.toString() ?? '0') ?? 0; logDebug('8 $addOnsDependentOpenForEnrollment'); addOnsdependentValue = addOnsDependentPolicies['gmc_dependent_addon'] @@ -746,18 +921,14 @@ class _addOnsDetailsState extends State { if (addOnsSelectedDependent != null) { _addOnsDependentSwitcher = true; } - addOnDependentPremiumValue = (addOnsDependentPolicies[ + addOnDependentPremiumValue = toDoubleSafe(addOnsDependentPolicies[ 'gmc_dependent_addon'] - ['family_floaters_of_dependent_and_si_premium_value'] - as double) - .toInt(); + ['family_floaters_of_dependent_and_si_premium_value']).toInt(); ; - addOnsDependentPremiumGst = - (addOnsDependentPolicies['gmc_dependent_addon'] - ['family_floaters_of_dependent_and_gst_value'] - as double) - .toInt(); + addOnsDependentPremiumGst = toDoubleSafe( + addOnsDependentPolicies['gmc_dependent_addon'] + ['family_floaters_of_dependent_and_gst_value']).toInt(); addOnsDependentTotalAmt = addOnDependentPremiumValue + addOnsDependentPremiumGst; @@ -905,6 +1076,7 @@ class _addOnsDetailsState extends State { getGmcEmpPolicyDetails(enrollmentEmpPrimaryId); fetchRelationshipList(); getGmcSiTopUp(); + getGmcOPD(); getGmcSiParentTopUp(); getGmcDependentAddOns(); ToastHelper.showSuccessToast(context, 'Item deleted successfully'); @@ -954,6 +1126,7 @@ class _addOnsDetailsState extends State { getGmcEmpPolicyDetails(enrollmentEmpPrimaryId); fetchRelationshipList(); getGmcSiTopUp(); + getGmcOPD(); getGmcSiParentTopUp(); getGmcDependentAddOns(); ToastHelper.showSuccessToast(context, 'Saved Successfully...'); @@ -1020,7 +1193,7 @@ class _addOnsDetailsState extends State { _memberNameController.clear(); _dobController.clear(); } else { - ToastHelper.showErrorToast(context, 'Failed to save'); + // ToastHelper.showErrorToast(context, 'Failed to save'); } } catch (error) { logDebug('Error saving family members'); @@ -1149,6 +1322,51 @@ class _addOnsDetailsState extends State { } } + void sendAddonsGmcOPDToAPI() async { + if (_opdSwitcher == true) { + if (opdSiSelectedSI == null || opdSiSelectedSI.isEmpty) { + return; + } else { + try { + dynamic getTrueObjects = opdMappedFamilyFloatersSi + .where((element) => element['is_value_exist'] == true) + .toList(); + + List> getTrueDataObjects = getTrueObjects + .map>( + (element) => element['data'] as Map) + .toList(); + + List> selectedValues = []; + if (getTrueDataObjects != null || getTrueDataObjects.isNotEmpty) { + for (var floater in getTrueDataObjects) { + String employee_id = floater['employee_id']; + String client_policy_id = floater['client_policy_id'].toString(); + + Map floaterData = { + 'basic_cover_si': opdSiSelectedSI, + 'client_policy_id': client_policy_id, + 'employee_id': employee_id, + 'client_id': enrollmentClient_id, + 'emp_code': enrollmentEmpCodeString, + 'client_branch_id': enrollmentEmpClientBranchId, + }; + + selectedValues.add(floaterData); + } + } + + final response = await apiService.sendAddonsGmcSiToAPI(selectedValues); + if (response['status'] != 'success') { + logDebug('Failed to Save OPD...'); + } + } catch (error) { + logDebug('Error saving OPD family members'); + } + } + } + } + Future removeAddonsGmcDependentToAPI() async { try { if (enrollmentEmpCodeString == null || @@ -1208,6 +1426,21 @@ class _addOnsDetailsState extends State { } } + Future removeAddonsGmcOPDToAPI() async { + try { + if (enrollmentEmpCodeString == null || opdClientPolicyId == null) { + return; + } + final response = await apiService.removeAddonsGmcSiToAPI( + enrollmentEmpCodeString!, opdClientPolicyId!); + if (response['status'] != 'success') { + logDebug('Request failed with status: ${response['code']}'); + } + } catch (e) { + logDebug('Exception occurred: $e'); + } + } + void topUpSiCalculation(choosedSiValue) async { try { var calculation = { @@ -1231,7 +1464,7 @@ class _addOnsDetailsState extends State { if (topUpEmpArray != null) { double topUpRataPremimumSum = 0; for (var item in topUpEmpArray) { - var topUpRataPremimum = item['policy_details']['rata_premimum']; + var topUpRataPremimum = item['policy_details']['rata_premimum']; if (topUpRataPremimum != null) { topUpRataPremimumSum += topUpRataPremimum; } @@ -1244,14 +1477,19 @@ class _addOnsDetailsState extends State { topUpGstSum += topUpGst; } } + setState(() { - topUpSiPremiumValue = (topUpRataPremimumSum as double).toInt(); - topUpSiPremiumGst = (topUpGstSum as double).toInt(); + topUpSiPremiumValue = topUpRataPremimumSum.toInt(); + topUpSiPremiumGst = topUpGstSum.toInt(); topUpSiTotalAmt = topUpSiPremiumValue + topUpSiPremiumGst; + print('topUpSiPremiumValue $topUpSiPremiumValue'); + print('topUpSiPremiumGst $topUpSiPremiumGst'); print('topUpSiTotalAmt $topUpSiTotalAmt'); + topUpIsPremiumSummary = true; + // topUpSiTotalAmt = (topUpSum as double).toInt(); }); sendAddonsGmcSiToAPI(); @@ -1324,6 +1562,70 @@ class _addOnsDetailsState extends State { } } + void opdSiCalculation(choosedSiValue) async { + try { + var calculation = { + 'client_policy_id': opdClientPolicyId, + 'emp_code': enrollmentEmpCodeString, + 'si': choosedSiValue, + 'client_branch_id': enrollmentEmpClientBranchId + }; + + final response = await apiService.topUpSiCalculationToAPI(calculation); + if (response['status'] == 'success') { + List? opdEmpArray; + final data = response['data']; + if (data is List) { + for (var item in data) { + if (item is Map) { + final targetKey = enrollmentEmpCodeString?.toString(); + if (targetKey != null && item.containsKey(targetKey)) { + opdEmpArray = item[targetKey] as List?; + break; + } + // Fallback: take first entry when key name differs from local value. + if (item.isNotEmpty && opdEmpArray == null) { + final firstValue = item.values.first; + if (firstValue is List) { + opdEmpArray = firstValue.cast(); + } + } + } + } + } + + if (opdEmpArray != null) { + double opdRataPremimumSum = 0; + for (var item in opdEmpArray) { + final opdRataPremimum = toDoubleSafe( + item['policy_details']?['rata_premimum']); + if (opdRataPremimum != null) { + opdRataPremimumSum += opdRataPremimum; + } + } + + double opdGstSum = 0; + for (var item in opdEmpArray) { + final opdGst = toDoubleSafe(item['policy_details']?['gst']); + if (opdGst != null) { + opdGstSum += opdGst; + } + } + setState(() { + opdSiPremiumValue = opdRataPremimumSum.toInt(); + opdSiPremiumGst = opdGstSum.toInt(); + opdSiTotalAmt = opdSiPremiumValue + opdSiPremiumGst; + print('opdSiPremiumValue $opdSiTotalAmt'); + opdIsPremiumSummary = true; + }); + sendAddonsGmcOPDToAPI(); + } + } + } catch (error) { + logDebug('Error calculating OPD premium'); + } + } + void addOnsDependentCalculation(choosedDependentValue) async { try { var calculation = { @@ -1488,6 +1790,18 @@ class _addOnsDetailsState extends State { await prefs.setString('dependentData', jsonEncode(dependentData)); } + void checkOPDTopUp() async { + List> opdData = [ + { + 'opdSiPremiumValue': opdSiPremiumValue, + 'opdSiPremiumGst': opdSiPremiumGst, + 'opdSiTotalAmt': opdSiTotalAmt, + } + ]; + SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString('opdData', jsonEncode(opdData)); + } + void _showSuccessDialog() { setState(() { isEnrollCompletedStatus = true; @@ -1549,7 +1863,7 @@ class _addOnsDetailsState extends State { color: Colors.red, ), ), - content: Text('Something went wrong. Enrollemnt not completed'), + content: Text('Unable to process. Please try again later. Enrollemnt not completed'), actions: [ TextButton( child: Text( @@ -2143,7 +2457,8 @@ class _addOnsDetailsState extends State { ), SizedBox(height: 16), if (addOnsDependentMappedFamilyFloatersDependent != null || - topUpMappedFamilyFloatersSi != null) + topUpMappedFamilyFloatersSi != null || + opdMappedFamilyFloatersSi != null) Card( elevation: 0, color: Colors.white, @@ -2329,7 +2644,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -2442,7 +2757,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -2496,7 +2811,13 @@ class _addOnsDetailsState extends State { if (_addOnsDependentSwitcher) SizedBox(height: 20), if (Responsive.isDesktop(context) && - _addOnsDependentSwitcher && addOnsDependentIsPremiumSummary) + _addOnsDependentSwitcher && + shouldShowPremiumSummary( + isPremiumSummary: addOnsDependentIsPremiumSummary, + premiumValue: addOnDependentPremiumValue, + gstValue: addOnsDependentPremiumGst, + totalValue: addOnsDependentTotalAmt, + )) Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -2621,7 +2942,13 @@ class _addOnsDetailsState extends State { ], ), if (!Responsive.isDesktop(context) && - _addOnsDependentSwitcher == true && addOnsDependentIsPremiumSummary) + _addOnsDependentSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: addOnsDependentIsPremiumSummary, + premiumValue: addOnDependentPremiumValue, + gstValue: addOnsDependentPremiumGst, + totalValue: addOnsDependentTotalAmt, + )) Container( child: Column( crossAxisAlignment: @@ -2956,7 +3283,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -3067,7 +3394,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -3138,7 +3465,13 @@ class _addOnsDetailsState extends State { if (_topUpSiSwitcher == true) SizedBox(height: 20), if (Responsive.isDesktop(context) && - _topUpSiSwitcher == true && topUpIsPremiumSummary) + _topUpSiSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: topUpIsPremiumSummary, + premiumValue: topUpSiPremiumValue, + gstValue: topUpSiPremiumGst, + totalValue: topUpSiTotalAmt, + )) Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -3257,7 +3590,13 @@ class _addOnsDetailsState extends State { ], ), if (!Responsive.isDesktop(context) && - _topUpSiSwitcher == true && topUpIsPremiumSummary) + _topUpSiSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: topUpIsPremiumSummary, + premiumValue: topUpSiPremiumValue, + gstValue: topUpSiPremiumGst, + totalValue: topUpSiTotalAmt, + )) Container( child: Column( crossAxisAlignment: @@ -3460,6 +3799,655 @@ class _addOnsDetailsState extends State { ), ), SizedBox(height: 15), + if (opdMappedFamilyFloatersSi != null) + Container( + decoration: BoxDecoration( + color: Color( + 0xFFDDF3FF), // Set background color for the container + borderRadius: BorderRadius.circular( + 5), // Set border radius for the container + ), + padding: Responsive.isDesktop(context) + ? EdgeInsets.all(25) + : EdgeInsets.all(10), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: + Responsive.isDesktop(context) + ? 6 + : 8, + child: Container( + alignment: + Alignment.centerLeft, + child: Column( + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + Responsive.isDesktop( + context) + ? opdPolicyName + : opdPolicyType, + textAlign: + TextAlign.left, + style: + GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 20 + : 16, + fontWeight: + FontWeight.w600, + ), + ), + Text( + '', + textAlign: + TextAlign.left, + style: + GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 20 + : 16, + fontWeight: + FontWeight.w600, + ), + ), + ], + ))), + if (Responsive.isDesktop(context) && + _opdSwitcher == true) + Expanded( + flex: 3, + child: Container( + width: 50, + alignment: Alignment.center, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + IgnorePointer( + ignoring: (opdOpenForEnrollment == + 0 || + (opdOpenForEnrollment != + 0 && + _hasValidECardDownload( + opdECardDownload))), + child: + DropdownButtonFormField< + String>( + onChanged: (value) { + setState(() { + opdSiSelectedSI = + value; + }); + opdSiCalculation( + value); + // calculationForopdSiSumValue( + // 'Edit'); + }, + decoration: + InputDecoration( + border: + OutlineInputBorder(), + hintText: + 'Sum Insured', + labelText: + 'Sum Insured', + contentPadding: + EdgeInsets.symmetric( + vertical: + 5, + horizontal: + 5), + ), + value: + opdSiSelectedSI, + items: (opdSlabRates ?? + []) + .map< + DropdownMenuItem< + String>>( + (addOn) { + return DropdownMenuItem< + String>( + value: addOn[ + 'si'] + .toString(), + child: Text( + '₹ ${formatNullableAmount(addOn['si'])}'), + ); + }).toList(), + ), + ) + ], + ))), + Expanded( + flex: 3, + child: Container( + alignment: + Alignment.centerRight, + child: Column( + mainAxisAlignment: + MainAxisAlignment.end, + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Switch( + value: _opdSwitcher, + onChanged: (opdOpenForEnrollment == + 0 || + (opdOpenForEnrollment != + 0 && + _hasValidECardDownload( + opdECardDownload))) + ? null + : (value) { + setState(() { + _opdSwitcher = + value; + // Add your logic here based on the toggle state + }); + }, + activeColor: Colors + .green, // Color when the switch is ON + inactiveTrackColor: Colors + .grey, // Color of the switch track when OFF + ), + ], + ))), + ], + ), + if (_opdSwitcher == true) + SizedBox(height: 10), + if (!Responsive.isDesktop(context) && + _opdSwitcher == true) + Row( + children: [ + Expanded( + flex: 12, + child: Container( + width: 50, + alignment: Alignment.center, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + IgnorePointer( + ignoring: (opdOpenForEnrollment == + 0 || + (opdOpenForEnrollment != + 0 && + _hasValidECardDownload( + opdECardDownload))), + child: + DropdownButtonFormField< + String>( + onChanged: (value) { + setState(() { + opdSiSelectedSI = + value; + }); + opdSiCalculation( + value); + // calculationForTopUpSiSumValue( + // 'Edit'); + }, + decoration: + InputDecoration( + border: + OutlineInputBorder(), + hintText: + 'Sum Insured', + labelText: + 'Sum Insured', + contentPadding: + EdgeInsets.symmetric( + vertical: + 5, + horizontal: + 5), + ), + value: + opdSiSelectedSI, + items: (opdSlabRates ?? + []) + .map< + DropdownMenuItem< + String>>( + (addOn) { + return DropdownMenuItem< + String>( + value: addOn[ + 'si'] + .toString(), + child: Text( + '₹ ${formatNullableAmount(addOn['si'])}'), + ); + }).toList(), + ), + ) + ], + ))), + ], + ), + if (_opdSwitcher == true) + SizedBox(height: 10), + if (_opdSwitcher == + true) // Add space between rows + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: 12, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: + opdMappedFamilyFloatersSi + .where((item) => + item[ + 'is_value_exist'] == + true) + .map((item) { + Map data = + item['data']; + String formattedDate = data['dob'] ?? ''; + return Row( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Icon(Icons.arrow_right, + color: Color( + 0xFFE26728)), // Arrow icon + SizedBox( + width: Responsive + .isDesktop( + context) + ? 10 + : 5), // Space between icon and text + Expanded( + child: Text( + '${data['name']} ~ ${data['relationship']} ~ DOB : $formattedDate', + style: GoogleFonts + .poppins( + fontSize: Responsive + .isDesktop( + context) + ? 18 + : 14, + color: Color( + 0xFF232526), + ), + ), + ), + ], + ); + }).toList(), + ), + ) + ], + ), + if (_opdSwitcher == true) + SizedBox(height: 20), + if (Responsive.isDesktop(context) && + _opdSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: opdIsPremiumSummary, + premiumValue: opdSiPremiumValue, + gstValue: opdSiPremiumGst, + totalValue: opdSiTotalAmt, + )) + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'Additional Premium', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight.w500, + ), + ), + Text( + opdSiPremiumValue != + null + ? '₹ $opdSiPremiumValue/Year' + : 'N/A', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + Expanded( + flex: 2, + child: Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'GST', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight.w500, + ), + ), + Text( + opdSiPremiumGst != + null + ? '₹ $opdSiPremiumGst/-' + : 'N/A', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + Expanded( + flex: 5, + child: Container( + alignment: Alignment.center, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'Total Payable', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight.w500, + ), + ), + Text( + opdSiTotalAmt != + null + ? '₹ $opdSiTotalAmt/-' + : 'N/A', + textAlign: + TextAlign.start, + style: GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + ], + ), + if (!Responsive.isDesktop(context) && + _opdSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: opdIsPremiumSummary, + premiumValue: opdSiPremiumValue, + gstValue: opdSiPremiumGst, + totalValue: opdSiTotalAmt, + )) + Container( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: Container( + alignment: Alignment + .centerLeft, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'Additional Premium', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight + .w500, + ), + ), + ], + ))), + Expanded( + flex: 6, + child: Container( + alignment: Alignment + .centerRight, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + opdSiPremiumValue != + null + ? '₹ $opdSiPremiumValue/Year' + : 'N/A', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + ], + ), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: Container( + alignment: Alignment + .centerLeft, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'GST', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight + .w500, + ), + ), + ], + ))), + Expanded( + flex: 6, + child: Container( + alignment: Alignment + .centerRight, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + opdSiPremiumGst != + null + ? '₹ $opdSiPremiumGst/-' + : 'N/A', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + ], + ), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: Container( + alignment: Alignment + .centerLeft, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + 'Total Payable', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 16 : 13, + fontWeight: + FontWeight + .w500, + ), + ), + ], + ))), + Expanded( + flex: 5, + child: Container( + alignment: Alignment + .centerRight, + child: Column( + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .center, + children: [ + Text( + opdSiTotalAmt != + null + ? '₹ $opdSiTotalAmt/-' + : 'N/A', + textAlign: + TextAlign + .start, + style: + GoogleFonts + .poppins( + fontSize: Responsive.isDesktop(context) ? 14 : 12, + ), + ), + ], + ))), + ], + ), + ])) + ], + ), + ), + SizedBox(height: (opdPolicyName != null || opdPolicyType != null) ? 15 : 0), if (topUpParentMappedFamilyFloatersSi != null) Container( decoration: BoxDecoration( @@ -3591,7 +4579,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -3703,7 +4691,7 @@ class _addOnsDetailsState extends State { 'si'] .toString(), child: Text( - '₹ ${addOn['si']}'), + '₹ ${formatNullableAmount(addOn['si'])}'), ); }).toList(), ), @@ -3774,7 +4762,13 @@ class _addOnsDetailsState extends State { if (_topUpParentSiSwitcher == true) SizedBox(height: 20), if (Responsive.isDesktop(context) && - _topUpParentSiSwitcher == true && topUpParentIsPremiumSummary) + _topUpParentSiSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: topUpParentIsPremiumSummary, + premiumValue: topUpParentSiPremiumValue, + gstValue: topUpParentSiPremiumGst, + totalValue: topUpParentSiTotalAmt, + )) Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -3893,7 +4887,13 @@ class _addOnsDetailsState extends State { ], ), if (!Responsive.isDesktop(context) && - _topUpParentSiSwitcher == true && topUpParentIsPremiumSummary) + _topUpParentSiSwitcher == true && + shouldShowPremiumSummary( + isPremiumSummary: topUpParentIsPremiumSummary, + premiumValue: topUpParentSiPremiumValue, + gstValue: topUpParentSiPremiumGst, + totalValue: topUpParentSiTotalAmt, + )) Container( child: Column( crossAxisAlignment: @@ -4101,7 +5101,7 @@ class _addOnsDetailsState extends State { SizedBox(height: 16), if (addOnsDependentMappedFamilyFloatersDependent == null && topUpMappedFamilyFloatersSi == null && - topUpParentMappedFamilyFloatersSi == null) + topUpParentMappedFamilyFloatersSi == null && opdMappedFamilyFloatersSi == null) Card( elevation: 0, color: Colors.white, @@ -4304,7 +5304,7 @@ class _addOnsDetailsState extends State { CrossAxisAlignment.end, children: [ Text( - '₹${allTotalSum()}/Year', + '₹${formatAmount(allTotalSum())}/Year', textAlign: TextAlign.right, style: GoogleFonts.poppins( fontSize: @@ -4520,25 +5520,34 @@ class _addOnsDetailsState extends State { null || topUpMappedFamilyFloatersSi != null || + opdMappedFamilyFloatersSi != + null || topUpParentMappedFamilyFloatersSi != null - ? () { + ? () async { if (_topUpSiSwitcher == false) { - removeAddonsGmcSiToAPI(); + await removeAddonsGmcSiToAPI(); } else { checkSiTopUp(); } + if (_opdSwitcher == false) { + await removeAddonsGmcOPDToAPI(); + } else { + checkOPDTopUp(); + sendAddonsGmcOPDToAPI(); + } + if (_topUpParentSiSwitcher == false) { - removeAddonsGmcParentSiToAPI(); + await removeAddonsGmcParentSiToAPI(); } else { checkSiParentTopUp(); } if (_addOnsDependentSwitcher == false) { - removeAddonsGmcDependentToAPI(); + await removeAddonsGmcDependentToAPI(); } else { checkDependentTopUp(); sendAddonsGmcDependentToAPI(); @@ -4689,6 +5698,181 @@ class _addOnsDetailsState extends State { logDebug("Normalized relationship list: $relationshipObjects"); + Future showEditConfirmationDialog( + BuildContext dialogContext, + Map oldData, + Map newData, + bool relationshipEnabled, + ) async { + final oldRelationship = (oldData["relationship"] ?? "").toString().trim(); + final oldName = (oldData["name"] ?? "").toString().trim(); + final oldDob = (oldData["dob"] ?? "").toString().trim(); + + final newRelationship = (newData["relationship"] ?? "").toString().trim(); + final newName = (newData["memberName"] ?? "").toString().trim(); + final newDob = (newData["dateOfBirth"] ?? "").toString().trim(); + + final List> changedFields = []; + if (oldRelationship != newRelationship) { + changedFields.add({ + "label": "Relationship", + "old": oldRelationship, + "new": newRelationship, + }); + } + if (oldName != newName) { + changedFields.add({ + "label": "Member Name", + "old": oldName, + "new": newName, + }); + } + if (oldDob != newDob) { + changedFields.add({ + "label": "Date of Birth", + "old": oldDob, + "new": newDob, + }); + } + + if (changedFields.isEmpty) { + ToastHelper.showWarningToast( + dialogContext, "No changes detected to save"); + return false; + } + + final List> reviewFields = []; + if (relationshipEnabled || oldRelationship != newRelationship) { + reviewFields.add({ + "label": "Relationship", + "old": oldRelationship, + "new": newRelationship, + }); + } + reviewFields.add({ + "label": "Member Name", + "old": oldName, + "new": newName, + }); + reviewFields.add({ + "label": "Date of Birth", + "old": oldDob, + "new": newDob, + }); + + final bool? confirmed = await showDialog( + context: dialogContext, + barrierDismissible: false, + builder: (confirmContext) { + return AlertDialog( + title: Text( + "Confirm Changes", + style: GoogleFonts.poppins(fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Please review old and new values before saving:", + style: GoogleFonts.poppins(fontSize: 13), + ), + SizedBox(height: 12), + ...reviewFields.map((field) { + final oldValue = (field["old"] ?? '').trim().isEmpty + ? '-' + : (field["old"] ?? ''); + final newValue = (field["new"] ?? '').trim().isEmpty + ? '-' + : (field["new"] ?? ''); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE0E0E0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field["label"] ?? '', + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFFE26728), + ), + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + fontSize: 13, + color: const Color(0xFF232526), + ), + children: [ + const TextSpan( + text: "OLD: ", + style: TextStyle( + fontWeight: FontWeight.w700, + color: Color(0xFFD32F2F), + ), + ), + TextSpan(text: oldValue), + ], + ), + ), + const SizedBox(height: 4), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + fontSize: 13, + color: const Color(0xFF232526), + ), + children: [ + const TextSpan( + text: "NEW: ", + style: TextStyle( + fontWeight: FontWeight.w700, + color: Color(0xFF2E7D32), + ), + ), + TextSpan(text: newValue), + ], + ), + ), + ], + ), + ), + ); + }).toList(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(confirmContext, false), + child: Text("Cancel"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFFE26728), + ), + onPressed: () => Navigator.pop(confirmContext, true), + child: Text("Confirm", style: TextStyle(color: Colors.white)), + ), + ], + ); + }, + ); + + return confirmed == true; + } + // Reset UI Fields _relationShipController.clear(); _memberNameController.clear(); @@ -4829,7 +6013,7 @@ class _addOnsDetailsState extends State { style: ElevatedButton.styleFrom( backgroundColor: Color(0xFFE26728), ), - onPressed: () { + onPressed: () async { if (_relationShipController .text.isEmpty || _memberNameController @@ -4848,6 +6032,18 @@ class _addOnsDetailsState extends State { // Add more form fields as needed }; + if (action == "Edit" && floaterData != null) { + final shouldProceed = await showEditConfirmationDialog( + context, + floaterData, + formData, + action != "Edit", + ); + if (!shouldProceed) { + return; + } + } + // Call the function to send form data to API saveAddOnsDetails( formData, @@ -5181,6 +6377,15 @@ class _addOnsDetailsState extends State { : value.toStringAsFixed(2); } + String formatNullableAmount(dynamic value, {String fallback = 'NA'}) { + if (value == null) return fallback; + final numericValue = value is num + ? value.toDouble() + : double.tryParse(value.toString()); + if (numericValue == null) return value.toString(); + return formatAmount(numericValue); + } + List generateGmcCards(List data) { List cards = []; @@ -5319,7 +6524,7 @@ class _addOnsDetailsState extends State { ), ), Text( - '₹ ${gmcSumInsured != null ? gmcSumInsured : 'NA'}', + '₹ ${formatNullableAmount(gmcSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts.poppins( fontSize: @@ -5787,7 +6992,7 @@ class _addOnsDetailsState extends State { ), ), Text( - '₹ ${gpaSumInsured != null ? gpaSumInsured : 'NA'}', + '₹ ${formatNullableAmount(gpaSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts.poppins( fontSize: diff --git a/lib/pages/enrollment/empDetails.dart b/lib/pages/enrollment/empDetails.dart index 30377e8..54ba0cf 100755 --- a/lib/pages/enrollment/empDetails.dart +++ b/lib/pages/enrollment/empDetails.dart @@ -347,7 +347,7 @@ class _empDetailsState extends State { gpaDataIsEmpty = 0; }); // Handle other status codes - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); logDebug('Request failed with status: ${response['code']}'); } } catch (e) { @@ -1161,6 +1161,181 @@ class _empDetailsState extends State { logDebug("Normalized relationship list: $relationshipObjects"); + Future showEditConfirmationDialog( + BuildContext dialogContext, + Map oldData, + Map newData, + bool relationshipEnabled, + ) async { + final oldRelationship = (oldData["relationship"] ?? "").toString().trim(); + final oldName = (oldData["name"] ?? "").toString().trim(); + final oldDob = (oldData["dob"] ?? "").toString().trim(); + + final newRelationship = (newData["relationship"] ?? "").toString().trim(); + final newName = (newData["memberName"] ?? "").toString().trim(); + final newDob = (newData["dateOfBirth"] ?? "").toString().trim(); + + final List> changedFields = []; + if (oldRelationship != newRelationship) { + changedFields.add({ + "label": "Relationship", + "old": oldRelationship, + "new": newRelationship, + }); + } + if (oldName != newName) { + changedFields.add({ + "label": "Member Name", + "old": oldName, + "new": newName, + }); + } + if (oldDob != newDob) { + changedFields.add({ + "label": "Date of Birth", + "old": oldDob, + "new": newDob, + }); + } + + if (changedFields.isEmpty) { + ToastHelper.showWarningToast( + dialogContext, "No changes detected to save"); + return false; + } + + final List> reviewFields = []; + if (relationshipEnabled || oldRelationship != newRelationship) { + reviewFields.add({ + "label": "Relationship", + "old": oldRelationship, + "new": newRelationship, + }); + } + reviewFields.add({ + "label": "Member Name", + "old": oldName, + "new": newName, + }); + reviewFields.add({ + "label": "Date of Birth", + "old": oldDob, + "new": newDob, + }); + + final bool? confirmed = await showDialog( + context: dialogContext, + barrierDismissible: false, + builder: (confirmContext) { + return AlertDialog( + title: Text( + "Confirm Changes", + style: GoogleFonts.poppins(fontWeight: FontWeight.w600), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Please review old and new values before saving:", + style: GoogleFonts.poppins(fontSize: 13), + ), + SizedBox(height: 12), + ...reviewFields.map((field) { + final oldValue = (field["old"] ?? '').trim().isEmpty + ? '-' + : (field["old"] ?? ''); + final newValue = (field["new"] ?? '').trim().isEmpty + ? '-' + : (field["new"] ?? ''); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF9F9F9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE0E0E0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field["label"] ?? '', + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFFE26728), + ), + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + fontSize: 13, + color: const Color(0xFF232526), + ), + children: [ + const TextSpan( + text: "OLD: ", + style: TextStyle( + fontWeight: FontWeight.w700, + color: Color(0xFFD32F2F), + ), + ), + TextSpan(text: oldValue), + ], + ), + ), + const SizedBox(height: 4), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + fontSize: 13, + color: const Color(0xFF232526), + ), + children: [ + const TextSpan( + text: "NEW: ", + style: TextStyle( + fontWeight: FontWeight.w700, + color: Color(0xFF2E7D32), + ), + ), + TextSpan(text: newValue), + ], + ), + ), + ], + ), + ), + ); + }).toList(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(confirmContext, false), + child: Text("Cancel"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFFE26728), + ), + onPressed: () => Navigator.pop(confirmContext, true), + child: Text("Confirm", style: TextStyle(color: Colors.white)), + ), + ], + ); + }, + ); + + return confirmed == true; + } + // Reset UI Fields _relationShipController.clear(); _memberNameController.clear(); @@ -1323,7 +1498,7 @@ class _empDetailsState extends State { style: ElevatedButton.styleFrom( backgroundColor: Color(0xFFE26728), ), - onPressed: () { + onPressed: () async { if (_memberNameController.text.isEmpty || _dobController.text.isEmpty || dropdownValue == null) { @@ -1342,6 +1517,18 @@ class _empDetailsState extends State { logDebug(floaterData); logDebug(selectedFloaterData); + if (action == "Edit" && floaterData != null) { + final shouldProceed = await showEditConfirmationDialog( + context, + floaterData, + formData, + action != "Edit", + ); + if (!shouldProceed) { + return; + } + } + saveFamilyMemberDetails( formData, selectedFloaterData ?? floaterData ?? {}, diff --git a/lib/pages/enrollment/empReview.dart b/lib/pages/enrollment/empReview.dart index 94f4bba..804a877 100755 --- a/lib/pages/enrollment/empReview.dart +++ b/lib/pages/enrollment/empReview.dart @@ -32,6 +32,7 @@ class _empReviewDetailsState extends State { List typeOrder = [ 'GPA', 'GMC', + 'GMC - OPD', 'GMC - Parents', 'GMC - Topup', 'GMC - Topup(Parents)' @@ -111,10 +112,13 @@ class _empReviewDetailsState extends State { dynamic topUpSiSelectedSI; dynamic topUpSumInsured; dynamic topUpFloaterTextHeading; + dynamic opdFloaterTextHeading; dynamic topUpECardDownload; bool topUpIsPremiumSummary = false; dynamic topUpDisclaimer; + int showHideTopUpCard = 0; + int showHideOpdCard = 0; int showHideAddOnsCard = 0; int showHideTopUpParentCard = 0; dynamic iAgreeForAddOn = []; @@ -133,7 +137,25 @@ class _empReviewDetailsState extends State { dynamic topUpParentFloterTextHeading; dynamic topUpParentDisclaimer; // int topUpParentOpenForEnrollment = 0; + dynamic opdPolicies = []; + dynamic opdClientPolicyId; + dynamic opdSlabRates; + dynamic opdFamilyFloater; + dynamic opdPolicyName; + dynamic opdPolicyType; + dynamic opdMappedFamilyFloatersSi; + dynamic opdECardDownload; + int opdOpenForEnrollment = 0; + dynamic opdTypeName; + dynamic opdSiValue; + dynamic opdSiSelectedSI; + dynamic opdSiPremiumValue; + dynamic opdSiPremiumGst; + dynamic opdSiTotalAmt; + bool opdIsPremiumSummary = false; + dynamic opdDisclaimer; int activeSiData = 0; + int activeOPDSiData = 0; int activeSiParentData = 0; int activeDependentData = 0; int activePremiumSummary = 0; @@ -215,6 +237,7 @@ class _empReviewDetailsState extends State { getClientLogoAndDetails(); } getGmcSiTopUp(); + getGmcOPD(); getGmcSiParentTopUp(); getGmcDependentAddOns(); getGpaEmpPolicyDetails(enrollmentEmpPrimaryId); @@ -268,6 +291,11 @@ class _empReviewDetailsState extends State { return intValue != 0; } + bool _isOpenForEnrollment(dynamic value) { + if (value is int) return value == 1; + return value?.toString() == '1'; + } + void primarySummary() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); if (prefs.containsKey('siData')) { @@ -286,6 +314,21 @@ class _empReviewDetailsState extends State { topUpSiTotalAmt = firstSiData['topUpSiTotalAmt']; } } + if (prefs.containsKey('opdData')) { + activeOPDSiData = 1; + String? opdDataJson = prefs.getString('opdData'); + logDebug('siDataJson'); + logDebug(opdDataJson); + List siDataList = jsonDecode(opdDataJson!); + if (siDataList.isNotEmpty) { + // Access the first map in the list + Map firstSiData = siDataList.first; + // Get the value of topUpSiPremiumValue + opdSiPremiumValue = firstSiData['opdSiPremiumValue']; + opdSiPremiumGst = firstSiData['opdSiPremiumGst']; + opdSiTotalAmt = firstSiData['opdSiTotalAmt']; + } + } if (prefs.containsKey('siParentData')) { activeSiParentData = 1; String? siParentDataJson = prefs.getString('siParentData'); @@ -327,7 +370,8 @@ class _empReviewDetailsState extends State { logDebug(addOnsDependentTotalAmt); } } - if (prefs.containsKey('siData') && + + if (prefs.containsKey('siData') && prefs.containsKey('opdData') && prefs.containsKey('siParentData') && prefs.containsKey('dependentData')) { activePremiumSummary = 0; @@ -343,9 +387,9 @@ class _empReviewDetailsState extends State { logDebug('gmcTotalAmt: $gmcTotalAmt'); if (topUpSiTotalAmt != null) totalPayableAmt += topUpSiTotalAmt; + if (opdSiTotalAmt != null) totalPayableAmt += opdSiTotalAmt; if (topUpParentSiTotalAmt != null) totalPayableAmt += topUpParentSiTotalAmt; - if (addOnsDependentTotalAmt != null) - totalPayableAmt += addOnsDependentTotalAmt; + if (addOnsDependentTotalAmt != null) totalPayableAmt += addOnsDependentTotalAmt; logDebug('Final Total Payable Amount: $totalPayableAmt'); } @@ -423,7 +467,7 @@ class _empReviewDetailsState extends State { for (var item in gpaPolicies) { // Extract disclaimer value from each object - if (item['OpenForEnrollment'] == '1') { + if (_isOpenForEnrollment(item['OpenForEnrollment'])) { dynamic disclaimer = item['disclaimer']; if (disclaimer != null && disclaimer.isNotEmpty) { // Temporary list to hold new entries @@ -472,7 +516,7 @@ class _empReviewDetailsState extends State { gpaDataIsEmpty = 0; }); // Handle other status codes - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); logDebug('Request failed with status: ${response['code']}'); } } catch (e) { @@ -508,7 +552,7 @@ class _empReviewDetailsState extends State { } for (var item in gmcPolicies) { - if (item['OpenForEnrollment'] == '1') { + if (_isOpenForEnrollment(item['OpenForEnrollment'])) { dynamic disclaimer = item['disclaimer']; if (disclaimer != null && disclaimer.isNotEmpty) { // Temporary list to hold new entries @@ -632,7 +676,7 @@ class _empReviewDetailsState extends State { topUpDisclaimer = await topUpSiPolicies['gmc_si_topup']['disclaimer']; - if (topUpOpenForEnrollment == '1' && activeSiData == 1) { + if (_isOpenForEnrollment(topUpOpenForEnrollment) && activeSiData == 1) { if (topUpDisclaimer != null && topUpDisclaimer.isNotEmpty) { // Temporary list to hold new entries List> newEntries = []; @@ -693,6 +737,128 @@ class _empReviewDetailsState extends State { } } + Future getGmcOPD() async { + try { + if (enrollmentClient_id == null || + enrollmentEmpCodeString == null || + enrollmentEmpClientBranchId == null) { + return; + } + final response = await apiService.getGmcSiTopUpToApi( + enrollmentClient_id!, + enrollmentEmpCodeString!, + 'GMC-OPD', + enrollmentEmpClientBranchId!); + if (response['status'] == 'success') { + if (response.containsKey('data')) { + setState(() { + opdPolicies = response['data']; + }); + if (opdPolicies.isNotEmpty) { + int _toInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + return int.tryParse(value?.toString() ?? '0') ?? 0; + } + + final opdNode = opdPolicies['gmc_opd']; + opdClientPolicyId = opdNode['client_policy_id']; + opdSlabRates = opdNode['SlabRates']; + opdFloaterTextHeading = opdNode['floter_text_heading']; + opdPolicyName = opdNode['policy_name']; + opdPolicyType = opdNode['type']; + opdFamilyFloater = opdNode['policy_terms']['family_floater']; + opdMappedFamilyFloatersSi = + opdNode['family_floaters_of_only_si_array']; + opdECardDownload = opdNode['eCardDownload']; + opdOpenForEnrollment = _toInt(opdNode['OpenForEnrollment']); + opdTypeName = opdNode['type']; + opdSiValue = opdNode['family_floaters_of_only_si_value']; + logDebug(opdSiValue); + + if (opdSiValue == 0) { + showHideOpdCard = 0; + } else { + showHideOpdCard = 1; + } + opdSiSelectedSI = opdSiValue != 0 ? opdSiValue.toString() : null; + + // // OPD can have SI value 0 initially; still show card when policy exists. + // showHideOpdCard = (opdPolicyName != null || + // opdPolicyType != null || + // (opdMappedFamilyFloatersSi is List && + // opdMappedFamilyFloatersSi.isNotEmpty)) + // ? 1 + // : 0; + + setState(() { + opdSiPremiumValue = + _toInt(opdNode['family_floaters_of_only_si_premium_value']); + opdSiPremiumGst = + _toInt(opdNode['family_floaters_of_only_si_gst_value']); + opdSiTotalAmt = opdSiPremiumValue + opdSiPremiumGst; + opdIsPremiumSummary = + isPremiumEnabled(opdNode?['is_premium_summery']); + }); + + opdDisclaimer = opdNode['disclaimer']; + + if (_isOpenForEnrollment(opdOpenForEnrollment) && + activeOPDSiData == 1) { + if (opdDisclaimer != null && opdDisclaimer.isNotEmpty) { + // Temporary list to hold new entries + List> newEntries = []; + + if (opdDisclaimer is String) { + // Wrap string in a map with id and checked + newEntries.add({ + 'id': allDisclaimer.length + 1, // Unique ID based on length + 'text': opdDisclaimer, + 'checked': false, + 'type': opdTypeName + }); + } else if (opdDisclaimer is List) { + // Convert each string in the list to a map with id and checked + newEntries.addAll( + List>.from( + opdDisclaimer.map((text) => { + 'id': allDisclaimer.length + newEntries.length + 1, + 'text': text, + 'checked': false, + 'type': opdTypeName + }), + ), + ); + } else { + logDebug('Unexpected type for disclaimer'); + } + + // Add new entries to the main list, ensuring no duplicate texts + newEntries.forEach((entry) { + if (!allDisclaimer + .any((item) => item['text'] == entry['text'])) { + allDisclaimer.add(entry); + } + }); + + // Sort disclaimers after adding + _sortDisclaimerByTypeOrder(); + } + } + } else { + logDebug('No data found in the response'); + } + } else { + logDebug('API request failed with status: ${response['status']}'); + } + } else { + logDebug('Request failed with status: ${response['code']}'); + } + } catch (e) { + logDebug('Exception occurred: $e'); + } + } + Future getGmcSiParentTopUp() async { try { if (enrollmentClient_id == null || @@ -786,7 +952,7 @@ class _empReviewDetailsState extends State { await topUpSiParentPolicies['gmc_si_parent_topup'] ['disclaimer']; - if (topUpParentOpenForEnrollment == '1' && + if (_isOpenForEnrollment(topUpParentOpenForEnrollment) && activeSiParentData == 1) { if (topUpParentDisclaimer != null && topUpParentDisclaimer.isNotEmpty) { @@ -936,7 +1102,7 @@ setState(() { logDebug( 'addOnsDependentOpenForEnrollment $addOnsDependentOpenForEnrollment'); logDebug('activeDependentData $activeDependentData'); - if (addOnsDependentOpenForEnrollment == '1' && + if (_isOpenForEnrollment(addOnsDependentOpenForEnrollment) && activeDependentData == 1) { logDebug('addOnsDependentOpenForEnrollment'); if (addOnsDependentDisclaimer != null && @@ -1025,6 +1191,18 @@ setState(() { } } + if (opdMappedFamilyFloatersSi != null) { + dynamic getSiTrueObjects = opdMappedFamilyFloatersSi + .where((element) => element['is_value_exist'] == true) + .toList(); + + logDebug('getSiTrueObjects : $getSiTrueObjects'); + + if (getSiTrueObjects.isNotEmpty) { + iAgreeForAddOn.add(int.parse(opdClientPolicyId)); + } + } + if (topUpParentMappedFamilyFloatersSi != null) { dynamic getSiTrueObjects = topUpParentMappedFamilyFloatersSi .where((element) => element['is_value_exist'] == true) @@ -1172,7 +1350,7 @@ setState(() { color: Colors.red, ), ), - content: Text('Something went wrong. Enrollemnt not completed'), + content: Text('Unable to process. Please try again later. Enrollemnt not completed'), actions: [ TextButton( child: Text( @@ -1251,6 +1429,7 @@ setState(() { double total = 0.0; // Initialize to avoid unwanted accumulation if (topUpSiTotalAmt != null) total += topUpSiTotalAmt!; + if (opdSiTotalAmt != null) total += opdSiTotalAmt!; if (topUpParentSiTotalAmt != null) total += topUpParentSiTotalAmt!; if (addOnsDependentTotalAmt != null) total += addOnsDependentTotalAmt!; if (gpaTotalAmt != null) total += gpaTotalAmt!; @@ -1633,7 +1812,7 @@ setState(() { gmchasPremiumSummary || topUpIsPremiumSummary || topUpParentIsPremiumSummary || - addOnsDependentIsPremiumSummary)...[ + addOnsDependentIsPremiumSummary || opdIsPremiumSummary)...[ Card( elevation: 0, color: Colors.white, @@ -1716,7 +1895,7 @@ setState(() { CrossAxisAlignment.start, children: [ Text( - '₹${allTotalSum()}/Year', + '₹${formatAmount(allTotalSum())}/Year', textAlign: TextAlign.left, style: GoogleFonts.poppins( @@ -1936,7 +2115,7 @@ setState(() { ), ), Text( - '₹ ${addOnsDependentSumInsured != null ? addOnsDependentSumInsured : 'NA'}', + '₹ ${formatNullableAmount(addOnsDependentSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts @@ -2193,7 +2372,7 @@ setState(() { ), ), Text( - '₹ ${topUpSumInsured != null ? topUpSumInsured : 'NA'}', + '₹ ${formatNullableAmount(topUpSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts @@ -2271,6 +2450,187 @@ setState(() { ), )), SizedBox(height: showHideTopUpCard == 1 ? 16 : 0), + if (showHideOpdCard == 1) + Card( + elevation: 0, + color: Colors.white, + child: Padding( + padding: Responsive.isDesktop(context) + ? EdgeInsets.all(30) + : EdgeInsets.all(10), + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: Color(0xFFFFF1DD), + borderRadius: BorderRadius.circular(5), + ), + padding: Responsive.isDesktop(context) + ? EdgeInsets.only( + top: 15, + bottom: 15, + left: 25, + right: 25) + : EdgeInsets.only( + top: 10, + bottom: 10, + left: 10, + right: 10), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + flex: Responsive.isDesktop(context) + ? 9 + : 7, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + Responsive.isDesktop( + context) + ? opdPolicyName + : opdPolicyType, + textAlign: TextAlign.left, + style: + GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 20 + : 14, + fontWeight: + FontWeight.w600, + ), + ), + if (opdECardDownload != null) + GestureDetector( + onTap: () async { + _launchURL( + opdECardDownload); + }, + child: MouseRegion( + cursor: + SystemMouseCursors + .click, + child: Icon( + Icons.file_download, + color: Color( + 0xFFE26728), + size: 25, + ), + ), + ), + ], + ), + ], + ), + ), + Expanded( + flex: Responsive.isDesktop( + context) + ? 3 + : 5, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + opdFloaterTextHeading ?? '', + textAlign: TextAlign.right, + style: + GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 18 + : 13, + ), + ), + Text( + '₹ ${formatNullableAmount(opdSiValue)}', + textAlign: TextAlign.right, + style: + GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 20 + : 14, + fontWeight: + FontWeight.w600, + ), + ), + ], + )), + ], + ), + ), + SizedBox(height: 15), + Container( + padding: Responsive.isDesktop(context) + ? EdgeInsets.only( + top: 0, + bottom: 0, + left: 15, + right: 15) + : EdgeInsets.only( + top: 0, + bottom: 0, + left: 5, + right: 5), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: + (opdMappedFamilyFloatersSi ?? + []) + .where((item) => + item['is_value_exist'] == + true) + .map((item) { + Map data = + item['data']; + String formattedDate = + data['dob'] ?? ''; + return Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Icon(Icons.arrow_right, + color: + Color(0xFFE26728)), + SizedBox( + width: Responsive.isDesktop( + context) + ? 10 + : 5), + Expanded( + child: Text( + '${data['name']} ~ ${data['relationship']} ~ DOB : $formattedDate', + style: GoogleFonts.poppins( + fontSize: Responsive + .isDesktop( + context) + ? 18 + : 14, + color: Color(0xFF232526), + ), + ), + ), + ], + ); + }).toList(), + ), + ), + ], + ), + )), + SizedBox(height: showHideOpdCard == 1 ? 16 : 0), if (showHideTopUpParentCard == 1) Card( elevation: 0, @@ -2460,7 +2820,7 @@ setState(() { ), ), Text( - '₹ ${topUpParentSiValue != null ? topUpParentSiValue : 'NA'}', + '₹ ${formatNullableAmount(topUpParentSiValue)}', textAlign: TextAlign.right, style: GoogleFonts @@ -2552,7 +2912,7 @@ setState(() { gmchasPremiumSummary || topUpIsPremiumSummary || topUpParentIsPremiumSummary || - addOnsDependentIsPremiumSummary)...[ + addOnsDependentIsPremiumSummary || opdIsPremiumSummary)...[ Container( child: Row( crossAxisAlignment: @@ -2804,6 +3164,102 @@ setState(() { ), ], ), + + if (activeOPDSiData == 1 && + opdIsPremiumSummary) + TableRow( + children: [ + TableCell( + child: Padding( + padding: + EdgeInsets + .all(8.0), + child: Text( + (Responsive.isDesktop( + context) + ? (opdPolicyName ?? + 'Default OPD Name') + : 'GMC - OPD'), + textAlign: + TextAlign + .start, + style: GoogleFonts.poppins( + fontSize: + Responsive.isDesktop(context) + ? 14 + : 12), + ), + ), + ), + TableCell( + child: Padding( + padding: + EdgeInsets + .all(8.0), + child: Text( + '₹$opdSiPremiumValue/Year', + textAlign: Responsive + .isDesktop( + context) + ? TextAlign + .start + : TextAlign + .start, + style: GoogleFonts.poppins( + fontSize: + Responsive.isDesktop(context) + ? 14 + : 12), + ), + ), + ), + TableCell( + child: Padding( + padding: + EdgeInsets + .all(8.0), + child: Text( + '₹$opdSiPremiumGst/-', + textAlign: Responsive + .isDesktop( + context) + ? TextAlign + .start + : TextAlign + .start, + style: GoogleFonts.poppins( + fontSize: + Responsive.isDesktop(context) + ? 14 + : 12), + ), + ), + ), + TableCell( + child: Padding( + padding: + EdgeInsets + .all(8.0), + child: Text( + '₹$opdSiTotalAmt/Year', + textAlign: Responsive + .isDesktop( + context) + ? TextAlign + .start + : TextAlign + .start, + style: GoogleFonts.poppins( + fontSize: + Responsive.isDesktop(context) + ? 14 + : 12), + ), + ), + ), + ], + ), + if (activeSiParentData == 1 && topUpParentIsPremiumSummary) @@ -3051,7 +3507,7 @@ setState(() { .end, children: [ Text( - '₹${allTotalSum()}/Year', + '₹${formatAmount(allTotalSum())}/Year', textAlign: TextAlign.right, style: GoogleFonts @@ -3198,17 +3654,12 @@ setState(() { child: ElevatedButton( onPressed: () async { // Remove siData, siParentData, and dependentData from local storage - final SharedPreferences - prefs = - await SharedPreferences - .getInstance(); + final SharedPreferences prefs = await SharedPreferences.getInstance(); prefs.remove('siData'); - prefs - .remove('siParentData'); - prefs.remove( - 'dependentData'); - context - .go('/addOnsDetails'); + prefs.remove('siParentData'); + prefs.remove('dependentData'); + prefs.remove('opdData'); + context.go('/addOnsDetails'); // Navigator.pushNamed( // context, 'addOnsDetails'); }, @@ -3569,7 +4020,7 @@ setState(() { ), ), Text( - '₹ ${gpaSumInsured != null ? gpaSumInsured : 'NA'}', + '₹ ${formatNullableAmount(gpaSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts.poppins( fontSize: @@ -3766,7 +4217,7 @@ setState(() { gmchasPremiumSummary || topUpIsPremiumSummary || topUpParentIsPremiumSummary || - addOnsDependentIsPremiumSummary) + addOnsDependentIsPremiumSummary || opdIsPremiumSummary) Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -3829,6 +4280,15 @@ setState(() { : value.toStringAsFixed(2); } + String formatNullableAmount(dynamic value, {String fallback = 'NA'}) { + if (value == null) return fallback; + final numericValue = value is num + ? value.toDouble() + : double.tryParse(value.toString()); + if (numericValue == null) return value.toString(); + return formatAmount(numericValue); + } + List generateGmcCards(List data) { List cards = []; // gmcEnrollmentStatus = isEnrollmentOpen(data); @@ -3996,7 +4456,7 @@ setState(() { ), ), Text( - '₹ ${gmcSumInsured != null ? gmcSumInsured : 'NA'}', + '₹ ${formatNullableAmount(gmcSumInsured)}', textAlign: TextAlign.right, style: GoogleFonts.poppins( fontSize: @@ -4368,7 +4828,7 @@ setState(() { child: Padding( padding: EdgeInsets.all(8.0), child: Text( - '₹${(policy["si_premium_value"] ?? 0) + (policy["si_gst_value"] ?? 0)}/-', + '₹${(policy["si_premium_value"] ?? 0) + (policy["si_gst_value"] ?? 0)}/Year', textAlign: TextAlign.start, style: GoogleFonts.poppins( fontSize: Responsive.isDesktop(context) ? 14 : 12), @@ -4444,7 +4904,7 @@ setState(() { child: Padding( padding: EdgeInsets.all(8.0), child: Text( - '₹${(policy["family_floaters_of_dependent_and_si_premium_value"] ?? 0) + (policy["family_floaters_of_dependent_and_gst_value"] ?? 0)}/-', + '₹${(policy["family_floaters_of_dependent_and_si_premium_value"] ?? 0) + (policy["family_floaters_of_dependent_and_gst_value"] ?? 0)}/Year', textAlign: TextAlign.start, style: GoogleFonts.poppins( fontSize: Responsive.isDesktop(context) ? 14 : 12), diff --git a/lib/pages/helpers/browser_href.dart b/lib/pages/helpers/browser_href.dart new file mode 100644 index 0000000..f4bd54d --- /dev/null +++ b/lib/pages/helpers/browser_href.dart @@ -0,0 +1,7 @@ +import 'browser_href_stub.dart' + if (dart.library.html) 'browser_href_web.dart' as impl; + +String currentBrowserHref() => impl.currentBrowserHref(); + +void stripPayloadParamFromBrowserUrl() => + impl.stripPayloadParamFromBrowserUrl(); diff --git a/lib/pages/helpers/browser_href_stub.dart b/lib/pages/helpers/browser_href_stub.dart new file mode 100644 index 0000000..689858b --- /dev/null +++ b/lib/pages/helpers/browser_href_stub.dart @@ -0,0 +1,3 @@ +String currentBrowserHref() => ''; + +void stripPayloadParamFromBrowserUrl() {} diff --git a/lib/pages/helpers/browser_href_web.dart b/lib/pages/helpers/browser_href_web.dart new file mode 100644 index 0000000..799fbbd --- /dev/null +++ b/lib/pages/helpers/browser_href_web.dart @@ -0,0 +1,22 @@ +import 'dart:html' as html; + +String currentBrowserHref() => html.window.location.href; + +/// Removes `payload` from the query string so a failed SSO return does not +/// re-trigger handling when [GoRouter] rebuilds `/login`. +void stripPayloadParamFromBrowserUrl() { + final u = Uri.parse(html.window.location.href); + if (!u.queryParameters.containsKey('payload')) return; + final m = Map.from(u.queryParameters); + m.remove('payload'); + final clean = Uri( + scheme: u.scheme, + userInfo: u.userInfo.isEmpty ? null : u.userInfo, + host: u.host.isEmpty ? null : u.host, + port: u.hasPort ? u.port : null, + path: u.path, + queryParameters: m.isEmpty ? null : m, + fragment: u.fragment, + ); + html.window.history.replaceState(null, '', clean.toString()); +} diff --git a/lib/pages/helpers/sso_same_tab_redirect.dart b/lib/pages/helpers/sso_same_tab_redirect.dart new file mode 100644 index 0000000..bd46287 --- /dev/null +++ b/lib/pages/helpers/sso_same_tab_redirect.dart @@ -0,0 +1,4 @@ +import 'sso_same_tab_redirect_stub.dart' + if (dart.library.html) 'sso_same_tab_redirect_web.dart' as impl; + +void ssoRedirectSameTab(String url) => impl.ssoRedirectSameTab(url); diff --git a/lib/pages/helpers/sso_same_tab_redirect_stub.dart b/lib/pages/helpers/sso_same_tab_redirect_stub.dart new file mode 100644 index 0000000..cb1fbbc --- /dev/null +++ b/lib/pages/helpers/sso_same_tab_redirect_stub.dart @@ -0,0 +1,2 @@ +/// Non-web: caller should not invoke same-tab redirect. +void ssoRedirectSameTab(String url) {} diff --git a/lib/pages/helpers/sso_same_tab_redirect_web.dart b/lib/pages/helpers/sso_same_tab_redirect_web.dart new file mode 100644 index 0000000..a90bcbc --- /dev/null +++ b/lib/pages/helpers/sso_same_tab_redirect_web.dart @@ -0,0 +1,6 @@ +import 'dart:html' as html; + +/// Full-page navigation so Microsoft IdP and SAML complete in the same tab. +void ssoRedirectSameTab(String url) { + html.window.location.assign(url); +} diff --git a/lib/pages/login.dart b/lib/pages/login.dart index e91e0f4..32e35f9 100755 --- a/lib/pages/login.dart +++ b/lib/pages/login.dart @@ -17,10 +17,14 @@ import '../config/environment.dart'; import '../customAppBar/responsive.dart'; import '../customAppBar/toastHelper.dart'; import '../models/platform_helper_mobile.dart' -if (dart.library.html) '../models/platform_helper_other.dart'; + if (dart.library.html) '../models/platform_helper_other.dart'; import 'package:pinput/pinput.dart'; import 'package:nhance_app_pwa/logger.dart'; +import 'package:nhance_app_pwa/pages/helpers/browser_href.dart'; +import 'package:nhance_app_pwa/pages/helpers/sso_same_tab_redirect.dart'; +import 'package:nhance_app_pwa/pages/login_saml_auth.dart'; +import 'package:nhance_app_pwa/pages/login_saml_webview_screen.dart'; class login extends StatefulWidget { const login({Key? key}); @@ -35,7 +39,7 @@ class _loginState extends State { final TextEditingController passwordController = TextEditingController(); final TextEditingController resetPasswordController = TextEditingController(); final TextEditingController confirmPasswordController = - TextEditingController(); + TextEditingController(); final TextEditingController _otpController = TextEditingController(); TextEditingController countryController = TextEditingController(); TextEditingController mobileController = TextEditingController(); @@ -57,6 +61,7 @@ class _loginState extends State { bool _resetObscurePassword = true; bool _obscureConfirmPassword = true; late SessionManager session; + Map? _ssoReturnPayloadCaptured; bool clickedForgotPassword = false; bool resetPasswordEnable = false; bool otpFieldShow = false; @@ -79,16 +84,72 @@ class _loginState extends State { bool hasUpperLower = false; bool hasNumber = false; bool hasSpecialChar = false; - bool get isPasswordValid => hasMinLength && hasUpperLower && hasNumber && hasSpecialChar; - + bool get isPasswordValid => + hasMinLength && hasUpperLower && hasNumber && hasSpecialChar; @override void initState() { countryController.text = "+91"; super.initState(); + if (kIsWeb) { + _captureSsoPayloadFromBrowserUrl(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _tryConsumeSsoPayloadFromReturnUrl()); + } // checkLoginPinOnMobile(); } + /// Reads `payload` synchronously before [GoRouter] can rewrite the address + /// bar (e.g. `...?payload=...#/login`). + void _captureSsoPayloadFromBrowserUrl() { + for (final u in [currentBrowserHref(), Uri.base.toString()]) { + if (u.isEmpty || !u.contains('payload=')) continue; + final decoded = decodeSsoPayloadFromUrl(u); + if (decoded == null) continue; + _ssoReturnPayloadCaptured = decoded; + logDebug( + 'SSO capture from URL: status=${decoded['status']} keys=${decoded.keys.toList()}'); + print( + '[SSO] payload map (capture from URL):\n${prettySsoJsonForConsole(decoded)}'); + final jwt = decodeJwtClaimsFromSsoPayloadMap(decoded); + printSsoDecodedTokensToConsole(jwt); + break; + } + } + + /// Web: SAML may return `?payload=` on `/login` (see [buildWebSsoRelayStateUrl]) + /// or after `index.html` redirect from `/sso-login`. + Future _tryConsumeSsoPayloadFromReturnUrl() async { + if (!kIsWeb || !mounted) return; + + Map? decoded = _ssoReturnPayloadCaptured; + _ssoReturnPayloadCaptured = null; + + if (decoded == null) { + for (final candidate in [ + currentBrowserHref(), + Uri.base.toString(), + ]) { + if (candidate.isEmpty || !candidate.contains('payload=')) continue; + decoded = decodeSsoPayloadFromUrl(candidate); + if (decoded != null) break; + } + } + + if (decoded == null || !mounted) return; + + try { + await completeLoginFromSsoPayloadMap(context, decoded); + } finally { + if (kIsWeb) stripPayloadParamFromBrowserUrl(); + } + } + + @override + void dispose() { + super.dispose(); + } + void validatePassword(String password) { setState(() { hasMinLength = password.length >= 8; @@ -98,6 +159,73 @@ class _loginState extends State { }); } + bool _isValidEmailForSso(String value) { + final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$'); + return emailRegex.hasMatch(value.trim()); + } + + Future _handleSsoLoginButtonTap() async { + var skipLoadingReset = false; + try { + if (!_formKey.currentState!.validate()) return; + + final emailInput = emailMobileController.text.trim(); + + if (emailInput.isEmpty) { + ToastHelper.showWarningToast( + context, 'Please enter your email to continue'); + return; + } + + if (!_isValidEmailForSso(emailInput)) { + ToastHelper.showWarningToast( + context, 'Please enter a valid email address for SSO'); + return; + } + + if (!mounted) return; + setState(() => _isLoading = true); + + final base = Uri.parse(Environment.SsoLogin); + final qp = {'email': emailInput}; + if (kIsWeb) { + final relay = buildWebSsoRelayStateUrl(); + if (relay.isNotEmpty) { + qp['RelayState'] = relay; + } + } + final samlEntry = base.replace(queryParameters: qp); + + if (kIsWeb) { + skipLoadingReset = true; + ssoRedirectSameTab(samlEntry.toString()); + return; + } + + final result = await Navigator.of(context).push?>( + MaterialPageRoute( + builder: (_) => + LoginSamlWebViewScreen(initialUrl: samlEntry.toString()), + ), + ); + + if (!mounted) return; + if (result != null && result.isNotEmpty) { + await completeLoginFromSsoPayloadMap(context, result); + } + } catch (e) { + if (mounted) { + ToastHelper.showErrorToast( + context, 'Unable to process. Please try again later'); + logDebug('Error: $e'); + } + } finally { + if (mounted && !skipLoadingReset) { + setState(() => _isLoading = false); + } + } + } + Future checkLoginPinOnMobile() async { if (isMobilePlatform()) { final SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -140,7 +268,8 @@ class _loginState extends State { body: json.encode(params), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -179,25 +308,42 @@ class _loginState extends State { _isLoading = false; }); ToastHelper.showErrorToast(context, 'Session Out'); - } else if (response.statusCode == 403) { - setState(() { - _isLoading = false; - }); - ToastHelper.showErrorToast(context, 'Session Out'); - } else if (response.statusCode == 451) { - setState(() { - _isLoading = false; - }); - final body = jsonDecode(response.body); - final message = body['message']; - ToastHelper.showWarningToast(context, message); } else if (response.statusCode == 429) { setState(() { _isLoading = false; }); - final body = jsonDecode(response.body); - final message = body['message']; - ToastHelper.showWarningToast(context, message); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { setState(() { _isLoading = false; @@ -205,7 +351,7 @@ class _loginState extends State { throw Exception('Failed to load data'); } } catch (e) { - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -225,7 +371,6 @@ class _loginState extends State { Future verifyMobileAndEmailNumber() async { try { if (_formKey.currentState!.validate()) { - setState(() { _isLoading = true; }); @@ -245,7 +390,6 @@ class _loginState extends State { logDebug("User entered Mobile: $input"); } - // Determine the API and the payload based on the visible field String apiEndpoint = isEmailFieldVisible ? Environment.apiUrlEnrollment + 'verifyEmployeeEmailId' @@ -261,7 +405,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -271,11 +416,11 @@ class _loginState extends State { String message = data['data']['message']; if (userVerification) { final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); // var enteredMobileNumber = mobileController.text; // prefs.setString('empMobileNo', enteredMobileNumber); - ToastHelper.showSuccessToast( - context, 'Verification code sent to ${emailMobileController.text}'); + ToastHelper.showSuccessToast(context, + 'Verification code sent to ${emailMobileController.text}'); if (isEmailFieldVisible) { logDebug('isEmailFieldVisible $isEmailFieldVisible'); // prefs.setString('empEmail', emailController.text); @@ -313,13 +458,42 @@ class _loginState extends State { _isLoading = false; }); Map data = json.decode(response.body); - final message = data['message']; - ToastHelper.showErrorToast(context, message); - } else { + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else { + setState(() { + _isLoading = false; + }); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } @@ -327,7 +501,7 @@ class _loginState extends State { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -497,7 +671,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); logDebug('response : ${response.statusCode}'); @@ -511,7 +686,7 @@ class _loginState extends State { /// Check API response status first if (data['status'] == 'Invalid Password') { final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); prefs.clear(); ToastHelper.showErrorToast( context, data['message'] ?? 'Invalid Password'); @@ -561,20 +736,56 @@ class _loginState extends State { _isLoading = false; }); final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); prefs.clear(); ToastHelper.showErrorToast( context, 'Invalid Passsword. Please try again'); // Show a Snackbar if the OTP is invalid logDebug('Invalid Password. Please try again'); } + } else if (response.statusCode == 429) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { setState(() { _isLoading = false; }); final SharedPreferences prefs = await SharedPreferences.getInstance(); prefs.clear(); - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify OTP'); } } @@ -585,7 +796,7 @@ class _loginState extends State { final SharedPreferences prefs = await SharedPreferences.getInstance(); prefs.clear(); logDebug('Error: $e'); - ToastHelper.showWarningToast(context, 'Something went wrong'); + ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); // Show a Snackbar if there's an error while verifying OTP logDebug('Failed to verify OTP. Please try again.'); } @@ -610,9 +821,11 @@ class _loginState extends State { } if (_postToken != null && _postToken.isNotEmpty) { ToastHelper.showSuccessToast(context, 'Successfully Logged In'); + SessionManager().unlockMobileAppSession(); context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); } @@ -627,9 +840,11 @@ class _loginState extends State { logDebug(isMobilePlatform()); if (_preToken != null && _preToken.isNotEmpty) { ToastHelper.showSuccessToast(context, 'Successfully Logged In'); + SessionManager().unlockMobileAppSession(); context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); } @@ -643,8 +858,9 @@ class _loginState extends State { url, headers: { 'Authorization': - 'Bearer $_preToken', // Add token to the Authorization header - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'Bearer $_preToken', // Add token to the Authorization header + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); if (response.statusCode == 200) { @@ -722,7 +938,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -732,7 +949,7 @@ class _loginState extends State { String message = data['data']['message']; if (userVerification) { final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); // var enteredMobileNumber = mobileController.text; // prefs.setString('empMobileNo', enteredMobileNumber); ToastHelper.showSuccessToast( @@ -750,11 +967,47 @@ class _loginState extends State { ToastHelper.showErrorToast(context, message); logDebug('Invalid mobile number'); } - } else { + } else if (response.statusCode == 429) { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else { + setState(() { + _isLoading = false; + }); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } @@ -762,10 +1015,9 @@ class _loginState extends State { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } - } Future otpVerify() async { @@ -788,7 +1040,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -801,7 +1054,7 @@ class _loginState extends State { logDebug(message); if (verificationStatus == 'success') { final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); // var enteredMobileNumber = mobileController.text; // prefs.setString('empMobileNo', enteredMobileNumber); ToastHelper.showSuccessToast(context, message!); @@ -831,11 +1084,47 @@ class _loginState extends State { ToastHelper.showErrorToast(context, message!); logDebug('Invalid mobile number'); } + } else if (response.statusCode == 429) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } @@ -843,7 +1132,7 @@ class _loginState extends State { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -874,7 +1163,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -915,14 +1205,14 @@ class _loginState extends State { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } catch (e) { setState(() { _isLoading = false; }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } @@ -967,7 +1257,8 @@ class _loginState extends State { body: json.encode(payload), headers: { HttpHeaders.contentTypeHeader: 'application/json', - 'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', }, ); @@ -977,7 +1268,7 @@ class _loginState extends State { String message = data['data']['message']; if (userVerification) { final SharedPreferences prefs = - await SharedPreferences.getInstance(); + await SharedPreferences.getInstance(); // var enteredMobileNumber = mobileController.text; // prefs.setString('empMobileNo', enteredMobileNumber); ToastHelper.showSuccessToast( @@ -995,11 +1286,47 @@ class _loginState extends State { ToastHelper.showErrorToast(context, message); logDebug('Invalid mobile number'); } + } else if (response.statusCode == 429) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 403) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } + } else if (response.statusCode == 451) { + setState(() { + _isLoading = false; + }); + Map data = json.decode(response.body); + if (data.containsKey('error')) { + final message = data['error']['message']; + ToastHelper.showErrorToast(context, message); + } else { + final message = data['message']; + ToastHelper.showErrorToast(context, message); + } } else { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify mobile number'); } } @@ -1007,10 +1334,9 @@ class _loginState extends State { setState(() { _isLoading = false; }); - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } - } String getLoginDescription() { @@ -1045,7 +1371,6 @@ class _loginState extends State { return "Welcome to Nhance"; } - //Ends Login with UserName and Password @override @@ -1087,982 +1412,1206 @@ class _loginState extends State { }, child: Scaffold( body: SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: Container( - height: _size.height, - color: Colors.white, - child: Stack( - children: [ - Visibility( - visible: _size.width <= 1100, - child: ClipRRect( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(30), - bottomRight: Radius.circular(30), - ), - child: Container( - height: _size.height / 3, - width: double.infinity, - color: Color(0xFFFFFCE5), - child: Stack( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Container( + height: _size.height, + color: Colors.white, + child: Stack( + children: [ + Visibility( + visible: _size.width <= 1100, + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), + child: Container( + height: _size.height / 3, + width: double.infinity, + color: Color(0xFFFFFCE5), + child: Stack( + children: [ + Column( children: [ - Column( + SizedBox(height: _size.height / 6.4), + Row( + mainAxisAlignment: MainAxisAlignment + .center, // Align to the center children: [ - SizedBox(height: _size.height / 6.4), - Row( - mainAxisAlignment: MainAxisAlignment - .center, // Align to the center - children: [ - Expanded( - flex: Responsive.isDesktop(context) - ? 10 - : 12, - child: Align( - alignment: + Expanded( + flex: Responsive.isDesktop(context) + ? 10 + : 12, + child: Align( + alignment: Responsive.isDesktop(context) ? Alignment.centerLeft : Alignment.bottomCenter, - child: Image.asset( - 'assets/nhance_app_logo.png', - width: 150, - height: Responsive.isDesktop(context) ? 150 : 100, - )), - ), - ], + child: Image.asset( + 'assets/nhance_app_logo.png', + width: 150, + height: + Responsive.isDesktop(context) + ? 150 + : 100, + )), ), ], ), ], ), - ), + ], ), ), - Container( - margin: marginInsets, - alignment: Responsive.isDesktop(context) ? Alignment.center : Alignment.bottomCenter, - child: SingleChildScrollView( - child: Form( - key: _formKey, - child: Column( + ), + ), + Container( + margin: marginInsets, + alignment: Responsive.isDesktop(context) + ? Alignment.center + : Alignment.bottomCenter, + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + children: [ + Row( children: [ - Row( - children: [ - Expanded( - flex: _size.width < 1100 ? 6 : 12, - child: Container( - margin: _size.width > 1100 - ? EdgeInsets.only(left: 20, right: 20) - : EdgeInsets.only(left: 0, right: 0), - child: Column( - mainAxisAlignment: + Expanded( + flex: _size.width < 1100 ? 6 : 12, + child: Container( + margin: _size.width > 1100 + ? EdgeInsets.only(left: 20, right: 20) + : EdgeInsets.only(left: 0, right: 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (!Responsive.isMobile(context) && - !Responsive.isTablet(context)) - Row( - children: [ - Expanded( - flex: 12, - child: Align( - alignment: Alignment.center, - child: _size.width <= 1100 + children: [ + if (!Responsive.isMobile(context) && + !Responsive.isTablet(context)) + Row( + children: [ + Expanded( + flex: 12, + child: Align( + alignment: Alignment.center, + child: _size.width <= 1100 + ? Image.asset( + 'assets/nhance_app_logo.png', + width: 150, + height: 150, + ) + : _size.width > 1100 ? Image.asset( - 'assets/nhance_app_logo.png', - width: 150, - height: 150, - ) - : _size.width > 1100 - ? Image.asset( - 'assets/nhance_app_logo.png', - width: 150, - height: 150, - ) + 'assets/nhance_app_logo.png', + width: 150, + height: 150, + ) : Image.asset( - 'assets/nhance_app_logo.png', - width: 150, - height: 150, + 'assets/nhance_app_logo.png', + width: 150, + height: 150, + ), + ), + ), + ], + ), + SizedBox( + height: Responsive.isDesktop(context) + ? _size.height * 0.0 + : 10, + ), + SizedBox(height: 10), + if (Responsive.isDesktop(context)) + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + padding: + const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: + BorderRadius.circular(50), + ), + child: Row( + children: [ + buildTab( + "Login with OTP", 0), + const SizedBox(width: 10), + buildTab( + "Login with Password", + 1), + ], + ), + ), + ], + ), + SizedBox(height: 10), + Container( + margin: Responsive.isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Text( + getLoginHeading(), + style: GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + SizedBox( + height: 10, + ), + Container( + margin: Responsive.isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + getLoginDescription(), + style: GoogleFonts.poppins( + fontSize: 12, + color: Color(0xFF000000)), + textAlign: TextAlign.center, + ), + ) + ], + ), + ), + SizedBox( + height: 20, + ), + if (switcherStatus == 1) ...[ + Column( + children: [ + Container( + height: 55, + margin: Responsive.isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: 150) + : const EdgeInsets + .symmetric( + horizontal: 0), + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: + BorderRadius.circular(10), + ), + child: TextFormField( + controller: + emailMobileController, + keyboardType: + TextInputType.text, + decoration: + const InputDecoration( + border: InputBorder.none, + hintText: + "Email / Mobile Number ", + contentPadding: + EdgeInsets.symmetric( + horizontal: 10), + ), + validator: (value) { + if (value == null || + value + .trim() + .isEmpty) { + return "Please enter email or mobile number"; + } + + String input = + value.trim(); + + // ❌ Reject all spaces + if (input.contains(' ')) { + return "No spaces allowed"; + } + + final emailRegex = RegExp( + r'^[^@]+@[^@]+\.[^@]+$'); + final mobileRegex = + RegExp( + r'^[0-9]{10}$'); + + bool isEmailFormat = + emailRegex + .hasMatch(input); + bool isMobileFormat = + mobileRegex + .hasMatch(input); + + // --------------------------- + // 🛑 MOBILE VALIDATION + // --------------------------- + if (RegExp(r'^[0-9]+$') + .hasMatch(input)) { + if (input.length != + 10) { + return "Mobile number must be exactly 10 digits"; + } + } + + // --------------------------- + // 🛑 EMAIL VALIDATION + // --------------------------- + + // Reject anything that has '@' but is NOT a valid email format + if (input.contains('@') && + !isEmailFormat) { + return "Enter a valid email address"; + } + + // Reject email with extra digits at the end + if (input.contains('@') && + RegExp(r'\d+$') + .hasMatch( + input)) { + return "Email cannot contain extra numbers"; + } + + // Reject email+mobile combination + if (input.contains('@') && + RegExp(r'\d{10}$') + .hasMatch( + input)) { + return "Enter only email OR mobile number"; + } + + // --------------------------- + // 🛑 MIXED CONTENT (letters + digits but NOT email) + // --------------------------- + bool hasLetters = + RegExp(r'[A-Za-z]') + .hasMatch(input); + bool hasDigits = + RegExp(r'[0-9]') + .hasMatch(input); + + if ((hasLetters && + hasDigits) && + !input + .contains('@')) { + return "Enter only email OR 10-digit mobile number"; + } + + // --------------------------- + // 🟢 FINAL CHECK + // --------------------------- + if (!isEmailFormat && + !isMobileFormat) { + return "Enter a valid email or 10-digit mobile number"; + } + + return null; + }), + ), + SizedBox(height: 15), + Container( + margin: Responsive.isDesktop( + context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + child: SizedBox( + width: double.infinity, + height: 45, + child: ElevatedButton( + style: ElevatedButton + .styleFrom( + backgroundColor: + Color(0xFF00989E), + shape: + RoundedRectangleBorder( + borderRadius: + BorderRadius + .circular(10), ), ), + onPressed: _isLoading + ? null + : verifyMobileAndEmailNumber, + child: _isLoading + ? CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation< + Color>( + Color(0xFF00989E), + ), + ) + : Text( + "Login with Email / Mobile OTP", + style: GoogleFonts + .poppins( + color: Color( + 0xFFFFFFFF), + ), + ), ), - ], + ), ), - SizedBox( - height: Responsive.isDesktop(context) - ? _size.height * 0.0 - : 10, - ), - SizedBox(height: 10), - if (Responsive.isDesktop(context)) - Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Container( - padding: - const EdgeInsets.all(3), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: - BorderRadius.circular(50), + SizedBox(height: 15), + Container( + margin: Responsive.isDesktop(context) + ? const EdgeInsets.symmetric(horizontal: 150) + : const EdgeInsets.symmetric(horizontal: 0), + child: SizedBox( + width: double.infinity, + height: 45, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSsoLoginButtonTap, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide( + color: Colors.black, + width: 0.5, + ), + ), ), child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - buildTab( - "Login with OTP", 0), - const SizedBox(width: 10), - buildTab( - "Login with Password", - 1), + Text( + "Sign In With Microsoft", + style: GoogleFonts.poppins( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + const SizedBox(width: 8), + Image.asset( + 'assets/images/login/microsoft.png', + width: 22, + height: 22, + fit: BoxFit.contain, + ), ], ), ), - ], + ), ), - SizedBox(height: 10), - Container( - margin: Responsive.isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Text( - getLoginHeading(), - style: GoogleFonts.poppins( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - SizedBox( - height: 10, - ), - Container( - margin: Responsive.isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Expanded( - child: Text( - getLoginDescription(), - style: GoogleFonts.poppins( - fontSize: 12, - color: Color(0xFF000000)), - textAlign: TextAlign.center, - ), - ) - ], - ), - ), - SizedBox( - height: 20, - ), - if (switcherStatus == 1) ...[ - Column( - children: [ + SizedBox(height: 15), + // MouseRegion( + // cursor: + // SystemMouseCursors.click, + // child: GestureDetector( + // onTap: toggleField, + // child: Text( + // isEmailFieldVisible + // ? "Login with Mobile No" + // : "Login with Email", + // style: TextStyle( + // color: Color(0xFF00989E), + // ), + // ), + // ), + // ), + ], + ), + ] else if (switcherStatus == 0) ...[ + if (!resetPasswordEnable) + Column( + children: [ + if (!clickedForgotPassword) ...[ Container( height: 55, - margin: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 0), + margin: Responsive + .isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), decoration: BoxDecoration( - border: Border.all(width: 1, color: Colors.grey), - borderRadius: BorderRadius.circular(10), + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: + BorderRadius.circular( + 10), ), child: TextFormField( - controller: emailMobileController, - keyboardType: TextInputType.text, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: "Email / Mobile Number ", - contentPadding: EdgeInsets.symmetric(horizontal: 10), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Please enter email or mobile number"; - } - - String input = value.trim(); - - // ❌ Reject all spaces - if (input.contains(' ')) { - return "No spaces allowed"; - } - - final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$'); - final mobileRegex = RegExp(r'^[0-9]{10}$'); - - bool isEmailFormat = emailRegex.hasMatch(input); - bool isMobileFormat = mobileRegex.hasMatch(input); - - // --------------------------- - // 🛑 MOBILE VALIDATION - // --------------------------- - if (RegExp(r'^[0-9]+$').hasMatch(input)) { - if (input.length != 10) { - return "Mobile number must be exactly 10 digits"; - } - } - - // --------------------------- - // 🛑 EMAIL VALIDATION - // --------------------------- - - // Reject anything that has '@' but is NOT a valid email format - if (input.contains('@') && !isEmailFormat) { - return "Enter a valid email address"; - } - - // Reject email with extra digits at the end - if (input.contains('@') && RegExp(r'\d+$').hasMatch(input)) { - return "Email cannot contain extra numbers"; - } - - // Reject email+mobile combination - if (input.contains('@') && RegExp(r'\d{10}$').hasMatch(input)) { - return "Enter only email OR mobile number"; - } - - // --------------------------- - // 🛑 MIXED CONTENT (letters + digits but NOT email) - // --------------------------- - bool hasLetters = RegExp(r'[A-Za-z]').hasMatch(input); - bool hasDigits = RegExp(r'[0-9]').hasMatch(input); - - if ((hasLetters && hasDigits) && !input.contains('@')) { - return "Enter only email OR 10-digit mobile number"; - } - - // --------------------------- - // 🟢 FINAL CHECK - // --------------------------- - if (!isEmailFormat && !isMobileFormat) { - return "Enter a valid email or 10-digit mobile number"; - } - - return null; + controller: + emailController, + keyboardType: + TextInputType + .emailAddress, + decoration: + InputDecoration( + border: + InputBorder.none, + hintText: + "Enter your email", + contentPadding: + EdgeInsets + .symmetric( + horizontal: + 10), + ), + validator: (value) { + if (value == null || + value.isEmpty) { + return 'Please enter your email'; } - + if (!RegExp( + r'^[^@]+@[^@]+\.[^@]+') + .hasMatch(value)) { + return 'Please enter a valid email'; + } + return null; + }, + ), + ), + SizedBox(height: 10), + Container( + height: 55, + margin: Responsive + .isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: + BorderRadius.circular( + 10), + ), + child: TextFormField( + controller: + passwordController, + obscureText: + _obscurePassword, + textAlignVertical: + TextAlignVertical + .center, // ✅ THE FIX + decoration: + InputDecoration( + border: + InputBorder.none, + hintText: + "Enter your password", + contentPadding: + EdgeInsets + .symmetric( + horizontal: 10, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons + .visibility_off + : Icons + .visibility, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscurePassword = + !_obscurePassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || + value.isEmpty) { + return 'Please enter your password'; + } + // if (value.length < 6) { + // return 'Password must be at least 6 characters'; + // } + // // Optional strong password rule + // if (!RegExp( + // r'^(?=.*[A-Z])(?=.*[0-9]).{6,}$') + // .hasMatch(value)) { + // return 'Include at least 1 uppercase letter and 1 number'; + // } + return null; + }, ), ), SizedBox(height: 15), Container( - margin: Responsive.isDesktop( - context) + margin: Responsive + .isDesktop(context) ? EdgeInsets.symmetric( - horizontal: 150) + horizontal: 150) : EdgeInsets.symmetric( - horizontal: 0), + horizontal: 0), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .end, // Align text to the right + children: [ + InkWell( + onTap: () { + forgotPassword(); + }, + child: Text( + "Forgot Password?", + style: GoogleFonts + .poppins( + color: Colors + .blue), + ), + ), + ], + ), + ), + SizedBox(height: 15), + Container( + margin: Responsive + .isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), child: SizedBox( width: double.infinity, - height: 45, + height: 40, child: ElevatedButton( style: ElevatedButton .styleFrom( backgroundColor: - Color(0xFF00989E), + Color(0xFF00989E), shape: - RoundedRectangleBorder( + RoundedRectangleBorder( borderRadius: - BorderRadius - .circular(10), + BorderRadius + .circular( + 10), ), ), onPressed: _isLoading ? null - : verifyMobileAndEmailNumber, + : loginWithUsernameAndPw, child: _isLoading ? CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation< - Color>( - Color(0xFF00989E), - ), - ) - : Text( "Login with Email / Mobile OTP", - style: GoogleFonts - .poppins( - color: Color( - 0xFFFFFFFF), - ), - ), + valueColor: + AlwaysStoppedAnimation< + Color>( + Color( + 0xFF00989E), + ), + ) + : Text( + "Login", + style: + GoogleFonts + .poppins( + color: Color( + 0xFFFFFFFF), + ), + ), ), ), ), - SizedBox(height: 15), - // MouseRegion( - // cursor: - // SystemMouseCursors.click, - // child: GestureDetector( - // onTap: toggleField, - // child: Text( - // isEmailFieldVisible - // ? "Login with Mobile No" - // : "Login with Email", - // style: TextStyle( - // color: Color(0xFF00989E), - // ), - // ), - // ), - // ), ], - ), - ] else if (switcherStatus == 0) ...[ - if (!resetPasswordEnable) - Column( - children: [ - if (!clickedForgotPassword) ...[ - Container( - height: 55, - margin: Responsive + if (clickedForgotPassword) ...[ + Container( + height: 55, + margin: Responsive .isDesktop(context) - ? EdgeInsets.symmetric( + ? EdgeInsets.symmetric( horizontal: 150) - : EdgeInsets.symmetric( + : EdgeInsets.symmetric( horizontal: 0), - decoration: BoxDecoration( - border: Border.all( - width: 1, - color: Colors.grey), - borderRadius: + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: BorderRadius.circular( 10), - ), - child: TextFormField( - controller: - emailController, - keyboardType: - TextInputType - .emailAddress, - decoration: - InputDecoration( - border: - InputBorder.none, - hintText: - "Enter your email", - contentPadding: - EdgeInsets - .symmetric( - horizontal: - 10), - ), - validator: (value) { - if (value == null || - value.isEmpty) { - return 'Please enter your email'; - } - if (!RegExp( - r'^[^@]+@[^@]+\.[^@]+') - .hasMatch(value)) { - return 'Please enter a valid email'; - } - return null; - }, - ), - ), - SizedBox(height: 10), - Container( - height: 55, - margin: Responsive - .isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - decoration: BoxDecoration( - border: Border.all( - width: 1, - color: Colors.grey), - borderRadius: - BorderRadius.circular( - 10), - ), - child: TextFormField( - controller: - passwordController, - obscureText: - _obscurePassword, - textAlignVertical: - TextAlignVertical - .center, // ✅ THE FIX - decoration: - InputDecoration( - border: - InputBorder.none, - hintText: - "Enter your password", - contentPadding: - EdgeInsets - .symmetric( - horizontal: 10, - ), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons - .visibility_off - : Icons - .visibility, - color: Colors.grey, - ), - onPressed: () { - setState(() { - _obscurePassword = - !_obscurePassword; - }); - }, - ), - ), - validator: (value) { - if (value == null || - value.isEmpty) { - return 'Please enter your password'; - } - // if (value.length < 6) { - // return 'Password must be at least 6 characters'; - // } - // // Optional strong password rule - // if (!RegExp( - // r'^(?=.*[A-Z])(?=.*[0-9]).{6,}$') - // .hasMatch(value)) { - // return 'Include at least 1 uppercase letter and 1 number'; - // } - return null; - }, - ), - ), - SizedBox(height: 15), - Container( - margin: Responsive - .isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: Row( - mainAxisAlignment: - MainAxisAlignment - .end, // Align text to the right - children: [ - InkWell( - onTap: () { - forgotPassword(); - }, - child: Text( - "Forgot Password?", - style: GoogleFonts - .poppins( - color: Colors - .blue), - ), - ), - ], - ), - ), - SizedBox(height: 15), - Container( - margin: Responsive - .isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: SizedBox( - width: double.infinity, - height: 40, - child: ElevatedButton( - style: ElevatedButton - .styleFrom( - backgroundColor: - Color(0xFF00989E), - shape: - RoundedRectangleBorder( - borderRadius: - BorderRadius - .circular( - 10), - ), - ), - onPressed: _isLoading - ? null - : loginWithUsernameAndPw, - child: _isLoading - ? CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation< - Color>( - Color( - 0xFF00989E), - ), - ) - : Text( - "Login", - style: - GoogleFonts - .poppins( - color: Color( - 0xFFFFFFFF), - ), - ), - ), - ), - ), - ], - if (clickedForgotPassword) ...[ - Container( - height: 55, - margin: Responsive - .isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - decoration: BoxDecoration( - border: Border.all( - width: 1, - color: Colors.grey), - borderRadius: - BorderRadius.circular( - 10), - ), - child: TextFormField( - controller: - emailController, - keyboardType: - TextInputType - .emailAddress, - decoration: - InputDecoration( - border: - InputBorder.none, - hintText: - "Enter your email", - contentPadding: - EdgeInsets - .symmetric( - horizontal: - 10), - ), - readOnly: otpFieldShow, - validator: (value) { - if (value == null || - value.isEmpty) { - return 'Please enter your email'; - } - if (!RegExp( - r'^[^@]+@[^@]+\.[^@]+') - .hasMatch(value)) { - return 'Please enter a valid email'; - } - return null; - }, - ), - ), - if (otpFieldShow && clickedForgotPassword) ...[ - const SizedBox(height: 10), - AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - switchInCurve: Curves.easeInOutCirc, - switchOutCurve: Curves.easeOutCirc, - child: Column( - key: const ValueKey('otp_block'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Text( - 'OTP', - style: TextStyle( - fontSize: Responsive.isMobile(context) ? 14 : 18, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - ), - const SizedBox(height: 10), - Container( - alignment: Alignment.center, - margin: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 0), - child: Pinput( - length: 6, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - keyboardType: TextInputType.number, - showCursor: true, - controller: _otpController, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter OTP'; - } - if (value.length < 6) { - return 'OTP must be 6 digits'; - } - return null; - }, - ), - ), - const SizedBox(height: 10), - Container( - margin: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 0), - alignment: Alignment.centerRight, - child: InkWell( - onTap: () { - setState(() { - otpFieldShow = false; - }); - resendOTP(); - }, - mouseCursor: SystemMouseCursors.click, - child: Text( - 'Didn\'t Receive Code?', - style: GoogleFonts.poppins( - color: Colors.blue, - fontSize: 14, - ), - ), - ), - ), - ], - ), - ), - ], - SizedBox(height: 20), - Container( - margin: Responsive - .isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: SizedBox( - width: double.infinity, - height: 40, - child: ElevatedButton( - style: ElevatedButton - .styleFrom( - backgroundColor: - Color(0xFF00989E), - shape: - RoundedRectangleBorder( - borderRadius: - BorderRadius - .circular( - 10), - ), - ), - onPressed: _isLoading - ? null - : () => { - otpFieldShow - ? otpVerify() - : mailVerify() - }, - child: _isLoading - ? CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation< - Color>( - Color( - 0xFF00989E), - ), - ) - : Text( - otpFieldShow - ? "Verify OTP " - : "Verify Mail", - style: - GoogleFonts - .poppins( - color: Color( - 0xFFFFFFFF), - ), - ), - ), - ), - ), - ], - ], - ), - if (resetPasswordEnable) ...[ - Column( - children: [ - Container( - height: 55, - margin: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 0), - decoration: BoxDecoration( - border: Border.all(width: 1, color: Colors.grey), - borderRadius: BorderRadius.circular(10), - ), - child: TextFormField( - key: const ValueKey('new_password_field'), - controller: resetPasswordController, - obscureText: _resetObscurePassword, - onChanged: validatePassword, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - border: InputBorder.none, - hintText: "New Password", - contentPadding: const EdgeInsets.symmetric(horizontal: 10), - suffixIcon: IconButton( - icon: Icon( - _resetObscurePassword ? Icons.visibility_off : Icons.visibility, - color: Colors.grey, - ), - onPressed: () { - setState(() { - _resetObscurePassword = !_resetObscurePassword; - }); - }, - ), - ), - ), ), - - const SizedBox(height: 8), - - // 🔹 VALIDATION LIST - Padding( - padding: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 5), + child: TextFormField( + controller: + emailController, + keyboardType: + TextInputType + .emailAddress, + decoration: + InputDecoration( + border: + InputBorder.none, + hintText: + "Enter your email", + contentPadding: + EdgeInsets + .symmetric( + horizontal: + 10), + ), + readOnly: otpFieldShow, + validator: (value) { + if (value == null || + value.isEmpty) { + return 'Please enter your email'; + } + if (!RegExp( + r'^[^@]+@[^@]+\.[^@]+') + .hasMatch(value)) { + return 'Please enter a valid email'; + } + return null; + }, + ), + ), + if (otpFieldShow && + clickedForgotPassword) ...[ + const SizedBox(height: 10), + AnimatedSwitcher( + duration: const Duration( + milliseconds: 500), + switchInCurve: + Curves.easeInOutCirc, + switchOutCurve: + Curves.easeOutCirc, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + key: const ValueKey( + 'otp_block'), + crossAxisAlignment: + CrossAxisAlignment + .start, children: [ - Row( - children: [ - Expanded(child: _buildCheckItem(hasMinLength, "Minimum 8 characters")), - Expanded(child: _buildCheckItem(hasSpecialChar, "1 special character")), - ], + Center( + child: Text( + 'OTP', + style: TextStyle( + fontSize: Responsive + .isMobile( + context) + ? 14 + : 18, + fontWeight: + FontWeight + .w600, + color: Colors + .black, + ), + ), ), - Row( - children: [ - Expanded(child: _buildCheckItem(hasUpperLower, "1 UPPER or lower case")), - Expanded(child: _buildCheckItem(hasNumber, "1 numerical")), - ], + const SizedBox( + height: 10), + Container( + alignment: Alignment + .center, + margin: Responsive + .isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: + 150) + : const EdgeInsets + .symmetric( + horizontal: + 0), + child: Pinput( + length: 6, + inputFormatters: [ + FilteringTextInputFormatter + .digitsOnly, + ], + keyboardType: + TextInputType + .number, + showCursor: true, + controller: + _otpController, + validator: + (value) { + if (value == + null || + value + .isEmpty) { + return 'Please enter OTP'; + } + if (value + .length < + 6) { + return 'OTP must be 6 digits'; + } + return null; + }, + ), + ), + const SizedBox( + height: 10), + Container( + margin: Responsive + .isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: + 150) + : const EdgeInsets + .symmetric( + horizontal: + 0), + alignment: Alignment + .centerRight, + child: InkWell( + onTap: () { + setState(() { + otpFieldShow = + false; + }); + resendOTP(); + }, + mouseCursor: + SystemMouseCursors + .click, + child: Text( + 'Didn\'t Receive Code?', + style: + GoogleFonts + .poppins( + color: Colors + .blue, + fontSize: 14, + ), + ), + ), ), ], ), ), - const SizedBox(height: 10), - Container( - height: 55, - margin: Responsive.isDesktop(context) - ? const EdgeInsets.symmetric(horizontal: 150) - : const EdgeInsets.symmetric(horizontal: 0), - decoration: BoxDecoration( - border: Border.all(width: 1, color: Colors.grey), - borderRadius: BorderRadius.circular(10), - ), - child: TextFormField( - key: const ValueKey('confirm_password_field'), - controller: confirmPasswordController, - obscureText: _obscureConfirmPassword, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - border: InputBorder.none, - hintText: "Confirm Password", - contentPadding: const EdgeInsets.symmetric(horizontal: 10), - suffixIcon: IconButton( - icon: Icon( - _obscureConfirmPassword - ? Icons.visibility_off - : Icons.visibility, - color: Colors.grey, - ), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, + ], + SizedBox(height: 20), + Container( + margin: Responsive + .isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + child: SizedBox( + width: double.infinity, + height: 40, + child: ElevatedButton( + style: ElevatedButton + .styleFrom( + backgroundColor: + Color(0xFF00989E), + shape: + RoundedRectangleBorder( + borderRadius: + BorderRadius + .circular( + 10), ), ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please confirm your password'; - } - if (value != resetPasswordController.text) { - return 'Passwords do not match'; - } - return null; - }, + onPressed: _isLoading + ? null + : () => { + otpFieldShow + ? otpVerify() + : mailVerify() + }, + child: _isLoading + ? CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation< + Color>( + Color( + 0xFF00989E), + ), + ) + : Text( + otpFieldShow + ? "Verify OTP " + : "Verify Mail", + style: + GoogleFonts + .poppins( + color: Color( + 0xFFFFFFFF), + ), + ), ), ), - ], - ), - SizedBox(height: 15), + ), + ], + ], + ), + if (resetPasswordEnable) ...[ + Column( + children: [ Container( - margin: - Responsive.isDesktop(context) - ? EdgeInsets.symmetric( - horizontal: 150) - : EdgeInsets.symmetric( - horizontal: 0), - child: SizedBox( - width: double.infinity, - height: 40, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: isPasswordValid ? Color(0xFF00989E) : Colors.grey.shade400, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + height: 55, + margin: Responsive.isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: 150) + : const EdgeInsets + .symmetric( + horizontal: 0), + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: + BorderRadius.circular( + 10), + ), + child: TextFormField( + key: const ValueKey( + 'new_password_field'), + controller: + resetPasswordController, + obscureText: + _resetObscurePassword, + onChanged: validatePassword, + textAlignVertical: + TextAlignVertical + .center, + decoration: InputDecoration( + border: InputBorder.none, + hintText: "New Password", + contentPadding: + const EdgeInsets + .symmetric( + horizontal: 10), + suffixIcon: IconButton( + icon: Icon( + _resetObscurePassword + ? Icons + .visibility_off + : Icons + .visibility, + color: Colors.grey, ), - ), - onPressed: _isLoading || !isPasswordValid - ? null - : () { - resetYourPassword(); - }, - child: _isLoading - ? CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ) - : Text( - "Reset Password", - style: GoogleFonts.poppins(color: Colors.white), + onPressed: () { + setState(() { + _resetObscurePassword = + !_resetObscurePassword; + }); + }, ), ), ), ), - ] - ], - SizedBox( - height: _size.width <= 1100 ? 0 : 0, + + const SizedBox(height: 8), + + // 🔹 VALIDATION LIST + Padding( + padding: Responsive.isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: 150) + : const EdgeInsets + .symmetric( + horizontal: 5), + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Row( + children: [ + Expanded( + child: _buildCheckItem( + hasMinLength, + "Minimum 8 characters")), + Expanded( + child: _buildCheckItem( + hasSpecialChar, + "1 special character")), + ], + ), + Row( + children: [ + Expanded( + child: _buildCheckItem( + hasUpperLower, + "1 UPPER or lower case")), + Expanded( + child: _buildCheckItem( + hasNumber, + "1 numerical")), + ], + ), + ], + ), + ), + const SizedBox(height: 10), + Container( + height: 55, + margin: Responsive.isDesktop( + context) + ? const EdgeInsets + .symmetric( + horizontal: 150) + : const EdgeInsets + .symmetric( + horizontal: 0), + decoration: BoxDecoration( + border: Border.all( + width: 1, + color: Colors.grey), + borderRadius: + BorderRadius.circular( + 10), + ), + child: TextFormField( + key: const ValueKey( + 'confirm_password_field'), + controller: + confirmPasswordController, + obscureText: + _obscureConfirmPassword, + textAlignVertical: + TextAlignVertical + .center, + decoration: InputDecoration( + border: InputBorder.none, + hintText: + "Confirm Password", + contentPadding: + const EdgeInsets + .symmetric( + horizontal: 10), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirmPassword + ? Icons + .visibility_off + : Icons + .visibility, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _obscureConfirmPassword = + !_obscureConfirmPassword; + }); + }, + ), + ), + validator: (value) { + if (value == null || + value.isEmpty) { + return 'Please confirm your password'; + } + if (value != + resetPasswordController + .text) { + return 'Passwords do not match'; + } + return null; + }, + ), + ), + ], ), - SizedBox( - height: Responsive.isDesktop(context) - ? _size.height * 0.1 - : _size.height * 0.2, - ), - // SizedBox( - // height: _size.height * 0.1, - // ), + SizedBox(height: 15), Container( - alignment: Alignment.bottomCenter, - padding: + margin: + Responsive.isDesktop(context) + ? EdgeInsets.symmetric( + horizontal: 150) + : EdgeInsets.symmetric( + horizontal: 0), + child: SizedBox( + width: double.infinity, + height: 40, + child: ElevatedButton( + style: + ElevatedButton.styleFrom( + backgroundColor: + isPasswordValid + ? Color(0xFF00989E) + : Colors + .grey.shade400, + shape: + RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 10), + ), + ), + onPressed: _isLoading || + !isPasswordValid + ? null + : () { + resetYourPassword(); + }, + child: _isLoading + ? CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation< + Color>( + Colors.white), + ) + : Text( + "Reset Password", + style: GoogleFonts + .poppins( + color: Colors + .white), + ), + ), + ), + ), + ] + ], + SizedBox( + height: _size.width <= 1100 ? 0 : 0, + ), + SizedBox( + height: Responsive.isDesktop(context) + ? _size.height * 0.1 + : _size.height * 0.2, + ), + // SizedBox( + // height: _size.height * 0.1, + // ), + Container( + alignment: Alignment.bottomCenter, + padding: EdgeInsets.symmetric(vertical: 8), - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - text: + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: 'By continuing, you agree with our ', + style: GoogleFonts.poppins( + color: Colors.black, + fontSize: 9, + ), + children: [ + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors + .click, + child: GestureDetector( + onTap: () { + context.go( + '/privacypolicy'); + // Navigator.pushNamed( + // context, + // 'privacypolicy'); + }, + child: Text( + 'privacy policy ', + style: + GoogleFonts.poppins( + color: + Color(0xFF00989E), + fontSize: 9, + decoration: + TextDecoration + .underline, + ), + ), + ), + ), + ), + TextSpan( + text: 'and ', style: GoogleFonts.poppins( color: Colors.black, fontSize: 9, ), - children: [ - WidgetSpan( - child: MouseRegion( - cursor: SystemMouseCursors - .click, - child: GestureDetector( - onTap: () { - context.go( - '/privacypolicy'); - // Navigator.pushNamed( - // context, - // 'privacypolicy'); - }, - child: Text( - 'privacy policy ', - style: - GoogleFonts.poppins( - color: - Color(0xFF00989E), - fontSize: 9, - decoration: - TextDecoration - .underline, - ), - ), - ), - ), - ), - TextSpan( - text: 'and ', - style: GoogleFonts.poppins( - color: Colors.black, - fontSize: 9, - ), - ), - WidgetSpan( - child: MouseRegion( - cursor: SystemMouseCursors - .click, - child: GestureDetector( - onTap: () { - context - .go('/termsofuse'); - // Navigator.pushNamed( - // context, - // 'termsofuse'); - }, - child: Text( - 'terms of use', - style: - GoogleFonts.poppins( - color: - Color(0xFF00989E), - fontSize: 9, - decoration: - TextDecoration - .underline, - ), - ), - ), - ), - ), - ], ), - ), + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors + .click, + child: GestureDetector( + onTap: () { + context + .go('/termsofuse'); + // Navigator.pushNamed( + // context, + // 'termsofuse'); + }, + child: Text( + 'terms of use', + style: + GoogleFonts.poppins( + color: + Color(0xFF00989E), + fontSize: 9, + decoration: + TextDecoration + .underline, + ), + ), + ), + ), + ), + ], ), - ], + ), ), - ), + ], ), - if (_size.width > 1100) - Expanded( - flex: _size.width < 1100 ? 6 : 12, - child: LayoutBuilder( - builder: (BuildContext context, - BoxConstraints constraints) { - if (constraints.maxWidth > 600) { - return Image.asset( - 'assets/login_web.jpg', - height: _size.height, - fit: BoxFit.cover, - ); - } else { - return SizedBox(); - } - }, - ), - ), - ], + ), ), + if (_size.width > 1100) + Expanded( + flex: _size.width < 1100 ? 6 : 12, + child: LayoutBuilder( + builder: (BuildContext context, + BoxConstraints constraints) { + if (constraints.maxWidth > 600) { + return Image.asset( + 'assets/login_web.jpg', + height: _size.height, + fit: BoxFit.cover, + ); + } else { + return SizedBox(); + } + }, + ), + ), ], ), - ), + ], ), ), - ], - )), - ))); + ), + ), + ], + )), + ))); } Widget _buildCheckItem(bool status, String text) { @@ -2088,7 +2637,6 @@ class _loginState extends State { ); } - Widget buildTab(String title, int index) { final isSelected = selectedIndex == index + 1; return GestureDetector( @@ -2134,15 +2682,15 @@ class _loginState extends State { color: isSelected ? const Color(0xFF00989E) : Colors.transparent, boxShadow: isSelected ? [ - BoxShadow( - color: isSelected - ? const Color(0xFF00989E) - : Colors.transparent, // Grey shadow - spreadRadius: 0.2, - blurRadius: 1, - offset: const Offset(0, 1), // Horizontal, Vertical - ), - ] + BoxShadow( + color: isSelected + ? const Color(0xFF00989E) + : Colors.transparent, // Grey shadow + spreadRadius: 0.2, + blurRadius: 1, + offset: const Offset(0, 1), // Horizontal, Vertical + ), + ] : [], borderRadius: BorderRadius.circular(16), ), @@ -2157,4 +2705,4 @@ class _loginState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/login_saml_auth.dart b/lib/pages/login_saml_auth.dart new file mode 100644 index 0000000..c11757a --- /dev/null +++ b/lib/pages/login_saml_auth.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:jwt_decode/jwt_decode.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/environment.dart'; +import '../customAppBar/toastHelper.dart'; +import '../logger.dart'; +import 'service/SessionManager.dart'; +import 'service/TokenService.dart'; + +/// Where the IdP should return the browser after SAML (web). +/// +/// Uses **`/login`** (not `/sso-login`) so static hosts that do not rewrite +/// unknown paths to `index.html` still return **200** and Flutter can read +/// `?payload=`. `/sso-login` is still supported via `web/index.html` redirect. +String buildWebSsoRelayStateUrl() { + if (!kIsWeb) return ''; + final origin = Uri.base.origin; + final frag = Uri.base.fragment; + // Hash routing: e.g. `http://host/#/login` → return with payload on `#/login` + if (frag.startsWith('/')) { + return '$origin/#/login'; + } + final href = Environment.baseHref; + if (href.isEmpty || href == '/') { + return '$origin/login'; + } + final trimmed = + href.endsWith('/') ? href.substring(0, href.length - 1) : href; + return '$origin$trimmed/login'; +} + +/// Decodes the `payload` query value (base64 / base64url) into a JSON map. +Map decodeSsoPayloadQueryValue(String encoded) { + var normalized = encoded.trim().replaceAll('-', '+').replaceAll('_', '/'); + final mod = normalized.length % 4; + if (mod > 0) { + normalized = normalized.padRight(normalized.length + (4 - mod), '='); + } + final jsonStr = utf8.decode(base64.decode(normalized)); + final decoded = json.decode(jsonStr); + if (decoded is Map) return decoded; + if (decoded is Map) { + return Map.from(decoded); + } + throw const FormatException('SSO payload is not a JSON object'); +} + +String? readPayloadFromRouterUri(Uri uri) { + final direct = uri.queryParameters['payload']; + if (direct != null && direct.isNotEmpty) return direct; + final frag = uri.fragment; + if (frag.contains('?')) { + final queryPart = frag.split('?').last; + return Uri.splitQueryString(queryPart)['payload']; + } + return null; +} + +/// Locates the raw `payload` query value on a SAML return URL, e.g. +/// `https://staging.nhanceindia.in/sso-login?payload=eyJ...` or +/// `https://host/#/sso-login?payload=eyJ...`. +String? readSsoPayloadEncodedFromReturnUrl(String url) { + final trimmed = url.trim(); + if (trimmed.isEmpty) return null; + + final uri = Uri.tryParse(trimmed); + if (uri != null) { + final fromUri = readPayloadFromRouterUri(uri); + if (fromUri != null && fromUri.isNotEmpty) { + return fromUri; + } + } + + // Fallback: stop before `#` so `...?payload=eyJ...#/login` does not swallow the hash. + final ampOrQ = RegExp(r'(?:\?|&)payload=([^&#]+)'); + final match = ampOrQ.firstMatch(trimmed); + if (match != null) { + final raw = match.group(1); + if (raw != null && raw.isNotEmpty) { + return Uri.decodeQueryComponent(raw); + } + } + + final hashIdx = trimmed.indexOf('#'); + if (hashIdx != -1) { + final afterHash = trimmed.substring(hashIdx + 1); + if (afterHash.contains('payload=')) { + final queryPart = + afterHash.contains('?') ? afterHash.split('?').last : afterHash; + final fromFrag = Uri.splitQueryString(queryPart)['payload']; + if (fromFrag != null && fromFrag.isNotEmpty) { + return fromFrag; + } + } + } + + return null; +} + +Map? decodeSsoPayloadFromUrl(String url) { + try { + final raw = readSsoPayloadEncodedFromReturnUrl(url); + if (raw == null || raw.isEmpty) return null; + return decodeSsoPayloadQueryValue(raw); + } catch (_) { + return null; + } +} + +/// JWT claim maps for pre-enrollment (`data`) and post-enrollment tokens when +/// those fields hold a JWT string (typically after successful SAML). +class SsoPayloadJwtDecode { + final Map? preTokenClaims; + final Map? postTokenClaims; + + const SsoPayloadJwtDecode({ + this.preTokenClaims, + this.postTokenClaims, + }); +} + +SsoPayloadJwtDecode decodeJwtClaimsFromSsoPayloadMap( + Map data) { + Map? preClaims; + Map? postClaims; + + final pre = data['data']; + if (pre is String && pre.contains('.')) { + try { + preClaims = Jwt.parseJwt(pre); + } catch (_) {} + } + + final rawPe = data['post_enrollment']; + if (rawPe is Map) { + final pt = rawPe['data']; + if (pt is String && pt.contains('.')) { + try { + postClaims = Jwt.parseJwt(pt); + } catch (_) {} + } + } + + return SsoPayloadJwtDecode( + preTokenClaims: preClaims, + postTokenClaims: postClaims, + ); +} + +String prettySsoJsonForConsole(Object? value) { + if (value == null) return 'null'; + try { + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return value.toString(); + } +} + +/// Writes JWT claim maps to stdout / the browser DevTools console. Unlike +/// [logDebug], this is not filtered by [LoggerConfig.minLevel]. +void printSsoDecodedTokensToConsole(SsoPayloadJwtDecode jwt) { + print( + '[SSO] decodedToken (pre / data field):\n${prettySsoJsonForConsole(jwt.preTokenClaims)}'); + print( + '[SSO] decodedToken (post_enrollment.data):\n${prettySsoJsonForConsole(jwt.postTokenClaims)}'); +} + +Future _persistClientBranding() async { + final session = SessionManager(); + final pre = await TokenService.getPreToken(); + if (pre == null || pre.isEmpty) return; + + final url = Uri.parse(Environment.apiUrlEnrollment + + 'getClientDetails?post_client_id=${session.client_id}&post_branch_id=${session.empClientBranchId}&pre_client_id=${session.enrollmentClient_id}&pre_branch_id=${session.enrollmentEmpClientBranchId}'); + + try { + final response = await http.get( + url, + headers: { + 'Authorization': 'Bearer $pre', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }, + ); + if (response.statusCode != 200) return; + final data = json.decode(response.body) as Map; + if (!data.containsKey('data')) return; + final clientDetails = data['data']; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'clientLogo', clientDetails['client']['client_logo'] as String? ?? ''); + await prefs.setString( + 'clientName', clientDetails['client']['client_name'] as String? ?? ''); + await prefs.setString('addon_subheading', + clientDetails['client']['addon_subheading'] as String? ?? ''); + } catch (e) { + logDebug('SSO client branding fetch failed: $e'); + } +} + +/// Completes login after SAML callback payload has been decoded to a map. +Future completeLoginFromSsoPayloadMap( + BuildContext context, Map data) async { + logDebug('SSO payload map: $data'); + print('[SSO] payload map (decoded from URL):\n${prettySsoJsonForConsole(data)}'); + final jwt = decodeJwtClaimsFromSsoPayloadMap(data); + printSsoDecodedTokensToConsole(jwt); + if (jwt.preTokenClaims != null) { + logDebug('SSO decodedToken (pre / data): ${jwt.preTokenClaims}'); + } + if (jwt.postTokenClaims != null) { + logDebug('SSO decodedToken (post_enrollment / data): ${jwt.postTokenClaims}'); + } + + if (data['status']?.toString() != 'success') { + final msg = data['message']?.toString() ?? 'SSO sign-in was not successful'; + if (context.mounted) ToastHelper.showErrorToast(context, msg); + if (context.mounted) context.go('/login'); + return; + } + + String? preToken; + if (data['data'] is String) { + preToken = data['data'] as String; + } + String? postToken; + Map? postEnrollment; + final rawPe = data['post_enrollment']; + if (rawPe is Map) { + postEnrollment = Map.from(rawPe); + } + if (postEnrollment != null && postEnrollment['data'] is String) { + postToken = postEnrollment['data'] as String; + } + + await TokenService.saveTokens(preToken: preToken, postToken: postToken); + + if (postEnrollment != null && + postEnrollment['status']?.toString() == 'success' && + postToken != null && + postToken.isNotEmpty) { + await SessionManager().initializeFromPostToken(postToken); + } + if (data['status']?.toString() == 'success' && + preToken != null && + preToken.isNotEmpty) { + await SessionManager().initializeFromPreToken(preToken); + } + + await _persistClientBranding(); + + if (!context.mounted) return; + + if (postToken != null && postToken.isNotEmpty) { + ToastHelper.showSuccessToast(context, 'Successfully Logged In'); + SessionManager().unlockMobileAppSession(); + context.go('/home'); + } else { + SessionManager().unlockMobileAppSession(); + context.go('/empDetails'); + } +} + +Future completeLoginFromSsoPayloadString( + BuildContext context, String encodedPayload) async { + final data = decodeSsoPayloadQueryValue(encodedPayload); + await completeLoginFromSsoPayloadMap(context, data); +} diff --git a/lib/pages/login_saml_webview_screen.dart b/lib/pages/login_saml_webview_screen.dart new file mode 100644 index 0000000..ef33ccf --- /dev/null +++ b/lib/pages/login_saml_webview_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import 'login_saml_auth.dart'; + +/// Mobile: in-app WebView for SAML entry; when URL contains `payload=`, decodes and pops. +class LoginSamlWebViewScreen extends StatefulWidget { + final String initialUrl; + + const LoginSamlWebViewScreen({super.key, required this.initialUrl}); + + @override + State createState() => _LoginSamlWebViewScreenState(); +} + +class _LoginSamlWebViewScreenState extends State { + late final WebViewController _controller; + bool _finished = false; + + void _tryFinishWithUrl(String url) { + if (_finished || !mounted) return; + if (!url.contains('payload=')) return; + final decoded = decodeSsoPayloadFromUrl(url); + if (decoded != null) { + _finished = true; + Navigator.of(context).pop(decoded); + } + } + + void _tryFinishFromHttpError(HttpResponseError error) { + if (_finished || !mounted) return; + final status = error.response?.statusCode; + if (status == null) return; + if (status < 400) return; + final reqUri = error.request?.uri; + final resUri = error.response?.uri; + if (reqUri != null) { + _tryFinishWithUrl(reqUri.toString()); + } + if (!_finished && resUri != null) { + _tryFinishWithUrl(resUri.toString()); + } + } + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (NavigationRequest request) { + if (!_finished && request.url.contains('payload=')) { + _tryFinishWithUrl(request.url); + if (_finished) return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + onPageStarted: (String url) => _tryFinishWithUrl(url), + onPageFinished: (String url) => _tryFinishWithUrl(url), + onUrlChange: (UrlChange change) { + final url = change.url; + if (url != null && url.isNotEmpty) { + _tryFinishWithUrl(url); + } + }, + onHttpError: _tryFinishFromHttpError, + onWebResourceError: (WebResourceError error) { + final url = error.url; + if (url != null && url.isNotEmpty) { + _tryFinishWithUrl(url); + } + }, + ), + ) + ..loadRequest(Uri.parse(widget.initialUrl)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sign in with SSO')), + body: WebViewWidget(controller: _controller), + ); + } +} diff --git a/lib/pages/postEnrollment/claims.dart b/lib/pages/postEnrollment/claims.dart index d925fc1..0a3257f 100755 --- a/lib/pages/postEnrollment/claims.dart +++ b/lib/pages/postEnrollment/claims.dart @@ -1043,7 +1043,7 @@ class _claimsState extends State { Text( isRetail ? insurerShortName : policyName, style: GoogleFonts.poppins( - fontSize: Responsive.isDesktop(context) ? 18 : 14, + fontSize: Responsive.isDesktop(context) ? 18 : 11, fontWeight: FontWeight.w600), ), ], @@ -1065,7 +1065,7 @@ class _claimsState extends State { Text( isRetail ? policyType : policyStatus, style: GoogleFonts.poppins( - fontSize: Responsive.isDesktop(context) ? 18 : 14, + fontSize: Responsive.isDesktop(context) ? 18 : 11, fontWeight: FontWeight.w600, color: isRetail ? Colors.black @@ -1093,7 +1093,7 @@ class _claimsState extends State { Text( isRetail ? vehicleNo : "₹ $siValue", style: GoogleFonts.poppins( - fontSize: Responsive.isDesktop(context) ? 18 : 14, + fontSize: Responsive.isDesktop(context) ? 18 : 11, fontWeight: FontWeight.w600), ), ], diff --git a/lib/pages/postEnrollment/help.dart b/lib/pages/postEnrollment/help.dart index 0d17038..5944e31 100755 --- a/lib/pages/postEnrollment/help.dart +++ b/lib/pages/postEnrollment/help.dart @@ -222,7 +222,7 @@ class _helpState extends State { // trackClaimsClosedList = []; // }); // } else { - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // logDebug('API request failed with status: ${response['status']}'); // } } @@ -266,7 +266,7 @@ class _helpState extends State { // setState(() { // isLoading = false; // }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // logDebug('API request failed with status: ${response['status']}'); // } } catch (e) { diff --git a/lib/pages/postEnrollment/planclaimsform.dart b/lib/pages/postEnrollment/planclaimsform.dart index 166ef7e..51b3377 100755 --- a/lib/pages/postEnrollment/planclaimsform.dart +++ b/lib/pages/postEnrollment/planclaimsform.dart @@ -236,6 +236,7 @@ class _planclaimsformState extends State { return { 'id': int.parse(memberList['id']), 'name': memberList['name'], + 'relationship': memberList['relationship'], }; }).toList(); logDebug('employeePolicyList : $employeePolicyList'); @@ -566,6 +567,7 @@ class _planclaimsformState extends State { return { 'id': int.parse(memberList['id']), 'name': memberList['name'], + 'relationship': memberList['relationship'], }; }).toList(); logDebug('employeePolicyList : $employeePolicyList'); @@ -753,59 +755,59 @@ class _planclaimsformState extends State { } // ✅ NEW VALIDATION: At least one PDF must be uploaded - // bool hasPdf = uploadedFiles.any((uf) { - // final ext = uf.file.extension?.toLowerCase() ?? ''; - // return ext == 'pdf'; - // }); - // - // if (!hasPdf) { - // ToastHelper.showErrorToast(context, 'Please upload at least one PDF document'); - // setState(() => isLoading = false); - // return; - // } + bool hasPdf = uploadedFiles.any((uf) { + final ext = uf.file.extension?.toLowerCase() ?? ''; + return ext == 'pdf'; + }); + + if (!hasPdf) { + ToastHelper.showErrorToast(context, 'Please upload at least one PDF document'); + setState(() => isLoading = false); + return; + } // Add files - // for (var uf in uploadedFiles) { - // final pf = uf.file; - // if (pf.bytes != null) { - // request.files.add(http.MultipartFile.fromBytes( - // 'claim_docs[]', - // pf.bytes!, - // filename: pf.name, - // )); - // } - // } - - // Add files — convert images to PDF if needed for (var uf in uploadedFiles) { final pf = uf.file; - final ext = pf.extension?.toLowerCase() ?? ''; - if (pf.bytes != null) { - Uint8List fileBytes = pf.bytes!; - - if (['jpg', 'jpeg', 'png', 'heic'].contains(ext)) { - // ✅ Convert image → PDF - final pdf = pw.Document(); - final image = pw.MemoryImage(fileBytes); - pdf.addPage(pw.Page( - build: (pw.Context context) => - pw.Center(child: pw.Image(image, fit: pw.BoxFit.contain)), - )); - fileBytes = await pdf.save(); - final pdfFileName = pf.name.replaceAll(RegExp(r'\.\w+$'), '.pdf'); - - logDebug('📄 Converted image ${pf.name} → PDF ($pdfFileName)'); - request.files.add(http.MultipartFile.fromBytes( - 'claim_docs[]', fileBytes, filename: pdfFileName)); - } else { - // ✅ Already a PDF - request.files.add(http.MultipartFile.fromBytes( - 'claim_docs[]', fileBytes, filename: pf.name)); - } + request.files.add(http.MultipartFile.fromBytes( + 'claim_docs[]', + pf.bytes!, + filename: pf.name, + )); } } + // Add files — convert images to PDF if needed + // for (var uf in uploadedFiles) { + // final pf = uf.file; + // final ext = pf.extension?.toLowerCase() ?? ''; + + // if (pf.bytes != null) { + // Uint8List fileBytes = pf.bytes!; + + // if (['jpg', 'jpeg', 'png', 'heic'].contains(ext)) { + // // ✅ Convert image → PDF + // final pdf = pw.Document(); + // final image = pw.MemoryImage(fileBytes); + // pdf.addPage(pw.Page( + // build: (pw.Context context) => + // pw.Center(child: pw.Image(image, fit: pw.BoxFit.contain)), + // )); + // fileBytes = await pdf.save(); + // final pdfFileName = pf.name.replaceAll(RegExp(r'\.\w+$'), '.pdf'); + + // logDebug('📄 Converted image ${pf.name} → PDF ($pdfFileName)'); + // request.files.add(http.MultipartFile.fromBytes( + // 'claim_docs[]', fileBytes, filename: pdfFileName)); + // } else { + // // ✅ Already a PDF + // request.files.add(http.MultipartFile.fromBytes( + // 'claim_docs[]', fileBytes, filename: pf.name)); + // } + // } + // } + // ✅ Combine all names into a JSON array string final claimDocNames = uploadedFiles.map((uf) => uf.label.trim()).toList(); diff --git a/lib/pages/postEnrollment/raisedTicketList.dart b/lib/pages/postEnrollment/raisedTicketList.dart index 907160f..313dba9 100755 --- a/lib/pages/postEnrollment/raisedTicketList.dart +++ b/lib/pages/postEnrollment/raisedTicketList.dart @@ -141,7 +141,7 @@ class _raisedTicketHistoryState extends State { // setState(() { // isLoading = false; // }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // logDebug('API request failed with status: ${response['status']}'); // } } catch (e) { diff --git a/lib/pages/postEnrollment/retailClaimForm.dart b/lib/pages/postEnrollment/retailClaimForm.dart index 70c363f..3f0c468 100755 --- a/lib/pages/postEnrollment/retailClaimForm.dart +++ b/lib/pages/postEnrollment/retailClaimForm.dart @@ -193,7 +193,7 @@ class _retailClaimFormsState extends State { } } catch (e) { logDebug("Retail claim submit ERROR: $e"); - ToastHelper.showErrorToast(context, "Something went wrong"); + ToastHelper.showErrorToast(context, "Unable to process. Please try again later"); } setState(() => isLoading = false); diff --git a/lib/pages/postEnrollment/tickettracklist.dart b/lib/pages/postEnrollment/tickettracklist.dart index 898d8af..b28cac6 100755 --- a/lib/pages/postEnrollment/tickettracklist.dart +++ b/lib/pages/postEnrollment/tickettracklist.dart @@ -212,7 +212,7 @@ class _tickettracklistState extends State { // setState(() { // isLoading = false; // }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // logDebug('API request failed with status: ${response['status']}'); // } } catch (e) { diff --git a/lib/pages/service/SessionManager.dart b/lib/pages/service/SessionManager.dart index 83bfb62..a1535d2 100755 --- a/lib/pages/service/SessionManager.dart +++ b/lib/pages/service/SessionManager.dart @@ -156,6 +156,22 @@ class SessionManager { bool isReady = false; + /// Mobile only: false after a full app restart until login / MPIN succeeds. + /// Stays true while the process stays alive (e.g. user only minimized the app). + /// Web always behaves as unlocked ([isMobileAppSessionUnlocked] is true). + bool _mobileAppSessionUnlocked = false; + + bool get isMobileAppSessionUnlocked => + kIsWeb ? true : _mobileAppSessionUnlocked; + + void unlockMobileAppSession() { + if (!kIsWeb) _mobileAppSessionUnlocked = true; + } + + void lockMobileAppSession() { + if (!kIsWeb) _mobileAppSessionUnlocked = false; + } + // ===== Post Enrollment Fields ===== String? mobileNo; String? empClientBranchId; @@ -279,5 +295,6 @@ class SessionManager { await prefs.clear(); await DataManager().clearData(); await TokenService.clearTokens(); + lockMobileAppSession(); } } diff --git a/lib/pages/session/SetPinBiometric.dart b/lib/pages/session/SetPinBiometric.dart index 60c9564..6d292ff 100755 --- a/lib/pages/session/SetPinBiometric.dart +++ b/lib/pages/session/SetPinBiometric.dart @@ -120,12 +120,12 @@ import 'package:nhance_app_pwa/logger.dart'; } } } else { - logDebug('checkLoginPin Something went wrong'); - ToastHelper.showErrorToast(context, 'Something went wrong'); + logDebug('checkLoginPin Unable to process. Please try again later'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); throw Exception('Failed to verify pin number'); } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } } @@ -233,7 +233,7 @@ import 'package:nhance_app_pwa/logger.dart'; throw Exception('Failed to load data'); } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); context.go('/login'); logDebug('Error: $e'); } @@ -309,6 +309,7 @@ import 'package:nhance_app_pwa/logger.dart'; logDebug('Token12345: ${await TokenService.getPostToken()}'); logDebug('Token00000'); // if (context.mounted) { + SessionManager().unlockMobileAppSession(); context.go('/home'); // } // Navigator.pushReplacementNamed(context, 'home'); @@ -353,6 +354,7 @@ import 'package:nhance_app_pwa/logger.dart'; // enrollmentEmp_status == 'active') { // Navigator.pushReplacementNamed(context, 'home'); // } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); // } @@ -370,7 +372,7 @@ import 'package:nhance_app_pwa/logger.dart'; try { await enterPinApi(_pinController.text); } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } finally { if (mounted) { @@ -469,7 +471,7 @@ import 'package:nhance_app_pwa/logger.dart'; throw Exception('Failed to load data'); } } catch (e) { - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } finally { if (mounted) { diff --git a/lib/pages/session/changePin.dart b/lib/pages/session/changePin.dart index c03ad0e..d268282 100755 --- a/lib/pages/session/changePin.dart +++ b/lib/pages/session/changePin.dart @@ -135,7 +135,7 @@ class _changePinState extends State { } } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } finally { if (mounted) { diff --git a/lib/pages/session/settingUpPinAndBiometric.dart b/lib/pages/session/settingUpPinAndBiometric.dart index 722b1f8..6008497 100755 --- a/lib/pages/session/settingUpPinAndBiometric.dart +++ b/lib/pages/session/settingUpPinAndBiometric.dart @@ -143,9 +143,11 @@ class _pinSettingPageState extends State { await _authService.authenticateWithBiometrics(); if (biometricEnabled) { if (_postToken != null && _postToken.isNotEmpty) { + SessionManager().unlockMobileAppSession(); context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); } @@ -158,9 +160,11 @@ class _pinSettingPageState extends State { } } else { if (_postToken != null && _postToken.isNotEmpty) { + SessionManager().unlockMobileAppSession(); context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); } @@ -194,7 +198,7 @@ class _pinSettingPageState extends State { } // } } catch (e) { - ToastHelper.showErrorToast(context, 'Something went wrong'); + ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); print('Error: $e'); } finally { if (mounted) { @@ -237,9 +241,11 @@ class _pinSettingPageState extends State { await _authService.saveSkipStatus(0); if (_postToken != null && _postToken.isNotEmpty) { + SessionManager().unlockMobileAppSession(); context.go('/home'); // Navigator.pushReplacementNamed(context, 'home'); } else { + SessionManager().unlockMobileAppSession(); context.go('/empDetails'); // Navigator.pushReplacementNamed(context, 'empDetails'); } diff --git a/lib/pages/setPassword.dart b/lib/pages/setPassword.dart index c8e9c4d..8ea48c6 100755 --- a/lib/pages/setPassword.dart +++ b/lib/pages/setPassword.dart @@ -163,7 +163,7 @@ class _setPasswordState extends State { setState(() { _isLoading = false; }); - // ToastHelper.showErrorToast(context, 'Something went wrong'); + // ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); logDebug('Error: $e'); } diff --git a/lib/pages/sso_login_return_page.dart b/lib/pages/sso_login_return_page.dart new file mode 100644 index 0000000..b7152e6 --- /dev/null +++ b/lib/pages/sso_login_return_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../customAppBar/toastHelper.dart'; +import '../logger.dart'; +import 'helpers/browser_href.dart'; +import 'login_saml_auth.dart'; + +/// Handles the browser return URL after SAML (query or hash `payload=`). +class SsoLoginReturnPage extends StatefulWidget { + const SsoLoginReturnPage({super.key}); + + @override + State createState() => _SsoLoginReturnPageState(); +} + +class _SsoLoginReturnPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _consumePayload()); + } + + Future _consumePayload() async { + if (!mounted) return; + + final routerUri = GoRouterState.of(context).uri; + final href = kIsWeb ? currentBrowserHref() : routerUri.toString(); + + Map? decoded; + for (final candidate in [ + href, + if (kIsWeb) Uri.base.toString(), + routerUri.toString(), + ]) { + if (candidate.isEmpty) continue; + decoded = decodeSsoPayloadFromUrl(candidate); + if (decoded != null) break; + } + + if (!mounted) return; + + if (decoded == null) { + logDebug('SSO return: no payload in href=$href router=${routerUri.toString()}'); + ToastHelper.showWarningToast( + context, 'SSO return missing or invalid payload'); + context.go('/login'); + return; + } + + logDebug('SSO return: decoded payload (keys)=${decoded.keys.toList()}'); + await completeLoginFromSsoPayloadMap(context, decoded); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } +} diff --git a/lib/pages/verify.dart b/lib/pages/verify.dart index f1b62b5..02f228b 100755 --- a/lib/pages/verify.dart +++ b/lib/pages/verify.dart @@ -313,7 +313,7 @@ import 'package:nhance_app_pwa/logger.dart'; // }); // final SharedPreferences prefs = await SharedPreferences.getInstance(); // prefs.clear(); -// ToastHelper.showWarningToast(context, 'Something went wrong'); +// ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); // throw Exception('Failed to verify OTP'); // } // } catch (e) { @@ -323,7 +323,7 @@ import 'package:nhance_app_pwa/logger.dart'; // logDebug('Error: $e'); // final SharedPreferences prefs = await SharedPreferences.getInstance(); // prefs.clear(); -// ToastHelper.showWarningToast(context, 'Something went wrong'); +// ToastHelper.showWarningToast(context, 'Unable to process. Please try again later'); // // Show a Snackbar if there's an error while verifying OTP // logDebug('Failed to verify OTP. Please try again.'); // } @@ -674,12 +674,12 @@ import 'package:nhance_app_pwa/logger.dart'; // // Navigator.pushReplacementNamed(context, 'pinSettingPage'); // } // } else { -// logDebug('checkLoginPin Something went wrong'); -// ToastHelper.showErrorToast(context, 'Something went wrong'); +// logDebug('checkLoginPin Unable to process. Please try again later'); +// ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // throw Exception('Failed to verify pin number'); // } // } catch (e) { -// ToastHelper.showErrorToast(context, 'Something went wrong'); +// ToastHelper.showErrorToast(context, 'Unable to process. Please try again later'); // logDebug('Error: $e'); // } // } diff --git a/pubspec.yaml b/pubspec.yaml index b724ff1..cd3a162 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,8 +17,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. #version: 1.2.41+98 -version: 1.0.32+38 -#version: 2.0.20+58 +version: 1.0.35+41 +#version: 2.0.23+61 environment: sdk: '>=3.3.3 <4.0.0' @@ -101,6 +101,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/ + - assets/images/login/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/web/index.html b/web/index.html index 60824fa..3d3f5a1 100755 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,22 @@ + + + @@ -47,6 +63,9 @@ +